GitNexus/gitnexus/test/integration/parse-impl-clone-skip.test.ts
Gergő Magyar 9718e1247a
fix(parse): stabilize parse-cache chunks and cheapen ParsedFile loads (#3093)
* fix(parse): stabilize parse-cache chunks and cheapen ParsedFile loads

Hash-bucket membership so worker count and add/delete no longer reshuffle cache keys; GC and path sidecars keep small-shard scope-resolution from full-store JSON and empty GCs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): apply review findings

Drop the unused pool argument from cache-budget resolution, reuse path compare helpers, and copy durable sidecars via full shard paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): copy durable path sidecars via full shard paths

Keep restore destinations relative to the run store even when sidecar names are derived from absolute json paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): fail closed on truncated ParsedFile path sidecars

Skip JSON only when a sidecar is complete (NUL-free, trailing newline). Truncated listings without a NUL were able to omit wanted paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: apply prettier to ParsedFile store and tests

Match the PR autofix formatter so CI quality does not flag wrap-only diffs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): tighten ParsedFile path sidecars from review

Skip sidecar writes when a path contains CR/LF, and assert the skip path does not open non-intersecting JSON shards.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): yield on sidecar skips and assert restore copies listing bytes

Skipped shards now count toward the 128-shard event-loop yield, and restore tests check sidecar contents rather than existence only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): treat path sidecars as best-effort after a JSON shard write

A sidecar ENOSPC/EACCES must not fail persist; load already falls back to the JSON shard when the listing is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): drop stale path sidecars when a shard is no longer listing-safe

Rewriting a shard with a newline-bearing path must unlink the old listing so load does not skip the JSON payload.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(parse): keep worker-integration tests aligned with hash buckets

Quarantine cache-skip asserts the poison pack hash, clone-skip keeps poison and survivors in one bucket, and restore unlinks a stale dest sidecar when the durable source has none.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(parse): address review follow-ups for cache packs and sidecars

Record SCHEMA_BUMP 80, pin pack locality and sidecar load/restore tests, and keep sidecar I/O best-effort with shared ENOENT handling.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): drop stale path sidecars after a failed listing write

A leftover .paths file after ENOSPC (or similar) made load skip the new JSON shard. Hash expected packs with the same env budget production uses.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(store): drop path sidecars before overwriting parsed-file JSON

Load trusts a leftover .paths listing, so rewriting a shard must unlink that listing first. Otherwise an interrupted sidecar refresh can hide newly written files.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(store): fail closed on truncated or CR path sidecars

Count-prefix listings so a newline-terminated partial sidecar cannot skip the JSON shard, and reject CR instead of stripping it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): expect single-file watch refresh telemetry

The production analyze --watch e2e was still pinned to the old pack-cascade
"8 re-parsed" line, so shard 1/3 timed out after a correct 1-file refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): expect one reparsed file on a non-bean incremental touch

Pack-cascade leftover: the drift-skip test still required 7 reparsed files
after logger.ts-only edits. Cheap ParsedFile loads now reparse just that file.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-30 08:31:47 +00:00

304 lines
13 KiB
TypeScript

