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. 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'); }); });