fix(cross-file): remove wall-clock timeout from type propagation

The query compilation cache and live progress reporting address the
original stall; the 2-minute deadline could truncate cross-file work on
large repos. MAX_CROSS_FILE_REPROCESS (2000) remains as the only cap.
This commit is contained in:
Gergo Magyar 2026-05-16 09:09:49 +01:00
parent ea760adf4c
commit 9ba5d37865
2 changed files with 0 additions and 92 deletions

View file

@ -37,19 +37,6 @@ const CROSS_FILE_SKIP_THRESHOLD = 0.03;
/** Hard cap on files re-processed during cross-file propagation. */
const MAX_CROSS_FILE_REPROCESS = 2000;
/**
* Default wall-clock time limit (ms) for cross-file propagation.
* Override via `GITNEXUS_CROSS_FILE_TIMEOUT_MS` env var.
* Prevents the phase from stalling for hours on very large repositories
* where per-file re-resolution is expensive.
*/
const DEFAULT_CROSS_FILE_ELAPSED_MS = 120_000; // 2 minutes
function getCrossFileTimeoutMs(): number {
const raw = Number(process.env.GITNEXUS_CROSS_FILE_TIMEOUT_MS);
return raw > 0 && Number.isFinite(raw) ? raw : DEFAULT_CROSS_FILE_ELAPSED_MS;
}
/**
* Cross-file binding propagation.
* Returns the number of files re-processed.
@ -131,13 +118,11 @@ export async function runCrossFileBindingPropagation(
let crossFileResolved = 0;
const crossFileStart = Date.now();
const crossFileDeadlineMs = getCrossFileTimeoutMs();
const astCache = createASTCache(AST_CACHE_CAP);
// Compiled query objects keyed by language name. Shared across all processCalls
// invocations in this phase so the same tree-sitter query string is only
// compiled once per language instead of once per file (O(1) vs O(N)).
const compiledQueryCache = new Map<SupportedLanguages, Parser.Query>();
let timedOut = false;
// Snapshot total topological candidates for progress math. We walk the
// levels once more here (fast — no I/O) so we can report meaningful
@ -161,8 +146,6 @@ export async function runCrossFileBindingPropagation(
const PROGRESS_INTERVAL = 25;
for (const level of levels) {
if (timedOut) break;
const levelCandidates: {
filePath: string;
seeded: Map<string, string>;
@ -208,15 +191,6 @@ export async function runCrossFileBindingPropagation(
const contentMap = await readFileContents(repoPath, levelPaths);
for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) {
if (Date.now() - crossFileStart >= crossFileDeadlineMs) {
timedOut = true;
logger.warn(
`⏱️ Cross-file type propagation timed out after ${crossFileDeadlineMs}ms` +
` (${crossFileResolved} files processed). Override with GITNEXUS_CROSS_FILE_TIMEOUT_MS env var.`,
);
break;
}
const content = contentMap.get(filePath);
if (!content) continue;

View file

@ -162,72 +162,6 @@ describe('runCrossFileBindingPropagation', () => {
}
});
it('stops processing when time deadline is exceeded', async () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
const exportedTypeMap: ExportedTypeMap = new Map([
['upstream.ts', new Map([['User', 'User']])],
]);
const allPaths: string[] = ['upstream.ts'];
// Create 50 downstream importers
ctx.importMap.set('upstream.ts', new Set());
for (let i = 0; i < 50; i++) {
const file = `downstream${i}.ts`;
allPaths.push(file);
const bindings = new Map();
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
ctx.namedImportMap.set(file, bindings);
ctx.importMap.set(file, new Set(['upstream.ts']));
}
// Set a very short timeout via env var. The processCalls mock below
// advances Date.now past this deadline after the first invocation.
const origEnv = process.env.GITNEXUS_CROSS_FILE_TIMEOUT_MS;
process.env.GITNEXUS_CROSS_FILE_TIMEOUT_MS = '50';
let callCount = 0;
processCallsMock.mockImplementation(async () => {
callCount++;
// After the first call, busy-wait past the 50ms deadline so the
// time check before the next candidate triggers.
if (callCount === 1) {
const start = Date.now();
while (Date.now() - start < 60) {
/* spin */
}
}
return [];
});
try {
const result = await runCrossFileBindingPropagation(
graph,
ctx,
exportedTypeMap,
new Set(allPaths),
allPaths.length,
'/repo',
Date.now(),
() => {},
);
// Should have processed at least 1 file (the first one goes through
// before the deadline check), but NOT all 50.
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThan(50);
expect(processCallsMock.mock.calls.length).toBeLessThan(50);
} finally {
if (origEnv === undefined) {
delete process.env.GITNEXUS_CROSS_FILE_TIMEOUT_MS;
} else {
process.env.GITNEXUS_CROSS_FILE_TIMEOUT_MS = origEnv;
}
processCallsMock.mockImplementation(async () => []);
}
});
it('caps processing at MAX_CROSS_FILE_REPROCESS (2000)', async () => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();