Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/24091-serve-runtime-state-write-warning.md
Original file line number Diff line number Diff line change
@@ -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.<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.
- **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.
41 changes: 33 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,8 @@ export interface BoundPortChannels {
/**
* Writes `runtime.<environment>.<project>.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;
/**
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
55 changes: 46 additions & 9 deletions packages/cli/test/uncreatable-directory-doors.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand All @@ -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
*
Expand All @@ -50,14 +51,21 @@
* 4. CONTROL: `os create plugin --dir <tmp>/a/b/foo` exits 0 and writes the
* project.
* 5. `os serve` under `OS_HOME=/proc/<missing>`: 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.<project>.json` naming the child's own pid.
* `runtime.env_local.<project>.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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Run>]> = [
['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(
Expand Down Expand Up @@ -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/<missing> 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', () => {
Expand All @@ -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');
});
});
Loading