Skip to content

fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir - #23851

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23737-uncreatable-db-dir-refusal
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23737-uncreatable-db-dir-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 ADR-0062 D1 收尾:default driver 的 connect 与失败判决仍是第二份实现(阻塞点已定位) #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 driver-turso / os migrate organization-ownership: the v18 ceremony cannot run on a Turso remote database (deferred DDL, drift detection and migration entries are refused on the remote face), so every hosted tenant's v18 upgrade stops #23816 regions of standalone-stack.ts (the skipSchemaSync field and its pass-through) are not touched by these hunks.

Generated by Claude Code

…once instead of looping in recursive mkdir

createStandaloneStack's sqlite and sqlite-wasm arms and os dev's unified-default
prep called Node's recursive mkdirSync, which never returns when a level answers
ENOENT under an existing parent (/proc/<missing>/...). The host now runs the
bounded walk driver-sql's connect runs and rejects at once, naming the database
file, the directory and the OS error code.

Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp
Co-authored-by: Claude <noreply@anthropic.com>
…atabase directory

The /proc shape runs in a node --import tsx child bounded by spawnSync's timeout
with SIGKILL, so a reintroduced synchronous recursive-mkdir loop fails at the
bound instead of hanging the worker. Controls: a creatable nested path is made
in both SQLite arms; the read-only boot still makes no directory; os dev leaves
every non-default source to its serve child.

Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 4 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/data-modeling/drivers.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/deployment/single-project-mode.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/plugins/index.mdx (via createStandaloneStack (symbol, a top-level function))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-7.mdx (via createStandaloneStack (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os dev (command, 33 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 66d456d01663b32014c9ea562f4ba3054ea7024c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from baa489a8e034f845c6165b0bc8306cc817ee3f16 — the merge of head 70777e1be27906fd8dc7732823b149347b93eac8 into base 66d456d01663b32014c9ea562f4ba3054ea7024c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin baa489a8e034f845c6165b0bc8306cc817ee3f16 && git checkout baa489a8e034f845c6165b0bc8306cc817ee3f16
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 66d456d01663b32014c9ea562f4ba3054ea7024c 70777e1be27906fd8dc7732823b149347b93eac8 && git checkout -B drift-repro 66d456d01663b32014c9ea562f4ba3054ea7024c && git merge --no-ff 70777e1be27906fd8dc7732823b149347b93eac8

node scripts/docs-audit/affected-docs.mjs --json 66d456d01663b32014c9ea562f4ba3054ea7024c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 66d456d01663b32014c9ea562f4ba3054ea7024c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #23851 at 70777e1be2 (#23737, Fixes #23737). It queues on a true-green head; no contract review is owed

domain:cli seat 1 (#6024) · marchtian · session session_01PFB9ZGRWYvjhTXsd8i6KBp · 2026-10-16T09:07Z · holder of claim 6210181396. Report: os-dev-report 6211844192 on #23737.

席位核对

  1. Shape, read on GitHub:
    • Draft against main, head 70777e1be2. Line 1 is Fixes #23737, the only closing keyword in the body. Line 3 is a bare Clause-②: no. Assignee marchtian.
    • Files: 5, +545 / −7, all on the claim's surface: standalone-stack.ts (the two mkdirSync arms and a module-private helper), dev.ts (the :577 database-directory prep), two new test files, one changeset. No content/docs/releases/, no packages/spec, no driver-sql edit.
    • check-governed-merges --pr 23851: NOT governed. git merge-tree against origin/main 1eac7ad751 is clean, and nothing on main since the merge base touches standalone-stack.ts or dev.ts. All three commits carry the model-free trailer pair.
  2. The fix, read against the diff:
    • Both SQLite arms of createStandaloneStack now await makeSqliteDatabaseDirectory, the bounded walk of driver-sql's makeDatabaseDirectory (non-recursive mkdir, top-down from the nearest existing level, EEXIST accepted on a created level, anything else refused). The refusal is SqliteDatabaseDirectoryError, carrying the OS code, the file and the directory, and never the URL.
    • The mkdir stays on the host, as ADR-0062's ADR-0062 D1 收尾:default driver 的 connect 与失败判决仍是第二份实现(阻塞点已定位) #3826 resolution requires. The read-only boot's skip (sqliteAbsentFile: 'empty-in-memory' over an absent file) is unchanged.
    • os dev's unified-default prep goes through prepareDevDatabaseDirectory (same walk) and exits 1 with the refusal and a remedy line. Every other source is left to the serve child's host.
  3. Clause-②: no holds; no contract-review face is touched:
    • No Clause-②: yes, no packages/spec/src/**, no governed text, no (narrowing) declaration. A hang becomes a refusal; no accepted OS_DATABASE_URL value changes its answer.
    • prepareDevDatabaseDirectory is a named export of commands/dev.ts, which no published entry reaches: @objectstack/cli's exports are ., ./console, ./hook-body, and src/index.ts re-exports only DevCommand, the module's default (the pattern resolveDevDatabase already follows). The two runtime symbols are module-private.
  4. Pins, read in the diff: the /proc/<missing> shape is refused in both arms inside a child bounded at 30 s with SIGKILL, so a regression fails at the bound and never hangs the suite. ENOTDIR and EEXIST are refused in-process. Each refusal asserts the OS code and both named paths. CONTROLs: a creatable path three levels deep is made in both arms, and the read-only boot makes no directory. The CLI file holds the same four shapes in one bounded child.
  5. Verification: the report's local runs, at the head: runtime local 5983 passed, CLI unit 4438 passed, both typechecks exit 0. Gates 65 derived, 63 exit 0, 2 NOT MEASURED on prerequisites (check:dual-build-cjs-loads, check:i18n-coverage, both CI's), --ran exit 0. Ablation: both legs red at the bound, each restored to the HEAD blob.
  6. The deviation, accepted: the order's "assert the exit at os start" is held as a public-door measurement (exit 1 in 9.3 s, path and ENOENT named; os dev both shapes, the wasm arm) plus the in-repo pins, not a permanent os start spawn test. A source spawn costs two tsx start-ups, over the 10 s per-file line, and the pins sit at the layer that changed.
  7. The changeset, checked sentence by sentence (@objectstack/runtime patch, @objectstack/cli patch): "What was wrong" matches the measured loop and the two arms; the quoted [StandaloneStack] … message matches the constructor's text; the os dev message and remedy match dev.ts; "Nothing else changes" holds (creatable paths made, the read-only boot untouched, the URL never named).
  8. Out of scope:

Landing: CI at this head reads 37 of 39 check-runs completed, every one success or an expected skip; Test Core (2/6) and (3/6) are still running. When they turn success, this seat flips ready and arms auto-merge through the relay.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 09:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 09:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit f7a5ebe Oct 16, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23737-uncreatable-db-dir-refusal branch October 16, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants