diff --git a/src/core/cli-force-exit.ts b/src/core/cli-force-exit.ts index a83edbdaf..982b7da87 100644 --- a/src/core/cli-force-exit.ts +++ b/src/core/cli-force-exit.ts @@ -114,6 +114,26 @@ function resolveFlushGraceMs(): number { /** Default per-sink drain budget (matches drainAllBackgroundWorkForCliExit). */ const DEFAULT_DRAIN_TIMEOUT_MS = 2_000; +/** + * Resolve the per-sink drain budget: `GBRAIN_DRAIN_TIMEOUT_MS` env override + * (slow-provider escape hatch, same env-only pattern as + * GBRAIN_TEARDOWN_DEADLINE_MS) over the 2000ms default. An explicit + * `drainTimeoutMs` from a call site still wins — the env replaces only the + * DEFAULT. The 2s default assumes a sub-second cloud chat provider; a + * self-hosted model (e.g. ollama at 10-20s per completion) can never finish a + * fire-and-forget facts:absorb extraction inside it, so every one-shot CLI + * exit — sync timers especially — aborts the in-flight chat and the + * extraction never lands, retrying (and re-aborting) on each subsequent sync + * of the same page. Raising the budget via env lets those installs drain + * instead of abort; computeTeardownDeadlineMs already scales the backstop + * from the resolved value, so the deadline widens with it. + */ +export function resolveDrainTimeoutMs(): number { + const env = Number(process.env.GBRAIN_DRAIN_TIMEOUT_MS); + if (Number.isFinite(env) && env > 0) return env; + return DEFAULT_DRAIN_TIMEOUT_MS; +} + /** * Backstop deadline for drain + disconnect COMBINED, computed from the bounds * it guards so it fires only when a component violated its own bound (#2084 @@ -262,7 +282,10 @@ export function flushThenExit(code: number, opts: FlushThenExitOpts = {}): void export interface FinishCliTeardownOpts { /** Engine to disconnect. A disconnect throw is warned + swallowed (D3). */ engine: { disconnect(): Promise }; - /** Per-sink drain budget. Default 2000 (the registry default). */ + /** + * Per-sink drain budget. Default: `GBRAIN_DRAIN_TIMEOUT_MS` env override, + * else 2000 (the registry default). + */ drainTimeoutMs?: number; /** Test seam — wins over the env override and the computed formula. */ deadlineMs?: number; @@ -284,7 +307,7 @@ export interface FinishCliTeardownOpts { * exit in here, and it means a component violated its own bound. */ export async function finishCliTeardown(opts: FinishCliTeardownOpts): Promise { - const drainTimeoutMs = opts.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS; + const drainTimeoutMs = opts.drainTimeoutMs ?? resolveDrainTimeoutMs(); const warn = opts.warn ?? ((m: string) => console.warn(m)); const drain = opts.drain ?? drainAllBackgroundWorkForCliExit; const deadlineMs = diff --git a/test/cli-finish-teardown.test.ts b/test/cli-finish-teardown.test.ts index fa1b4e4df..c1ee39e73 100644 --- a/test/cli-finish-teardown.test.ts +++ b/test/cli-finish-teardown.test.ts @@ -14,6 +14,7 @@ import { finishCliTeardown, flushThenExit, computeTeardownDeadlineMs, + resolveDrainTimeoutMs, TEARDOWN_DEADLINE_FLOOR_MS, setCliExitVerdict, currentExitCode, @@ -115,6 +116,67 @@ describe('computeTeardownDeadlineMs', () => { }); }); +describe('resolveDrainTimeoutMs', () => { + test('defaults to the 2000ms registry budget', () => { + expect(resolveDrainTimeoutMs()).toBe(2_000); + }); + + test('GBRAIN_DRAIN_TIMEOUT_MS env override wins over the default', async () => { + await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '30000' }, async () => { + expect(resolveDrainTimeoutMs()).toBe(30_000); + }); + }); + + test('garbage, zero, and negative env values fall back to the default', async () => { + await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: 'banana' }, async () => { + expect(resolveDrainTimeoutMs()).toBe(2_000); + }); + await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '0' }, async () => { + expect(resolveDrainTimeoutMs()).toBe(2_000); + }); + await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '-5' }, async () => { + expect(resolveDrainTimeoutMs()).toBe(2_000); + }); + }); + + test('finishCliTeardown drains with the env-resolved budget when no explicit drainTimeoutMs', async () => { + await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '12345' }, async () => { + let drainBudget = -1; + await finishCliTeardown({ + engine: { disconnect: async () => {} }, + deadlineMs: 250, + drain: async ({ timeoutMs }) => { + drainBudget = timeoutMs; + }, + exit: () => {}, + warn: () => {}, + stdout: fakeStream(), + stderr: fakeStream(), + }); + expect(drainBudget).toBe(12_345); + }); + }); + + test('an explicit drainTimeoutMs still wins over the env override', async () => { + await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '12345' }, async () => { + let drainBudget = -1; + await finishCliTeardown({ + engine: { disconnect: async () => {} }, + drainTimeoutMs: 777, + deadlineMs: 250, + drain: async ({ timeoutMs }) => { + drainBudget = timeoutMs; + }, + exit: () => {}, + warn: () => {}, + stdout: fakeStream(), + stderr: fakeStream(), + }); + expect(drainBudget).toBe(777); + }); + }); +}); + describe('finishCliTeardown — clean path', () => { test('drains with the injected budget, disconnects, returns; no exit, no warn', async () => { const calls: string[] = [];