Skip to content

Commit f7a5ebe

Browse files
fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir (#23851)
Fixes #23737 Clause-②: no ## What was wrong `createStandaloneStack` prepared the directory of a file-backed SQLite `default` with Node's recursive `mkdirSync`, in both the `sqlite` and the `sqlite-wasm` arm. Node's recursive mkdir (v22.22.0, sync and async alike) never returns when a level answers `ENOENT` although its parent exists. For `OS_DATABASE_URL=file:/proc/os-23737/ctl.db` it loops `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`, retry, holding the event loop. So `os start` printed `→ Starting server...` and hung: no plugin timeout could fire, and the SQL driver's bounded refusal from PR #23730 was never reached. `os dev` had the same shape in its own process (`dev.ts`, the unified-default directory prep). ## What changed - **`packages/runtime/src/standalone-stack.ts`:** both arms now `await makeSqliteDatabaseDirectory(filename)`. This is a module-private copy of the bounded walk `driver-sql`'s connect runs (`makeDatabaseDirectory`): - find the nearest existing level, then create the missing ones top-down with a non-recursive `mkdir`; - accept `EEXIST` on a created level (a concurrent creator); - refuse at once on anything else, with a module-private `SqliteDatabaseDirectoryError` carrying `code`, `filename`, `directory` and `cause`. The message names the database file, the directory and the OS error, never the URL. The read-only boot's skip (`sqliteAbsentFile: 'empty-in-memory'` over an absent file) is unchanged. The directory prep stays on the host, as ADR-0062's #3826 resolution decides ("URL→config translation and `mkdir` stay host concerns"). The card's option of dropping it in favour of the driver's connect is not taken. - **`packages/cli/src/commands/dev.ts`:** the `unified-default` prep goes through a new module-level `prepareDevDatabaseDirectory(resolvedDb)`, which runs the same walk. `os dev` prints the refusal plus a one-line remedy (point `OS_HOME` at a directory that can be created, or pass `--database`) and exits 1. - No export is added to any package entry (`@objectstack/runtime`'s index names its exports explicitly; `@objectstack/cli`'s exports only `DevCommand`). No `packages/spec` edit. - `.changeset/23737-uncreatable-db-dir-refusal.md`: `@objectstack/runtime` patch, `@objectstack/cli` patch. ## Measured at the public door Built CLI, this container, Node v22.22.0. Each run is in its own process group with a hard outer kill. | command | `origin/main` 43567a8 | this branch (`32f287c4e`, the fix; same source as head) | |:--|:--|:--| | `OS_DATABASE_URL=file:/proc/os-23737/ctl.db os start` (no config) | hung; killed at 25 s after `→ Starting server...` | exit 1 in 9.3 s wall (two Node start-ups): `✗ [StandaloneStack] SQLite database file '/proc/os-23737/ctl.db' cannot be created: its directory '/proc/os-23737' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-23737'` | | same with `wasm-sqlite:///proc/os-23737/ctl.db` | `createStandaloneStack` alone looped (14,537 failing `mkdir` in 8 s) | exit 1 in 9.1 s, the same line | | `OS_DATABASE_URL=file:/proc/os-23737/ctl.db os dev` (`examples/app-todo`) | hung; the serve child made 58,613 failing `mkdir` calls in about 25 s | exit 1 in 9.1 s, the same line | | `OS_HOME=/proc/os-23737 os dev` (`examples/app-todo`, unified default) | hung in the `os dev` process itself (64,440 failing calls in 25 s; the `Environment ID` row after the prep never printed) | exit 1 in 4.3 s: `✗ The default database '/proc/os-23737/data/objectstack.db' cannot be created: its directory '/proc/os-23737/data' could not be made: ENOENT: ...` plus the remedy line | | CONTROL: `OS_DATABASE_URL=file:SCRATCH/ctl-db/a/b/c/ctl.db os start` | not run | `✓ Server is ready`; `a/b/c/` made, `ctl.db` written | | CONTROL: `createStandaloneStack({ databaseUrl: 'file:/proc/os-23737/ctl.db', sqliteAbsentFile: 'empty-in-memory' })` (built runtime) | returned in 12 ms | covered by the pin below | `createStandaloneStack` alone, built, `file:/proc/os-23737/ctl.db`, on `origin/main`: 19,640 failing `mkdir("/proc/os-23737")` calls in 10 s under `strace`, killed by the bound. ## Pins - `packages/runtime/src/standalone-stack-uncreatable-db-directory.test.ts` (`local` project): 1. The `/proc` shape in both arms runs `createStandaloneStack` from source in a `node --import tsx` child, bounded by `spawnSync`'s timeout (30 s, SIGKILL). The loop was synchronous, so an in-process case could not be bounded. It asserts the child settled, the code is `ENOENT`, and the message names the file and the directory. Runs where `/proc/self` exists. 2. `ENOTDIR` (a path through a regular file) and `EEXIST` (a file holding the directory's name), in-process. 3. CONTROL: a creatable path three levels deep is made, in both arms. 4. CONTROL: the read-only boot over an absent file makes no directory. - `packages/cli/src/commands/dev-database-directory.test.ts` (`unit` tier by the predicate): - All four cases run in ONE bounded child (45 s, SIGKILL), because importing `dev.ts` costs about 7 s of the child's wall. The vitest worker never imports it. - Cases: `/proc` gives `ENOENT` naming the file and the directory; a path through a file gives `ENOTDIR`; CONTROL, a creatable unified-default path is made; CONTROL, any other source is left to the serve child (nothing made). - File wall: 7.16 s. ## Negative control (ablation) Both legs were run on the committed fix through `scripts/ablation-replace.mjs`, inside a trap-guarded script, under the verify lock. The subject resolves to source in both runners: a relative import in vitest, and a `.ts` file URL in the tsx child. So there is no `dist/` leg. - **Runtime.** Both arms' `await makeSqliteDatabaseDirectory(filename);` (anchor x2 → x0) became `(await import('node:fs')).mkdirSync(resolvePath(filename, '..'), { recursive: true });` (x0 → x2), which is the original call. - Blob `ca7e1c0c79a4` → `76537e4c8f4a`. - Result: **2 failed, 2 passed**. The `/proc` case reported `createStandaloneStack had not settled after 30057 ms (child killed by SIGKILL)`. The ENOTDIR case failed with `expected 'ENOTDIR: not a directory, mkdir ...' to contain '...plain/sub/ctl.db'`. Both CONTROLs stayed green. - Restore proven: blob == HEAD `ca7e1c0c79a4`, `git diff HEAD` empty. - **CLI.** Inside `prepareDevDatabaseDirectory`, `const missing: string[] = [];` (x1 → x0) became `fs.mkdirSync(directory, { recursive: true }); return;` (x0 → x1). - Blob `c31d6cd08223` → `89ecc6801be3`. - Result: **file FAIL**. The shared `beforeAll` threw `prepareDevDatabaseDirectory had not settled after 45080 ms (child killed by SIGKILL)` and its 4 cases were skipped. - Restore proven: blob == HEAD `c31d6cd08223`, `git diff HEAD` empty. - Fixed state: runtime file 4/4 and CLI file 4/4. ## Tests and gates Local readings are on head `70777e1be2`. - `pnpm --filter @objectstack/runtime exec vitest run --project local`: 400 files passed, 5,983 tests passed, 19 skipped, exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 298 files passed, 4,438 tests passed, exit 0. - `pnpm --filter @objectstack/runtime --filter @objectstack/cli run typecheck`: exit 0. `check:test-typecheck` is OK for both, with no new test-layer debt. - `pnpm exec eslint --no-inline-config --max-warnings=0` over the four changed `.ts` files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 65 commands. `--ran` reconciliation: 63 run with exit 0, 2 NOT MEASURED (exit 3, PREREQUISITE NOT MET), 0 unrun. - NOT MEASURED, `check:dual-build-cjs-loads`: needs every package's `dist/`; nine packages outside this card's build closure are unbuilt here. - NOT MEASURED, `check:i18n-coverage`: `examples/app-showcase` imports `@objectstack/connector-slack`, which has no build here. - Both run in CI's `Lint & Repo Gates`. - Two gates first refused and were re-run after their prerequisite was met: - `check-plugin-teardown-shape.mjs --self-test` needed a pinned commit that this shallow clone lacked; it was fetched and the gate exits 0. - `check:type-check-debt` had queued behind this card's own test run on the verify lock; re-run under the lock, it exits 0. - `packages/cli`'s `integration` tier is left to CI: no integration-tier file or spawn entry is touched. Runtime's `test:repo` project is left to CI too. ## Acceptance notes - **The walk now has three copies:** `driver-sql`'s `makeDatabaseDirectory`, the runtime's `makeSqliteDatabaseDirectory`, and the CLI's `prepareDevDatabaseDirectory`. None of them is on a package entry, and this card is fenced from adding an export. Consolidating them, for example as one exported helper in a shared package, would widen a published surface and is a separate decision. - **What the CLI pin does not cover:** it holds the function `os dev` awaits, not the call in `DevCommand.run()`. The call site is held by the public-door measurement above. - **Refusal latency:** the `os start` refusal arrives after both processes have loaded (about 9 s wall here). The walk itself answers in under 10 ms. - **Out of scope, same mechanism, measured:** - `OS_HOME=/proc/os-23737-home os start` (no config) hangs in the `os start` process itself: 176,004 failing `mkdir` calls in 20 s, at `packages/cli/src/commands/start.ts`'s `fs.mkdirSync(path.join(homeDir, 'data'), { recursive: true })`. - Other recursive `mkdirSync` calls on `OS_HOME`-derived paths sit on the same boot path, read but not measured: `start.ts` (the home dir), `default-host.ts` (the empty-boot stub artifact), `package-state-store.ts`, `artifact-reference.ts` and `serve.ts`. - None of these files is on this card's surface; they are reported to the lane seat, not changed here. - The PR #23816 regions of `standalone-stack.ts` (the `skipSchemaSync` field and its pass-through) are not touched by these hunks. --- _Generated by [Claude Code](https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a31ff2e commit f7a5ebe

5 files changed

Lines changed: 545 additions & 7 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging
7+
8+
Clause-②: no
9+
10+
**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`).
11+
12+
**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:
13+
14+
```text
15+
✗ [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'
16+
```
17+
18+
`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`.
19+
20+
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.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `os dev` refuses a unified-default database directory it cannot make — at
4+
// once, naming the database file, the directory and the operating system's
5+
// code — instead of looping in Node's recursive mkdir (#23737).
6+
//
7+
// ## The defect, measured on `origin/main` 43567a8fcb
8+
//
9+
// `os dev` prepared `<state dir>/data/` for its unified-default SQLite file with
10+
// `fs.mkdirSync(dir, { recursive: true })`. Under `OS_HOME=/proc/os-23737` that
11+
// call looped `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`,
12+
// retry, in the `os dev` process itself: 64,440 failing calls in 25 s under
13+
// `strace`, and the command never reached its next line.
14+
//
15+
// ## Why every case runs in ONE bounded child process
16+
//
17+
// The loop was SYNCHRONOUS: it holds the event loop, so no timer fires and a
18+
// vitest timeout cannot end it. A regression to it would hang this worker
19+
// rather than fail, so `prepareDevDatabaseDirectory` (the function `os dev`'s
20+
// boot awaits) runs from source in a `node --import tsx` child bounded by
21+
// `spawnSync`'s timeout with SIGKILL. All cases share that one child because
22+
// importing `dev.ts` costs about 7 s of the child's wall (measured, cold and
23+
// warm tsx cache alike), and this worker never imports it at all.
24+
//
25+
// ## What is asserted
26+
//
27+
// 1. `/proc/<missing>/` (where `/proc/self` exists — Linux, which CI is): the
28+
// child settles inside the bound and the refusal carries `ENOENT`, naming
29+
// the file and the directory — the shape that looped.
30+
// 2. A path through a regular file: refused with `ENOTDIR`, naming the file.
31+
// 3. CONTROL: a creatable unified-default path three levels deep is made.
32+
// 4. CONTROL: any other source is left to the serve child — nothing is made.
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { spawnSync } from 'node:child_process';
36+
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
37+
import { tmpdir } from 'node:os';
38+
import { dirname, join, resolve } from 'node:path';
39+
import { fileURLToPath, pathToFileURL } from 'node:url';
40+
41+
const HERE = dirname(fileURLToPath(import.meta.url));
42+
const PKG_ROOT = resolve(HERE, '../..');
43+
const SUBJECT = pathToFileURL(resolve(HERE, 'dev.ts')).href;
44+
45+
/** The child's whole budget; the loop it guards against never ends. */
46+
const CHILD_BOUND_MS = 45_000;
47+
48+
interface Outcome {
49+
label: string;
50+
outcome: 'returned' | 'rejected';
51+
code?: string;
52+
message?: string;
53+
}
54+
55+
const PROC_DIR = `/proc/os-23737-${process.pid}/data`;
56+
const PROC_FILE = `${PROC_DIR}/objectstack.db`;
57+
const RUNS_PROC = existsSync('/proc/self');
58+
59+
let root: string;
60+
let outcomes: Map<string, Outcome>;
61+
62+
beforeAll(() => {
63+
root = mkdtempSync(join(tmpdir(), 'os-23737-dev-'));
64+
writeFileSync(join(root, 'plain'), '');
65+
const cases: Array<{ label: string; url: string; source: string }> = [
66+
...(RUNS_PROC ? [{ label: 'proc', url: `file:${PROC_FILE}`, source: 'unified-default' }] : []),
67+
{ label: 'through-file', url: `file:${join(root, 'plain', 'data', 'objectstack.db')}`, source: 'unified-default' },
68+
{ label: 'creatable', url: `file:${join(root, 'home', 'a', 'data', 'objectstack.db')}`, source: 'unified-default' },
69+
{ label: 'other-source', url: `file:${join(root, 'env', 'a', 'data', 'objectstack.db')}`, source: 'env' },
70+
];
71+
const script = [
72+
'const [subject, json] = process.argv.slice(1);',
73+
'const { prepareDevDatabaseDirectory } = await import(subject);',
74+
'const out = [];',
75+
'for (const { label, url, source } of JSON.parse(json)) {',
76+
' try { await prepareDevDatabaseDirectory({ url, source }); out.push({ label, outcome: "returned" }); }',
77+
' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }',
78+
'}',
79+
'process.stdout.write("\\nDEV-DIR-OUTCOMES " + JSON.stringify(out) + "\\n");',
80+
].join('\n');
81+
const t0 = Date.now();
82+
const child = spawnSync(
83+
process.execPath,
84+
['--import', 'tsx', '--input-type=module', '-e', script, SUBJECT, JSON.stringify(cases)],
85+
{
86+
cwd: PKG_ROOT,
87+
env: {
88+
PATH: process.env.PATH ?? '',
89+
...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}),
90+
},
91+
encoding: 'utf8',
92+
timeout: CHILD_BOUND_MS,
93+
killSignal: 'SIGKILL',
94+
maxBuffer: 16 * 1024 * 1024,
95+
},
96+
);
97+
if (child.signal) {
98+
throw new Error(
99+
`prepareDevDatabaseDirectory had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` +
100+
`the directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`,
101+
);
102+
}
103+
const line = String(child.stdout).split('\n').find((l) => l.startsWith('DEV-DIR-OUTCOMES '));
104+
if (!line) {
105+
throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`);
106+
}
107+
const parsed: Outcome[] = JSON.parse(line.slice('DEV-DIR-OUTCOMES '.length));
108+
outcomes = new Map(parsed.map((o) => [o.label, o]));
109+
}, CHILD_BOUND_MS + 15_000);
110+
111+
afterAll(() => {
112+
if (root) rmSync(root, { recursive: true, force: true });
113+
});
114+
115+
function outcomeOf(label: string): Outcome {
116+
const found = outcomes.get(label);
117+
if (!found) throw new Error(`no outcome recorded for case '${label}'`);
118+
return found;
119+
}
120+
121+
describe('prepareDevDatabaseDirectory — os dev\'s unified-default directory (#23737)', () => {
122+
it.runIf(RUNS_PROC)('refuses /proc/<missing>/ at once with ENOENT, naming the file and the directory', () => {
123+
const o = outcomeOf('proc');
124+
expect(o.outcome).toBe('rejected');
125+
expect(o.code).toBe('ENOENT');
126+
expect(o.message).toContain(`'${PROC_FILE}'`);
127+
expect(o.message).toContain(`'${PROC_DIR}'`);
128+
});
129+
130+
it('refuses a path through a regular file with ENOTDIR, naming the file', () => {
131+
const o = outcomeOf('through-file');
132+
expect(o.outcome).toBe('rejected');
133+
expect(o.code).toBe('ENOTDIR');
134+
expect(o.message).toContain(`'${join(root, 'plain', 'data', 'objectstack.db')}'`);
135+
});
136+
137+
it('CONTROL: a creatable unified-default path three levels deep is made', () => {
138+
expect(outcomeOf('creatable').outcome).toBe('returned');
139+
expect(statSync(join(root, 'home', 'a', 'data')).isDirectory()).toBe(true);
140+
});
141+
142+
it('CONTROL: any other source is left to the serve child — nothing is made', () => {
143+
expect(outcomeOf('other-source').outcome).toBe('returned');
144+
expect(existsSync(join(root, 'env'))).toBe(false);
145+
});
146+
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,69 @@ export async function resolveDevDatabase(opts: {
9090
});
9191
}
9292

93+
/**
94+
* Make the directory of `os dev`'s unified-default SQLite database
95+
* (`<state dir>/data/`), bounded — or reject at once, naming the database
96+
* file, the directory and the operating system's code (#23737).
97+
*
98+
* Only the `unified-default` source is prepared here; every other source is
99+
* the serve child's to open (and its standalone host prepares the directory
100+
* with the same bounded walk).
101+
*
102+
* ## Why not `fs.mkdirSync(dir, { recursive: true })`
103+
*
104+
* Node's recursive mkdir (v22.22.0, measured; sync and async alike) never
105+
* returns when a level answers `ENOENT` although its parent exists. Under
106+
* `OS_HOME=/proc/os-23737` this command looped `mkdir("/proc/os-23737")` ↔
107+
* `mkdir("/proc")` in its own process (64,440 failing calls in 25 s) and never
108+
* reached its next line.
109+
*
110+
* The walk is the one `driver-sql`'s connect and the runtime's standalone host
111+
* run (neither is on a package entry, hence this module-level copy): find the
112+
* nearest level that exists, create the missing ones top-down with a
113+
* NON-recursive `mkdir`, accept `EEXIST` on a created level (a concurrent
114+
* creator), and reject on anything else.
115+
*/
116+
export async function prepareDevDatabaseDirectory(
117+
resolved: Pick<ResolvedProjectDatabaseUrl, 'url' | 'source'>,
118+
): Promise<void> {
119+
if (resolved.source !== 'unified-default') return;
120+
const filename = resolved.url.replace(/^file:/, '');
121+
const directory = path.resolve(path.dirname(filename));
122+
try {
123+
const missing: string[] = [];
124+
let level = directory;
125+
for (;;) {
126+
try {
127+
if (!(await fs.promises.stat(level)).isDirectory() && missing.length === 0) await fs.promises.mkdir(level);
128+
break;
129+
} catch (e) {
130+
if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e;
131+
missing.push(level);
132+
const parent = path.dirname(level);
133+
if (parent === level) break;
134+
level = parent;
135+
}
136+
}
137+
for (const each of missing.reverse()) {
138+
try {
139+
await fs.promises.mkdir(each);
140+
} catch (e) {
141+
if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e;
142+
}
143+
}
144+
} catch (e) {
145+
const code = (e as { code?: unknown } | null)?.code;
146+
const reason = e instanceof Error ? e.message : String(e);
147+
throw Object.assign(
148+
new Error(
149+
`The default database '${filename}' cannot be created: its directory '${directory}' could not be made: ${reason}`,
150+
),
151+
{ code: typeof code === 'string' ? code : undefined, filename, directory, cause: e },
152+
);
153+
}
154+
}
155+
93156
/**
94157
* The `os dev` MCP connect hint — the three lines a reader PASTES (#3167,
95158
* #16734).
@@ -573,8 +636,15 @@ export default class Dev extends Command {
573636
// prompt (CI-safe), never silent (#6469).
574637
console.log(chalk.yellow(` ⚠ ${resolvedDb.notice}`));
575638
}
576-
if (resolvedDb.source === 'unified-default') {
577-
fs.mkdirSync(path.dirname(resolvedDb.url.replace(/^file:/, '')), { recursive: true });
639+
// Bounded, and refused at once when the directory cannot be made (#23737).
640+
try {
641+
await prepareDevDatabaseDirectory(resolvedDb);
642+
} catch (error: any) {
643+
printError(error?.message ?? String(error));
644+
console.error(chalk.yellow(
645+
' Point OS_HOME at a directory that can be created, or choose the database with --database.',
646+
));
647+
process.exit(1);
578648
}
579649
const effectiveDb = resolvedDb.url;
580650
// `dev` has a resolved artifact by this point (it compiled one, or was

0 commit comments

Comments
 (0)