Files
gbrain/test/core/background-work.test.ts
T
ec5fed2921 v0.42.20.0 fix: reliability wave — PGLite capture lock-pin + Postgres reconnect race + search embed-hang (#1762 #1745 #1775) (#1810)
* fix(core): drain fire-and-forget sinks before disconnect via a background-work registry (#1762)

New src/core/background-work.ts registry (Map<name,drainer>, ordered drain,
awaited abort). facts-queue (order 0, abort=shutdown), last-retrieved (1), and
eval-capture (3, now self-tracked) register as sinks. Both CLI exit paths
(op-dispatch finally + handleCliOnly finally) drain the registry before
engine.disconnect() so a PGLite db.close() can't race in-flight work into the
re-pump busy-loop that pinned the single-writer lock. Op-dispatch error path
converts process.exit(1) to exitCode+return so the finally still drains.

* fix(ai): bound every outbound AI call so a stalled provider can't hang (#1762/#1775)

withDefaultTimeout composes a per-touchpoint default deadline (chat 300s,
embed/multimodal 60s) with any caller signal via AbortSignal.any. Applied at the
SDK call layer (chat generateText, expand generateObject, OCR, per-sub-batch
embed) — covers native-anthropic + retries — plus per-request multimodal fetch.
embedQuery forwards abortSignal. Env: GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS.

* fix(postgres): module-mode reconnect preserves the shared singleton (#1745)

reconnect() branches on connection style. Module-singleton engines re-establish
idempotently via db.connect() (no-op when alive) + refresh the ConnectionManager
read pool, never db.disconnect() — so a transient blip no longer nulls the shared
sql out from under concurrent ops (which threw 'connect() has not been called').
Fail-loud on real connect failure. Instance pools keep teardown+recreate.

* fix(search): bound the query-time embed so a stall falls back to keyword (#1775)

search/query default to cheap-hybrid (embeds the query); a stalled provider made
the embed never settle, so the keyword fallback never engaged and the command
force-exited with no output. One shared QueryEmbedDeadline (6s, floored 2s per
embed) covers both the cache-lookup and inner embeds via embedQueryBounded
(abortSignal + Promise.race) → existing keyword fallback engages. Also registers
the search-cache background-work drainer (now bounded). Env: GBRAIN_QUERY_EMBED_TIMEOUT_MS.

* test+chore: reliability wave tests + v0.42.11.0 (#1762 #1745 #1775)

New: background-work registry unit, query-embed deadline unit, eval-capture
drain unit, postgres reconnect E2E (#1745), gbrain capture exit-clean case in
the PGLite serial test. Updated fix-wave-structural assertions to the registry
shape. VERSION/package.json/CHANGELOG -> 0.42.11.0; TODOS retrofit marked done.

Incorporates + hardens PR #1763 (drain-before-disconnect + embed fetch timeout);
the residual hung-Haiku hole is closed by the facts shutdown() abort belt.

Co-Authored-By: ElliotDrel <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document background-work registry + v0.42.11.0 reliability wave in CLAUDE.md (regen llms)

* chore: bump release version 0.42.11.0 -> 0.42.20.0

Rename the reliability-wave release version per request. Trio
(VERSION / package.json / CHANGELOG) reconciled; in-code version-tag
comments and test fixtures updated; llms regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ElliotDrel <noreply@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:42:25 -07:00

137 lines
5.2 KiB
TypeScript

/**
* v0.42.20.0 (#1762) — background-work registry unit tests.
*
* Pure, no DB / no engine: drives the registry via the __registerDrainerForTest
* seam. Pins the contracts the reliability wave depends on:
* - drains in explicit (order, name) order — facts (0) first
* - a drainer reporting unfinished>0 has its abort() AWAITED
* - a throwing drainer doesn't block the others
* - empty registry is a fast no-op
* - Map idempotency: re-registering the same name REPLACES (no duplicate)
* - the unregister handle removes it
*/
import { describe, test, expect } from 'bun:test';
import {
drainAllBackgroundWorkForCliExit,
__registerDrainerForTest,
__listDrainerNamesForTest,
type BackgroundWorkDrainer,
} from '../../src/core/background-work.ts';
function makeRecorder() {
const calls: string[] = [];
return { calls };
}
describe('background-work registry', () => {
test('drains in explicit (order, name) order; facts-order-0 first', async () => {
const { calls } = makeRecorder();
const mk = (name: string, order: number): [BackgroundWorkDrainer, () => void] => {
const d: BackgroundWorkDrainer = {
name,
order,
drain: async () => { calls.push(`drain:${name}`); return { unfinished: 0 }; },
};
return [d, __registerDrainerForTest(d)];
};
const [, u1] = mk('zzz', 2);
const [, u0] = mk('facts', 0);
const [, u15] = mk('mid', 1);
try {
await drainAllBackgroundWorkForCliExit({ timeoutMs: 50 });
// Only assert ordering among the ones we registered (production sinks may
// also be registered when their modules were imported).
const ours = calls.filter((c) => ['drain:facts', 'drain:mid', 'drain:zzz'].includes(c));
expect(ours).toEqual(['drain:facts', 'drain:mid', 'drain:zzz']);
} finally {
u0(); u15(); u1();
}
});
test('abort() is AWAITED only when unfinished>0', async () => {
const seq: string[] = [];
const withUnfinished: BackgroundWorkDrainer = {
name: 'test-straggler',
order: 0,
drain: async () => { seq.push('drain'); return { unfinished: 3 }; },
abort: async () => {
await new Promise((r) => setTimeout(r, 5));
seq.push('abort-done');
},
};
const noUnfinished: BackgroundWorkDrainer = {
name: 'test-clean',
order: 1,
drain: async () => { seq.push('drain-clean'); return { unfinished: 0 }; },
abort: async () => { seq.push('abort-clean-SHOULD-NOT-RUN'); },
};
const u1 = __registerDrainerForTest(withUnfinished);
const u2 = __registerDrainerForTest(noUnfinished);
try {
await drainAllBackgroundWorkForCliExit({ timeoutMs: 50 });
// straggler: drain then abort (awaited → abort-done present); clean: no abort.
expect(seq).toContain('drain');
expect(seq).toContain('abort-done');
expect(seq).toContain('drain-clean');
expect(seq).not.toContain('abort-clean-SHOULD-NOT-RUN');
// abort-done comes after its own drain (awaited).
expect(seq.indexOf('abort-done')).toBeGreaterThan(seq.indexOf('drain'));
} finally {
u1(); u2();
}
});
test('a throwing drainer does not block the others', async () => {
const seen: string[] = [];
const boom: BackgroundWorkDrainer = {
name: 'test-boom',
order: 0,
drain: async () => { throw new Error('drain blew up'); },
};
const ok: BackgroundWorkDrainer = {
name: 'test-ok-after-boom',
order: 1,
drain: async () => { seen.push('ok'); return { unfinished: 0 }; },
};
const u1 = __registerDrainerForTest(boom);
const u2 = __registerDrainerForTest(ok);
try {
// Must not reject despite the throwing drainer.
await drainAllBackgroundWorkForCliExit({ timeoutMs: 50 });
expect(seen).toContain('ok');
} finally {
u1(); u2();
}
});
test('Map idempotency: re-registering the same name replaces, no duplicate', () => {
const before = __listDrainerNamesForTest().filter((n) => n === 'test-dup').length;
expect(before).toBe(0);
const d1: BackgroundWorkDrainer = { name: 'test-dup', order: 0, drain: async () => ({ unfinished: 0 }) };
const d2: BackgroundWorkDrainer = { name: 'test-dup', order: 9, drain: async () => ({ unfinished: 0 }) };
const u1 = __registerDrainerForTest(d1);
const u2 = __registerDrainerForTest(d2);
try {
const count = __listDrainerNamesForTest().filter((n) => n === 'test-dup').length;
expect(count).toBe(1); // replaced, not duplicated
} finally {
u1(); u2();
}
});
test('unregister handle removes the drainer', () => {
const d: BackgroundWorkDrainer = { name: 'test-unreg', order: 0, drain: async () => ({ unfinished: 0 }) };
const unreg = __registerDrainerForTest(d);
expect(__listDrainerNamesForTest()).toContain('test-unreg');
unreg();
expect(__listDrainerNamesForTest()).not.toContain('test-unreg');
});
test('empty registry (no test drainers) resolves fast', async () => {
// Production sinks may be registered, but they fast-path on empty pending
// sets. This just asserts the call resolves without throwing.
await drainAllBackgroundWorkForCliExit({ timeoutMs: 10 });
expect(true).toBe(true);
});
});