Repository navigation
Commit 570c7a2
fix(cli): os serve no longer hangs on a runtime state file directory that can never be made (#24088)
Fixes #23858
Clause-②: no
The card's remainder after PR #23979 (which landed as `Part of`): `os
serve`'s runtime state file directory, plus the two in-package copies of
the bounded walk. The state-file site now goes through the CLI's
module-internal `makeDirectorySync`. **The two copies are kept, not
folded**, by the claim's own rule: a fold onto `makeDirectorySync`
cannot keep their answers and error text byte for byte (measured below).
## What was wrong
`runtimeBoundPortChannels().writeRuntimeState` in
`packages/cli/src/commands/serve.ts` made `OS_HOME` with
`fs.mkdirSync(path.dirname(runtimeFile), { recursive: true })`. Node's
recursive `mkdir` (v22.22.0) never returns when a level answers `ENOENT`
while its parent exists, as `/proc` answers for a name it does not have.
`publishBoundPort` writes the state file after the bind and before the
banner, so the server held its port, printed no `Press Ctrl+C to stop`,
sent no listening message to an `os dev` parent, and answered nothing.
## What changed
- `serve.ts`: that one call is now
`makeDirectorySync(path.dirname(runtimeFile))`, imported from
`../utils/make-directory.js`. Nothing else in the file moves. The
refusal lands in the write's existing best-effort `catch`, so the boot
goes on without the file, as it already did for every other refusal (see
Acceptance notes: nothing tells the operator).
- `packages/cli/test/uncreatable-directory-doors.integration.test.ts`
gains two legs (below). No new spawning file, so the
`check:cli-test-child-env` population is unchanged.
- `.changeset/23858-serve-state-file-directory.md`: `@objectstack/cli`
patch, `Clause-②: no`.
- `.changeset/23858-uncreatable-directory-refusal.md` (PR #23979's
pending note) is NOT in this diff: it was restored byte-for-byte from
the merge base by seat ruling B (`6227399342`). This PR's changeset says
that it covers the runtime state file that note lists as "Not covered
yet".
## The two folds: kept, measured
The claim says each fold keeps its callers, its answers and its error
text byte for byte, or the copy is kept. Measured function by function
on this branch: each copy as it stands, against the fold as it would be
written (each copy's own wrapper and message, verbatim, around its
package's `makeDirectorySync`). `dev` = `prepareDevDatabaseDirectory`
(`dev.ts`); `runtime` = `makeSqliteDatabaseDirectory`
(`standalone-stack.ts`), reached through `createStandaloneStack` because
it is module-private.
| input (the database file's directory) | dev | runtime |
|:--|:--|:--|
| creatable, three levels deep | same | same |
| already exists | same | same |
| `/proc/MISSING/a` | same: `ENOENT`, same text | same: `ENOENT`, same
text |
| through a regular file | same: `ENOTDIR`, same text | same: `ENOTDIR`,
same text |
| a regular file holds the directory's name | same: `EEXIST`, same text
| same: `EEXIST`, same text |
| **a dangling symlink is the directory** | **differs:** copy returns;
fold refuses `EEXIST` | **differs:** copy returns; fold refuses `EEXIST`
|
| **a dangling symlink mid-path** | **differs:** copy `ENOENT … mkdir
'ROOT/dl2/sub'`; fold `EEXIST … mkdir 'ROOT/dl2'` | **differs**, the
same way |
Two causes, both in the walk itself, so neither wrapper can absorb them:
1. **`EEXIST` on a created level.** The copies accept any `EEXIST`;
`makeDirectorySync` accepts it only when a directory stands there (PR
#23979's acceptance notes named this). A dangling symlink answers
`EEXIST` to `mkdir` and `ENOENT` to `stat`.
2. **Async to sync.** Both copies `await` each `stat` and `mkdir`.
Measured on `prepareDevDatabaseDirectory`, event-loop turns that ran
while the walk was pending: 46 for a three-level create, 100 for
`/proc`, against 0 for the fold.
So both copies stay, byte for byte. Neither file is in this diff.
**What the copies' answer costs at the door** (base `b6805ac0f`, built
CLI; unchanged here):
- `OS_HOME=SCRATCH/dl-home os dev`, where `SCRATCH/dl-home/data` is a
dangling symlink, exits 1 after 11.2 s with `✗ Plugin
com.objectstack.auth failed to start - rollback complete: The database
refused to run this query for object 'sys_organization'. …`. Nothing
names the file, the directory or the symlink.
- `OS_DATABASE_URL=file:SCRATCH/dl-db/link/ctl.db os serve`, where
`link` is a dangling symlink, exits 1 after 5.3 s with `✗ Plugin
package-service failed to start - rollback complete: The database
refused to run a raw statement. …`.
- The fold would turn both into an immediate refusal that names the
file, the directory and `EEXIST`. That is a change of answer, which the
claim's rule keeps out of this PR. It is in the report for the seat.
## Measured at the public door
Built CLI, Node v22.22.0, a scratch project outside the repo with
`export default {};` (and a `defineStack` project for `os dev`). Each
run is in its own process group, killed whole at the bound. `strace -f
-c -e trace=mkdir,mkdirat` counts the calls.
| command | base `b6805ac0f` | this branch |
|:--|:--|:--|
| `OS_HOME=/proc/os-23858-r2 OS_DATABASE_URL=:memory: os serve` |
**hung** after the bind: **794,594** failing `mkdir` in 20 s (all
`mkdir("/proc/os-23858-r2")` / `mkdir("/proc")`), no banner, killed at
the bound | `Press Ctrl+C to stop` at 4.7 s; 1 failing `mkdir` in the
whole run |
| `OS_HOME=/proc/os-23858-r2 os dev --database :memory: --no-watch` (the
serve child reaches the same write) | **hung**: **1,392,733** failing
`mkdir` in 40 s, no banner | banner and MCP connect block at 13.2 s; 1
failing `mkdir` |
| `OS_HOME=/proc/os-23858-r2 os serve` (SQLite default inside `OS_HOME`)
| exit 1 in 3.5 s: `✗ [StandaloneStack] SQLite database file
'/proc/os-23858-r2/data/objectstack.db' cannot be created: … ENOENT …` |
the same line, exit 1 in 3.8 s |
| `OS_HOME=/proc/os-23858-r2 os start` (also with
`OS_DATABASE_URL=:memory:`) | exit 1 in 3.5 s: `✗ Cannot create home
directory at /proc/os-23858-r2: ENOENT …` (PR #23979's door, before any
serve child) | the same, exit 1 in 4.2 s |
| fold door, `OS_HOME=/proc/os-23858-r2 os dev` (unified default) | exit
1 in 3.6 s: `✗ The default database
'/proc/os-23858-r2/data/objectstack.db' cannot be created: its directory
'/proc/os-23858-r2/data' could not be made: ENOENT: no such file or
directory, mkdir '/proc/os-23858-r2'` | the same line, exit 1 in 3.5 s |
| fold door, `OS_DATABASE_URL=file:/proc/os-23858-r2-db/a/ctl.db os
serve` | exit 1 in 3.3 s: `✗ [StandaloneStack] SQLite database file
'/proc/os-23858-r2-db/a/ctl.db' cannot be created: its directory
'/proc/os-23858-r2-db/a' could not be made: ENOENT: …, mkdir
'/proc/os-23858-r2-db'` | the same line, exit 1 in 3.5 s |
`os start` never reaches the state-file write under an uncreatable home:
it makes `HOME_DIR/data` first and refuses there. The door that reaches
it is `os serve` (or `os dev`'s serve child) with the database outside
`OS_HOME`.
## The remaining recursive `mkdir` sites in the two packages
`git grep -n "recursive: true"` over `packages/cli/src` and
`packages/runtime/src` at base `b6805ac0f`, test files excluded: 18
lines. 3 are `rmSync`, 2 are `readdirSync` in doc comments, and 6 are
doc comments naming the old call. That leaves 7 `mkdir` code sites; a
multi-line search (`mkdir(Sync)?\(` without `recursive` on the line)
finds only the walks' own non-recursive calls.
| site | path | can operator input make it uncreatable? |
|:--|:--|:--|
| `cli/src/commands/serve.ts:1097` | `OS_HOME` (the state file) |
**yes**: this PR |
| `cli/src/commands/create.ts:740` | template-constant dirs under
`targetDir`, just made by `makeDirectorySync` | no, by construction |
| `cli/src/commands/dev.ts:572`, `:577` | `--fresh` home's `data` and
`uploads`, one level under `mkdtempSync` | no, by construction |
| `cli/src/commands/init.ts:1122` | template-constant dirs under
`targetDir`, just written into | no, by construction |
| `cli/src/utils/build-runtime.ts:198` | `outputDir`; its only caller
passes `compile`'s `artifactDir`, made or refused by `makeDirectorySync`
| no, by construction |
| `cli/src/utils/dev-organization-ownership-upgrade.ts:128` | two
constant levels under the SQLite file's own directory, which exists |
no, by construction |
`packages/runtime/src` has no recursive `mkdir` left.
## Pins
`packages/cli/test/uncreatable-directory-doors.integration.test.ts`, two
new legs among its concurrent children (6 in all), each its own process
group, killed whole at 60 s:
5. `os serve --port 0` under `OS_HOME=/proc/MISSING` with
`OS_DATABASE_URL=:memory:` prints `Press Ctrl+C to stop` inside the
bound. The group is killed on that text.
6. CONTROL: the same under a creatable `OS_HOME` three levels deep
prints the banner, and by then the home holds exactly one
`runtime.env_local.PROJECT.json` whose `pid` is the child's own.
File wall 6.38 s (6 of 6), under the 10 s line. `--port 0` keeps both
off any fixed port.
## Negative control (ablation)
On committed head `b3fa3f11d`, through `scripts/ablation-replace.mjs` in
wrap mode under the verify lock:
- **Mutation:** the anchor
`makeDirectorySync(path.dirname(runtimeFile));` (x1 → x0) became
`fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });` (x0 →
x1), blob `87014c3470c7` → `f238ec2b0a69`.
- **Into `dist/`:** `pnpm --filter @objectstack/cli build` exit 0; `node
scripts/ablation-dist-preflight.mjs @objectstack/cli
'dirname(runtimeFile), { recursive: true }'` exit 0, marker present in 1
built file. The door pin runs `dist/`.
- **Result:** 1 failed, 5 passed in 60.44 s: `'serve-proc' had not
settled after 60000 ms and was killed — a directory prep is looping
again.` Both serve and start CONTROLs stayed green.
- **Restore:** blob after restore == HEAD (`87014c3470c7`), `git diff
HEAD` empty. CLI rebuilt exit 0; preflight `--absent`: marker absent
from all 652 built files, working tree clean. The pin re-ran 6 of 6
green (6.52 s).
## Tests and gates
Exit codes captured before any pipe. After merging `origin/main`
(`8133cc780`) into the branch, head `fd250408f`:
- `pnpm exec turbo run build --filter='@objectstack/cli...'
--concurrency=2`: exit 0 (61 tasks, 27 cached).
- `pnpm --filter @objectstack/cli run typecheck`: exit 0. `pnpm --filter
@objectstack/runtime run typecheck`: exit 0. The pin file is in
`tsconfig.test.json`'s program (`--listFiles`) with 0 errors.
- `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2
--project unit` (the whole unit tier): **exit 0, 308 files, 4,582
tests**. It includes `src/commands/dev-database-directory.test.ts`,
`src/utils/make-directory.test.ts`, `serve-bound-port-publication`,
`serve-no-transport-announcements`, `serve-bound-port-publish-order` and
`serve-publishes-bound-port.e2e`.
- `--project integration` for
`test/uncreatable-directory-doors.integration.test.ts` and
`test/serve-runtime-state-project-key.test.ts` (the other state-file
pin): exit 0, 2 files, 18 tests.
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2
--project local src/standalone-stack-uncreatable-db-directory.test.ts
src/make-directory.test.ts` (the runtime copy's own pin, unchanged, and
the runtime walk's): exit 0, 2 files, 12 tests.
- `pnpm exec eslint --no-inline-config --max-warnings=0` over the two
changed `.ts` files: exit 0. The repo-wide run is CI's `Lint & Repo
Gates`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 67 commands, all run one by one on
`fd250408f`: 66 exit 0, and `node scripts/check-empty-changeset.mjs
--base origin/main` exit 1 there (the pending-note edit, since
restored). On `c31899a8a` all 67 are accounted for with exit 0, and CI's
`Check Changeset` is success.
- Three first ran as exit 3 (NOT MEASURED) and were re-run to a reading:
`check:type-check-debt`, whose closure build had not got the verify
lock, re-run under the lock: exit 0, `--re-measure: OK — 1 ledger
entr(ies) re-measured … none above its recorded number`. That build also
built the packages `check:dual-build-cjs-loads` (exit 0, 110 require
entry points across 68 packages load) and `check:i18n-coverage` (exit 0,
14 configs, none new) had found missing.
- `--ran` reconciliation: `67 derived famil(ies) accounted for — 67 run,
0 NOT-MEASURED`, exit 0.
- `node scripts/check-nul-bytes.mjs`: exit 0.
- Left to CI: the rest of `packages/cli`'s integration tier (no other
integration file and no spawn entry is touched), and the runtime
package's suites (the runtime is not in this diff).
## Acceptance notes
- **Seat ruling B (`6227399342`).** The pending
`.changeset/23858-uncreatable-directory-refusal.md` line "Not covered
yet: the runtime state file `os serve` writes into `OS_HOME` once it is
listening." is superseded by this PR's changeset. It is left in place
because the #17712 guard gates any edit of another PR's pending
changeset on a person's confirmation. `carrier:` the release owner, at
`changeset version` time: drop that line from the compiled entry.
- **A refused state-file write still tells nobody.** The `catch { /*
non-fatal — supervision file is best-effort */ }` around the write
prints nothing. Measured on this branch: under
`OS_HOME=/proc/os-23858-r2` and under `OS_HOME=SCRATCH/afile/sub`, where
`afile` is a regular file (`ENOTDIR`), `os serve` prints its banner at
about 4.6 s, and its output names neither the state file nor the error.
A supervisor told "ready" then gets `ENOENT` on the file. The `ENOTDIR`
case answered that way before this PR too. Before this PR the `/proc`
case hung instead. The catch's posture is not this card's; it is in the
report.
- `dev.ts` and `standalone-stack.ts` are not in this diff (see The two
folds).
- The walk now has five definitions in three packages, two semantics:
the strict one (`makeDirectorySync`, both packages) and the lenient one
(`driver-sql`'s `makeDatabaseDirectory` and the two copies above).
---
_Generated by [Claude
Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b75129f commit 570c7a2
3 files changed
Lines changed: 103 additions & 14 deletions
File tree
- .changeset
- packages/cli
- src/commands
- test
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
1094 | 1095 | | |
1095 | 1096 | | |
1096 | 1097 | | |
1097 | | - | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
1098 | 1104 | | |
1099 | 1105 | | |
1100 | 1106 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
20 | 27 | | |
21 | 28 | | |
22 | 29 | | |
23 | 30 | | |
24 | 31 | | |
25 | 32 | | |
26 | 33 | | |
27 | | - | |
28 | | - | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
29 | 38 | | |
30 | 39 | | |
31 | 40 | | |
| |||
34 | 43 | | |
35 | 44 | | |
36 | 45 | | |
37 | | - | |
38 | | - | |
| 46 | + | |
| 47 | + | |
39 | 48 | | |
40 | 49 | | |
41 | 50 | | |
42 | 51 | | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
43 | 58 | | |
44 | 59 | | |
45 | 60 | | |
46 | 61 | | |
47 | 62 | | |
48 | 63 | | |
49 | 64 | | |
50 | | - | |
| 65 | + | |
51 | 66 | | |
52 | 67 | | |
53 | 68 | | |
54 | | - | |
| 69 | + | |
55 | 70 | | |
56 | 71 | | |
57 | 72 | | |
| |||
65 | 80 | | |
66 | 81 | | |
67 | 82 | | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
68 | 89 | | |
69 | 90 | | |
70 | 91 | | |
71 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
72 | 96 | | |
73 | 97 | | |
74 | 98 | | |
75 | 99 | | |
| 100 | + | |
76 | 101 | | |
77 | 102 | | |
78 | | - | |
79 | | - | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
80 | 108 | | |
81 | 109 | | |
82 | | - | |
| 110 | + | |
83 | 111 | | |
84 | 112 | | |
85 | 113 | | |
| |||
94 | 122 | | |
95 | 123 | | |
96 | 124 | | |
97 | | - | |
98 | | - | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
99 | 135 | | |
100 | 136 | | |
101 | 137 | | |
| |||
104 | 140 | | |
105 | 141 | | |
106 | 142 | | |
107 | | - | |
| 143 | + | |
108 | 144 | | |
109 | 145 | | |
110 | 146 | | |
111 | 147 | | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
112 | 161 | | |
113 | 162 | | |
114 | 163 | | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
115 | 167 | | |
116 | 168 | | |
117 | 169 | | |
| 170 | + | |
118 | 171 | | |
119 | 172 | | |
120 | 173 | | |
121 | 174 | | |
122 | 175 | | |
| 176 | + | |
123 | 177 | | |
124 | 178 | | |
125 | 179 | | |
| |||
169 | 223 | | |
170 | 224 | | |
171 | 225 | | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
172 | 240 | | |
0 commit comments