fix(cli): own the exit verdict — never trust ambient process.exitCode (#2084)

Caught by the full unit suite: `gbrain apply-migrations` on PGLite started
exiting 99. Root cause: PGLite's Emscripten runtime writes the WASM
backend's proc_exit status into process.exitCode (initdb at create-time,
the postmaster at close-time — `exitCode=status` in pglite's dist), and
the writes land ASYNCHRONOUSLY, outside any snapshot/restore window around
create/close (a guarded attempt verified this). The pre-#2084 success path
never read process.exitCode, so the pollution was invisible; the new
deliberate flush-exit propagated it faithfully.

Fix: gbrain records its own verdict. setCliExitCode(n)/getCliExitCode() in
cli-force-exit.ts — every gbrain-owned exit-code assignment routes through
the setter (still mirrored to process.exitCode for outside readers), and
both exit paths (entrypoint flushStdoutThenExit + the drainThenDisconnect
hard-deadline backstop) read the getter. Swept all assignment sites:
cli.ts (op error, friction, claw-test, smoke-test, eval runners, status,
import errors) + reindex/transcripts/brainstorm/frontmatter/autopilot.

Also updates the v0.42.20 structural pins to the drainThenDisconnect shape
(ordering invariant asserted INSIDE the helper + >=8 helper call sites,
superseding the two-inline-pairs assertion).

Verified: apply-migrations spawn test green; `init --migrate-only` exits 0;
an errored op still exits 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-11 23:06:29 -07:00
co-authored by Claude Fable 5
parent fa27dd2e7b
commit 421a06eebe
10 changed files with 90 additions and 36 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `src/core/search/mode.ts` extension — `graph_signals: boolean` knob in `ModeBundle` (defaults: `conservative=false`, `balanced=true`, `tokenmax=true`). `KNOBS_HASH_VERSION` appends a `gs=` parts entry per the cache-key contamination convention so a graph-on cache write can't be served to a graph-off lookup. `SearchKeyOverrides` + `SearchPerCallOpts` + `loadOverridesFromConfig` + `SEARCH_MODE_CONFIG_KEYS` + `resolveSearchMode` + `attributeKnob` all carry the field. Opt-out: `gbrain config set search.graph_signals false`. Mid-deploy `query_cache` rows from before the upgrade hash differently — natural row segregation, clears within `cache.ttl_seconds` (3600s default).
- `src/core/context-engine.ts` + `src/openclaw-context-engine.ts` — the deterministic context engine OpenClaw loads on every turn (`assemble()` injects the Live Context block, zero-LLM). `createGBrainContextEngine({workspaceDir, resolveEntities?})` accepts an OPTIONAL host-injected resolver (`ENGINE_API_VERSION` 0.2.0, additive — older hosts work unchanged; the plugin entry maps `ctx.resolveEntities`/`ctx.brainQuery` onto it). `assemble()` runs the Retrieval Reflex after the Live Context block: extracts the current turn's user text, builds prior-context text (every message EXCEPT the current turn — suppression must not see the triggering mention), passes the rolling window (`getWindowTurns`, last 12 user/assistant turns; the reflex slices to its configured `retrieval_reflex_window_turns`), and appends the pointer block. `warmReflex()` fires at construction.
- `src/core/context/` — Retrieval Reflex (Layer 1, issue #1981). `entity-salience.ts`: pure, zero-LLM, precision-biased `extractCandidates(text)` (capitalized runs + `@handles`, STOPWORDS + soft COMMON_WORDS + sentence-start guard, deterministic, capped) + `extractCandidatesFromWindow(turns)` (#2095: merges per-turn extraction across the last N turns by normalizeAlias form with occurrence/newest-turn/user-mention metadata; salience-ordered — recency > frequency > user-role — so the cap drops stale assistant chatter first). `retrieval-reflex.ts`: `resolveEntitiesToPointers(engine, sourceId, candidates, opts)` — alias arm (`resolveAliases`, caught per-arm for pre-v110 brains) + exact title/slug-suffix arm (the recall fix: real slugs are namespaced `people/x` but `slugify` drops the prefix); pointers carry `source_id`/`arm`/`confidence`/`matchedNorm` (#2095`ARM_CONFIDENCE` alias 0.9 / title 0.8 / slug-suffix 0.6 lives next to the arm definitions; arm-2 provenance classified in JS since the combined OR can't report which predicate matched); opts: `sourceIds?` federated scope (alias arm loops per source, arm 2 uses `source_id = ANY`), `suppression?` ('slug-and-title' legacy default; 'slug-only' REQUIRED under windowing — the title rule would suppress every entity merely mentioned in a prior window turn), `logChannel?: 'reflex'` (server-side ambient-channel event logging through the volunteer-events sink); synopsis runs through `stripTakesFence`/`stripFactsFence` (the same privacy boundary `get_page` applies) so private facts never reach the prompt; capped at `MAX_POINTERS`. `reflex.ts`: the orchestrator + engine-aware resolver ladder (host `resolveEntities` → PGLite serve IPC → Postgres cached process-singleton → disabled), zero-candidate fast path, fail-open + timeout, heartbeat write for the doctor check, `reflexEnabled(cfg)` (file/env gate, default ON; DB-plane does NOT gate — `assemble()` is sync); windowed extraction when `windowTurns` present and `retrieval_reflex_window_turns` (default 4; 1 = exact legacy behavior) > 1 — switches suppression to slug-only and the direct-Postgres rung logs `channel: 'reflex'` events. `resolve-ipc.ts`: local unix-socket resolve protocol (client + server) so PGLite resolves through the single connection `gbrain serve` holds (a second opener would hit the exclusive lock; a subprocess would force-steal it past the 5-min staleness window and crash). Wired into `src/mcp/server.ts` (serve binds `<dataDir>/.gbrain-resolve.sock` on PGLite, cleaned up on shutdown). Doctor surface: `retrieval_reflex_health` in `src/commands/doctor.ts` (reads the heartbeat for truthful runtime status; categorized in `doctor-categories.ts`). Config: `retrieval_reflex` + `retrieval_reflex_max_pointers` + `retrieval_reflex_window_turns` in `src/core/config.ts` (env `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`). `volunteer.ts` (#2095): `parseWindow` (lenient `user:`/`assistant:` prefixes, unprefixed → one user turn), `volunteerContext` (extract → resolve → +0.05 multi-turn/newest-turn boost → `min_confidence` 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text; slug-only suppression), `volunteerUsageStats` (per-arm/channel precision from the `pages.last_retrieved_at > volunteered_at` join — APPROXIMATE: the 5-min last-retrieved throttle causes false negatives, unrelated reads false positives). `volunteer-events.ts` (#2095): `insertVolunteerEvents` (ONE multi-row parameterized INSERT), `logVolunteerEventsFireAndForget` + bounded drain registered as the `volunteer-events` background-work sink (order 4), `purgeStaleVolunteerEvents` (90-day GC, called from the dream cycle's purge phase). Policy layer ships as the `retrieval-reflex` recipe (`recipes/retrieval-reflex/`). Pinned by `test/context/entity-salience.test.ts`, `test/retrieval-reflex.test.ts`, `test/context/resolve-ipc.test.ts`, `test/doctor-retrieval-reflex.test.ts`, `test/volunteer-context.test.ts`, `test/e2e/volunteer-context-postgres.test.ts`.
- `src/core/cli-force-exit.ts` — the CLI exit contract (#2084). `shouldForceExitAfterMain(argv)`: daemon gate — only `serve` returns from main() while the event loop carries the server; every other long-runner (`jobs work`, `autopilot`, `watch`) BLOCKS in its awaited handler, so the deliberate exit on main()-resolve is correct for them. `flushStdoutThenExit(code, deps?)`: drains stdout AND stderr (`writableLength === 0`, 'drain'-event + 25ms poll loop — 'drain' only fires after backpressure) under a 2s unref'd guard for blocked pipes, then exits. The cli.ts entrypoint calls it when main() resolves — the fix for lingering embedding/PgBouncer sockets riding the 10s backstop on every `gbrain query`; the backstop timers remain as the never-hit safety net for a HUNG teardown. Stdout flush is load-bearing: incident #1959 was a force-exit truncating piped output. Pinned by `test/cli-flush-exit.test.ts`, `test/cli-should-force-exit.test.ts`, `test/cli-pipe-truncation.test.ts` (256KB real-pipe byte-exact), `test/cli-drain-then-disconnect.test.ts`, `test/e2e/pgbouncer-teardown.test.ts` (CI txn-mode pooler, the #1972/#2015/#2084 class).
- `src/core/cli-force-exit.ts` — the CLI exit contract (#2084). `setCliExitCode(n)`/`getCliExitCode()`: gbrain's OWN exit verdict — every gbrain exit-code assignment routes through the setter (mirrored to process.exitCode), and the exit paths read the getter, NEVER ambient `process.exitCode`: PGLite's Emscripten runtime asynchronously writes the WASM backend's proc_exit status into process.exitCode (initdb at create, postmaster at close), so trusting it made a clean `gbrain apply-migrations` exit 99. `shouldForceExitAfterMain(argv)`: daemon gate — only `serve` returns from main() while the event loop carries the server; every other long-runner (`jobs work`, `autopilot`, `watch`) BLOCKS in its awaited handler, so the deliberate exit on main()-resolve is correct for them. `flushStdoutThenExit(code, deps?)`: drains stdout AND stderr (`writableLength === 0`, 'drain'-event + 25ms poll loop — 'drain' only fires after backpressure) under a 2s unref'd guard for blocked pipes, then exits. The cli.ts entrypoint calls it when main() resolves — the fix for lingering embedding/PgBouncer sockets riding the 10s backstop on every `gbrain query`; the backstop timers remain as the never-hit safety net for a HUNG teardown. Stdout flush is load-bearing: incident #1959 was a force-exit truncating piped output. Pinned by `test/cli-flush-exit.test.ts`, `test/cli-should-force-exit.test.ts`, `test/cli-pipe-truncation.test.ts` (256KB real-pipe byte-exact), `test/cli-drain-then-disconnect.test.ts`, `test/e2e/pgbouncer-teardown.test.ts` (CI txn-mode pooler, the #1972/#2015/#2084 class).
- `src/commands/watch.ts``gbrain watch` (#2095): the push transport. Reads turns from stdin as they arrive (`user:`/`assistant:` prefixes; unprefixed = user turn), keeps a rolling in-process window (`--window-turns`, default 4), calls `volunteerContext` per turn, streams pointers to stdout (`--json` for JSONL with turn attribution), logs `channel: 'watch'` events with a per-session id. Session dedupe feeds already-pushed slugs back as priorContext so the core's slug-only suppression dedupes. Blocks in the stdin iteration (interactive alive until Ctrl-C/Ctrl-D; piped ends at EOF) — deliberately NOT in DAEMON_COMMANDS; SIGINT closes the stream so teardown flows through drainThenDisconnect. Per-turn resolution failures are fail-open. Registered in CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS (thin clients use the `volunteer_context` MCP op). Pinned by `test/watch-command.test.ts`.
- `src/commands/integrations.ts` — recipe install. The resolver-row install fence is keyed by `manifest.recipe` (`gbrain:<recipe>:resolver-rows`), so a second `copy-into-host-repo` recipe no longer writes a block mislabeled with the first recipe's name. Pinned by `test/integrations-install.test.ts`.
- `src/core/audit/audit-writer.ts` — shared JSONL audit primitive consolidating the hand-rolled audit modules. Exports `createAuditWriter({kind, recordSchema})` returning `{log, readRecent}` plus shared helpers `computeIsoWeekFilename(kind, now?)` and `resolveAuditDir()` (honors `GBRAIN_AUDIT_DIR`). ISO-week file rotation; best-effort writes (stderr warn on failure, never throws); read-path scans current-week + previous-week files for boundary spans. Refactored onto it for parity: `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts` (each module's public API preserved bit-for-bit). The `graph-signals-failures` audit (`logGraphSignalsFailure`) uses the same primitive. One hand-rolled audit remains at `src/core/skillpack/audit.ts`. Pinned by `test/audit/audit-writer.test.ts`.
+14 -14
View File
@@ -26,7 +26,7 @@ import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { drainAllBackgroundWorkForCliExit } from './core/background-work.ts';
import { shouldForceExitAfterMain, flushStdoutThenExit } from './core/cli-force-exit.ts';
import { shouldForceExitAfterMain, flushStdoutThenExit, setCliExitCode, getCliExitCode } from './core/cli-force-exit.ts';
import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import type { CliOptions } from './core/cli-options.ts';
@@ -376,7 +376,7 @@ async function main() {
} else {
console.error(e instanceof Error ? e.message : String(e));
}
process.exitCode = 1;
setCliExitCode(1);
} finally {
// 1s per-sink drain timeout: read paths with no pending work pay the
// ~0ms fast path; capture/import that DO enqueue pay up to 1s (+ facts
@@ -419,7 +419,7 @@ export async function drainThenDisconnect(
);
// Honor an exit code an errored op already set — a bare process.exit(0)
// here would mask a failed op as success if the drain/disconnect hangs.
process.exit(process.exitCode ?? 0);
process.exit(getCliExitCode());
}, DISCONNECT_HARD_DEADLINE_MS);
// unref so the timer itself doesn't keep the event loop alive — only
// the actual pending work (PGLite WASM handle) does.
@@ -1169,12 +1169,12 @@ async function handleCliOnly(command: string, args: string[]) {
// v0.43 (#2084 inner-exit sweep): exitCode + return instead of a
// mid-handler process.exit — flows through the entrypoint flush-exit
// so buffered stdout is never truncated.
process.exitCode = runFriction(args);
setCliExitCode(runFriction(args));
return;
}
if (command === 'claw-test') {
const { runClawTest } = await import('./commands/claw-test.ts');
process.exitCode = await runClawTest(args);
setCliExitCode(await runClawTest(args));
return;
}
if (command === 'report') {
@@ -1276,7 +1276,7 @@ async function handleCliOnly(command: string, args: string[]) {
execSync(`bash "${scriptPath}"`, { stdio: 'inherit', env: { ...process.env } });
} catch (e: any) {
// Non-zero exit = some tests failed (exit code = failure count)
process.exitCode = e.status ?? 1;
setCliExitCode(e.status ?? 1);
}
return;
}
@@ -1324,7 +1324,7 @@ async function handleCliOnly(command: string, args: string[]) {
// The handler self-configures the AI gateway from loadConfig() + process.env.
if (command === 'eval' && args[0] === 'cross-modal') {
const { runEvalCrossModal } = await import('./commands/eval-cross-modal.ts');
process.exitCode = await runEvalCrossModal(args.slice(1));
setCliExitCode(await runEvalCrossModal(args.slice(1)));
return;
}
@@ -1336,7 +1336,7 @@ async function handleCliOnly(command: string, args: string[]) {
// engine-required path below.
if (command === 'eval' && args[0] === 'takes-quality' && args[1] === 'replay') {
const { runReplayNoBrain } = await import('./commands/eval-takes-quality.ts');
process.exitCode = await runReplayNoBrain(args.slice(2));
setCliExitCode(await runReplayNoBrain(args.slice(2)));
return;
}
@@ -1369,7 +1369,7 @@ async function handleCliOnly(command: string, args: string[]) {
// gate runs on machines with no `~/.gbrain/config.json`.
if (command === 'eval' && args[0] === 'conversation-parser') {
const { runEvalConversationParser } = await import('./commands/eval-conversation-parser.ts');
process.exitCode = await runEvalConversationParser(args.slice(1));
setCliExitCode(await runEvalConversationParser(args.slice(1)));
return;
}
@@ -1398,7 +1398,7 @@ async function handleCliOnly(command: string, args: string[]) {
const cfgPre = loadConfig();
if (isThinClient(cfgPre)) {
const { runEvalWhoknows } = await import('./commands/eval-whoknows.ts');
process.exitCode = await runEvalWhoknows(null, args.slice(1));
setCliExitCode(await runEvalWhoknows(null, args.slice(1)));
return;
}
}
@@ -1413,7 +1413,7 @@ async function handleCliOnly(command: string, args: string[]) {
if (cfgPre && isThinClient(cfgPre)) {
const { runStatus } = await import('./commands/status.ts');
const result = await runStatus(null, args);
process.exitCode = result.exitCode;
setCliExitCode(result.exitCode);
return;
}
}
@@ -1547,7 +1547,7 @@ async function handleCliOnly(command: string, args: string[]) {
// so wrappers (sync, CI scripts, `&& gbrain doctor`) propagate.
const importResult = await runImport(engine, args);
if (importResult.errors > 0) {
process.exitCode = 1;
setCliExitCode(1);
}
break;
}
@@ -1732,7 +1732,7 @@ async function handleCliOnly(command: string, args: string[]) {
// v0.43 (#2084 inner-exit sweep): a mid-switch process.exit skipped
// the finally's drain + disconnect entirely. exitCode + break flows
// through both, then the entrypoint flush-exit ends the process.
process.exitCode = result.exitCode;
setCliExitCode(result.exitCode);
break;
}
// v0.38 — Capture: single human-facing entrypoint for ingestion.
@@ -2317,7 +2317,7 @@ if (import.meta.main) {
// are excluded by shouldForceExitAfterMain and keep the event loop.
main().then(
() => {
if (shouldForceExitAfterMain()) void flushStdoutThenExit(Number(process.exitCode ?? 0));
if (shouldForceExitAfterMain()) void flushStdoutThenExit(getCliExitCode());
},
(e) => {
console.error(e instanceof Error ? (e.message || String(e)) : String(e));
+3 -2
View File
@@ -37,6 +37,7 @@ import { logSelfUpgrade } from '../core/audit/self-upgrade-audit.ts';
import { detectInstallMethod } from './upgrade.ts';
import { evaluateQuietHours } from '../core/minions/quiet-hours.ts';
import { inspectLock } from '../core/db-lock.ts';
import { setCliExitCode } from '../core/cli-force-exit.ts';
/**
* v0.37.7.0 #1162 — classify autopilot reconnect-loop errors.
@@ -542,7 +543,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
`Exiting so launchd ThrottleInterval can apply backoff.`,
);
stopping = true;
process.exitCode = 1;
setCliExitCode(1);
break;
}
if (autopilotReconnectFails >= AUTOPILOT_MAX_RECONNECT_FAILS) {
@@ -551,7 +552,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
`Last error: ${(e as Error).message ?? 'unknown'}. Exiting.`,
);
stopping = true;
process.exitCode = 1;
setCliExitCode(1);
break;
}
}
+2 -1
View File
@@ -27,6 +27,7 @@ import { serializeMarkdown } from '../core/markdown.ts';
import { importFromContent } from '../core/import-file.ts';
import { writePageThrough, type WriteThroughResult } from '../core/write-through.ts';
import { randomBytes } from 'crypto';
import { setCliExitCode } from '../core/cli-force-exit.ts';
export interface BrainstormCliArgs {
question?: string;
@@ -322,7 +323,7 @@ async function runBrainstormCli(
const msg = formatSaveOutcome(outcome, { profileLabel: profile.label, slug });
if (msg.stdout) console.log(msg.stdout);
for (const line of msg.stderr) console.error(line);
if (msg.exitCode) process.exitCode = msg.exitCode;
if (msg.exitCode) setCliExitCode(msg.exitCode);
}
}
+6 -5
View File
@@ -30,6 +30,7 @@ import {
type AuditFix,
} from '../core/brain-writer.ts';
import { isSyncable, pruneDir, slugifyPath } from '../core/sync.ts';
import { setCliExitCode } from '../core/cli-force-exit.ts';
export async function runFrontmatter(args: string[]): Promise<void> {
const sub = args[0];
@@ -63,7 +64,7 @@ export async function runFrontmatter(args: string[]): Promise<void> {
}
console.error(`Unknown frontmatter subcommand: ${sub}\n`);
printHelp();
process.exitCode = 1;
setCliExitCode(1);
}
async function connectEngineForAudit(): Promise<BrainEngine> {
@@ -164,14 +165,14 @@ async function runValidate(rest: string[]): Promise<void> {
}
if (!target) {
console.error('error: gbrain frontmatter validate requires a <path> argument');
process.exitCode = 1;
setCliExitCode(1);
return;
}
const resolved = resolve(target);
if (!existsSync(resolved)) {
console.error(`error: path not found: ${target}`);
process.exitCode = 1;
setCliExitCode(1);
return;
}
@@ -242,7 +243,7 @@ async function runValidate(rest: string[]): Promise<void> {
}
}
process.exitCode = totalErrors > 0 && !flags.fix ? 1 : 0;
setCliExitCode(totalErrors > 0 && !flags.fix ? 1 : 0);
}
/**
@@ -378,7 +379,7 @@ async function runGenerate(args: string[]): Promise<void> {
if (!targetPath) {
console.error('error: gbrain frontmatter generate requires a <path> argument');
console.error('usage: gbrain frontmatter generate <path> [--fix] [--dry-run] [--json]');
process.exitCode = 1;
setCliExitCode(1);
return;
}
+2 -1
View File
@@ -29,6 +29,7 @@ import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { setCliExitCode } from '../core/cli-force-exit.ts';
// v0.41.15.0 (T10, D9): per-batch parallel workers.
import { runSlidingPool } from '../core/worker-pool.ts';
import { resolveWorkersWithClamp } from '../core/sync-concurrency.ts';
@@ -150,7 +151,7 @@ export async function runReindex(engine: BrainEngine, args: string[]): Promise<R
} else {
process.stderr.write('Usage: gbrain reindex --markdown [--limit N] [--dry-run] [--json] [--repo PATH]\n');
}
process.exitCode = 2;
setCliExitCode(2);
return { pending: 0, reindexed: 0, skipped: 0, failed: 0, dryRun: !!opts.dryRun, chunkerVersion: MARKDOWN_CHUNKER_VERSION };
}
+2 -1
View File
@@ -14,6 +14,7 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { setCliExitCode } from '../core/cli-force-exit.ts';
interface RunOpts {
days?: number;
@@ -63,7 +64,7 @@ export async function runTranscripts(engine: BrainEngine, args: string[]): Promi
const sub = args[0];
if (sub !== 'recent') {
console.log(HELP);
if (sub && sub !== '--help' && sub !== '-h') process.exitCode = 2;
if (sub && sub !== '--help' && sub !== '-h') setCliExitCode(2);
return;
}
+32
View File
@@ -32,6 +32,38 @@ export function shouldForceExitAfterMain(
return !DAEMON_COMMANDS.has(command);
}
/**
* v0.43 (#2084) — gbrain owns its exit verdict.
*
* `process.exitCode` is NOT trustworthy in this process: PGLite's Emscripten
* runtime writes the WASM backend's proc_exit status into it (initdb at
* create-time, the postmaster at close-time — see `exitCode=status` in
* pglite's dist), and those writes land asynchronously outside any
* snapshot/restore window. Pre-#2084 the success path never read
* process.exitCode so the pollution was invisible; the deliberate flush-exit
* MUST NOT propagate it (a clean `gbrain apply-migrations` was exiting 99).
*
* So gbrain records its own verdict here: every gbrain-owned exit-code
* assignment routes through `setCliExitCode()` (which also mirrors to
* process.exitCode for anything else that reads it), and the exit paths read
* `getCliExitCode()` — never ambient process.exitCode.
*/
let _cliExitCode: number | undefined;
export function setCliExitCode(code: number): void {
_cliExitCode = code;
process.exitCode = code;
}
export function getCliExitCode(): number {
return _cliExitCode ?? 0;
}
/** Test seam — reset the recorded verdict between cases. */
export function _resetCliExitCodeForTests(): void {
_cliExitCode = undefined;
}
/**
* v0.43 (#2084) — deliberate exit after bounded teardown.
*
+6
View File
@@ -234,6 +234,12 @@ export class PGLiteEngine implements BrainEngine {
}
}
// NOTE (#2084): PGLite's Emscripten runtime writes the WASM backend's
// proc_exit status into `process.exitCode` (initdb here at create-time,
// the postmaster at close-time), and the writes land asynchronously —
// a snapshot/restore around these awaits does NOT contain them. That is
// why the CLI's exit paths read gbrain's own verdict
// (cli-force-exit.ts getCliExitCode), never ambient process.exitCode.
try {
this._db = await PGlite.create({
dataDir,
+22 -11
View File
@@ -125,15 +125,20 @@ describe('v0.36.1.x #1124 — query --no-expand actually negates expand', () =>
describe('v0.42.20.0 — background-work registry drains every sink before disconnect', () => {
// Supersedes the v0.41.8.0 #1247/#1269/#1290 per-call last-retrieved drain:
// last-retrieved is now one of four registry sinks; cli.ts drains the whole
// registry (drainAllBackgroundWorkForCliExit) before disconnect on BOTH the
// op-dispatch path AND the CLI_ONLY path (the latter closes #1762 for capture).
test('cli.ts imports + uses drainAllBackgroundWorkForCliExit', () => {
// last-retrieved is one of the registry sinks. Since the v0.43 #2084 drain
// hoist, cli.ts routes EVERY owner-disconnect through drainThenDisconnect
// (one helper, all 8 sites) instead of two inline drain+disconnect pairs.
test('cli.ts imports the registry drain + routes owner-disconnects through drainThenDisconnect', () => {
const src = readFileSync('src/cli.ts', 'utf8');
expect(src).toMatch(/import\s+\{\s*drainAllBackgroundWorkForCliExit\s*\}\s*from\s+['"]\.\/core\/background-work\.ts['"]/);
// Two call sites: op-dispatch finally + handleCliOnly finally.
const calls = src.match(/await\s+drainAllBackgroundWorkForCliExit\s*\(/g) ?? [];
expect(calls.length).toBeGreaterThanOrEqual(2);
// The single registry-drain call site lives inside the shared helper...
expect(src).toMatch(/export async function drainThenDisconnect/);
expect(src).toMatch(/await\s+drainAllBackgroundWorkForCliExit\s*\(/);
// ...and the helper covers every owner-disconnect (op-dispatch, CLI_ONLY
// fall-through, search dashboard, doctor x3, ze-switch, dream,
// read-only-timeout path).
const calls = src.match(/await\s+drainThenDisconnect\s*\(/g) ?? [];
expect(calls.length).toBeGreaterThanOrEqual(8);
});
test('last-retrieved.ts still exports the bounded drain + registers a drainer', () => {
@@ -155,11 +160,13 @@ describe('v0.42.20.0 — background-work registry drains every sink before disco
.toMatch(/name:\s*'eval-capture'/);
});
test('cli.ts behavioral positioning: registry drain appears BEFORE engine.disconnect (op-dispatch)', () => {
test('cli.ts behavioral positioning: drainThenDisconnect drains the registry BEFORE engine.disconnect', () => {
const src = readFileSync('src/cli.ts', 'utf8');
const localPath = src.match(/\/\/ Local engine path \(unchanged behavior[\s\S]+?^\}/m);
expect(localPath).not.toBeNull();
const block = localPath![0];
// The ordering invariant lives ONCE, inside the shared helper (v0.43
// #2084 drain hoist): registry drain, THEN best-effort disconnect.
const helper = src.match(/export async function drainThenDisconnect[\s\S]+?^\}/m);
expect(helper).not.toBeNull();
const block = helper![0];
const drainCallRe = /await\s+drainAllBackgroundWorkForCliExit\s*\(/;
const disconnectCallRe = /await\s+engine\.disconnect\s*\(/;
expect(block).toMatch(drainCallRe);
@@ -167,6 +174,10 @@ describe('v0.42.20.0 — background-work registry drains every sink before disco
const drainIdx = block.indexOf(block.match(drainCallRe)![0]);
const disconnectIdx = block.indexOf(block.match(disconnectCallRe)![0]);
expect(drainIdx).toBeLessThan(disconnectIdx);
// And the op-dispatch finally routes through the helper.
const localPath = src.match(/\/\/ Local engine path \(unchanged behavior[\s\S]+?\n \}\n\}/m);
expect(localPath).not.toBeNull();
expect(localPath![0]).toMatch(/await\s+drainThenDisconnect\s*\(/);
});
test('background-work.ts: Map registry, ordered drain, awaited abort, test seam', () => {