Skip to content

Commit 7715d04

Browse files
fix(cli): os serve says once when it cannot write its runtime state file, naming the file and the OS error (#24234)
Fixes #24091 Clause-②: no When `os serve` cannot write its runtime state file (`runtime.ENV.PROJECT.json` in `OS_HOME`), it now says so once, on stderr, naming the file and the operating system's own error. The boot still goes on: the file stays best-effort. ## What was wrong `runtimeBoundPortChannels().writeRuntimeState` in `packages/cli/src/commands/serve.ts` ended in `catch { /* non-fatal — supervision file is best-effort */ }` (`:1113` at base `b9ac35cbf`). That catch printed nothing at any level. The doc of `BoundPortChannels.writeRuntimeState` (`:887`) says the file is "the file both of them send a consumer to". So under an uncreatable `OS_HOME` the banner and the IPC message announced a file that was not there, and nothing said why. ## What changed - `serve.ts`, the `writeRuntimeState` catch: one `console.warn(chalk.yellow(...))` line. It names the file it tried to write, carries the thrown error's own `message` verbatim (for example `ENOTDIR: not a directory, stat 'HOME'`), says the server runs on without the file, and names the remedy (point `OS_HOME` at a writable directory). When the path itself could not be resolved, the line says that instead of a file name. Nothing else in the leg moves: the same `try`, the same write, the same `exit` cleanup. - The doc of `BoundPortChannels.writeRuntimeState` gains one sentence (best-effort; the real channel says so once), and the doc of `runtimeBoundPortChannels` no longer says the failure handling is "unchanged". - `packages/cli/test/uncreatable-directory-doors.integration.test.ts`: a seventh concurrent child and three new assertions (below). - `.changeset/24091-serve-runtime-state-write-warning.md`: `@objectstack/cli` patch, `Clause-②: no`. Landing point: the card's expected one, `writeRuntimeState` itself. It is the producer: the only code that knows the write failed. ## The PM's four mechanism hypotheses, measured - **H1 (the silent catch): confirmed** at `b9ac35cbf`, `serve.ts:1113` as quoted above, and the interface doc at `:887`. - **H2 (re-measure both inputs at base): confirmed.** Built CLI at `b9ac35cbf`, Node v22.22.0, a scratch project outside the repo with `export default {};`, `OS_DATABASE_URL=:memory:`, `--port 0`, each run in its own process group, killed on the banner tail: | `OS_HOME` | base `b9ac35cbf` | this branch (`3770601b3`) | |:--|:--|:--| | `/proc/os-24091-x` | `Press Ctrl+C to stop` at 2.9 s; 0 lines name the state file or an error code | banner tail at 3.1 s; 1 line: `⚠ Runtime state file not written: /proc/os-24091-fix/runtime.env_local.proj-4d4278c111ec.json — ENOENT: no such file or directory, mkdir '/proc/os-24091-fix'. The server runs on …` | | `SCRATCH/afile/sub`, `afile` a regular file | banner tail at 3.2 s; 0 such lines | banner tail at 3.0 s; 1 line: `… runtime.env_local.proj-4d4278c111ec.json — ENOTDIR: not a directory, stat 'SCRATCH/afile/sub'. …` | (The base probe's one regex hit in both runs was the plugin-list row `com.objectstack.runtime.default-datasource`, not the state file.) The `/proc` case no longer hangs at base: PR #24088 (`570c7a2d9`) bounded that walk. - **H3 (the stream): stderr, through `console.warn`**, the channel `serve.ts`'s other boot warnings use (`console.warn(chalk.yellow(' ⚠ …'))`, 26 other sites in the file). `run()` forwards stdout to stderr for the whole process (`redirectStdoutToStderr`, #7915), and `console.warn` writes stderr directly, so the line never reaches the stdio MCP transport's stdout. Measured on both doors above: **0 bytes on stdout**. The line lands among the other `⚠` boot warnings, right above `⚠ Server is ready` / the banner. The quiet window is already closed there (`restoreOutput()` runs before the publish), and it only ever intercepted stdout, `console.log` and `console.debug`. - **H4 ("says so once"): structurally once.** `writeRuntimeState` is reached only through `publishBoundPort`, and `run()` calls `publishBoundPort` at exactly one site, once per boot. `serve-bound-port-publication.test.ts` already pins that count: the comment-masked source holds exactly two `publishBoundPort(` occurrences, the definition and the one call. There is no rebind path that re-publishes; an `os dev` restart is a new process and a new boot. So no once-guard was added. ## Pin `packages/cli/test/uncreatable-directory-doors.integration.test.ts`, the file PR #24088 added the serve legs to. It already spawns `bin/run.js` and is already one of `check-cli-test-child-env.mjs`'s thirteen built-entrypoint files, so **the pin did not join that population** and its count label is untouched. - New leg 7, `serve-notdir`, runs on every platform: `os serve --port 0` under `OS_HOME=WORKDIR/serve-home-file/sub`, where `serve-home-file` is a regular file. It must reach `Press Ctrl+C to stop`, and exactly one output line must name a file under that home with the `runtime.env_local.` prefix, and that line must contain `ENOTDIR`. - Leg 5 (`/proc`, Linux only): beside the banner it already asserted, exactly one line names the state file, with `ENOENT`. - Leg 6 (CONTROL, creatable home): no output line names the state file. Assertions are the named subject (the file's path) and the OS code, never the warning's prose. File wall: 6.29 s for 7 of 7 on the final head (6.30 s before the merge), under the 10 s line. ## Negative control (ablation) On committed head `3770601b3`, through `scripts/ablation-replace.mjs` in wrap mode under the verify lock: - **Mutation:** anchor `console.warn(chalk.yellow(` plus the next line (x1 → x0) became `String(chalk.yellow(` (x0 → x1), so the line is built and never printed. Blob `1e7eca72a6ad` → `4c06596388ab`. - **Into `dist/`:** `pnpm --filter @objectstack/cli build` exit 0; `node scripts/ablation-dist-preflight.mjs @objectstack/cli 'String(chalk.yellow('` exit 0, marker present in 1 built file (absent from `dist/` before the mutation). The pin runs `bin/run.js`, which resolves `dist/`. - **Result:** 2 failed, 5 passed: leg 5 and leg 7 each `expected [] to have a length of 1 but got +0`. Both banner assertions and the CONTROL legs stayed green, so the boot went on either way and only the line is missing. - **Restore:** blob after restore == HEAD (`1e7eca72a6ad`), `git diff HEAD` empty. CLI rebuilt exit 0; preflight `--absent`: marker absent from all 652 built files, working tree clean. The pin re-ran 7 of 7 green. ## Tests and gates Exit codes captured before any pipe. Final head `4fd5c3533` (the branch merged with `origin/main` `d3d064447` before this PR opened): - `pnpm --workspace-concurrency=2 --filter '@objectstack/cli...' build`: exit 0. - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit` (the whole unit tier): **exit 0, 310 files, 4,602 tests**, on `4fd5c3533`. - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project integration test/uncreatable-directory-doors.integration.test.ts`: exit 0, 7 of 7, on `4fd5c3533`. The diff touches this integration file, so it ran here; the rest of the integration tier is CI's. - `pnpm --filter @objectstack/cli run typecheck`: exit 0 (`tsc --noEmit`, then `check:test-typecheck`: OK, debt unchanged). The pin file is under `test/`, which `tsconfig.test.json` includes. - `pnpm exec eslint --no-inline-config --max-warnings=0` over the two changed `.ts` files: exit 0, no output. The repo-wide run is CI's `Lint & Repo Gates`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands on `4fd5c3533`; all 67 ran one by one. 65 exited 0 at once. `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET: packages with no `dist/` in this worktree); after `turbo run build --filter='!@objectstack/docs'` (75 of 76 cached) both re-ran to exit 0 (`111/69/723/1` entries/packages/cjsFiles/probes above the floors; `OK (14 config(s), 621 baselined untranslated string(s), none new)`). - `--ran` reconciliation: `67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED`, exit 0. - `pnpm check:cli-test-child-env`: exit 0, 17 spawns of the built CLI, population unchanged. `pnpm check:nul-bytes`: exit 0. - The derivation warned that `origin/main` had moved one commit past the merge (`6e43b7bc3`), changing only `scripts/check-route-envelope.mjs`; that one family ran its pre-move copy here, and CI runs the current one. ## Acceptance notes - The warning is `warn`, not `error`, by AGENTS.md's degradation-level question: the missing file is a functional degradation a supervisor finds out about on its next read, not a durability claim; `check:durability-log-level` does not cover this catch. - The IPC leg's `catch { /* IPC channel closed — best-effort */ }` stays silent: a closed channel is the ordinary `os serve` case, not a failure. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d702b18 commit 7715d04

3 files changed

Lines changed: 90 additions & 17 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os serve` says once when it cannot write its runtime state file, naming the file and the operating system's error, and goes on booting
6+
7+
Clause-②: no
8+
9+
- **Before:** when `runtime.<environment>.<project>.json` could not be written — an `OS_HOME` under `/proc`, or one below a regular file (`ENOTDIR`) — the write's catch printed nothing. The boot printed its ready banner, an `os dev` parent got its `objectstack:listening` message, and a supervisor that then read the file got `ENOENT` with no reason anywhere.
10+
- **Now:** one warning on stderr, among the other boot warnings and before the banner, names the file it could not write and carries the operating system's error verbatim (for example `ENOTDIR: not a directory, stat '…'`). It says the server runs on without the file and that `OS_HOME` must point at a directory the process can write.
11+
- **Unchanged:** the file stays best-effort, so the boot never fails on it; its name, its payload and its removal on exit; the banner and the IPC message; stdout, which stays the stdio MCP transport's alone.

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,8 @@ export interface BoundPortChannels {
885885
/**
886886
* Writes `runtime.<environment>.<project>.json`. ⛔ Must COMPLETE before
887887
* either announcement below: it is the file both of them send a consumer to.
888+
* Best-effort: when it cannot be written the boot goes on, and the real
889+
* channel says so once, naming the file (#24091).
888890
*/
889891
writeRuntimeState: (published: { port: number; url: string }) => void;
890892
/**
@@ -1074,16 +1076,20 @@ export function runtimeStateFileName(environmentId: string, servedAppRoot: strin
10741076
}
10751077

10761078
/**
1077-
* The real channels: the same three writes this command has always done, with
1078-
* their failure handling unchanged.
1079+
* The real channels: the same three writes this command has always done.
10791080
*
10801081
* Each leg keeps its OWN `try` — a boot must not die because a supervision file
10811082
* could not be written or because an IPC channel had already closed, and one
1082-
* leg failing must not cost the other two. Only the ORDER changed (commit faff497fd).
1083+
* leg failing must not cost the other two. The ORDER changed (commit faff497fd),
1084+
* and a state file that could not be written is now said once on stderr
1085+
* (#24091); a closed IPC channel is still the ordinary `os serve` case, silent.
10831086
*/
10841087
export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChannels {
10851088
return {
10861089
writeRuntimeState: ({ port, url }) => {
1090+
// Named the moment it is known, so the catch below can say WHICH file
1091+
// was not written (#24091).
1092+
let attemptedFile: string | undefined;
10871093
try {
10881094
const environmentId = process.env.OS_ENVIRONMENT_ID ?? 'env_local';
10891095
// Keyed by the SERVED APP'S ROOT as well as the environment, so two
@@ -1095,11 +1101,12 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan
10951101
resolveObjectStackHome(),
10961102
runtimeStateFileName(environmentId, servedAppRootOrCwd()),
10971103
);
1104+
attemptedFile = runtimeFile;
10981105
// Bounded (#23858): an `OS_HOME` that can never be made — a name under
1099-
// `/proc` — is refused here at once, and the best-effort catch below
1100-
// lets the boot go on without the file. Node's recursive mkdir looped
1101-
// on it after the bind and before the banner, holding the event loop,
1102-
// so the server never announced itself.
1106+
// `/proc` — is refused here at once, and the catch below lets the boot
1107+
// go on without the file, saying so. Node's recursive mkdir looped on
1108+
// it after the bind and before the banner, holding the event loop, so
1109+
// the server never announced itself.
11031110
makeDirectorySync(path.dirname(runtimeFile));
11041111
fs.writeFileSync(runtimeFile, JSON.stringify({
11051112
pid: process.pid,
@@ -1110,7 +1117,25 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan
11101117
}, null, 2));
11111118
const cleanupRuntimeFile = () => { try { fs.rmSync(runtimeFile, { force: true }); } catch { /* noop */ } };
11121119
process.on('exit', cleanupRuntimeFile);
1113-
} catch { /* non-fatal — supervision file is best-effort */ }
1120+
} catch (error) {
1121+
// Best-effort, NOT silent (#24091). The file stays optional — a boot
1122+
// must not die for a supervision file — but both announcements that
1123+
// follow (the banner, the IPC message) send a consumer to it, so a
1124+
// refusal swallowed here told a supervisor "ready" and then answered
1125+
// its read with nothing. One line, naming the file and the operating
1126+
// system's own error, carried verbatim rather than paraphrased.
1127+
//
1128+
// Said ONCE: `run()` publishes the bound port at exactly one site, so
1129+
// this leg runs once per boot. Channel: `console.warn`, i.e. stderr,
1130+
// like every other boot warning in this file — stdout belongs to the
1131+
// stdio MCP transport's JSON-RPC frames (#7915).
1132+
const thrown = error instanceof Error ? error.message : String(error);
1133+
console.warn(chalk.yellow(
1134+
` ⚠ Runtime state file not written: ${attemptedFile ?? '(its path could not be resolved)'} — ${thrown}. ` +
1135+
'The server runs on without it, so a supervisor that reads it will not find this process there; ' +
1136+
'point OS_HOME at a directory this process can write.',
1137+
));
1138+
}
11141139
},
11151140
announceListening: (message) => {
11161141
try {

‎packages/cli/test/uncreatable-directory-doors.integration.test.ts‎

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* `os start` and `os create` refuse a directory that can never be made at
55
* once, naming the path and the operating system's code, instead of hanging
66
* in Node's recursive mkdir (#23858). `os serve` stops hanging on its runtime
7-
* state file's directory the same way.
7+
* state file's directory the same way, and goes on to its banner without the
8+
* file — saying so once, naming the file and the error (#24091).
89
*
910
* ## The defect, measured on `origin/main` 0744f94366 (built CLI, Node v22.22.0)
1011
*
@@ -31,10 +32,10 @@
3132
* group with a hard SIGKILL of the whole group at `CHILD_BOUND_MS`, so a
3233
* reintroduced loop fails this file at the bound instead of hanging the suite.
3334
* `bin/run.js` with `NODE_ENV` unset is the entry an operator runs, and it
34-
* starts in about 3 s where the tsx source entry takes about 10 s, so the six
35-
* children (run concurrently) keep this file under the 10 s line. The two
35+
* starts in about 3 s where the tsx source entry takes about 10 s, so the seven
36+
* children (run concurrently) keep this file under the 10 s line. The three
3637
* `serve` children never exit by themselves: each is killed whole as soon as
37-
* its banner tail is out, and `--port 0` keeps both off any fixed port.
38+
* its banner tail is out, and `--port 0` keeps them off any fixed port.
3839
*
3940
* ## What is asserted — kind, exit code and named subject, never the prose
4041
*
@@ -50,14 +51,21 @@
5051
* 4. CONTROL: `os create plugin --dir <tmp>/a/b/foo` exits 0 and writes the
5152
* project.
5253
* 5. `os serve` under `OS_HOME=/proc/<missing>`: prints its banner tail
53-
* inside the bound. The state file cannot be written there, and the
54-
* write's best-effort catch lets the boot go on without it.
54+
* inside the bound. The state file cannot be written there; the boot
55+
* goes on without it, and exactly one output line names that file, with
56+
* `ENOENT`.
5557
* 6. CONTROL: `os serve` under a creatable `OS_HOME` three levels deep
5658
* prints its banner tail, and by then that home holds one
57-
* `runtime.env_local.<project>.json` naming the child's own pid.
59+
* `runtime.env_local.<project>.json` naming the child's own pid. No
60+
* output line names the file: a written file is not reported.
61+
* 7. `os serve` under an `OS_HOME` whose parent is a regular file, the
62+
* portable uncreatable home (#24091): prints its banner tail, and exactly
63+
* one output line names the state file it could not write, with
64+
* `ENOTDIR`. Before #24091 the write's catch printed nothing, so a
65+
* supervisor told "ready" met a missing file and no reason.
5866
*
59-
* They need a `/proc` that refuses `mkdir` with ENOENT, so the `/proc` legs
60-
* run where `/proc/self` exists (Linux, which CI is).
67+
* Legs 1, 3 and 5 need a `/proc` that refuses `mkdir` with ENOENT, so they run
68+
* where `/proc/self` exists (Linux, which CI is). Leg 7 runs everywhere.
6169
*/
6270

6371
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -86,6 +94,16 @@ const BANNER_TAIL = 'Press Ctrl+C to stop';
8694
/** The platform with no application — the cheapest project that reaches a banner. */
8795
const BARE_CONFIG = 'export default {};\n';
8896

97+
/**
98+
* The output lines that name a runtime state file under `home`. The file's
99+
* project half is a digest of the served root, so a line is matched on the
100+
* home-plus-`runtime.env_local.` prefix, never on a recomputed name.
101+
*/
102+
function linesNamingStateFile(output: string, home: string): string[] {
103+
const prefix = join(home, 'runtime.env_local.');
104+
return output.split('\n').filter((line) => line.includes(prefix));
105+
}
106+
89107
interface Run {
90108
code: number | null;
91109
signal: NodeJS.Signals | null;
@@ -164,10 +182,13 @@ beforeAll(async () => {
164182
serveDir = join(workdir, 'serve-project');
165183
mkdirSync(serveDir);
166184
writeFileSync(join(serveDir, 'objectstack.config.ts'), BARE_CONFIG);
185+
// A regular file where the home's parent directory would be: `ENOTDIR`.
186+
writeFileSync(join(workdir, 'serve-home-file'), '');
167187
const legs: Array<[string, Promise<Run>]> = [
168188
['start-control', runCli(['start', '-p', UNSERVABLE_PORT], { OS_HOME: join(workdir, 'home', 'a', 'b', 'c') })],
169189
['create-control', runCli(['create', 'plugin', 'foo', '--dir', join(workdir, 'create', 'a', 'b', 'foo')], {})],
170190
['serve-control', runServe(join(workdir, 'serve-home', 'a', 'b', 'c'))],
191+
['serve-notdir', runServe(join(workdir, 'serve-home-file', 'sub'))],
171192
];
172193
if (RUNS_PROC) {
173194
legs.push(
@@ -227,6 +248,9 @@ describe('a directory that can never be made is refused at the door, at once (#2
227248
it.runIf(RUNS_PROC)('os serve under OS_HOME=/proc/<missing> prints its banner instead of looping on the state file', () => {
228249
const run = runOf('serve-proc');
229250
expect(run.reached, run.output).toBe(true);
251+
const named = linesNamingStateFile(run.output, `${PROC_TOP}-sv`);
252+
expect(named, run.output).toHaveLength(1);
253+
expect(named[0]).toContain('ENOENT');
230254
});
231255

232256
it('CONTROL: os serve under a creatable OS_HOME three levels deep writes its state file before the banner', () => {
@@ -236,5 +260,18 @@ describe('a directory that can never be made is refused at the door, at once (#2
236260
const files = readdirSync(home).filter((name) => /^runtime\.env_local\..+\.json$/.test(name));
237261
expect(files, `entries in ${home}: ${readdirSync(home).join(', ')}`).toHaveLength(1);
238262
expect(JSON.parse(readFileSync(join(home, files[0]), 'utf8')).pid).toBe(run.pid);
263+
expect(linesNamingStateFile(run.output, home), run.output).toEqual([]);
264+
});
265+
});
266+
267+
describe('a runtime state file that cannot be written is said once, and the boot goes on (#24091)', () => {
268+
it('os serve under an OS_HOME below a regular file reaches its banner, and one line names the file and ENOTDIR', () => {
269+
const run = runOf('serve-notdir');
270+
const home = join(workdir, 'serve-home-file', 'sub');
271+
expect(run.reached, run.output).toBe(true);
272+
const named = linesNamingStateFile(run.output, home);
273+
expect(named, run.output).toHaveLength(1);
274+
expect(named[0]).toMatch(/runtime\.env_local\.\S+\.json/);
275+
expect(named[0]).toContain('ENOTDIR');
239276
});
240277
});

0 commit comments

Comments
 (0)