mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
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>
225 lines
9.6 KiB
TypeScript
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);
|
|
});
|
|
});
|