Skip to content

Commit 7164587

Browse files
fix(cli): os start forwards SIGTERM/SIGINT to its serve child and reaps it on exit (#21161)
Fixes #21114 Clause-②: no ## What changed `os start` now supervises its `serve` child through `ServeRestartCoordinator`, the forwarding mechanism `os dev` already runs, used as is (`packages/cli/src/commands/start.ts`): - SIGTERM or SIGINT sent to the `start` process is forwarded to the child (`beginShutdown`). The child's exit then ends the parent with the child's exit code. - Whatever else ends the parent, the child is sent SIGTERM on the way out (`killChildOnParentExit` on `process.on('exit')`). - A child that exits on its own still ends the parent with `code ?? 0`, which is the one handler `start` had before. Also in this PR: the pin `packages/cli/test/start-signal-forwarding.e2e.test.ts` and `.changeset/21114-start-signal-forwarding.md` (`@objectstack/cli` patch). ## Why the coordinator is used as is, and nothing is extracted Triage asked for one mechanism, "extracted to a shared helper if needed". It is not needed here: - `start` has no restart semantics, so it never calls `requestRestart`. The other paths are what `start` needs: `start()`, `beginShutdown`, `killChildOnParentExit`, and the child-exit path (`state === 'running'` ends the parent with `code ?? 0`). - The class's restart-only messages cannot print from `start`. They are gated on `spawnCount > 1` or are inside `requestRestart`. The spawn-failure message reads `failed to start server`, because `restartIndex` is 0. - `dev-restart.ts` and `dev.ts` are unchanged, and the coordinator's own unit tests (`src/utils/dev-restart.test.ts`) already cover `beginShutdown` and `killChildOnParentExit`. The only text the two commands now share is the three-line subscription (`process.on` for `SIGINT`, `SIGTERM` and `exit`). It stays at each call site, as the dispatch directed for the no-extraction route. What a signal *does* lives in the coordinator alone. ## Measured, before and after Run on `examples/app-showcase` with the built entry: `node packages/cli/bin/run.js start -p PORT --no-ui &` (control: `dev -p PORT --no-watch &`). The signal went to the parent's pid alone, on a random high port. The before tree is `origin/main` `7a606a9a34`; the after tree is `20eaab626f`, where `start.ts` is byte-identical to this head. | command | signal | tree | parent exit (shell) | `serve` child after | port after | `/api/v1/health` after | |---|---|---|---|---|---|---| | `os start` | SIGTERM | before | 143 | alive, PPID 1 | bound | 200 | | `os start` | SIGINT | before | 130 | alive, PPID 1 | bound | 200 | | `os start` | SIGTERM | after | 0 | gone | free | no answer | | `os start` | SIGINT | after | 0 | gone | free | no answer | | `os dev` (control) | SIGTERM | before | 0 | gone | free | no answer | | `os dev` (control) | SIGINT | before | 0 | gone | free | no answer | | `os dev` (control) | SIGTERM | after | 0 | gone | free | no answer | | `os dev` (control) | SIGINT | after | 0 | gone | free | no answer | On the before tree the orphans had to be killed by their own pid. On the after tree every recorded pid was gone without help. The Ctrl-C shape was measured on the after tree too (SIGINT to the whole process group). For both `start` and `dev`, the parent and the child were gone and the port was free. Both logged one `Shutdown already in progress, ignoring SIGINT` line, because the child receives the signal from the group and again from the forward. The changeset states this. ## The pin `test/start-signal-forwarding.e2e.test.ts` boots `os start` and `os dev` on the same minimal artifact. For SIGTERM and for SIGINT it signals the parent pid alone, never the group, and asserts that the `serve` child is gone and the port is free. Both readings have a positive control on the same boot before the signal: exactly one `serve` child is seen alive, and the port reads bound. - **Entry.** The pin spawns `bin/run-dev.js` under the tsx loader, passed as `--import`, so the spawned pid is the CLI parent itself. The built entry would have added a seventh spawner to the six-file population that `check:cli-test-child-env` pins. The subject is the supervisor wiring in `src/commands/start.ts`, which both entries run. The built entry is covered by the hand measurement above. - **Child selection, measured.** On a cold tsx transform cache the loader runs an `esbuild --service` process as a second child of the CLI parent. That process outlives the parent by a moment: it was alive at the parent's exit and gone 2 s later, in 2 of 2 probes with `TSX_DISABLE_CACHE=1`. One early run of the pin went red on exactly that. The probe now selects the child whose argv carries the `serve` token, and the pin is green with the cache forced cold. - **Tier.** The `.e2e` name puts the pin in the nightly tier (`OS_TEST_TIERS=nightly`), next to `start-port-banner-agreement.e2e.test.ts`. It does not run in this PR's CI. Each run boots 4 kernels, about 16 s each on a shared box. **Ablation** (source mode: the pin reads `src/`, so no build leg). The fix was committed first. `scripts/ablation-replace.mjs` replaced the three `process.on` lines with a planted marker statement and confirmed the change on disk (anchor 1 to 0, blob `b6d246505be6` to `1055d17fdbeb`). It ran the pin, then restored the file (blob back to `b6d246505be6`, `git diff HEAD` empty, porcelain empty). Results at head `4d2d7f9bc6`: - ablated: `os start` SIGTERM and SIGINT both red on both readings (`left its serve child running`, `left port N bound`); `os dev` control green on both signals; 2 failed, 2 passed; - restored: 4 passed. An earlier built-entry version of the pin was ablated the same way on `dist/`, with `scripts/ablation-dist-preflight.mjs` confirming the marker present and then absent. It gave the same direction on `b299389161` and on the merged `a096821511`. ## Verification All readings below were taken at head `4d2d7f9bc6` unless another commit is named. This branch merged `origin/main` `c6954d6d09` before the last commits, and the build state was refreshed after the merge. - **Pin**, nightly tier, run locally with `OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --project integration test/start-signal-forwarding.e2e.test.ts`: 4 passed. With the transform cache forced cold (`TSX_DISABLE_CACHE=1`): 4 passed. Ablated: 2 failed, 2 passed. Restored: 4 passed. - **Typecheck.** `pnpm --filter @objectstack/cli typecheck` exited 0 (`check:test-typecheck: OK`). - **`unit` tier.** `pnpm --filter @objectstack/cli exec vitest run --project unit`: 242 files and 3432 tests passed at `a096821511`. Since then only the e2e pin changed, and it is outside the `unit` project. At `4d2d7f9bc6`, `test/vitest-tiers-partition.test.ts`, `src/utils/port-contract-single-source.test.ts` and `src/utils/dev-restart.test.ts` were re-run: 3 files and 51 tests passed. The `integration` tier is left to CI, because this diff touches no spawn entry and no integration-layer file other than the new nightly pin. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 63 commands. Each was run, and every one exited 0. `--ran`, with an exit code on every line, reported `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET: 8 packages outside the cli closure had no `dist/`. They were built, and it was re-run as part of the 63. Derivation residual: the tree was 9 commits behind `origin/main` `70dae533c5`, and one derivation input, `scripts/doc-authoring-prose-id.baseline.json`, changed upstream. - **Lint**, a proven narrowing rather than the full `pnpm lint`, which is left to CI: 1. Population: both changed TypeScript files are in eslint's own linted population. `--print-config` resolves 6 and 5 rules for them, and neither file is ignored. 2. Count: `--format json` read 2 files, 0 errors and 0 warnings. 3. Invariance: the config enables no type-aware linting (`parserOptions.project` and `projectService` are undefined for both files). Its only inputs on disk are `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json`, and neither is in this diff, so no verdict on an untouched file can change. ## Acceptance notes - **`AGENTS.md` written by turbo.** The dev-dependency bump at `840ec9dab3` moved `turbo` from 2.10.10 to 2.11.5. That version appends a managed "turborepo agent rules" block, wrapped in HTML comment markers, to `AGENTS.md` on repository-scoped commands when it detects an AI agent. `turbo.json` sets no `agentGuidance` opt-out. Measured in this worktree: the first `pnpm exec turbo run build` after merging `main` left ` M AGENTS.md` (11 added lines). `scripts/ablation-dist-preflight.mjs --absent` then answered exit 3 on its tree reading. The file was restored from HEAD and is not part of this diff. It is reported to the seat for filing, not fixed here. - **Port-door anchor.** `src/utils/port-contract-single-source.test.ts` anchors on the text `const child = spawn(` in `start.ts` to keep the port door ahead of the spawn. The spawn keeps that spelling inside the coordinator's `spawnChild`, with a comment saying why, so that structural pin keeps measuring the same order. - **Coordinator docblock.** `ServeRestartCoordinator`'s docblock in `dev-restart.ts` still describes only `os dev`. It was left alone because `dev-restart.ts` is outside this card's surface on the no-extraction route. `start.ts` names the coordinator and the pin instead. - **Behaviour on signal.** After SIGTERM or SIGINT, `os start` now waits for the server's graceful shutdown and exits 0. Before, it died on the signal (shell status 143 or 130) while the server kept running. This matches `os dev`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c35436c commit 7164587

3 files changed

Lines changed: 362 additions & 12 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os start` forwards SIGTERM and SIGINT to its `serve` child and takes the child down when it exits
6+
7+
Clause-②: no
8+
9+
`os start` runs the server as a separate `serve` child process. It used to
10+
listen for that child's exit and nothing else. A SIGTERM or SIGINT sent to the
11+
`start` process alone (a plain `kill`, `docker stop`, a systemd stop, a CI step)
12+
ended `start` and left `serve` running with no parent. The port stayed bound,
13+
`/health` kept answering 200, and the next start on that port collided with it.
14+
15+
`os start` now supervises its child the way `os dev` already does, through the
16+
same mechanism:
17+
18+
- SIGTERM or SIGINT to `start` is forwarded to the child. `start` waits for the
19+
child to shut down, then exits with the child's exit code, which is 0 after a
20+
graceful shutdown. It used to die on the signal at once (shell status 143 for
21+
SIGTERM, 130 for SIGINT) while the child kept running.
22+
- Whatever else ends `start`, the child is sent SIGTERM on the way out.
23+
- A child that exits on its own still ends `start` with the child's exit code,
24+
as before.
25+
26+
One visible side effect, the same one `os dev` already has: Ctrl-C at a terminal
27+
signals `start` and the child together, so the child now receives SIGINT twice
28+
and logs one `Shutdown already in progress, ignoring SIGINT` warning. The
29+
terminal prompt also returns only after the server has stopped, not before.
30+
31+
No flag, port, environment variable, banner line or `os dev` behaviour changes.

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

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { redirectStdoutToStderr } from '../utils/json-stdout.js';
1313
import { redactConnectionUrl } from '../utils/connection-display.js';
1414
import { databaseDriverFlag } from '../utils/database-driver-flag.js';
1515
import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js';
16+
import { ServeRestartCoordinator } from '../utils/dev-restart.js';
1617
import { readEnvWithDeprecation } from '@objectstack/types';
1718
// The ONE port contract, shared with `dev` and with the `serve` child this
1819
// command spawns (#12673). ⛔ Nothing about ports is declared in this file —
@@ -445,18 +446,50 @@ export default class Start extends Command {
445446
}
446447

447448
const binPath = process.argv[1];
448-
const child = spawn(
449-
process.execPath,
450-
[
451-
binPath,
452-
'serve',
453-
flags.ui ? '--ui' : '--no-ui',
454-
...(flags.verbose ? ['--verbose'] : []),
455-
...(flags['log-level'] ? ['--log-level', flags['log-level']] : []),
456-
],
457-
{ stdio: 'inherit', env: localEnv },
458-
);
459-
child.on('exit', (code) => process.exit(code ?? 0));
449+
450+
// ── The serve child's lifecycle: `os dev`'s mechanism, not a copy (#21114)
451+
// `start` is a supervisor, so a signal sent to ITS pid alone — a plain
452+
// `kill`, `docker stop`, a systemd stop, a CI step — has to reach the
453+
// child too. This command used to listen for the child's `exit` and
454+
// nothing else: a SIGTERM to the parent ended the parent and left `serve`
455+
// running, reparented to init, port still bound and `/health` still
456+
// answering 200.
457+
//
458+
// ⭐ Supervised by `ServeRestartCoordinator`, the ONE forwarding mechanism
459+
// `os dev` already runs, used as is — `start` never calls
460+
// `requestRestart`, so only the parts both commands need are reached:
461+
// `beginShutdown` forwards SIGINT / SIGTERM and lets the child's exit end
462+
// the parent; `killChildOnParentExit` reaps the child on whatever path
463+
// ends the parent; and a child that exits on its own still ends the
464+
// parent with `code ?? 0`, exactly the handler this replaces.
465+
// ⛔ No forwarding or reaping logic of this command's own: what a signal
466+
// DOES belongs in the coordinator, where `dev` gets the same change.
467+
// Pinned end to end, with `os dev` as the control, by
468+
// `test/start-signal-forwarding.e2e.test.ts`.
469+
const coordinator = new ServeRestartCoordinator({
470+
spawnChild: () => {
471+
// `const child = spawn(` is the anchor
472+
// `utils/port-contract-single-source.test.ts` reads to hold the port
473+
// door above AHEAD of this spawn — keep the spelling.
474+
const child = spawn(
475+
process.execPath,
476+
[
477+
binPath,
478+
'serve',
479+
flags.ui ? '--ui' : '--no-ui',
480+
...(flags.verbose ? ['--verbose'] : []),
481+
...(flags['log-level'] ? ['--log-level', flags['log-level']] : []),
482+
],
483+
{ stdio: 'inherit', env: localEnv },
484+
);
485+
return child;
486+
},
487+
exitParent: (code) => process.exit(code),
488+
});
489+
process.on('SIGINT', () => coordinator.beginShutdown('SIGINT'));
490+
process.on('SIGTERM', () => coordinator.beginShutdown('SIGTERM'));
491+
process.on('exit', () => coordinator.killChildOnParentExit());
492+
coordinator.start();
460493
}
461494
}
462495

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A signal to `os start` takes its `serve` child down with it (#21114).
5+
*
6+
* ## The defect, as measured on `origin/main` before the repair
7+
*
8+
* `os start` is a supervisor: it spawns `os serve` as a SEPARATE process and
9+
* then waits. It listened for the child's `exit` and nothing else, so a SIGTERM
10+
* sent to the `start` pid alone (a plain `kill`, a CI step, a process manager)
11+
* ended the parent and left the child running, reparented to init, with its
12+
* port still bound and `/health` still answering 200. `os dev` never had the
13+
* defect: its `ServeRestartCoordinator` forwards SIGINT / SIGTERM to the child
14+
* and reaps the child when the parent exits.
15+
*
16+
* The repair is that `start` supervises its child through that SAME
17+
* coordinator, so there is one forwarding mechanism in the CLI, not two.
18+
*
19+
* ## What is asserted
20+
*
21+
* The ruling's pin, literally: a SIGTERM (and a SIGINT) to the PARENT leaves no
22+
* child process and a free port. `os dev`, booted on the same artifact and sent
23+
* the same signal, is the control: it states that the instrument reads the
24+
* shipped mechanism correctly, and that `dev`'s behaviour did not move.
25+
*
26+
* Both readings carry a positive control taken on the same boot before the
27+
* signal: the probe sees the child ALIVE and the port BOUND. Without that, "no
28+
* child" passes on a probe that cannot see children at all, and "port free"
29+
* passes on a child that bound somewhere else.
30+
*
31+
* ⛔ The signal goes to the parent's pid ALONE, never to its process group. A
32+
* group signal reaches the child directly and passes whether or not the parent
33+
* forwards anything — it is the shape that HID this defect (the neighbouring
34+
* `start-port-banner-agreement.e2e.test.ts` documents why it kills the group,
35+
* and for its purpose it is right to).
36+
*
37+
* ## Spawn shape
38+
*
39+
* The SOURCE entry, `bin/run-dev.js`, under the tsx loader passed as an
40+
* `--import` flag — never the `tsx` CLI, which spawns the script as a child of
41+
* its own and relays signals to it, so the pid this file signals would be tsx's
42+
* rather than the command's. Loaded this way the spawned pid IS the `os start` /
43+
* `os dev` parent, and the `serve` process is its direct child.
44+
*
45+
* The subject is the supervisor's wiring in `src/commands/start.ts`, which the
46+
* built entry runs byte for byte from `dist/`, so the source entry measures the
47+
* same code without a build prerequisite. The `development` posture it pins
48+
* re-opens `serve`'s port auto-shift; that is harmless here because the port is
49+
* read back out of the child's own banner (`boundPortFromBanner`) rather than
50+
* assumed from what this file asked for. The built entry was measured too, by
51+
* hand, before and after the repair (the PR that landed this records both).
52+
*
53+
* Each child gets its own process group (`detached: true`) so `afterEach` can
54+
* still SIGKILL a survivor — which is exactly what the unrepaired command
55+
* leaves behind — instead of leaking it into the container.
56+
*/
57+
58+
import { describe, it, expect, afterEach } from 'vitest';
59+
import { execFileSync, spawn, type ChildProcess } from 'node:child_process';
60+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
61+
import { tmpdir } from 'node:os';
62+
import { join, resolve, dirname } from 'node:path';
63+
import { fileURLToPath } from 'node:url';
64+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
65+
import {
66+
childEnv,
67+
boundPortFromBanner,
68+
portIsFree,
69+
reservePort,
70+
} from './helpers/serve-process.js';
71+
72+
const HERE = dirname(fileURLToPath(import.meta.url));
73+
const RUN_DEV_JS = resolve(HERE, '../bin/run-dev.js');
74+
const TSX_LOADER = resolve(HERE, '../../../node_modules/tsx/dist/loader.mjs');
75+
76+
/** The banner tail `printServerReady` ends with — the boot is fully printed. */
77+
const BANNER_TAIL = /Press Ctrl\+C to stop/;
78+
79+
const BOOT_TIMEOUT_MS = 180_000;
80+
/** The kernel's graceful shutdown, plus event-loop delivery of the exit. */
81+
const EXIT_TIMEOUT_MS = 60_000;
82+
83+
/** A minimal but real compiled artifact, booted identically by both commands. */
84+
const ARTIFACT = JSON.stringify({
85+
manifest: {
86+
id: 'com.example.signals',
87+
name: 'signals',
88+
version: '1.0.0',
89+
type: 'app',
90+
engines: { protocol: `^${PROTOCOL_MAJOR}` },
91+
},
92+
objects: [
93+
{ name: 'sig_note', label: 'Note', fields: { name: { type: 'text', label: 'Name' } } },
94+
],
95+
views: [],
96+
apps: [],
97+
flows: [],
98+
requires: [],
99+
});
100+
101+
type Command = 'start' | 'dev';
102+
103+
/** The argv that boots `command` on `artifact` at `port`, with no Console. */
104+
function argvFor(command: Command, artifact: string, port: number): string[] {
105+
// `start` declares `--ui` with `allowNo` (default on); `dev` declares it
106+
// default-off and rejects `--no-ui`. Both therefore boot without a Console.
107+
return command === 'start'
108+
? ['start', '--artifact', artifact, '--port', String(port), '--no-ui']
109+
: ['dev', '--artifact', artifact, '--port', String(port)];
110+
}
111+
112+
/**
113+
* The pids of `ppid`'s `serve` children, read from the process table — a child
114+
* whose argv carries the `serve` command token.
115+
*
116+
* ⚠️ Selected by argv, not "every child", and MEASURED to need it: on a cold
117+
* tsx transform cache the loader runs an `esbuild --service` process as a
118+
* second child of the CLI parent. It exits on its own a moment AFTER the parent
119+
* (alive at the parent's exit, gone 2 s later, 2 of 2 runs with
120+
* `TSX_DISABLE_CACHE=1`), so counting it reads as an orphaned server on the
121+
* file's first boot and on no other. The ruling's sentence is about the
122+
* `serve` child, and so is this probe. `-ww` keeps BSD `ps` from cutting the
123+
* argv at a terminal width.
124+
*/
125+
function serveChildPidsOf(ppid: number): number[] {
126+
const table = execFileSync('ps', ['-A', '-ww', '-o', 'pid=,ppid=,args='], {
127+
encoding: 'utf8',
128+
env: childEnv(),
129+
});
130+
return table
131+
.split('\n')
132+
.map((line) => line.trim().split(/\s+/))
133+
.filter(([pid, parent, ...args]) =>
134+
Number.isInteger(Number(pid)) && Number(parent) === ppid && args.includes('serve'))
135+
.map(([pid]) => Number(pid));
136+
}
137+
138+
/** Does `pid` name a live process? (`EPERM` = alive, owned by someone else.) */
139+
function isAlive(pid: number): boolean {
140+
try {
141+
process.kill(pid, 0);
142+
return true;
143+
} catch (err) {
144+
return (err as NodeJS.ErrnoException).code === 'EPERM';
145+
}
146+
}
147+
148+
/** SIGKILL a whole process group — the survivor sweep, never the measurement. */
149+
const killGroup = (child: ChildProcess | undefined): void => {
150+
if (child?.pid === undefined) return;
151+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* group already gone */ }
152+
};
153+
154+
interface Exit { code: number | null; signal: NodeJS.Signals | null }
155+
156+
interface Booted {
157+
parent: ChildProcess;
158+
port: number;
159+
output: () => string;
160+
exited: Promise<Exit>;
161+
}
162+
163+
let running: ChildProcess | undefined;
164+
let workdir: string | undefined;
165+
166+
afterEach(() => {
167+
killGroup(running);
168+
running = undefined;
169+
if (workdir) rmSync(workdir, { recursive: true, force: true });
170+
workdir = undefined;
171+
});
172+
173+
/** Boot a real `os <command>` and resolve once its child printed the ready banner. */
174+
function boot(command: Command): Promise<Booted> {
175+
const dir = mkdtempSync(join(tmpdir(), `os-${command}-signal-`));
176+
workdir = dir;
177+
const artifact = join(dir, 'objectstack.json');
178+
writeFileSync(artifact, ARTIFACT);
179+
const port = reservePort();
180+
181+
return new Promise((resolveBoot, rejectBoot) => {
182+
const parent = spawn(
183+
process.execPath,
184+
['--import', TSX_LOADER, RUN_DEV_JS, ...argvFor(command, artifact, port)],
185+
{
186+
cwd: dir,
187+
env: childEnv({
188+
NO_COLOR: '1',
189+
OS_HOME: join(dir, 'home'),
190+
OS_LOG_LEVEL: 'error',
191+
}),
192+
stdio: ['ignore', 'pipe', 'pipe'],
193+
detached: true,
194+
},
195+
);
196+
running = parent;
197+
198+
let output = '';
199+
let ready = false;
200+
const exited = new Promise<Exit>((settle) => {
201+
parent.once('exit', (code, signal) => settle({ code, signal }));
202+
});
203+
204+
const timer = setTimeout(() => {
205+
if (ready) return;
206+
rejectBoot(new Error(`os ${command} never printed a complete banner.\n--- output ---\n${output}`));
207+
}, BOOT_TIMEOUT_MS);
208+
209+
const onData = (d: unknown) => {
210+
output += String(d);
211+
if (!ready && BANNER_TAIL.test(output)) {
212+
ready = true;
213+
clearTimeout(timer);
214+
const readback = boundPortFromBanner(output);
215+
if (readback.state !== 'bound') {
216+
rejectBoot(new Error(`os ${command}: banner unreadable (${readback.state}).\n--- output ---\n${output}`));
217+
return;
218+
}
219+
resolveBoot({ parent, port: readback.port, output: () => output, exited });
220+
}
221+
};
222+
parent.stdout?.on('data', onData);
223+
parent.stderr?.on('data', onData);
224+
parent.on('error', (err) => { clearTimeout(timer); rejectBoot(err); });
225+
void exited.then(({ code, signal }) => {
226+
if (ready) return;
227+
clearTimeout(timer);
228+
rejectBoot(new Error(
229+
`os ${command} exited (${signal ?? code}) before its banner.\n--- output ---\n${output}`,
230+
));
231+
});
232+
});
233+
}
234+
235+
/** Signal the parent alone, then read what is left of its child and its port. */
236+
async function signalParent(command: Command, signal: NodeJS.Signals): Promise<void> {
237+
const { parent, port, output, exited } = await boot(command);
238+
const parentPid = parent.pid!;
239+
240+
// ── Positive controls, on the same boot, before the signal ─────────────
241+
const children = serveChildPidsOf(parentPid);
242+
expect(children, `os ${command}: the probe does not see exactly one serve child.\n${output()}`)
243+
.toHaveLength(1);
244+
expect(children.every(isAlive), `os ${command}: a child reads dead before the signal`).toBe(true);
245+
expect(portIsFree(port), `os ${command}: port ${port} reads free while the server is up`).toBe(false);
246+
247+
// ── The measurement: the PARENT's pid alone, never its group ───────────
248+
process.kill(parentPid, signal);
249+
250+
let timer: ReturnType<typeof setTimeout> | undefined;
251+
const fate = await Promise.race([
252+
exited,
253+
new Promise<'timeout'>((settle) => { timer = setTimeout(() => settle('timeout'), EXIT_TIMEOUT_MS); }),
254+
]);
255+
clearTimeout(timer);
256+
expect(fate, `os ${command} did not exit within ${EXIT_TIMEOUT_MS}ms of ${signal}.\n${output()}`)
257+
.not.toBe('timeout');
258+
259+
// Soft, both: the two readings are independent facts, and a regression
260+
// should report each of them rather than stop at the first.
261+
expect.soft(
262+
children.filter(isAlive),
263+
`os ${command}: ${signal} to the parent left its serve child running (orphaned, ` +
264+
`reparented to init).\n--- output ---\n${output()}`,
265+
).toEqual([]);
266+
expect.soft(
267+
portIsFree(port),
268+
`os ${command}: ${signal} to the parent left port ${port} bound.\n--- output ---\n${output()}`,
269+
).toBe(true);
270+
}
271+
272+
describe('a signal to the CLI parent takes its `serve` child down with it', () => {
273+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
274+
it(
275+
`os start: ${signal} to the parent leaves no child process and a free port`,
276+
() => signalParent('start', signal),
277+
BOOT_TIMEOUT_MS + EXIT_TIMEOUT_MS + 30_000,
278+
);
279+
280+
it(
281+
`os dev (control): ${signal} to the parent leaves no child process and a free port`,
282+
() => signalParent('dev', signal),
283+
BOOT_TIMEOUT_MS + EXIT_TIMEOUT_MS + 30_000,
284+
);
285+
}
286+
});

0 commit comments

Comments
 (0)