From 3770601b3b234dbd3cc5c465af4e4b50a80302c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:30:09 +0000 Subject: [PATCH 1/2] fix(cli): os serve says once when its runtime state file cannot be written The write's catch swallowed every refusal, so under an uncreatable OS_HOME the boot printed its ready banner while the state file both announcements name was missing, and nothing said why. The catch now prints one stderr warning naming the file and the operating system's own error; the boot still goes on, since the file stays best-effort. The uncreatable-directory doors pin gains a portable ENOTDIR serve leg and asserts the /proc leg's one ENOENT line and the control leg's silence. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- packages/cli/src/commands/serve.ts | 41 +++++++++++--- ...atable-directory-doors.integration.test.ts | 55 ++++++++++++++++--- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index cd1aa3d2935..1e7eca72a6a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -885,6 +885,8 @@ export interface BoundPortChannels { /** * Writes `runtime...json`. ⛔ Must COMPLETE before * either announcement below: it is the file both of them send a consumer to. + * Best-effort: when it cannot be written the boot goes on, and the real + * channel says so once, naming the file (#24091). */ writeRuntimeState: (published: { port: number; url: string }) => void; /** @@ -1074,16 +1076,20 @@ export function runtimeStateFileName(environmentId: string, servedAppRoot: strin } /** - * The real channels: the same three writes this command has always done, with - * their failure handling unchanged. + * The real channels: the same three writes this command has always done. * * Each leg keeps its OWN `try` — a boot must not die because a supervision file * could not be written or because an IPC channel had already closed, and one - * leg failing must not cost the other two. Only the ORDER changed (commit faff497fd). + * leg failing must not cost the other two. The ORDER changed (commit faff497fd), + * and a state file that could not be written is now said once on stderr + * (#24091); a closed IPC channel is still the ordinary `os serve` case, silent. */ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChannels { return { writeRuntimeState: ({ port, url }) => { + // Named the moment it is known, so the catch below can say WHICH file + // was not written (#24091). + let attemptedFile: string | undefined; try { const environmentId = process.env.OS_ENVIRONMENT_ID ?? 'env_local'; // Keyed by the SERVED APP'S ROOT as well as the environment, so two @@ -1095,11 +1101,12 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan resolveObjectStackHome(), runtimeStateFileName(environmentId, servedAppRootOrCwd()), ); + attemptedFile = runtimeFile; // Bounded (#23858): an `OS_HOME` that can never be made — a name under - // `/proc` — is refused here at once, and the best-effort catch below - // lets the boot go on without the file. Node's recursive mkdir looped - // on it after the bind and before the banner, holding the event loop, - // so the server never announced itself. + // `/proc` — is refused here at once, and the catch below lets the boot + // go on without the file, saying so. Node's recursive mkdir looped on + // it after the bind and before the banner, holding the event loop, so + // the server never announced itself. makeDirectorySync(path.dirname(runtimeFile)); fs.writeFileSync(runtimeFile, JSON.stringify({ pid: process.pid, @@ -1110,7 +1117,25 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan }, null, 2)); const cleanupRuntimeFile = () => { try { fs.rmSync(runtimeFile, { force: true }); } catch { /* noop */ } }; process.on('exit', cleanupRuntimeFile); - } catch { /* non-fatal — supervision file is best-effort */ } + } catch (error) { + // Best-effort, NOT silent (#24091). The file stays optional — a boot + // must not die for a supervision file — but both announcements that + // follow (the banner, the IPC message) send a consumer to it, so a + // refusal swallowed here told a supervisor "ready" and then answered + // its read with nothing. One line, naming the file and the operating + // system's own error, carried verbatim rather than paraphrased. + // + // Said ONCE: `run()` publishes the bound port at exactly one site, so + // this leg runs once per boot. Channel: `console.warn`, i.e. stderr, + // like every other boot warning in this file — stdout belongs to the + // stdio MCP transport's JSON-RPC frames (#7915). + const thrown = error instanceof Error ? error.message : String(error); + console.warn(chalk.yellow( + ` ⚠ Runtime state file not written: ${attemptedFile ?? '(its path could not be resolved)'} — ${thrown}. ` + + 'The server runs on without it, so a supervisor that reads it will not find this process there; ' + + 'point OS_HOME at a directory this process can write.', + )); + } }, announceListening: (message) => { try { diff --git a/packages/cli/test/uncreatable-directory-doors.integration.test.ts b/packages/cli/test/uncreatable-directory-doors.integration.test.ts index f9ce570ab9e..937dba37988 100644 --- a/packages/cli/test/uncreatable-directory-doors.integration.test.ts +++ b/packages/cli/test/uncreatable-directory-doors.integration.test.ts @@ -4,7 +4,8 @@ * `os start` and `os create` refuse a directory that can never be made at * once, naming the path and the operating system's code, instead of hanging * in Node's recursive mkdir (#23858). `os serve` stops hanging on its runtime - * state file's directory the same way. + * state file's directory the same way, and goes on to its banner without the + * file — saying so once, naming the file and the error (#24091). * * ## The defect, measured on `origin/main` 0744f94366 (built CLI, Node v22.22.0) * @@ -31,10 +32,10 @@ * group with a hard SIGKILL of the whole group at `CHILD_BOUND_MS`, so a * reintroduced loop fails this file at the bound instead of hanging the suite. * `bin/run.js` with `NODE_ENV` unset is the entry an operator runs, and it - * starts in about 3 s where the tsx source entry takes about 10 s, so the six - * children (run concurrently) keep this file under the 10 s line. The two + * starts in about 3 s where the tsx source entry takes about 10 s, so the seven + * children (run concurrently) keep this file under the 10 s line. The three * `serve` children never exit by themselves: each is killed whole as soon as - * its banner tail is out, and `--port 0` keeps both off any fixed port. + * its banner tail is out, and `--port 0` keeps them off any fixed port. * * ## What is asserted — kind, exit code and named subject, never the prose * @@ -50,14 +51,21 @@ * 4. CONTROL: `os create plugin --dir /a/b/foo` exits 0 and writes the * project. * 5. `os serve` under `OS_HOME=/proc/`: prints its banner tail - * inside the bound. The state file cannot be written there, and the - * write's best-effort catch lets the boot go on without it. + * inside the bound. The state file cannot be written there; the boot + * goes on without it, and exactly one output line names that file, with + * `ENOENT`. * 6. CONTROL: `os serve` under a creatable `OS_HOME` three levels deep * prints its banner tail, and by then that home holds one - * `runtime.env_local..json` naming the child's own pid. + * `runtime.env_local..json` naming the child's own pid. No + * output line names the file: a written file is not reported. + * 7. `os serve` under an `OS_HOME` whose parent is a regular file, the + * portable uncreatable home (#24091): prints its banner tail, and exactly + * one output line names the state file it could not write, with + * `ENOTDIR`. Before #24091 the write's catch printed nothing, so a + * supervisor told "ready" met a missing file and no reason. * - * They need a `/proc` that refuses `mkdir` with ENOENT, so the `/proc` legs - * run where `/proc/self` exists (Linux, which CI is). + * Legs 1, 3 and 5 need a `/proc` that refuses `mkdir` with ENOENT, so they run + * where `/proc/self` exists (Linux, which CI is). Leg 7 runs everywhere. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -86,6 +94,16 @@ const BANNER_TAIL = 'Press Ctrl+C to stop'; /** The platform with no application — the cheapest project that reaches a banner. */ const BARE_CONFIG = 'export default {};\n'; +/** + * The output lines that name a runtime state file under `home`. The file's + * project half is a digest of the served root, so a line is matched on the + * home-plus-`runtime.env_local.` prefix, never on a recomputed name. + */ +function linesNamingStateFile(output: string, home: string): string[] { + const prefix = join(home, 'runtime.env_local.'); + return output.split('\n').filter((line) => line.includes(prefix)); +} + interface Run { code: number | null; signal: NodeJS.Signals | null; @@ -164,10 +182,13 @@ beforeAll(async () => { serveDir = join(workdir, 'serve-project'); mkdirSync(serveDir); writeFileSync(join(serveDir, 'objectstack.config.ts'), BARE_CONFIG); + // A regular file where the home's parent directory would be: `ENOTDIR`. + writeFileSync(join(workdir, 'serve-home-file'), ''); const legs: Array<[string, Promise]> = [ ['start-control', runCli(['start', '-p', UNSERVABLE_PORT], { OS_HOME: join(workdir, 'home', 'a', 'b', 'c') })], ['create-control', runCli(['create', 'plugin', 'foo', '--dir', join(workdir, 'create', 'a', 'b', 'foo')], {})], ['serve-control', runServe(join(workdir, 'serve-home', 'a', 'b', 'c'))], + ['serve-notdir', runServe(join(workdir, 'serve-home-file', 'sub'))], ]; if (RUNS_PROC) { legs.push( @@ -227,6 +248,9 @@ describe('a directory that can never be made is refused at the door, at once (#2 it.runIf(RUNS_PROC)('os serve under OS_HOME=/proc/ prints its banner instead of looping on the state file', () => { const run = runOf('serve-proc'); expect(run.reached, run.output).toBe(true); + const named = linesNamingStateFile(run.output, `${PROC_TOP}-sv`); + expect(named, run.output).toHaveLength(1); + expect(named[0]).toContain('ENOENT'); }); 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 const files = readdirSync(home).filter((name) => /^runtime\.env_local\..+\.json$/.test(name)); expect(files, `entries in ${home}: ${readdirSync(home).join(', ')}`).toHaveLength(1); expect(JSON.parse(readFileSync(join(home, files[0]), 'utf8')).pid).toBe(run.pid); + expect(linesNamingStateFile(run.output, home), run.output).toEqual([]); + }); +}); + +describe('a runtime state file that cannot be written is said once, and the boot goes on (#24091)', () => { + it('os serve under an OS_HOME below a regular file reaches its banner, and one line names the file and ENOTDIR', () => { + const run = runOf('serve-notdir'); + const home = join(workdir, 'serve-home-file', 'sub'); + expect(run.reached, run.output).toBe(true); + const named = linesNamingStateFile(run.output, home); + expect(named, run.output).toHaveLength(1); + expect(named[0]).toMatch(/runtime\.env_local\.\S+\.json/); + expect(named[0]).toContain('ENOTDIR'); }); }); From c15bf91fd67a058d1b51b17ea4102fe200870d9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:44:20 +0000 Subject: [PATCH 2/2] chore(changeset): cli patch for the runtime state file write warning Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/24091-serve-runtime-state-write-warning.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/24091-serve-runtime-state-write-warning.md diff --git a/.changeset/24091-serve-runtime-state-write-warning.md b/.changeset/24091-serve-runtime-state-write-warning.md new file mode 100644 index 00000000000..50c71b3a2c2 --- /dev/null +++ b/.changeset/24091-serve-runtime-state-write-warning.md @@ -0,0 +1,11 @@ +--- +'@objectstack/cli': patch +--- + +`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 + +Clause-②: no + +- **Before:** when `runtime...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. +- **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. +- **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.