GitNexus/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts

389 lines
14 KiB
TypeScript

/**
* U20 — Integration regression test for chunk-cache corruption on
* worker quarantine.
*
* Pins the fix for the Codex adversarial review finding on PR #1693
* (`docs/plans/2026-05-20-002-fix-chunk-cache-corruption-on-worker-quarantine-plan.md`):
*
* The chunk hash is computed from every file in the chunk, but the
* worker pool's Layer 3 quarantine filters quarantined files out of
* dispatch. Before the fix, the chunk-loop would cache the partial
* worker results under the full-coverage chunk hash, locking in
* silent corruption that the next analyze would replay.
*
* Runs with REAL `worker_threads` + `createWorkerPool`. Injects a
* custom worker script via `workerUrlForTest` that:
* 1. Implements the U17/U19 IPC protocol (decode Buffer or hybrid
* envelope/contents shape, decode header + JSON payload).
* 2. Emits a `{type:'ready'}` handshake so the pool's
* `waitForWorkerReady` resolves promptly.
* 3. On a sub-batch containing `poison.ts`, emits a starting-file
* and exits with code 134 — deterministic worker death the pool
* attributes to `poison.ts` via the in-flight signal, then
* adds to its session-scoped quarantine.
* 4. On a sub-batch without poison, synthesizes a minimal valid
* ParseWorkerResult with a Function node per file (no
* tree-sitter dependency in the test worker — the synthesized
* nodes give the merge step deterministic content to add to the
* graph).
*
* U20 design pivot — no sequential fallback. The U1 sequential
* reparse for quarantined chunk files was removed: relying on the
* worker pool's resilience layers (respawn budget, circuit breaker,
* quarantine, slot-attribution, cumulative timeout) as the SOLE
* contract avoids re-triggering tree-sitter native crashes on the
* main thread and gives operators a clear hard signal when workers
* exhaust. Quarantined files are missing from this run's graph;
* they're surfaced in the per-chunk warn log; U2's cache-skip keeps
* the chunk uncached so the next analyze with a fresh pool retries.
*
* Assertions exercised here:
* - Worker-path runs and produces results for surviving files
* (good_a, good_c) via the synthesized worker output.
* - The quarantined file (poison.ts) is NOT in the graph — no
* sequential reparse fired.
* - U2 (cache-write suppression): `parseCache.entries` does NOT
* contain the chunk hash after the run. `parseCache.usedKeys`
* DOES contain it (chunk was processed; the cache write was
* specifically skipped). A cross-run scenario verifies that a
* subsequent dispatch with a fresh pool re-attempts the chunk
* (cache miss) and the cache stays empty for that chunk.
*
* Why integration over unit:
* - The fix lives at the boundary between processParsing
* (`parsing-processor.ts`) and the chunk-loop
* (`pipeline-phases/parse-impl.ts`) under a real
* workerPool. Unit-mocking the worker-pool import bypasses the
* structured-clone boundary, the dispatch lifecycle, and the
* actual quarantine flow — it verifies the test setup rather
* than the contract. The real worker thread executing the test
* script through the U17/U19 IPC protocol IS the load-bearing
* surface; this test exercises it end-to-end.
* - The `writeReadyWorker` pattern from `worker-pool.test.ts` is
* reused inline here (the READY_PREAMBLE + test-worker script
* composition).
*
* Wall-clock budget: well under 5 s under normal CI conditions.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, statSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
import { computeChunkHash, fileContentHash } from '../../src/storage/parse-cache.js';
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
/**
* Inline READY preamble + IPC decode wrapper (mirrors
* `test/integration/worker-pool.test.ts`'s READY_PREAMBLE). Lets the
* test worker script below speak the production U17/U19 IPC protocol
* without importing dist/protocol.js (the script runs as a standalone
* CJS file at a temp path, so it can't resolve dist/ via relative
* paths reliably).
*/
const READY_PREAMBLE = `
const { parentPort: __pp } = require('node:worker_threads');
const __decoder = new TextDecoder('utf-8');
const __decodeFrame = (raw) => {
if (
raw && typeof raw === 'object' &&
raw.type === 'sub-batch' &&
Array.isArray(raw.files)
) {
return {
type: 'sub-batch',
files: raw.files.map((f) => ({
path: f.path,
content: typeof f.content === 'string' ? f.content : __decoder.decode(f.content),
})),
};
}
return raw;
};
const __origOn = __pp.on.bind(__pp);
__pp.on = (event, handler) => {
if (event !== 'message') return __origOn(event, handler);
return __origOn(event, (raw) => handler(__decodeFrame(raw)));
};
__pp.postMessage({ type: 'ready' });
`;
/**
* Test worker script. Synthesizes minimal ParseWorkerResult entries for
* non-poison files; deterministically crashes on poison.ts via
* `process.exit(134)`. Accumulates across sub-batches; emits the
* accumulated result on `flush`.
*/
const TEST_WORKER_SCRIPT = `
const { parentPort } = require('node:worker_threads');
const accumulated = {
nodes: [],
relationships: [],
symbols: [],
imports: [],
calls: [],
assignments: [],
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
fileScopeBindings: [],
parsedFiles: [],
skippedLanguages: {},
fileCount: 0,
};
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
const poison = msg.files.find((f) => f.path.endsWith('poison.ts'));
if (poison) {
parentPort.postMessage({ type: 'starting-file', path: poison.path });
process.exit(134);
}
for (const file of msg.files) {
const baseName = file.path.split('/').pop().replace(/\\.ts$/, '');
accumulated.nodes.push({
id: 'func:' + file.path,
label: 'Function',
properties: {
name: baseName,
filePath: file.path,
startLine: 1,
endLine: 1,
language: 'typescript',
isExported: true,
},
});
accumulated.fileCount++;
}
parentPort.postMessage({ type: 'progress', filesProcessed: accumulated.fileCount });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: accumulated });
}
});
`;
const FIXTURE_FILES = {
'src/good_a.ts': 'export function good_a() { return 1; }\n',
'src/poison.ts': 'export function poison() { return 2; }\n',
'src/good_c.ts': 'export function good_c() { return 3; }\n',
};
describe('U20: parse-impl quarantine + chunk-cache integration (PR #1693 Codex finding)', () => {
let tempDir: string;
let repoDir: string;
let workerPath: string;
beforeEach(() => {
tempDir = mkdtempSync(path.join(tmpdir(), 'parse-impl-quarantine-cache-skip-'));
repoDir = path.join(tempDir, 'repo');
mkdirSync(repoDir, { recursive: true });
// Write the fixture files to repoDir so filesystem-walker / chunk
// loop pick them up by relative path.
for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
const full = path.join(repoDir, rel);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, content);
}
// Write the test worker script to the same tempDir so it doesn't
// collide with anything else. The READY preamble + test script
// share one .js file the pool spawns via `new Worker(URL)`.
workerPath = path.join(tempDir, 'test-quarantine-worker.js');
writeFileSync(workerPath, READY_PREAMBLE + TEST_WORKER_SCRIPT);
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('worker quarantine leaves poison.ts out of the graph AND suppresses chunk-cache write', async () => {
const filePaths = Object.keys(FIXTURE_FILES);
const scanned = filePaths.map((rel) => ({
path: rel,
size: statSync(path.join(repoDir, rel)).size,
}));
// The chunk hash is computed from EVERY file's content hash. The
// load-bearing U2 assertion below checks `parseCache.entries.has`
// against this exact value, so we compute it the same way
// parse-impl does.
const expectedChunkHash = computeChunkHash(
filePaths.map((p) => ({
filePath: p,
contentHash: fileContentHash(FIXTURE_FILES[p as keyof typeof FIXTURE_FILES]),
})),
);
const parseCache = {
version: 'test',
entries: new Map<string, ParseWorkerResult[]>(),
usedKeys: new Set<string>(),
};
const graph = createKnowledgeGraph();
await runChunkedParseAndResolve(
graph,
scanned,
filePaths,
filePaths.length,
repoDir,
Date.now(),
() => {},
{
skipWorkers: false,
// Force the worker-pool gate to open on the 3-file fixture.
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
// Inject the custom worker script — the pool will spawn it
// instead of the production parse-worker.js.
workerUrlForTest: pathToFileURL(workerPath) as URL,
// Test-only worker pool size — keep at 1 so the poison-file
// sub-batch deterministically lands on the only slot (no
// chance of poison + good landing in different slots).
workerPoolSize: 1,
parseCache,
},
);
const nodes = Array.from(graph.nodes.values());
// Quarantine contract: poison.ts is genuinely missing from the
// graph for this run. The custom worker crashed on it; no
// sequential reparse rescued it; the operator sees the per-chunk
// quarantine warn log. A future analyze with a fresh pool gets
// another chance via U2's cache-skip below.
expect(
nodes.some(
(n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'poison',
),
).toBe(false);
// Surviving files' symbols come from the custom worker's
// synthesized output via the normal worker-path merge. Pinning
// them here catches a regression that would drop worker results
// entirely when quarantine fires.
expect(
nodes.some(
(n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'good_a',
),
).toBe(true);
expect(
nodes.some(
(n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'good_c',
),
).toBe(true);
// U2 assertion: chunk-cache write was suppressed. The chunk hash
// is in usedKeys (chunk WAS processed) but absent from entries
// (cache write skipped because of the quarantine intersection).
// This is the load-bearing cross-run protection: a future analyze
// with unchanged content will re-derive the same chunkHash, miss
// the cache, and re-dispatch — giving the file another chance
// against a fresh-quarantine pool.
expect(parseCache.entries.has(expectedChunkHash)).toBe(false);
expect(parseCache.usedKeys.has(expectedChunkHash)).toBe(true);
expect(parseCache.entries.size).toBe(0);
});
it('cross-run: unchanged fixture re-dispatches on a second pass because the cache was empty', async () => {
// First pass: same setup as the previous test. Cache stays empty
// because poison.ts triggered quarantine.
const filePaths = Object.keys(FIXTURE_FILES);
const scanned = filePaths.map((rel) => ({
path: rel,
size: statSync(path.join(repoDir, rel)).size,
}));
const expectedChunkHash = computeChunkHash(
filePaths.map((p) => ({
filePath: p,
contentHash: fileContentHash(FIXTURE_FILES[p as keyof typeof FIXTURE_FILES]),
})),
);
const parseCache = {
version: 'test',
entries: new Map<string, ParseWorkerResult[]>(),
usedKeys: new Set<string>(),
};
// FIRST PASS.
{
const graph = createKnowledgeGraph();
await runChunkedParseAndResolve(
graph,
scanned,
filePaths,
filePaths.length,
repoDir,
Date.now(),
() => {},
{
skipWorkers: false,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerUrlForTest: pathToFileURL(workerPath) as URL,
workerPoolSize: 1,
parseCache,
},
);
// Confirm the precondition for the second-pass test: cache is
// empty for this chunk hash.
expect(parseCache.entries.has(expectedChunkHash)).toBe(false);
}
// SECOND PASS — same content, same parseCache, fresh worker pool
// (createWorkerPool is called per `runChunkedParseAndResolve`, so
// every invocation gets a clean quarantine slate). With the cache
// empty for this chunk, the second pass MUST dispatch the chunk
// again rather than replaying a cache entry. The custom worker
// crashes again on poison.ts → quarantine again → cache still
// skipped. Symptom: cache state unchanged, graph still complete.
{
const graph2 = createKnowledgeGraph();
await runChunkedParseAndResolve(
graph2,
scanned,
filePaths,
filePaths.length,
repoDir,
Date.now(),
() => {},
{
skipWorkers: false,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerUrlForTest: pathToFileURL(workerPath) as URL,
workerPoolSize: 1,
parseCache,
},
);
// Cache stayed empty (still no entry for this chunk hash) — the
// load-bearing cross-run protection.
expect(parseCache.entries.has(expectedChunkHash)).toBe(false);
expect(parseCache.usedKeys.has(expectedChunkHash)).toBe(true);
// Worker path ran again; surviving files in the graph; poison
// still absent per the U20 contract (workers are the sole
// resilience layer, no sequential reparse).
const nodes2 = Array.from(graph2.nodes.values());
expect(
nodes2.some(
(n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'good_a',
),
).toBe(true);
expect(
nodes2.some(
(n) => n.label === 'Function' && (n.properties as { name?: string }).name === 'poison',
),
).toBe(false);
}
});
});