Files
gbrain/test/cli-flush-exit.test.ts
T
Garry TanandClaude Fable 5 43d89057d4 fix(cli): exit deliberately after bounded teardown instead of riding the 10s backstop (#2084)
Root cause: bounded teardown (endPoolBounded, #2015) RESOLVES, but lingering
sockets — embedding-provider fetch keep-alive, PgBouncer txn-mode sockets the
bound raced past — keep Bun's event loop alive, so every `gbrain query` paid
a flat 10s tax exiting via the hard-deadline force-exit banner.

Three changes, one contract:

- flushStdoutThenExit (cli-force-exit.ts): when main() resolves and the
  command is not a daemon, exit deliberately — after stdout AND stderr drain
  (writableLength===0, 'drain'-event + poll loop, 2s unref'd guard for a
  blocked pipe). Incident #1959 (force-exit truncating piped stdout) is the
  regression class; pinned by a 256KB real-pipe subprocess test.

- drainThenDisconnect (cli.ts): ONE owner-disconnect helper at all 8 sites
  (op-dispatch, CLI_ONLY fall-through, search dashboard, doctor remediation
  x3, ze-switch, dream, read-only timeout path). Drains the background-work
  registry, then disconnect (best-effort), bounded by the 10s unref'd
  hard-deadline — which is now armed around the TEARDOWN window only, not
  before the op handler (the old placement would have force-killed any op
  slower than 10s). Closes the filed TODOS P3 drain-hoist: six sites
  previously skipped the drain entirely and had no hang timer at all.

- Inner process.exit sweep: mid-handler exits in engine-owning/output-bearing
  paths (status, friction, claw-test, smoke-test, eval cross-modal /
  takes-quality replay / conversation-parser / whoknows-thin, status-thin)
  become process.exitCode + return so they flow through the drains and the
  flush-exit. Pre-engine usage/parse/refusal exits stay as-is.

BrainRegistry.disconnectAll deliberately unchanged: zero production callers
in src/, per-engine disconnects already bounded, and the kernel reclaims
sockets on exit (src/core/timeout.ts doctrine).

DAEMON_COMMANDS gains 'watch' ahead of the #2095 push transport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:15:33 -07:00

140 lines
4.2 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);
});
});