fix: add time-based deadline to cross-file type propagation to prevent stalling on large repos

Adds a 2-minute wall-clock time limit (DEFAULT_CROSS_FILE_ELAPSED_MS) to
runCrossFileBindingPropagation. When exceeded, the phase gracefully stops
and logs a warning. Users can override via GITNEXUS_CROSS_FILE_TIMEOUT_MS
env var. This prevents the analyze command from stalling for hours on very
large repositories where per-file re-resolution is expensive.

Fixes the reported issue where gitnexus analyze stalls at "Cross-file type
propagation" for several hours on repos with 15000+ files.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b8341947-557c-4111-a3a8-991ba455ab01

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-16 07:19:47 +00:00 • committed by GitHub
parent 375d98145f
commit c576c68503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 92 additions and 0 deletions

View file

@ -32,6 +32,19 @@ 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.
@ -113,9 +126,13 @@ export async function runCrossFileBindingPropagation(
let crossFileResolved = 0;
const crossFileStart = Date.now();
const crossFileDeadlineMs = getCrossFileTimeoutMs();
const astCache = createASTCache(AST_CACHE_CAP);
let timedOut = false;
for (const level of levels) {
if (timedOut) break;
const levelCandidates: {
filePath: string;
seeded: Map<string, string>;
@ -161,6 +178,15 @@ 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,6 +162,72 @@ 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();