Skip to content

Commit 4638625

Browse files
fix(cli): os dev prints the ready banner whole, then the MCP connect block (#22410) (#22551)
Fixes #22410 Clause-②: no The fix fixes the order of two blocks the CLI already prints. No accepted input, flag, key or export changes. `objectstack:listening` is the CLI's internal parent/child IPC. ## What changed `os dev` is two processes writing one terminal. The parent prints the MCP connect block when the `serve` child's `objectstack:listening` IPC message arrives. The child used to send that message before it printed its ready banner, so the two printed at once. - `packages/cli/src/commands/serve.ts`, `publishBoundPort`: the three bound-port channels now fire state file, then banner, then IPC (was state file, IPC, banner). The `BoundPortChannels` field order and docs, the function's docblock and the call-site comment say why. The state file still comes first, so the earlier race (the file must exist before anything announces it) stays closed. - The order is causal, not timed. The banner is written synchronously to a terminal (POSIX TTY stdio is synchronous, and `keepStderrNonBlocking` leaves a TTY alone), so every banner byte is in the terminal before the message is sent, and the parent prints only on receipt. No sleep, no timer. - `dev.ts` is untouched. The child-side reorder alone measured clean (see the tables), so no single-write change to the parent's block was needed. - The message shape `{ type, port, url }` is unchanged. Each place the child prints its banner is a call to this one function: `publishBoundPort` has one call site, and boot diagnostics print inside the banner (`printServerReady` calls `printBootDiagnostics`). The other `printBootDiagnostics` calls (the `OS_MIGRATE_AND_EXIT` path and the boot-failure catch) never announce at all. ## Measured before and after, under a real pty Door: `os dev --ui -p PORT` on the Build-with-Claude-Code tutorial project. The project is the bundled `blank` template, given the scaffold's identity rewrite for `support-desk`, plus the page's four files extracted verbatim and their four barrel lines. `os validate` prints `Data: 2 Objects 6 Fields`, `UI: 1 Apps 1 Views 1 Actions`, the page's own output. `@objectstack/*` resolve by symlink to this worktree's built packages. Each boot ran under `script -qfec` on a fresh `.objectstack/` and `dist/`, so it is a first boot that seeds the dev admin. The harness waited for the banner and the block, kept reading for 3 s, then typed a real Ctrl+C into the pty. A "foreign line" is any line between the block header and its `Disable` row that is not one of the block's rows. **Before** (dist built from base `d303b3e7a`, the `serve.js` order read off the built file as IPC before banner): | boot | foreign lines inside the block | credential inside the block | block position | parent rows inside the banner | |---|---|---|---|---| | 1 | 2 (`➜ Console:`, `➜ MCP:` rows) | no | inside the banner | 5 | | 2 | 0 | no | above the banner | 0 | | 3 | 0 | no | after the banner | 0 | | 4 | 0 | no | inside the banner | 5 | | 5 | 0 | no | above the banner | 0 | | 6 | 0 | no | inside the banner | 5 | | 7 | 1 (a blank banner line) | no | inside the banner | 5 | 6 of 7 boots printed the block above or inside the banner, 2 of 7 split the block, 0 of 7 put the credential line inside it. A setup boot before these seven printed 18 foreign lines between `Skill` and `Connect`: `➜ MCP:`, the whole `🔑 Dev admin: admin@objectos.ai / admin123` section, `Config` / `Mode` / `Driver` / `Tenancy` / `Plugins: 41 loaded` and `Press Ctrl+C to stop`. **After** (dist rebuilt with this fix, the built file's order read as banner before IPC): | boot | foreign lines inside the block | credential inside the block | block position | parent rows inside the banner | |---|---|---|---|---| | 1–7 | 0 | no | after the banner | 0 | 7 of 7 clean. A second after-run through the source entry (`bin/run-dev.js` under tsx) was also 7 of 7 clean. ## The four PM hypotheses - **H1 (the child): held.** At base, `publishBoundPort` drove `writeRuntimeState`, then `announceListening`, then `printBanner`. Boot diagnostics are not printed separately on the success path: `printServerReady` prints them inside the banner, before `Press Ctrl+C to stop`. The `printBootDiagnostics` call in the card was in the boot-failure `catch`, which never announces. - **H2 (the parent): held.** The block goes to the parent's stdout in six `console.log` calls; the banner goes to the child's stderr (`console.error`). In the setup boot the parent printed three block rows, the child printed about twenty lines, then the parent printed the last two. So each `console.log` is its own write that another writer can land between. With the message sent last, no child line printed during the block in 14 of 14 after-boots, so a single atomic write of the block was not needed. A child line logged in the instant after the banner could still land inside the block; nothing in these boots did. - **H3 (other readers): no reader needs the port earlier.** The readers of `objectstack:listening` are the `dev.ts` handler (the bound-port line, the MCP block, the restart line), the unit tests in `serve-bound-port-publication.test.ts` and `dev-seed-settled-forward.test.ts`, and `serve-publishes-bound-port.e2e.test.ts`, which waits for both the banner and the message. `ServeRestartCoordinator` reads no IPC. `start.ts` mentions it only in a comment, and `content/docs/deployment/cli.mdx` documents it as "the HTTP server is bound", which stays true. The message arrives one banner print later. No split into two messages. - **H4 (restart): holds after the fix.** In one `os dev --ui` session (watch on), 3 restarts were triggered by editing `src/objects/ticket.object.ts`. Each `✓ server restarted — the new build is live` line printed right after that restart's `Press Ctrl+C to stop` (3 of 3), and the MCP block printed once, on the first boot only. The restart leg was not run against base. ## Pins - **`packages/cli/test/dev-boot-output-order.integration.test.ts` (new, integration tier).** It runs the built `os dev --no-watch` on a minimal fixture under `script -qfec`, 5 boots, each on a fresh database file so the dev admin is seeded. For every boot it asserts three things: the block's five rows are contiguous; the `Dev admin` line is present and outside the block; the block starts after the banner's `Press Ctrl+C to stop`. A control asserts that every boot printed the block, the banner tail and the credential line. Without util-linux `script` it fails rather than skips, as `login-json-ndjson.e2e.test.ts` does. - Against a dist built from base: red, `4 of 5 boots printed the block inside or above the banner`. In that run 0 of 5 boots split the block and 0 of 5 put the credential inside, so the red comes from the order assertion. `Tests 1 failed | 2 passed (3)`, 76 s. - Against the rebuilt fix: `Tests 3 passed (3)`, 75 s. - **`packages/cli/test/serve-bound-port-publish-order.test.ts` (updated).** The deterministic in-process pin of the sequence now expects `['state-file', 'banner', 'ipc']`, and its header records the second constraint. Ablation: `scripts/ablation-replace.mjs` mutated `serve.ts` back to IPC before banner (anchor 1 to 0, blob `ab5982d524af` to `727152c21e85`). The test went red, 2 failed and 2 passed: `expected [ 'state-file', 'ipc', 'banner' ] to deeply equal [ 'state-file', 'banner', 'ipc' ]`. The restore was proven: blob equals HEAD `ab5982d524af` and `git diff HEAD` is empty. - **Control:** `src/commands/dev-mcp-connect-hint-origin.test.ts` (the block still prints, with the right endpoint origin) is in the unit layer, which passed. ## Tests and gates (at `c0f7c865d`) - `pnpm --filter @objectstack/cli typecheck`: exit 0. `check:test-typecheck` passed with its ledger unchanged at 3 files and 28 errors. `tsc -p tsconfig.test.json --listFiles` lists both test files (1 hit each); its 28 errors are the ledgered ones in 3 other files. - cli unit layer (`vitest run --project unit`): `Test Files 275 passed (275)`, `Tests 4057 passed (4057)`. - cli integration layer (`vitest run --project integration`): `Test Files 101 passed (101)`, `Tests 934 passed | 2 skipped (936)` (this run includes the new pin). Run under the verify lock, which it held for 44m47s on a shared box. - `serve-publishes-bound-port.e2e.test.ts` (nightly tier, run with `OS_TEST_TIERS=nightly`): `Tests 6 passed (6)`. It is the IPC message's ordinary consumer and waits for both the banner and the message. - The 67 commands `dispatch-gates.mjs --commands` derives for this diff all exited 0, each run once from the worktree root. `--ran` reconciliation: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered PREREQUISITE NOT MET (exit 3) for 9 packages this diff does not touch. They were re-run green after a build of those packages, all 58 tasks restored from the turbo cache. - Lint, by a narrowing rather than a full run. Each of the 3 changed `.ts` files is in ESLint's population: `--format json` lists all 3 with 0 errors, 0 warnings and no "File ignored" message. The changeset is outside it ("File ignored because no matching configuration was supplied"). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules) and reads only its two baseline JSON files, which this diff leaves alone. So the diff cannot move a verdict on any untouched file. The full `pnpm lint` is CI's. - Dogfood is declared to CI. `git grep -E 'connect a coding agent|Press Ctrl\+C to stop|objectstack:listening|Server is ready|printMcpConnectHint|printServerReady|Dev admin' -- packages/qa/` exits 1 with no hits. The same tree's dogfood tests do contain `describe(`. ## Acceptance notes - To boot the tutorial project on today's `main` the measurement made two changes to the scratch copy. It stamped `engines: { protocol: '^18' }`, because the template still carries `^17` while the runtime is protocol 18; the release's `sync-template-versions` pass stamps that field. It also set `OS_ALLOW_CONSOLE_DRIFT=1`, because the Console SPA was the published `@objectstack/console@17.7.0` dist, extracted into the gitignored `packages/console/dist` and built from a different objectui sha than the pin. Neither touches the order being measured. - The before-table counts 0 of 7 credential lines inside the block, where the card counted 1 of 7. The setup boot did put it inside, and so did the card's run. The red against base is measured on the order: in 6 of 7 boots the block was above or inside the banner. - A non-TTY stderr (a pipe whose reader has stalled) is written asynchronously by `keepStderrNonBlocking`. There the message can still overtake banner bytes that are waiting in the queue. That case is a program reading the output, not a terminal; this change leaves it alone. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86bf9ed commit 4638625

4 files changed

Lines changed: 353 additions & 12 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+
fix(cli): `os dev` prints the server's ready banner whole, then the MCP connect block whole, instead of interleaving the two (#22410)
6+
7+
Clause-②: no
8+
9+
- **What a terminal showed.** `os dev` is two processes writing one terminal. The parent prints the MCP connect block (`🤖 MCP server — connect a coding agent:` with its Endpoint, Skill, Connect and Disable rows) when the `serve` child sends `objectstack:listening`, and the child sent that message before it printed its ready banner. Measured under a pty on the Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block above or inside the banner. In 2 of them, banner lines landed between the block's rows, in one case the `➜ Console:` and `➜ MCP:` rows. A setup boot before those seven printed the `🔑 Dev admin` credential lines and the whole plugin section between `Skill` and `Connect`.
10+
- **What changed.** The child now sends `objectstack:listening` after its ready banner and boot diagnostics have printed. The parent prints the block on that message, so it always comes last, under the banner's `Press Ctrl+C to stop` row. The order follows from the sequence itself, with no timer: the banner is written to the terminal before the message is sent. After the change, every measured boot printed the banner whole and then the block whole.
11+
- **What did not change.** The message is still `{ type: 'objectstack:listening', port, url }`, carrying the port actually bound. The runtime state file is still written before either announcement, and `objectstack:seed-settled` still follows `objectstack:listening`. A program that spawns `os serve` with an IPC channel receives `objectstack:listening` slightly later than before: after the banner has printed instead of before.

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

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -731,10 +731,15 @@ export interface BoundPortChannels {
731731
* either announcement below: it is the file both of them send a consumer to.
732732
*/
733733
writeRuntimeState: (published: { port: number; url: string }) => void;
734-
/** Sends {@link ListeningMessage}, when an IPC channel is open. */
735-
announceListening: (message: ListeningMessage) => void;
736-
/** Prints the ready banner, whose `API:` row names the same address. */
734+
/**
735+
* Prints the ready banner, whose `API:` row names the same address, and the
736+
* boot diagnostics under it. ⛔ Must COMPLETE before the IPC message below:
737+
* the `os dev` parent prints its MCP connect block on that message, into the
738+
* same terminal (#22410).
739+
*/
737740
printBanner: () => void;
741+
/** Sends {@link ListeningMessage}, when an IPC channel is open. LAST — see above. */
742+
announceListening: (message: ListeningMessage) => void;
738743
}
739744

740745
/**
@@ -770,6 +775,25 @@ export interface BoundPortChannels {
770775
* into every consumer forever and hides it from the one place that can fix it.
771776
* The producer owns the ordering: **write the file, THEN announce it.**
772777
*
778+
* ## The second race: two processes, one terminal (#22410)
779+
*
780+
* The IPC message is not only a fact for a program. The `os dev` parent prints
781+
* its MCP connect block (`🤖 MCP server — connect a coding agent:` and its
782+
* Endpoint / Skill / Connect / Disable rows) the moment the message arrives,
783+
* into the terminal this process writes its banner to. Sent BEFORE the banner,
784+
* the two processes wrote that terminal at once: measured under a pty on the
785+
* Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block
786+
* above or inside the banner, 2 of them with banner lines inside the block, and
787+
* one probe boot printed the whole credential and plugin section between
788+
* `Skill` and `Connect`.
789+
*
790+
* So the banner comes SECOND and the message LAST. That makes the order
791+
* causal, not timed: the banner is written synchronously (a terminal is
792+
* synchronous stdio on POSIX, and `keepStderrNonBlocking` leaves a TTY
793+
* alone), so every banner byte is in the terminal before the message is sent,
794+
* and the parent prints only on receipt. ⛔ No sleep or timer orders anything
795+
* here. The message's shape is unchanged; it arrives a banner's print later.
796+
*
773797
* ## Why the channels are injected rather than called inline
774798
*
775799
* The ORDER is the contract here, and an order is only pinned by a test that
@@ -796,11 +820,12 @@ export function publishBoundPort(
796820
const url = `${boundProtocol}://localhost:${boundPort}`;
797821
// 1 ─ THE FILE FIRST. Both announcements below send a consumer to it.
798822
channels.writeRuntimeState({ port: boundPort, url });
799-
// 2 ─ IPC: the `os dev` parent learns the real port without polling.
800-
channels.announceListening({ type: 'objectstack:listening', port: boundPort, url });
801-
// 3 ─ The banner: a human, or a supervisor tailing stdout, reads the same
823+
// 2 ─ The banner: a human, or a supervisor tailing stdout, reads the same
802824
// address — and by now the state file it names is on disk.
803825
channels.printBanner();
826+
// 3 ─ IPC LAST: the `os dev` parent learns the real port without polling,
827+
// and prints its MCP connect block under a banner that has finished (#22410).
828+
channels.announceListening({ type: 'objectstack:listening', port: boundPort, url });
804829
}
805830

806831
/**
@@ -5331,14 +5356,16 @@ export default class Serve extends Command {
53315356
// one so supervisors and the `os dev` parent never have to guess:
53325357
// • runtime.json: a small state file under OS_HOME for external
53335358
// supervisors / health checks (pid + port + url).
5359+
// • the ready banner, whose `API:` row names the same address.
53345360
// • IPC: when spawned with an 'ipc' channel (as `os dev` does), the
53355361
// parent learns the real port without polling.
5336-
// • the ready banner, whose `API:` row names the same address.
53375362
//
53385363
// ⭐ That list is in ORDER, and the order is the whole point (commit faff497fd):
53395364
// the file is written BEFORE either channel announces the address that
5340-
// sends a consumer to it. {@link publishBoundPort} carries the race the
5341-
// old order lost, and the reason the repair is not reader-side polling.
5365+
// sends a consumer to it, and the banner is printed whole BEFORE the
5366+
// message that makes the `os dev` parent print into the same terminal
5367+
// (#22410). {@link publishBoundPort} carries both races the old orders
5368+
// lost, and the reason neither repair is a reader-side poll or a timer.
53425369
publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol);
53435370

53445371
// ── …and one beat later, whether the APP is ready too (#17329) ──
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22410 — the first screen of `os dev` prints in ONE fixed order, each block
5+
* whole: the `serve` child's ready banner (credentials and boot diagnostics
6+
* included), and only then the parent's MCP connect block.
7+
*
8+
* ## The defect, measured at the public door before the fix
9+
*
10+
* `os dev` is two processes writing one terminal. The parent prints the MCP
11+
* connect block (`🤖 MCP server — connect a coding agent:` and its Endpoint /
12+
* Skill / Connect / Disable rows) to its stdout when the child's
13+
* `objectstack:listening` IPC message arrives, and the child used to send that
14+
* message BEFORE it printed its ready banner to stderr. So both printed at
15+
* once: under a pty, on the tutorial project, the block's rows came out
16+
* interleaved line by line with the banner's — the `➜ API / Console / MCP`
17+
* rows, `Tenancy`, `Plugins: 41 loaded`, and on some boots the
18+
* `🔑 Dev admin: … / admin123` credential line, landing between `Connect` and
19+
* `Disable`. The PR that closes this card carries the measured before/after
20+
* tables.
21+
*
22+
* ## Why the order is now causal, and what this file pins
23+
*
24+
* `publishBoundPort` drives the banner BEFORE the IPC message. The banner is
25+
* written synchronously — a terminal is a synchronous stdio on POSIX, and
26+
* `keepStderrNonBlocking` deliberately leaves a TTY alone — so every banner
27+
* byte is in the terminal before `objectstack:listening` is even sent, and the
28+
* parent prints only on receipt. No timer orders anything.
29+
*
30+
* The SEQUENCE is pinned deterministically, in-process, by
31+
* `serve-bound-port-publish-order.test.ts`. This file pins what a person sees:
32+
* the real `os dev`, under a real pty (`script(1)` — the same tool
33+
* `login-json-ndjson.e2e.test.ts` drives a TTY with, and it fails rather than
34+
* skips without it), across several boots, because against the old order the
35+
* damage was a race that a single boot can miss. For every boot:
36+
*
37+
* - the block's five rows are contiguous — nothing printed between them;
38+
* - the `🔑 Dev admin` credential line is outside the block;
39+
* - the block starts after the banner's last row (`Press Ctrl+C to stop`), so
40+
* no parent row sits inside the banner either.
41+
*
42+
* ## Spawn shape
43+
*
44+
* `bin/run.js` with `NODE_ENV` unset — the shipped entry, resolving commands
45+
* from `dist/` (`requireBuiltCli`). `--no-watch` keeps the watcher's own lines
46+
* out of the reading; each boot gets a fresh database file so the dev admin is
47+
* seeded, and its credential line printed, on every boot. A boot ends with a
48+
* real Ctrl+C typed into the pty. Every boot runs in `beforeAll` and every `it`
49+
* reads what it recorded.
50+
*/
51+
52+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
53+
import { execFileSync, spawn, type ChildProcess } from 'node:child_process';
54+
import { mkdtempSync, rmSync } from 'node:fs';
55+
import { tmpdir } from 'node:os';
56+
import { join, resolve } from 'node:path';
57+
import { fileURLToPath } from 'node:url';
58+
import {
59+
childEnv,
60+
E2E_SECRET_KEY,
61+
randomPort,
62+
requireBuiltCli,
63+
RUN_JS_RESOLVES_FROM_DIST,
64+
} from './helpers/serve-process.js';
65+
import { writeDefineStackConfig } from './helpers/define-stack-fixture.js';
66+
67+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
68+
/** `bin/run.js` — the SHIPPED entrypoint. */
69+
const CLI = resolve(HERE, '../bin/run.js');
70+
71+
/** How many boots. Against the old order the damage was a race, so one boot proves little. */
72+
const BOOTS = 5;
73+
const BOOT_TIMEOUT_MS = 180_000;
74+
/** After the banner, how long the parent's block may take to arrive. */
75+
const BLOCK_GRACE_MS = 30_000;
76+
/** After both have printed, how long to keep reading — late lines would land here. */
77+
const LINGER_MS = 2_000;
78+
const ALL_BOOTS_TIMEOUT_MS = BOOTS * (BOOT_TIMEOUT_MS + BLOCK_GRACE_MS + 30_000);
79+
80+
const SCAFFOLD = {
81+
manifest: { id: 'com.example.order', namespace: 'order', version: '1.0.0', type: 'app', name: 'order' },
82+
objects: [{
83+
name: 'order_item',
84+
label: 'Item',
85+
sharingModel: 'private',
86+
fields: { title: { type: 'text', label: 'Title' } },
87+
}],
88+
};
89+
90+
const ESC = String.fromCharCode(27);
91+
/** SGR colour codes and other CSI sequences a terminal session carries. */
92+
const CSI = new RegExp(`${ESC}\\[[0-9;?]*[A-Za-z]`, 'g');
93+
/** The ETX byte a terminal sends for Ctrl+C. */
94+
const CTRL_C = String.fromCharCode(3);
95+
96+
const BLOCK_HEADER = 'MCP server — connect a coding agent:';
97+
/** The four rows under the header, in the order `printMcpConnectHint` prints them. */
98+
const BLOCK_ROWS = [/^\s+Endpoint\s/, /^\s+Skill\s/, /^\s+Connect\s/, /^\s+Disable\s+OS_MCP_SERVER_ENABLED=false/];
99+
const BANNER_TAIL = 'Press Ctrl+C to stop';
100+
const CREDENTIAL = 'Dev admin:';
101+
102+
interface BootReading {
103+
boot: number;
104+
/** The pty transcript, CSI sequences removed, split on line ends. */
105+
lines: string[];
106+
}
107+
108+
const readings: Array<BootReading | Error> = [];
109+
const groups: ChildProcess[] = [];
110+
let dir: string;
111+
112+
function screenLines(raw: string): string[] {
113+
return raw.replace(CSI, '').split(/\r?\n/).map((line) => line.replace(/\r/g, ''));
114+
}
115+
116+
/** Boot `os dev` under a pty until the banner and the block have both printed, then Ctrl+C it. */
117+
function bootUnderPty(boot: number): Promise<string> {
118+
const port = randomPort();
119+
const command = [
120+
`exec '${process.execPath}' '${CLI}' dev --no-watch`,
121+
`-d 'file:boot${boot}.db' -p ${port}`,
122+
].join(' ');
123+
return new Promise((resolveBoot, rejectBoot) => {
124+
const child = spawn('script', ['-qfec', command, '/dev/null'], {
125+
cwd: dir,
126+
// `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV`
127+
// unset, so the built entry resolves its commands from `dist/`. Every
128+
// variable that moves the block's origin is unset, so the block prints.
129+
env: childEnv({
130+
OS_HOME: join(dir, 'home'),
131+
OS_CLOUD_URL: 'off',
132+
OS_DISABLE_CONSOLE: '1',
133+
OS_SECRET_KEY: E2E_SECRET_KEY,
134+
OS_DATABASE_URL: undefined,
135+
OS_ARTIFACT_URL: undefined,
136+
OS_ARTIFACT_PATH: undefined,
137+
OS_MCP_SERVER_ENABLED: undefined,
138+
OS_AUTH_URL: undefined,
139+
BETTER_AUTH_URL: undefined,
140+
OS_BASE_URL: undefined,
141+
OS_MODE: undefined,
142+
NODE_ENV: undefined,
143+
NO_COLOR: undefined,
144+
FORCE_COLOR: undefined,
145+
}),
146+
stdio: ['pipe', 'pipe', 'pipe'],
147+
// Own process group: `script` owns the pty, whose session holds `dev` and its `serve` child.
148+
detached: true,
149+
});
150+
groups.push(child);
151+
let raw = '';
152+
let bannerSeen = false;
153+
let stopping = false;
154+
let failure: Error | null = null;
155+
const timers: Array<ReturnType<typeof setTimeout>> = [];
156+
const stop = (err: Error | null) => {
157+
if (stopping) return;
158+
stopping = true;
159+
failure = err;
160+
// Keep reading for a moment: a line that would land late lands here.
161+
timers.push(setTimeout(() => { try { child.stdin?.write(CTRL_C); } catch { /* pty gone */ } }, LINGER_MS));
162+
timers.push(setTimeout(() => { try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* gone */ } }, LINGER_MS + 15_000));
163+
};
164+
timers.push(setTimeout(
165+
() => stop(new Error(`boot ${boot}: os dev never printed its banner\n--- output ---\n${raw.slice(-4000)}`)),
166+
BOOT_TIMEOUT_MS,
167+
));
168+
const onData = (d: Buffer) => {
169+
raw += d.toString('utf8');
170+
const text = raw.replace(CSI, '');
171+
if (!bannerSeen && text.includes(BANNER_TAIL)) {
172+
bannerSeen = true;
173+
timers.push(setTimeout(
174+
() => stop(new Error(`boot ${boot}: the MCP block never printed\n--- output ---\n${raw.slice(-4000)}`)),
175+
BLOCK_GRACE_MS,
176+
));
177+
}
178+
if (bannerSeen && screenLines(raw).some((l) => BLOCK_ROWS[3].test(l))) stop(null);
179+
};
180+
child.stdout?.on('data', onData);
181+
child.stderr?.on('data', onData);
182+
child.on('exit', () => {
183+
for (const t of timers) clearTimeout(t);
184+
if (failure) rejectBoot(failure);
185+
else if (!stopping) rejectBoot(new Error(`boot ${boot}: os dev exited before its banner\n--- output ---\n${raw.slice(-4000)}`));
186+
else resolveBoot(raw);
187+
});
188+
});
189+
}
190+
191+
beforeAll(async () => {
192+
requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST);
193+
try {
194+
execFileSync('script', ['--version'], { stdio: 'ignore', env: childEnv() });
195+
} catch {
196+
throw new Error('script(1) from util-linux is required to put `os dev` on a real terminal — this file fails instead of skipping without one.');
197+
}
198+
dir = mkdtempSync(join(tmpdir(), 'os-22410-dev-order-'));
199+
writeDefineStackConfig(dir, SCAFFOLD);
200+
// Sequential: each boot owns a port and a database, and two at once would race each other.
201+
for (let boot = 1; boot <= BOOTS; boot++) {
202+
try {
203+
readings.push({ boot, lines: screenLines(await bootUnderPty(boot)) });
204+
} catch (error) {
205+
readings.push(error instanceof Error ? error : new Error(String(error)));
206+
}
207+
}
208+
}, ALL_BOOTS_TIMEOUT_MS);
209+
210+
afterAll(() => {
211+
for (const child of groups) {
212+
if (child.exitCode === null && child.signalCode === null) {
213+
try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ }
214+
}
215+
}
216+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
217+
});
218+
219+
function recorded(): BootReading[] {
220+
expect(readings, 'not every boot was run').toHaveLength(BOOTS);
221+
for (const r of readings) if (r instanceof Error) throw r;
222+
return readings as BootReading[];
223+
}
224+
225+
/** One boot's reading of the screen: where the block starts and ends, and what printed inside it. */
226+
interface ScreenReading {
227+
boot: number;
228+
/** The block header's line, or -1. */
229+
header: number;
230+
/** The block's last row (`Disable`), or -1. */
231+
disable: number;
232+
/** Lines between the header and the last row that are not the block's own rows. */
233+
foreign: string[];
234+
/** The `🔑 Dev admin` line, or -1. */
235+
credential: number;
236+
/** The banner's last row (`Press Ctrl+C to stop`), or -1. */
237+
tail: number;
238+
}
239+
240+
function readScreen(r: BootReading): ScreenReading {
241+
const header = r.lines.findIndex((l) => l.includes(BLOCK_HEADER));
242+
const disable = header < 0 ? -1 : r.lines.findIndex((l, i) => i > header && BLOCK_ROWS[3].test(l));
243+
const foreign = header < 0 || disable < 0
244+
? []
245+
: r.lines.slice(header + 1, disable).filter((l) => !BLOCK_ROWS.some((row) => row.test(l))).map((l) => l.trim());
246+
return {
247+
boot: r.boot,
248+
header,
249+
disable,
250+
foreign,
251+
credential: r.lines.findIndex((l) => l.includes(CREDENTIAL)),
252+
tail: r.lines.findIndex((l) => l.includes(BANNER_TAIL)),
253+
};
254+
}
255+
256+
/**
257+
* Every boot's reading, after the control: each boot printed the whole block,
258+
* its banner tail and its credential line, so every verdict below is a reading
259+
* of something that is there rather than of an absence.
260+
*/
261+
function screens(): ScreenReading[] {
262+
const all = recorded().map(readScreen);
263+
const absent = all.flatMap((s) => [
264+
...(s.header < 0 || s.disable < 0 ? [`boot ${s.boot}: no whole MCP connect block on the screen`] : []),
265+
...(s.tail < 0 ? [`boot ${s.boot}: no banner tail`] : []),
266+
...(s.credential < 0 ? [`boot ${s.boot}: no credential line — the boot did not seed its dev admin`] : []),
267+
]);
268+
expect(absent, 'the control: every boot prints the block, the banner and the credential line').toEqual([]);
269+
return all;
270+
}
271+
272+
describe('#22410 — `os dev` prints the banner, then the MCP block, each block whole', () => {
273+
it('the MCP block\'s rows are contiguous on every boot', () => {
274+
const split = screens()
275+
.filter((s) => s.disable - s.header !== BLOCK_ROWS.length || s.foreign.length > 0)
276+
.map((s) => `boot ${s.boot}: ${s.foreign.length} foreign line(s) inside the block: ${JSON.stringify(s.foreign)}`);
277+
expect(split, `${split.length} of ${BOOTS} boots split the block`).toEqual([]);
278+
});
279+
280+
it('the dev admin credential line sits outside the block on every boot', () => {
281+
const inside = screens()
282+
.filter((s) => s.credential > s.header && s.credential < s.disable)
283+
.map((s) => `boot ${s.boot}: the credential line printed inside the MCP block`);
284+
expect(inside, `${inside.length} of ${BOOTS} boots printed the credential inside the block`).toEqual([]);
285+
});
286+
287+
it('the block comes after the whole banner on every boot — one fixed order', () => {
288+
const early = screens()
289+
.filter((s) => s.header < s.tail)
290+
.map((s) => `boot ${s.boot}: the MCP block started before the banner finished`);
291+
expect(early, `${early.length} of ${BOOTS} boots printed the block inside or above the banner`).toEqual([]);
292+
});
293+
});

0 commit comments

Comments
 (0)