From fb89ac5bf0a9b60e9c9c305744b652ff68d5f01a Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 12:50:46 -0600 Subject: [PATCH 1/2] test: close the graph before the temp project it lives in is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests build a CodeGraph over a temp project and never close it, so the SQLite handle is still open when the directory is removed. POSIX unlinks an open file happily; Windows refuses, and the removal — in a finally or an afterEach, after the assertions have passed — throws EPERM and fails the test. 14 tests across the three files, every one of them green on the assertions themselves. Closed in the teardown rather than at the end of the test body, so a failing assertion cannot skip it and turn one red into two. resolution.test.ts also opens a second connection of its own for a raw edge query; that one is hoisted out of the try and closed the same way. --- __tests__/arkts-resolution.test.ts | 44 ++++++++++++++++++++---- __tests__/frameworks-integration.test.ts | 6 ++++ __tests__/resolution.test.ts | 28 ++++++++++++--- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/__tests__/arkts-resolution.test.ts b/__tests__/arkts-resolution.test.ts index b095a4433..53825498a 100644 --- a/__tests__/arkts-resolution.test.ts +++ b/__tests__/arkts-resolution.test.ts @@ -25,7 +25,12 @@ beforeAll(async () => { describe('ArkTS attribute-chain resolution precision', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + // Windows refuses to remove a directory holding an open file, so the + // graph's SQLite handle has to go before the temp project does. + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -65,7 +70,7 @@ describe('ArkTS attribute-chain resolution precision', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const fns = cg.getNodesByKind('function'); @@ -99,7 +104,12 @@ describe('ArkTS attribute-chain resolution precision', () => { describe('ArkTS ohpm workspace import resolution', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + // Windows refuses to remove a directory holding an open file, so the + // graph's SQLite handle has to go before the temp project does. + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -146,7 +156,7 @@ describe('ArkTS ohpm workspace import resolution', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const classes = cg.getNodesByKind('class'); @@ -168,7 +178,12 @@ describe('ArkTS ohpm workspace import resolution', () => { describe('ArkUI state → build() re-render bridge (assignment-gated)', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + // Windows refuses to remove a directory holding an open file, so the + // graph's SQLite handle has to go before the temp project does. + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -201,7 +216,7 @@ describe('ArkUI state → build() re-render bridge (assignment-gated)', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const methods = cg.getNodesByKind('method'); @@ -229,7 +244,12 @@ describe('ArkUI state → build() re-render bridge (assignment-gated)', () => { describe('ArkUI @ohos.events.emitter bridge', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + // Windows refuses to remove a directory holding an open file, so the + // graph's SQLite handle has to go before the temp project does. + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -266,7 +286,7 @@ describe('ArkUI @ohos.events.emitter bridge', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const methods = cg.getNodesByKind('method'); @@ -299,7 +319,7 @@ describe('ArkUI @ohos.events.emitter bridge', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const fns = cg.getNodesByKind('function'); @@ -314,7 +334,12 @@ describe('ArkUI @ohos.events.emitter bridge', () => { describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + // Windows refuses to remove a directory holding an open file, so the + // graph's SQLite handle has to go before the temp project does. + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -343,7 +368,7 @@ describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const methods = cg.getNodesByKind('method'); @@ -362,7 +387,12 @@ describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => { describe('ohpm main entry (custom barrel + .ts consumer)', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + // Windows refuses to remove a directory holding an open file, so the + // graph's SQLite handle has to go before the temp project does. + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -408,7 +438,7 @@ describe('ohpm main entry (custom barrel + .ts consumer)', () => { '}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const classes = cg.getNodesByKind('class'); diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 3df4f2d88..533018fb3 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -788,6 +788,8 @@ describe('JVM FQN imports — end-to-end', () => { const edge = cg.getIncomingEdges(util!.id).find((e) => e.kind === 'imports'); expect(edge, 'imports edge should reach the top-level function by FQN').toBeDefined(); + + cg.close(); }); it('resolves cross-language: Kotlin importing a Java class', async () => { @@ -809,6 +811,8 @@ describe('JVM FQN imports — end-to-end', () => { const edge = cg.getIncomingEdges(javaBar!.id).find((e) => e.kind === 'imports'); expect(edge, 'Kotlin caller should resolve its import to the Java class').toBeDefined(); + + cg.close(); }); it('disambiguates a class-name collision across packages', async () => { @@ -854,6 +858,8 @@ describe('JVM FQN imports — end-to-end', () => { edges.map((e) => cg.getNode(e.source)?.filePath).filter(Boolean); expect(sourceFiles(alphaIncoming).some((p) => p?.includes('CallerA.kt'))).toBe(true); expect(sourceFiles(betaIncoming).some((p) => p?.includes('CallerB.kt'))).toBe(true); + + cg.close(); }); }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee5..ab8c49eb6 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3570,6 +3570,7 @@ int run() { // feature can't silently regress to a no-op in the indexing flow. it('connects #include to the real header file via include-dir scan (end-to-end)', async () => { const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-e2e-')); + let db: DatabaseConnection | undefined; try { fs.mkdirSync(path.join(tempProject, 'include'), { recursive: true }); fs.mkdirSync(path.join(tempProject, 'src'), { recursive: true }); @@ -3592,7 +3593,7 @@ int run() { // The `#include "utils.h"` edge should target the real // `include/utils.h` file node — not a floating `import` node // living inside main.cpp. - const db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); + db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); const rows = db.getDb().prepare(` select dst.kind as dstKind, dst.file_path as dstPath from edges e @@ -3612,6 +3613,10 @@ int run() { ); expect(stdlibFile).toBeUndefined(); } finally { + // Windows refuses to remove a directory holding an open file: both the + // graph's handle and this test's own one have to go first. + db?.close(); + cg?.close(); fs.rmSync(tempProject, { recursive: true, force: true }); } }); @@ -3684,6 +3689,7 @@ class Both : public Base, public Plain {}; // templated + plain in one cla }); it('resolves require_once to a file→file imports edge (#660)', async () => { + let db: DatabaseConnection | undefined; const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-php-e2e-')); try { fs.mkdirSync(path.join(tempProject, 'src'), { recursive: true }); @@ -3701,7 +3707,7 @@ class Both : public Base, public Plain {}; // templated + plain in one cla // reporter's repro: page.php's `require_once("lib.php")` must resolve // to the real src/lib.php file node — a file→file `imports` edge, so // callers(lib.php) now includes page.php. - const db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); + db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); const rows = db.getDb().prepare(` select dst.kind as dstKind, dst.file_path as dstPath from edges e @@ -3716,11 +3722,16 @@ class Both : public Base, public Plain {}; // templated + plain in one cla ); expect(resolved, 'page.php → src/lib.php imports edge missing').toBeDefined(); } finally { + // Windows refuses to remove a directory holding an open file: both the + // graph's handle and this test's own one have to go first. + db?.close(); + cg?.close(); fs.rmSync(tempProject, { recursive: true, force: true }); } }); it('resolves a subdirectory include path to the correct file (#660)', async () => { + let db: DatabaseConnection | undefined; const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-php-subdir-')); try { fs.mkdirSync(path.join(tempProject, 'inc'), { recursive: true }); @@ -3735,7 +3746,7 @@ class Both : public Base, public Plain {}; // templated + plain in one cla cg = await CodeGraph.init(tempProject, { index: true }); - const db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); + db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); const rows = db.getDb().prepare(` select dst.kind as dstKind, dst.file_path as dstPath from edges e @@ -3750,11 +3761,16 @@ class Both : public Base, public Plain {}; // templated + plain in one cla 'index.php → inc/db.php imports edge missing' ).toBeDefined(); } finally { + // Windows refuses to remove a directory holding an open file: both the + // graph's handle and this test's own one have to go first. + db?.close(); + cg?.close(); fs.rmSync(tempProject, { recursive: true, force: true }); } }); it('does not mis-connect an unresolvable include to a same-named file elsewhere (#660)', async () => { + let db: DatabaseConnection | undefined; const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-php-misresolve-')); try { // app/page.php's `require "inc/db.php"` resolves relative to app/, where @@ -3774,7 +3790,7 @@ class Both : public Base, public Plain {}; // templated + plain in one cla cg = await CodeGraph.init(tempProject, { index: true }); - const db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); + db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db')); const rows = db.getDb().prepare(` select dst.kind as dstKind, dst.file_path as dstPath from edges e @@ -3789,6 +3805,10 @@ class Both : public Base, public Plain {}; // templated + plain in one cla 'app/page.php must NOT mis-connect to unrelated lib/inc/db.php' ).toBeUndefined(); } finally { + // Windows refuses to remove a directory holding an open file: both the + // graph's handle and this test's own one have to go first. + db?.close(); + cg?.close(); fs.rmSync(tempProject, { recursive: true, force: true }); } }); From 476b2299fc6784d5ec89385d48f64b8083e8fae6 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 12:50:46 -0600 Subject: [PATCH 2/2] test(mcp): wait for the server to exit before removing its temp project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signalled process still holds its file handles until it is actually gone, and these suites SIGKILL the spawned MCP server and then remove the directory synchronously — on Windows that is EPERM, and the test fails after its assertions passed. Await the exit instead of only sending the signal; mcp-daemon had a fixed 50ms grace period for this, which is now the same await plus the file's own waitProcessExit for the detached daemon it reaps by pid. Awaiting the tracked children is not sufficient on its own: the server may leave a detached daemon this suite never sees, and mcp-daemon's concurrent-launcher test deliberately leaves one alive. So the removals also retry, which is what fs.rmSync's maxRetries is for — 1s of retries in the three proxy suites, 6s in mcp-daemon where the daemon is the point of the test. Full suite on Windows: 4,017 passing, 0 failing, twice in a row; before this and the previous commit, 24 failing. --- __tests__/mcp-daemon.test.ts | 19 +++++++++++++++++-- __tests__/mcp-initialize.test.ts | 19 +++++++++++++++---- __tests__/mcp-roots.test.ts | 21 ++++++++++++++++----- __tests__/mcp-subproject-adoption.test.ts | 19 +++++++++++++++---- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c73ac564c..fd74c1d14 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -193,10 +193,25 @@ describe('Shared MCP daemon (issue #411)', () => { const daemonPid = readLockPid(realRoot); if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + await waitProcessExit(daemonPid, 5000); } - await new Promise((r) => setTimeout(r, 50)); + // A signalled process still holds its file handles until it is actually + // gone, and Windows refuses to remove a directory holding an open file — so + // wait for each exit rather than for a fixed grace period. + await Promise.all( + servers.map(({ child }) => + child.exitCode === null && child.signalCode === null + ? new Promise((r) => child.once('exit', () => r())) + : Promise.resolve() + ) + ); servers.length = 0; - fs.rmSync(tempDir, { recursive: true, force: true }); + // These tests deliberately leave a detached daemon alive, and a losing + // launcher can leave a candidate of its own mid-exit; either still holds + // the directory for a moment after the pid above is gone. Windows will not + // remove a directory holding an open file, so retry for a few seconds + // rather than assume the handles are released the instant the pid is. + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 60, retryDelay: 100 }); }); it('two invocations share ONE detached daemon; both attach as proxies', async () => { diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 0a320773d..9f95cecb0 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -107,12 +107,23 @@ describe('MCP initialize handshake (issue #172)', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + // A signalled child still holds its file handles until it is actually gone, + // and Windows refuses to remove a directory holding an open file — so wait + // for the exit, rather than only sending the signal. + if (child) { + const proc = child; child = null; + if (proc.exitCode === null && proc.signalCode === null) { + await new Promise((resolve) => { + proc.once('exit', () => resolve()); + proc.kill('SIGKILL'); + }); + } } - fs.rmSync(tempDir, { recursive: true, force: true }); + // The server may have started a detached daemon this suite does not track, + // so retry the removal while any straggler releases the directory. + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); }); it('responds to initialize quickly when no .codegraph exists in cwd', async () => { diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts index 8e1d4520d..dd9569884 100644 --- a/__tests__/mcp-roots.test.ts +++ b/__tests__/mcp-roots.test.ts @@ -84,13 +84,24 @@ describe('MCP project resolution via roots/list (issue #196)', () => { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + // A signalled child still holds its file handles until it is actually gone, + // and Windows refuses to remove a directory holding an open file — so wait + // for the exit, rather than only sending the signal. + if (child) { + const proc = child; child = null; + if (proc.exitCode === null && proc.signalCode === null) { + await new Promise((resolve) => { + proc.once('exit', () => resolve()); + proc.kill('SIGKILL'); + }); + } } - fs.rmSync(cwdDir, { recursive: true, force: true }); - fs.rmSync(projectDir, { recursive: true, force: true }); + // The server may have started a detached daemon this suite does not track, + // so retry the removal while any straggler releases the directory. + fs.rmSync(cwdDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + fs.rmSync(projectDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); }); it('resolves the project from the client roots/list when no rootUri is sent', async () => { diff --git a/__tests__/mcp-subproject-adoption.test.ts b/__tests__/mcp-subproject-adoption.test.ts index 39abac038..ca0068da3 100644 --- a/__tests__/mcp-subproject-adoption.test.ts +++ b/__tests__/mcp-subproject-adoption.test.ts @@ -113,12 +113,23 @@ describe('MCP workspace sub-project adoption (#1606) + no-default diagnostics (# ws = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-ws-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + // A signalled child still holds its file handles until it is actually gone, + // and Windows refuses to remove a directory holding an open file — so wait + // for the exit, rather than only sending the signal. + if (child) { + const proc = child; child = null; + if (proc.exitCode === null && proc.signalCode === null) { + await new Promise((resolve) => { + proc.once('exit', () => resolve()); + proc.kill('SIGKILL'); + }); + } } - fs.rmSync(ws, { recursive: true, force: true }); + // The server may have started a detached daemon this suite does not track, + // so retry the removal while any straggler releases the directory. + fs.rmSync(ws, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); }); it('adopts the single indexed sub-project below a workspace root as the default project', async () => {