Repository navigation
Commit f7a5ebe
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
- .changeset
- packages
- cli/src/commands
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
93 | 156 | | |
94 | 157 | | |
95 | 158 | | |
| |||
573 | 636 | | |
574 | 637 | | |
575 | 638 | | |
576 | | - | |
577 | | - | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
578 | 648 | | |
579 | 649 | | |
580 | 650 | | |
| |||
0 commit comments