From c576c685033d55a90f82e8be3c9c6320a9ff7b0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 07:19:47 +0000 Subject: [PATCH] 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> --- .../pipeline-phases/cross-file-impl.ts | 26 ++++++++ gitnexus/test/unit/cross-file-impl.test.ts | 66 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts index 5c014ed73..1d8d000fc 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts @@ -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; @@ -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; diff --git a/gitnexus/test/unit/cross-file-impl.test.ts b/gitnexus/test/unit/cross-file-impl.test.ts index 55a47a5b5..b32cb9006 100644 --- a/gitnexus/test/unit/cross-file-impl.test.ts +++ b/gitnexus/test/unit/cross-file-impl.test.ts @@ -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();