Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/23737-uncreatable-db-dir-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': patch
'@objectstack/cli': patch
---

fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging

Clause-②: no

**What was wrong.** The standalone host (`createStandaloneStack`, which `os start`, `os dev`'s standalone boot and every embedder run) prepared the directory of a file-backed SQLite database with Node's recursive `mkdirSync`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists: for `OS_DATABASE_URL=file:/proc/os-x/ctl.db` it looped `mkdir("/proc/os-x")` and `mkdir("/proc")` without end, holding the event loop. `os start` printed `→ Starting server...` and hung with no timeout and no message. The `sqlite-wasm` arm (`wasm-sqlite://…`, or `OS_DATABASE_DRIVER=sqlite-wasm`) did the same. `os dev` hung the same way in its own process when its default database lived under a state directory that cannot be made (for example `OS_HOME=/proc/os-x`).

**What an operator sees now.** The boot exits non-zero within its normal start-up time, naming the database file, the directory and the operating system's error:

```text
✗ [StandaloneStack] SQLite database file '/proc/os-x/ctl.db' cannot be created: its directory '/proc/os-x' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-x'
```

`os dev` with an unusable state directory prints `✗ The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: …`, followed by the remedy: point `OS_HOME` at a directory that can be created, or choose the database with `--database`.

Nothing else changes. A creatable path is still made, every missing level of it. The read-only boot (`os migrate plan` over an absent database file) still creates no directory. The refusal names the path, never the database URL.
146 changes: 146 additions & 0 deletions packages/cli/src/commands/dev-database-directory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `os dev` refuses a unified-default database directory it cannot make — at
// once, naming the database file, the directory and the operating system's
// code — instead of looping in Node's recursive mkdir (#23737).
//
// ## The defect, measured on `origin/main` 43567a8fcb
//
// `os dev` prepared `<state dir>/data/` for its unified-default SQLite file with
// `fs.mkdirSync(dir, { recursive: true })`. Under `OS_HOME=/proc/os-23737` that
// call looped `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`,
// retry, in the `os dev` process itself: 64,440 failing calls in 25 s under
// `strace`, and the command never reached its next line.
//
// ## Why every case runs in ONE bounded child process
//
// The loop was SYNCHRONOUS: it holds the event loop, so no timer fires and a
// vitest timeout cannot end it. A regression to it would hang this worker
// rather than fail, so `prepareDevDatabaseDirectory` (the function `os dev`'s
// boot awaits) runs from source in a `node --import tsx` child bounded by
// `spawnSync`'s timeout with SIGKILL. All cases share that one child because
// importing `dev.ts` costs about 7 s of the child's wall (measured, cold and
// warm tsx cache alike), and this worker never imports it at all.
//
// ## What is asserted
//
// 1. `/proc/<missing>/` (where `/proc/self` exists — Linux, which CI is): the
// child settles inside the bound and the refusal carries `ENOENT`, naming
// the file and the directory — the shape that looped.
// 2. A path through a regular file: refused with `ENOTDIR`, naming the file.
// 3. CONTROL: a creatable unified-default path three levels deep is made.
// 4. CONTROL: any other source is left to the serve child — nothing is made.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const PKG_ROOT = resolve(HERE, '../..');
const SUBJECT = pathToFileURL(resolve(HERE, 'dev.ts')).href;

/** The child's whole budget; the loop it guards against never ends. */
const CHILD_BOUND_MS = 45_000;

interface Outcome {
label: string;
outcome: 'returned' | 'rejected';
code?: string;
message?: string;
}

const PROC_DIR = `/proc/os-23737-${process.pid}/data`;
const PROC_FILE = `${PROC_DIR}/objectstack.db`;
const RUNS_PROC = existsSync('/proc/self');

let root: string;
let outcomes: Map<string, Outcome>;

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-23737-dev-'));
writeFileSync(join(root, 'plain'), '');
const cases: Array<{ label: string; url: string; source: string }> = [
...(RUNS_PROC ? [{ label: 'proc', url: `file:${PROC_FILE}`, source: 'unified-default' }] : []),
{ label: 'through-file', url: `file:${join(root, 'plain', 'data', 'objectstack.db')}`, source: 'unified-default' },
{ label: 'creatable', url: `file:${join(root, 'home', 'a', 'data', 'objectstack.db')}`, source: 'unified-default' },
{ label: 'other-source', url: `file:${join(root, 'env', 'a', 'data', 'objectstack.db')}`, source: 'env' },
];
const script = [
'const [subject, json] = process.argv.slice(1);',
'const { prepareDevDatabaseDirectory } = await import(subject);',
'const out = [];',
'for (const { label, url, source } of JSON.parse(json)) {',
' try { await prepareDevDatabaseDirectory({ url, source }); out.push({ label, outcome: "returned" }); }',
' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }',
'}',
'process.stdout.write("\\nDEV-DIR-OUTCOMES " + JSON.stringify(out) + "\\n");',
].join('\n');
const t0 = Date.now();
const child = spawnSync(
process.execPath,
['--import', 'tsx', '--input-type=module', '-e', script, SUBJECT, JSON.stringify(cases)],
{
cwd: PKG_ROOT,
env: {
PATH: process.env.PATH ?? '',
...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}),
},
encoding: 'utf8',
timeout: CHILD_BOUND_MS,
killSignal: 'SIGKILL',
maxBuffer: 16 * 1024 * 1024,
},
);
if (child.signal) {
throw new Error(
`prepareDevDatabaseDirectory had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` +
`the directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`,
);
}
const line = String(child.stdout).split('\n').find((l) => l.startsWith('DEV-DIR-OUTCOMES '));
if (!line) {
throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`);
}
const parsed: Outcome[] = JSON.parse(line.slice('DEV-DIR-OUTCOMES '.length));
outcomes = new Map(parsed.map((o) => [o.label, o]));
}, CHILD_BOUND_MS + 15_000);

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

function outcomeOf(label: string): Outcome {
const found = outcomes.get(label);
if (!found) throw new Error(`no outcome recorded for case '${label}'`);
return found;
}

describe('prepareDevDatabaseDirectory — os dev\'s unified-default directory (#23737)', () => {
it.runIf(RUNS_PROC)('refuses /proc/<missing>/ at once with ENOENT, naming the file and the directory', () => {
const o = outcomeOf('proc');
expect(o.outcome).toBe('rejected');
expect(o.code).toBe('ENOENT');
expect(o.message).toContain(`'${PROC_FILE}'`);
expect(o.message).toContain(`'${PROC_DIR}'`);
});

it('refuses a path through a regular file with ENOTDIR, naming the file', () => {
const o = outcomeOf('through-file');
expect(o.outcome).toBe('rejected');
expect(o.code).toBe('ENOTDIR');
expect(o.message).toContain(`'${join(root, 'plain', 'data', 'objectstack.db')}'`);
});

it('CONTROL: a creatable unified-default path three levels deep is made', () => {
expect(outcomeOf('creatable').outcome).toBe('returned');
expect(statSync(join(root, 'home', 'a', 'data')).isDirectory()).toBe(true);
});

it('CONTROL: any other source is left to the serve child — nothing is made', () => {
expect(outcomeOf('other-source').outcome).toBe('returned');
expect(existsSync(join(root, 'env'))).toBe(false);
});
});
74 changes: 72 additions & 2 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,69 @@ export async function resolveDevDatabase(opts: {
});
}

/**
* Make the directory of `os dev`'s unified-default SQLite database
* (`<state dir>/data/`), bounded — or reject at once, naming the database
* file, the directory and the operating system's code (#23737).
*
* Only the `unified-default` source is prepared here; every other source is
* the serve child's to open (and its standalone host prepares the directory
* with the same bounded walk).
*
* ## Why not `fs.mkdirSync(dir, { recursive: true })`
*
* Node's recursive mkdir (v22.22.0, measured; sync and async alike) never
* returns when a level answers `ENOENT` although its parent exists. Under
* `OS_HOME=/proc/os-23737` this command looped `mkdir("/proc/os-23737")` ↔
* `mkdir("/proc")` in its own process (64,440 failing calls in 25 s) and never
* reached its next line.
*
* The walk is the one `driver-sql`'s connect and the runtime's standalone host
* run (neither is on a package entry, hence this module-level copy): find the
* nearest level that exists, create the missing ones top-down with a
* NON-recursive `mkdir`, accept `EEXIST` on a created level (a concurrent
* creator), and reject on anything else.
*/
export async function prepareDevDatabaseDirectory(
resolved: Pick<ResolvedProjectDatabaseUrl, 'url' | 'source'>,
): Promise<void> {
if (resolved.source !== 'unified-default') return;
const filename = resolved.url.replace(/^file:/, '');
const directory = path.resolve(path.dirname(filename));
try {
const missing: string[] = [];
let level = directory;
for (;;) {
try {
if (!(await fs.promises.stat(level)).isDirectory() && missing.length === 0) await fs.promises.mkdir(level);
break;
} catch (e) {
if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e;
missing.push(level);
const parent = path.dirname(level);
if (parent === level) break;
level = parent;
}
}
for (const each of missing.reverse()) {
try {
await fs.promises.mkdir(each);
} catch (e) {
if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e;
}
}
} catch (e) {
const code = (e as { code?: unknown } | null)?.code;
const reason = e instanceof Error ? e.message : String(e);
throw Object.assign(
new Error(
`The default database '${filename}' cannot be created: its directory '${directory}' could not be made: ${reason}`,
),
{ code: typeof code === 'string' ? code : undefined, filename, directory, cause: e },
);
}
}

/**
* The `os dev` MCP connect hint — the three lines a reader PASTES (#3167,
* #16734).
Expand Down Expand Up @@ -573,8 +636,15 @@ export default class Dev extends Command {
// prompt (CI-safe), never silent (#6469).
console.log(chalk.yellow(` ⚠ ${resolvedDb.notice}`));
}
if (resolvedDb.source === 'unified-default') {
fs.mkdirSync(path.dirname(resolvedDb.url.replace(/^file:/, '')), { recursive: true });
// Bounded, and refused at once when the directory cannot be made (#23737).
try {
await prepareDevDatabaseDirectory(resolvedDb);
} catch (error: any) {
printError(error?.message ?? String(error));
console.error(chalk.yellow(
' Point OS_HOME at a directory that can be created, or choose the database with --database.',
));
process.exit(1);
}
const effectiveDb = resolvedDb.url;
// `dev` has a resolved artifact by this point (it compiled one, or was
Expand Down
Loading
Loading