/**
* #2112 — Integration regression test for the worker result clone-safety net.
*
* Reproduces the deterministic large-repo killer: a parse worker whose
* accumulated result carries a value the structured-clone algorithm can't
* serialize (the reporter's case was a node `properties` value pointing at a
* native `toString`). Before the fix, `parentPort.postMessage({type:'result',
* data})` threw a `DataCloneError` SENDER-side; the worker re-posted it as
* `{type:'error'}`, the pool counted it as a worker death, and under
* `GITNEXUS_WORKER_POOL_SIZE=1` the same graph re-threw on every respawn until
* the slot's budget was exhausted and the whole parse phase aborted.
*
* Runs with REAL `worker_threads` + `createWorkerPool` over the production
* pool / merge / graph wiring, under `workerPoolSize: 1` (matching the
* conservative workaround that still failed in the issue). The GREEN worker is
* an ESM module that statically imports and calls the REAL built
* `postResultCloneSafe` from `dist/` — so this exercises the actual production
* delivery wiring across a real `postMessage` boundary (the fake-worker doubles
* used by the unit suite bypass structured clone entirely and can't reproduce
* the failure).
*
* Build prerequisite: the worker imports `dist/.../post-result.js`, so
* `node scripts/build.js` must run first (the `pretest:integration` step does
* this; a stale `dist/` would test old behavior).
*/
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 { _captureLogger } from '../../src/core/logger.js';
import { parseCacheBucketId } from '../../src/storage/parse-cache.js';
// file:// URL of the BUILT production result-delivery helper, imported by the
// ESM test worker so it exercises the REAL postResultCloneSafe wiring (the
// {type:'warning'} post + skippedPaths append), not a re-implementation.
const POST_RESULT_URL = new URL('../../dist/core/ingestion/workers/post-result.js', import.meta.url)
.href;
const ACCUMULATED_INIT = `{
nodes: [], relationships: [], symbols: [], calls: [], assignments: [],
routes: [], fetchCalls: [], fetchWrapperDefs: [], decoratorRoutes: [],
routerIncludes: [], routerImports: [], toolDefs: [], ormQueries: [],
constructorBindings: [], fileScopeBindings: [], parsedFiles: [],
skippedLanguages: {}, fileCount: 0,
}`;
/**
* Synthesizes a Function node per file. For `poison.ts` it leaks an own native
* `toString` into the node's `properties` — the exact #2112 shape that throws
* `DataCloneError` across the real worker boundary.
*/
const SUB_BATCH_HANDLER = `
if (msg && msg.type === 'sub-batch') {
for (const file of msg.files) {
const baseName = file.path.split('/').pop().replace(/\\.ts$/, '');
const properties = {
name: baseName, filePath: file.path, startLine: 1, endLine: 1,
language: 'typescript', isExported: true,
};
if (file.path.endsWith('poison.ts')) {
properties.toString = Object.prototype.toString; // native fn → non-cloneable
}
accumulated.nodes.push({ id: 'func:' + file.path, label: 'Function', properties });
accumulated.fileCount++;
}
parentPort.postMessage({ type: 'progress', filesProcessed: accumulated.fileCount });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}`;
/**
* GREEN worker: delivers via the REAL production postResultCloneSafe, so this
* test covers the actual wiring (the {type:'warning'} post + skippedPaths
* append), not a re-implementation that could drift from production.
*/
const CLONE_SAFE_WORKER = `
import { parentPort } from 'node:worker_threads';
import { postResultCloneSafe } from '${POST_RESULT_URL}';
const accumulated = ${ACCUMULATED_INIT};
parentPort.postMessage({ type: 'ready' });
parentPort.on('message', (msg) => {
${SUB_BATCH_HANDLER}
if (msg && msg.type === 'flush') {
postResultCloneSafe(accumulated);
}
});
`;
/** RED control: posts the non-cloneable result raw (no clone-safety net). */
const RAW_WORKER = `
import { parentPort } from 'node:worker_threads';
const accumulated = ${ACCUMULATED_INIT};
parentPort.postMessage({ type: 'ready' });
parentPort.on('message', (msg) => {
${SUB_BATCH_HANDLER}
if (msg && msg.type === 'flush') {
parentPort.postMessage({ type: 'result', data: accumulated });
}
});
`;
/**
* GETTER worker: poison.ts's node carries an own-enumerable getter that THROWS.
* structuredClone invokes getters, so this surfaces a RangeError — NOT a
* DataCloneError — at the boundary. The net must still recover (route it into
* the sanitizer), not re-throw past it. Delivers via the real postResultCloneSafe.
*/
const GETTER_WORKER = `
import { parentPort } from 'node:worker_threads';
import { postResultCloneSafe } from '${POST_RESULT_URL}';
const accumulated = ${ACCUMULATED_INIT};
parentPort.postMessage({ type: 'ready' });
parentPort.on('message', (msg) => {
if (msg && msg.type === 'sub-batch') {
for (const file of msg.files) {
const baseName = file.path.split('/').pop().replace(/\\.ts$/, '');
const properties = {
name: baseName, filePath: file.path, startLine: 1, endLine: 1,
language: 'typescript', isExported: true,
};
if (file.path.endsWith('poison.ts')) {
Object.defineProperty(properties, 'boom', {
enumerable: true,
get() { throw new RangeError('boom getter'); },
});
}
accumulated.nodes.push({ id: 'func:' + file.path, label: 'Function', properties });
accumulated.fileCount++;
}
parentPort.postMessage({ type: 'progress', filesProcessed: accumulated.fileCount });
parentPort.postMessage({ type: 'sub-batch-done' });
return;
}
if (msg && msg.type === 'flush') {
postResultCloneSafe(accumulated);
}
});
`;
const POISON_PATH = 'src/poison.ts';
/** Pinned same-bucket fixtures (sha256(path) mod 128 of poison.ts). */
const GOOD_A_PATH = 'src/good_a_16.ts';
const GOOD_C_PATH = 'src/good_c_51.ts';
const GOOD_A_NAME = path.basename(GOOD_A_PATH, '.ts');
const GOOD_C_NAME = path.basename(GOOD_C_PATH, '.ts');
const FIXTURE_FILES: Record<string, string> = {
[GOOD_A_PATH]: 'export function good_a() { return 1; }\n',
[POISON_PATH]: 'export function poison() { return 2; }\n',
[GOOD_C_PATH]: 'export function good_c() { return 3; }\n',
};
const nodeNames = (graph: ReturnType<typeof createKnowledgeGraph>): Set<string> => {
const names = new Set<string>();
for (const n of graph.nodes.values()) {
if (n.label === 'Function') {
const name = (n.properties as { name?: string }).name;
if (name) names.add(name);
}
}
return names;
};
// These cases deliberately inject non-cloneable values, so they're meaningless
// under a global GITNEXUS_STRICT_CLONE=1 run (strict turns the sanitize into a
// throw). Skip the whole suite there — a global strict lane's value is running
// the REAL-extractor integration tests under strict, not this synthetic one.
// The strict-mode case below sets the flag itself (self-contained).
const STRICT = process.env.GITNEXUS_STRICT_CLONE === '1';
describe.skipIf(STRICT)('#2112: worker result clone-safety integration (POOL_SIZE=1)', () => {
it('pins survivors into the same parse-cache bucket as poison.ts', () => {
expect(parseCacheBucketId(GOOD_A_PATH)).toBe(parseCacheBucketId(POISON_PATH));
expect(parseCacheBucketId(GOOD_C_PATH)).toBe(parseCacheBucketId(POISON_PATH));
});
let tempDir: string;
let repoDir: string;
const writeWorker = (script: string): URL => {
const p = path.join(tempDir, `clone-skip-worker-${Math.abs(hash(script))}.mjs`);
writeFileSync(p, script);
return pathToFileURL(p) as URL;
};
// Stable name without Math.random (banned in this harness elsewhere) — index by content.
const hash = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return h;
};
const runWith = async (workerUrl: URL): Promise<ReturnType<typeof createKnowledgeGraph>> => {
const filePaths = Object.keys(FIXTURE_FILES);
const scanned = filePaths.map((rel) => ({
path: rel,
size: statSync(path.join(repoDir, rel)).size,
}));
const graph = createKnowledgeGraph();
await runChunkedParseAndResolve(
graph,
scanned,
filePaths,
filePaths.length,
repoDir,
1, // deterministic start time (Date.now is banned in this harness)
() => {},
{
skipWorkers: false,
workerUrlForTest: workerUrl,
workerPoolSize: 1, // poison lands on the only slot — the issue's workaround config
},
);
return graph;
};
beforeEach(() => {
tempDir = mkdtempSync(path.join(tmpdir(), 'parse-impl-clone-skip-'));
repoDir = path.join(tempDir, 'repo');
mkdirSync(repoDir, { recursive: true });
for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
const full = path.join(repoDir, rel);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, content);
}
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('GREEN: a non-cloneable result is sanitized and delivered; the run completes with all files', async () => {
// Capture the production telemetry (parsing-processor logs the per-file skip
// with its path + reason) so this asserts the REAL skippedPaths/warning
// wiring surfaced, not just that the graph ended up correct.
const cap = _captureLogger();
try {
const graph = await runWith(writeWorker(CLONE_SAFE_WORKER));
const names = nodeNames(graph);
// Survivors AND the sanitized poison file are all present — the run did not abort.
expect(names.has(GOOD_A_NAME)).toBe(true);
expect(names.has(GOOD_C_NAME)).toBe(true);
// The poison node is delivered with its legitimate data intact (only the
// leaked native `toString` was stripped), so it still lands in the graph.
expect(names.has('poison')).toBe(true);
// The clone-safety telemetry surfaced the offending file AND the exact
// stripped key path — the wiring this suite claims to cover.
const msgs = cap.records().map((r) => String(r.msg ?? ''));
const skipLine = msgs.find(
(m) => m.includes('poison.ts') && m.includes('properties.toString'),
);
expect(
skipLine,
`expected a sanitize warning naming poison.ts + properties.toString; saw: ${msgs.join(' | ')}`,
).toBeDefined();
} finally {
cap.restore();
}
});
it('GREEN: a throwing getter (RangeError, not DataCloneError) is recovered, not re-thrown', async () => {
// structuredClone invokes getters; a throwing getter surfaces its own
// RangeError at the boundary. The net must route it into the sanitizer
// (which drops the offending property) rather than re-throwing past it and
// re-arming the POOL_SIZE=1 worker-death cascade. Without the fix this run
// rejects; with it, all files (incl. the sanitized poison node) are present.
const graph = await runWith(writeWorker(GETTER_WORKER));
const names = nodeNames(graph);
expect(names.has(GOOD_A_NAME)).toBe(true);
expect(names.has(GOOD_C_NAME)).toBe(true);
expect(names.has('poison')).toBe(true);
});
it('strict mode (GITNEXUS_STRICT_CLONE=1) surfaces the leak loudly with the key path, not silent sanitize', async () => {
// The spawned worker inherits process.env, so postResultCloneSafe runs in
// strict mode: instead of sanitizing + delivering, it THROWS with the exact
// offending key path → the run rejects (a real future extractor leak would
// fail CI loudly at its origin instead of being silently stripped in prod).
const prev = process.env.GITNEXUS_STRICT_CLONE;
process.env.GITNEXUS_STRICT_CLONE = '1';
try {
await expect(runWith(writeWorker(CLONE_SAFE_WORKER))).rejects.toThrow(
/STRICT_CLONE|not structured-cloneable|properties\.toString/i,
);
} finally {
if (prev === undefined) delete process.env.GITNEXUS_STRICT_CLONE;
else process.env.GITNEXUS_STRICT_CLONE = prev;
}
});
it('RED control: without clone-safety, the same poison result aborts the parse phase', async () => {
// Pre-fix behavior: the raw non-cloneable result throws DataCloneError in
// the worker; under POOL_SIZE=1 the pool exhausts the slot's respawn
// budget and rejects. The matcher is the specific contract — not a bare
// .toThrow() — so an unrelated failure (spawn error, stale dist) can't pass
// it and mask a broken RED→GREEN flip.
await expect(runWith(writeWorker(RAW_WORKER))).rejects.toThrow(
/circuit breaker|consecutive failures|respawn budget|could not be cloned/i,
);
});
});