Files
gbrain/test/brain-writer-partial-scan.test.ts
d165e99f0b fix(brain-writer): deadline race verdict from the sentinel, not the clock the timer raced (#2947)
Part of #2946. The hung-COUNT deadline race derived its verdict from a
post-await wall-clock re-check, which races the timer's own drift: on
loaded CI runners setTimeout callbacks fire measurably EARLY relative to
Date.now(), so each padding/boundary adjustment (>=, +1ms) only moved
which wrong status the partial-scan test received ('scanned', then
'partial').

The race's timeout arm now resolves a module-private sentinel; the
sentinel winning IS the deadline verdict (deadlineHit), consulted by the
post-await check without re-reading the clock. A COUNT that resolves
null (failed/absent count) stays distinguishable and does not skip the
scan; a COUNT that resolves slowly without the timer winning is still
caught by the retained wall-clock re-check. The +1ms pad is gone — the
sentinel makes timer drift irrelevant for the hung path.

Verified: partial-scan suite green 8 consecutive runs incl. the new
null-vs-sentinel distinction test.


Claude-Session: https://claude.ai/code/session_01KFWV7zBZFcmD94Vek6xRDT

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:08:41 -07:00

225 lines
9.6 KiB
TypeScript

/**
* v0.38.2.0 — partial-scan state tests for scanBrainSources.
*
* Codex outside-voice C1 caught that AbortSignal.timeout cannot interrupt
* the sync walker (event loop blocked by readdirSync / readFileSync). The
* load-bearing interruption mechanism is `deadline?: number` checked
* inside scanOneSource's visit closure before parsing each file.
*
* These tests use `deadline: Date.now() - 1` (already-expired) to force
* partial state deterministically — NOT AbortSignal, which doesn't fire
* in the sync loop and would make this test flake or never trigger.
*
* They also cover codex C2 (`ok` after abort must be false even on clean
* prefix), C4 (`files_scanned` numerator surfaced), and the
* `aborted_at_source` field that lets doctor name the partial source.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { scanBrainSources } from '../src/core/brain-writer.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
let sourceA: string;
let sourceB: string;
let sourceC: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Three source dirs, each with a few markdown files.
sourceA = mkdtempSync(join(tmpdir(), 'partial-scan-a-'));
sourceB = mkdtempSync(join(tmpdir(), 'partial-scan-b-'));
sourceC = mkdtempSync(join(tmpdir(), 'partial-scan-c-'));
for (const dir of [sourceA, sourceB, sourceC]) {
mkdirSync(join(dir, 'people'), { recursive: true });
for (let i = 0; i < 5; i++) {
writeFileSync(
join(dir, 'people', `p${i}.md`),
`---\ntitle: Person ${i}\n---\n\nbody\n`,
);
}
}
});
afterAll(async () => {
await engine.disconnect();
for (const d of [sourceA, sourceB, sourceC]) {
rmSync(d, { recursive: true, force: true });
}
});
beforeEach(async () => {
await resetPgliteState(engine);
// Register all three sources for each test.
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path) VALUES ('src-a', 'A', $1), ('src-b', 'B', $2), ('src-c', 'C', $3)`,
[sourceA, sourceB, sourceC],
);
});
describe('scanBrainSources partial-scan state', () => {
test('no deadline + no abort: every source scanned, partial=false, ok reflects grandTotal', async () => {
const report = await scanBrainSources(engine);
expect(report.partial).toBe(false);
expect(report.aborted_at_source).toBe(null);
expect(report.per_source.length).toBe(3);
for (const src of report.per_source) {
expect(src.status).toBe('scanned');
expect(src.files_scanned).toBe(5);
}
expect(report.total).toBe(0);
expect(report.ok).toBe(true);
});
test('deadline expired before any source starts: all three skipped', async () => {
const report = await scanBrainSources(engine, {
deadline: Date.now() - 1, // already expired
});
expect(report.partial).toBe(true);
expect(report.per_source.length).toBe(3);
for (const src of report.per_source) {
expect(src.status).toBe('skipped');
expect(src.files_scanned).toBe(0);
}
// ok must be false even though zero errors were found — partial state
// means the clean count can't speak for unscanned files (codex C2).
expect(report.ok).toBe(false);
});
test('after-abort ok field is false even on clean prefix (codex C2 regression guard)', async () => {
// Force the abort path: deadline already expired. Even though no
// errors found (because no files scanned), `ok` must reflect the
// partial-scan reality.
const report = await scanBrainSources(engine, {
deadline: Date.now() - 1,
});
expect(report.total).toBe(0);
expect(report.partial).toBe(true);
expect(report.ok).toBe(false);
});
test('files_scanned numerator populated on completed sources (codex C4 regression guard)', async () => {
const report = await scanBrainSources(engine);
for (const src of report.per_source) {
// Each source has 5 .md files under people/; all syncable.
expect(src.files_scanned).toBe(5);
}
});
test('dbPageCountForSource hook plumbed onto db_page_count; failure degrades to null', async () => {
let calls = 0;
const report = await scanBrainSources(engine, {
dbPageCountForSource: async (sourceId) => {
calls++;
if (sourceId === 'src-b') throw new Error('synthetic query failure');
return sourceId === 'src-a' ? 42 : 99;
},
});
expect(calls).toBe(3);
const a = report.per_source.find(r => r.source_id === 'src-a')!;
const b = report.per_source.find(r => r.source_id === 'src-b')!;
const c = report.per_source.find(r => r.source_id === 'src-c')!;
expect(a.db_page_count).toBe(42);
// Throw → null, no crash, scan continues.
expect(b.db_page_count).toBe(null);
expect(c.db_page_count).toBe(99);
// files_scanned numerator still populated regardless of denominator outcome.
expect(b.files_scanned).toBe(5);
});
// Codex adversarial #3 regression: when the outer-loop deadline check fires
// BEFORE any source starts, aborted_at_source MUST stamp the first
// would-have-been-scanned source so the doctor message can name it.
test('aborted_at_source stamped when deadline fires before any source starts', async () => {
const report = await scanBrainSources(engine, {
deadline: Date.now() - 1,
});
expect(report.partial).toBe(true);
// First source in deterministic order (sources ORDER BY id) is 'src-a'.
expect(report.aborted_at_source).toBe('src-a');
// Every source skipped, no scans started.
expect(report.per_source.every(r => r.status === 'skipped')).toBe(true);
});
// Codex adversarial #2 regression: a slow dbPageCountForSource that exceeds
// the deadline must NOT result in scanOneSource running and reporting
// status='partial' with files_scanned=0 (misleading — nothing was scanned).
// The post-await deadline re-check should mark the source as 'skipped'.
test('slow COUNT that exceeds deadline marks source skipped, not partial', async () => {
const start = Date.now();
// CI timing fix (same class as the "hanging COUNT" test below): widened
// 50→250ms deadline + 100→500ms simulated query delay. Keeps the 2x
// ratio that proves "query exceeds deadline" while giving CI runners
// enough headroom to schedule both setTimeout callbacks on-budget.
const report = await scanBrainSources(engine, {
deadline: start + 250,
dbPageCountForSource: async () => {
// Simulate a hung query: take 500ms (past the deadline).
await new Promise(resolve => setTimeout(resolve, 500));
return 42;
},
});
// The first source should be skipped (post-await deadline re-check fires),
// NOT marked partial with files_scanned=0.
const firstSource = report.per_source.find(r => r.source_id === 'src-a')!;
expect(firstSource.status).toBe('skipped');
expect(firstSource.files_scanned).toBe(0);
expect(report.partial).toBe(true);
expect(report.aborted_at_source).toBe('src-a');
});
// #2946: the deadline race's timeout arm resolves a SENTINEL, not null —
// a COUNT that legitimately resolves null (failed/absent count) before the
// deadline must NOT be mistaken for a deadline hit: the source still gets
// scanned, with db_page_count simply unavailable.
test('COUNT resolving null before the deadline is not a deadline verdict — source still scans', async () => {
const start = Date.now();
const report = await scanBrainSources(engine, {
deadline: start + 5_000,
dbPageCountForSource: async () => null,
});
const firstSource = report.per_source.find(r => r.source_id === 'src-a')!;
expect(firstSource.status).toBe('scanned');
expect(firstSource.db_page_count == null).toBe(true);
});
// Codex adversarial #4 regression: even when dbPageCountForSource itself
// would hang indefinitely, the Promise.race against the deadline must
// resolve null and the scan must abort cleanly.
//
// Boundary fix (CI flake): brain-writer.ts post-await deadline check uses
// `>=` not `>`. The Promise.race setTimeout resolves at exactly
// `remainingMs` from now, so post-await Date.now() often equals deadline
// within integer-ms precision. Strict `>` missed those landings on CI
// runners and let scanOneSource run anyway, marking src-a as 'scanned'
// instead of 'skipped'. Test budget is generous enough to absorb runner
// timer drift; the real fix is the operator change in brain-writer.ts.
test('hanging COUNT does not exceed deadline — Promise.race timeout fires', async () => {
const start = Date.now();
const DEADLINE_MS = 500;
const BOUND_MS = 2500;
const report = await scanBrainSources(engine, {
deadline: start + DEADLINE_MS,
dbPageCountForSource: () => {
// Never resolves — would hang forever without the deadline race.
return new Promise<number | null>(() => {});
},
});
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(BOUND_MS);
expect(report.partial).toBe(true);
const firstSource = report.per_source.find(r => r.source_id === 'src-a')!;
expect(firstSource.status).toBe('skipped');
// Skipped sources never get db_page_count set — they weren't attempted.
// (Either null from the race or undefined from never reaching the DB
// path; both express "no denominator available" honestly.)
expect(firstSource.db_page_count == null).toBe(true);
});
});