Files
gbrain/test/cli-flush-exit.test.ts
T
Garry TanandClaude Fable 5 de4c29d037 fix: doctor's FAIL verdict was zeroed by the owned exit — sweep stragglers + class pin
The merged-state suite caught it: doctor --fast --json reported FAIL but
exited 0. Master's v0.42.41.0 brought raw `process.exitCode =` writes
(doctor.ts hasFail ternary, extract.ts) that the #2084 verdict-owning exit
silently zeroes — getCliExitCode() deliberately never reads ambient
process.exitCode (the PGLite-Emscripten pollution defense), so any setter
that bypasses setCliExitCode reports success on failure.

Swept both sites and added the structural class pin: a test greps src/ for
raw `process.exitCode =` outside cli-force-exit.ts, so the next merge that
introduces one fails loudly instead of lying about exit codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 06:41:54 -07:00

156 lines
5.1 KiB
TypeScript

/**
* v0.43 (#2084) — flushStdoutThenExit unit tests.
*
* The deliberate-exit path must never truncate buffered output (incident
* #1959: a force-exit cut piped stdout mid-payload) and must never hang on
* a blocked pipe. Streams + exit are injected so the tests drive both
* properties without killing the test process.
*/
import { describe, test, expect } from 'bun:test';
import { flushStdoutThenExit, type FlushableStream } from '../src/core/cli-force-exit.ts';
/** Minimal fake of the stdout/stderr surface flushStdoutThenExit touches. */
function makeStream(initialLength: number): FlushableStream & {
setLength(n: number): void;
emitDrain(): void;
drainListeners(): number;
} {
let length = initialLength;
const listeners: Array<() => void> = [];
return {
get writableLength() {
return length;
},
once(_event: 'drain', listener: () => void) {
listeners.push(listener);
return this;
},
off(_event: 'drain', listener: () => void) {
const i = listeners.indexOf(listener);
if (i >= 0) listeners.splice(i, 1);
return this;
},
setLength(n: number) {
length = n;
},
emitDrain() {
const current = listeners.splice(0, listeners.length);
for (const l of current) l();
},
drainListeners() {
return listeners.length;
},
};
}
describe('flushStdoutThenExit — deliberate exit after output drains', () => {
test('exits immediately with the given code when both streams are drained', async () => {
const calls: number[] = [];
await flushStdoutThenExit(0, {
streams: [makeStream(0), makeStream(0)],
exit: (c) => calls.push(c),
});
expect(calls).toEqual([0]);
});
test('honors a non-zero exit code (errored op sets process.exitCode=1)', async () => {
const calls: number[] = [];
await flushStdoutThenExit(1, {
streams: [makeStream(0)],
exit: (c) => calls.push(c),
});
expect(calls).toEqual([1]);
});
test('waits for a pending buffer to drain before exiting', async () => {
const stream = makeStream(4096);
const calls: number[] = [];
const done = flushStdoutThenExit(0, {
streams: [stream],
exit: (c) => calls.push(c),
guardMs: 5000,
});
// Not exited while bytes are queued.
await new Promise((r) => setTimeout(r, 10));
expect(calls).toEqual([]);
stream.setLength(0);
stream.emitDrain();
await done;
expect(calls).toEqual([0]);
});
test('drains stdout AND stderr — stderr alone can hold the exit', async () => {
const stdout = makeStream(0);
const stderr = makeStream(2048);
const calls: number[] = [];
const done = flushStdoutThenExit(1, {
streams: [stdout, stderr],
exit: (c) => calls.push(c),
guardMs: 5000,
});
await new Promise((r) => setTimeout(r, 10));
expect(calls).toEqual([]);
stderr.setLength(0);
stderr.emitDrain();
await done;
expect(calls).toEqual([1]);
});
test('poll tick drains without a drain event ("drain" only fires after backpressure)', async () => {
const stream = makeStream(512);
const calls: number[] = [];
const done = flushStdoutThenExit(0, {
streams: [stream],
exit: (c) => calls.push(c),
guardMs: 5000,
});
// Buffer empties WITHOUT an emitted drain event — the 25ms poll must see it.
setTimeout(() => stream.setLength(0), 30);
await done;
expect(calls).toEqual([0]);
});
test('blocked pipe: the guard deadline exits anyway (never hangs)', async () => {
const stream = makeStream(65536); // reader stopped consuming; never drains
const calls: number[] = [];
const t0 = Date.now();
await flushStdoutThenExit(0, {
streams: [stream],
exit: (c) => calls.push(c),
guardMs: 120,
});
expect(calls).toEqual([0]);
expect(Date.now() - t0).toBeLessThan(2000);
});
test('removes its drain listener when the poll tick wins (no listener leak)', async () => {
const stream = makeStream(256);
const calls: number[] = [];
const done = flushStdoutThenExit(0, {
streams: [stream],
exit: (c) => calls.push(c),
guardMs: 5000,
});
setTimeout(() => stream.setLength(0), 30);
await done;
expect(stream.drainListeners()).toBe(0);
});
});
describe('exit-verdict ownership — no raw process.exitCode assignments (#2084 class pin)', () => {
test('every exit-code write in src/ routes through setCliExitCode', async () => {
// A RAW `process.exitCode = N` is silently ZEROED by the deliberate
// flush-exit: getCliExitCode() reads only gbrain's owned verdict (the
// PGLite-Emscripten-pollution defense), so a command that bypasses the
// setter reports success on failure. Caught live: doctor's FAIL path
// exited 0 after the v0.42.41.0 merge introduced a raw write.
const { execSync } = await import('child_process');
const hits = execSync(
`grep -rn "process.exitCode = " src --include='*.ts' | grep -v "core/cli-force-exit.ts" || true`,
{ encoding: 'utf-8', cwd: new URL('..', import.meta.url).pathname },
).trim();
expect(hits).toBe('');
});
});