fix: make the full test suite pass on Windows (#2053)

* test: close resolution graphs before removing Windows fixtures

Close ArkTS and JVM graph connections in afterEach, including failed assertions. Verified both files on Windows 11 and macOS (32 tests).

* test: compare explore source lines across LF and CRLF checkouts

Normalize only line endings when matching emitted source against checked-out fixtures. Preserve the closure coverage and whole-line assertions. Both files pass on Windows 11 and macOS (15 tests).

* test: exercise old Git fallback without a POSIX shell shim

Inject only the unsupported ls-files invocation and run every other Git command against real repositories. Assert that each rejected call retries, use portable graph paths, and clean up fixtures. Passes on Windows 11 and macOS.

* test: normalize Python fixture before generating CRLF cases

The tests added in #2004 produced CRCRLF from a Windows checkout. Normalize the input to LF before constructing either variant. All 28 native and WASM cases pass on Windows 11 and macOS.

* test: allow time for eviction fixture indexes on Windows

The #2036 eviction test builds and reconciles nine real indexes and consistently exceeds its default five-second timeout on the VM. Allow 15 seconds for this test alone, preserving every assertion. The full file passes on Windows 11 and macOS (21 tests).

* test: finish MCP processes before resetting Windows fixtures

Launch actual servers with their runtime flags and await termination before directory removal. Use asynchronous removal retries for daemon fixtures. Establish a ready daemon and stop its first proxy before simulating PID reuse, then make source stale without opening a competing SQLite writer. Preserve lock and database byte-equality assertions. All touched files validated on Windows 11 and macOS.

* test: allow Windows named-pipe fallback polling to finish

The VM takes up to 25.7 seconds to complete the nominal six-second retry loop. Allow 30 seconds for the three fallback waits and 40 seconds for the two shorter enclosing tests, retaining their lock-preservation and read-only assertions. The full daemon file is validated on Windows 11 and macOS.

* fix(daemon): retry transient Windows PID-file sharing violations

A transient EPERM replacing daemon.pid aborted startup, observed in the quiet-client test and reproduced with a real locked handle at ba3c21e5. Retry only Windows sharing errors with five bounded delays, rechecking ownership each time and cleaning temporary files. Keep publication synchronous before client processing. Cover transient/permanent failures, changed ownership, non-retryable errors, and a real Windows handle; require a confirmed daemon round-trip before measuring a quiet session. Validated on Windows 11 and macOS.
This commit is contained in:
Colby Mchenry
2026-09-27 16:26:10 +00:00
committed by GitHub
parent 1d62a67d8a
commit 0a9f3de72c
13 changed files with 295 additions and 85 deletions
+25 -7
View File
@@ -25,7 +25,10 @@ beforeAll(async () => {
describe('ArkTS attribute-chain resolution precision', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -65,7 +68,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 +102,10 @@ describe('ArkTS attribute-chain resolution precision', () => {
describe('ArkTS ohpm workspace import resolution', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -146,7 +152,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 +174,10 @@ describe('ArkTS ohpm workspace import resolution', () => {
describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -201,7 +210,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 +238,10 @@ describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
describe('ArkUI @ohos.events.emitter bridge', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -266,7 +278,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 +311,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 +326,10 @@ describe('ArkUI @ohos.events.emitter bridge', () => {
describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -343,7 +358,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 +377,10 @@ 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(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -408,7 +426,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');
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawn } from 'child_process';
import { once } from 'events';
import { refreshDaemonLock, type DaemonLockInfo } from '../src/mcp/daemon';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, renameSync: vi.fn(actual.renameSync) };
});
describe('daemon ownership refresh under Windows sharing violations', () => {
let root: string;
let pidPath: string;
let initial: string;
let lock: DaemonLockInfo;
let realRename: typeof fs.renameSync;
beforeEach(async () => {
realRename = (await vi.importActual<typeof import('fs')>('fs')).renameSync;
vi.mocked(fs.renameSync).mockReset().mockImplementation(realRename);
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lock-refresh-'));
pidPath = path.join(root, 'daemon.pid');
lock = { pid: process.pid, version: 'test', socketPath: 'bound-socket', startedAt: Date.now() };
initial = JSON.stringify({ ...lock, socketPath: 'original-socket' });
fs.writeFileSync(pidPath, initial);
});
afterEach(() => {
vi.mocked(fs.renameSync).mockReset();
fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 });
});
const denied = (code: string) => Object.assign(new Error('sharing violation'), { code });
const noTemporaryFile = () => expect(fs.readdirSync(root)).toEqual(['daemon.pid']);
it.each(['EPERM', 'EACCES', 'EBUSY'])('survives a transient %s without exposing a partial record', (code) => {
vi.mocked(fs.renameSync).mockImplementationOnce(() => {
expect(fs.readFileSync(pidPath, 'utf8')).toBe(initial);
throw denied(code);
});
refreshDaemonLock(pidPath, initial, lock, 'win32');
expect(JSON.parse(fs.readFileSync(pidPath, 'utf8'))).toEqual(lock);
expect(fs.renameSync).toHaveBeenCalledTimes(2);
noTemporaryFile();
});
it('bounds persistent failures and preserves the original record', () => {
const error = denied('EPERM');
vi.mocked(fs.renameSync).mockImplementation(() => { throw error; });
expect(() => refreshDaemonLock(pidPath, initial, lock, 'win32')).toThrow(error);
expect(fs.renameSync).toHaveBeenCalledTimes(6);
expect(fs.readFileSync(pidPath, 'utf8')).toBe(initial);
noTemporaryFile();
});
it('does not overwrite an ownership change during a retry', () => {
const replacement = JSON.stringify({ ...lock, pid: process.pid + 1 });
vi.mocked(fs.renameSync).mockImplementationOnce(() => {
fs.writeFileSync(pidPath, replacement);
throw denied('EPERM');
});
expect(() => refreshDaemonLock(pidPath, initial, lock, 'win32')).toThrow('Lost daemon lock ownership');
expect(fs.renameSync).toHaveBeenCalledTimes(1);
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
noTemporaryFile();
});
it.each([['linux', 'EPERM'], ['darwin', 'EACCES'], ['win32', 'ENOSPC']] as const)(
'does not retry %s / %s', (platform, code) => {
const error = denied(code);
vi.mocked(fs.renameSync).mockImplementation(() => { throw error; });
expect(() => refreshDaemonLock(pidPath, initial, lock, platform)).toThrow(error);
expect(fs.renameSync).toHaveBeenCalledTimes(1);
expect(fs.readFileSync(pidPath, 'utf8')).toBe(initial);
noTemporaryFile();
},
);
it.runIf(process.platform === 'win32')('waits for a real Windows handle denying delete sharing', async () => {
// Node opens files with delete sharing; a .NET handle lets us reproduce
// the real rename failure deterministically without mocking filesystem I/O.
const script = `$h = [IO.File]::Open('${pidPath.replace(/'/g, "''")}', 'Open', 'Read', 'Read'); `
+ "[Console]::WriteLine('locked'); Start-Sleep -Milliseconds 200; $h.Dispose()";
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const closed = once(child, 'close');
try {
const [ready] = await once(child.stdout, 'data');
expect(String(ready)).toContain('locked');
refreshDaemonLock(pidPath, initial, lock);
expect(vi.mocked(fs.renameSync).mock.calls.length).toBeGreaterThan(1);
expect(JSON.parse(fs.readFileSync(pidPath, 'utf8'))).toEqual(lock);
noTemporaryFile();
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill();
await closed;
}
}, 10_000);
});
+3 -2
View File
@@ -77,9 +77,10 @@ describe('CG-27 — a factory-closure file delivers the closures inside it', ()
// A line counts as delivered only when the response numbers it AND the text
// matches that source line — a line number quoted in prose must not count.
sourceLines = fs.readFileSync(path.join(testDir, TARGET), 'utf-8').split('\n');
// Strip only line endings, including CRLF from a Windows Git checkout.
sourceLines = fs.readFileSync(path.join(testDir, TARGET), 'utf-8').split(/\r?\n/);
delivered = new Set();
for (const line of response.split('\n')) {
for (const line of response.split(/\r?\n/)) {
const m = /^(\d+)\t(.*)$/.exec(line);
if (!m) continue;
const n = Number(m[1]);
+2 -2
View File
@@ -157,9 +157,9 @@ describe('CG-30 — an oversize cluster member is bounded, not unbounded', () =>
});
it('cuts on whole lines — a body is never sliced mid-line', () => {
const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split('\n');
const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split(/\r?\n/);
const numbered = response
.split('\n')
.split(/\r?\n/)
.map((l) => /^(\d+)\t(.*)$/.exec(l))
.filter((m): m is RegExpExecArray => m !== null)
.filter((m) => Number(m[1]) >= 1 && Number(m[1]) <= source.length);
+32 -37
View File
@@ -6,13 +6,20 @@
* after it pushed the worker past its memory ceiling.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import { scanDirectory } from '../src/extraction';
// Fail only the unsupported invocation; repository discovery and fallback Git
// commands still execute against real repositories on every platform.
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'));
}
@@ -25,11 +32,9 @@ function createTempDir(): string {
// git-visible path went with it: `includeIgnored`, gitlink recursion and the
// `codegraph.json` `include` allowlist all silently stopped applying (#1549).
//
// A PATH shim reproduces that on any git version, which is what makes this
// testable in CI at all.
// Inject the old Git error on any installed version, including Git for Windows.
describe('Old git without `ls-files -s --recurse-submodules` support (#1549)', () => {
let tempDir: string;
let originalPath: string | undefined;
const runGit = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
@@ -44,42 +49,18 @@ describe('Old git without `ls-files -s --recurse-submodules` support (#1549)', (
runGit(dir, 'commit', '-q', '-m', `${base} init`);
};
/** A `git` that dies exactly like < 2.36 when it sees -s with --recurse-submodules. */
const installOldGitShim = () => {
const shimDir = path.join(tempDir, '.shim');
fs.mkdirSync(shimDir, { recursive: true });
const realGit = execFileSync('which', ['git']).toString().trim();
const shim = path.join(shimDir, 'git');
fs.writeFileSync(
shim,
[
'#!/bin/sh',
'for a in "$@"; do',
' [ "$a" = "--recurse-submodules" ] && rs=1',
' [ "$a" = "-s" ] && st=1',
'done',
'if [ -n "$rs" ] && [ -n "$st" ]; then',
' echo "fatal: ls-files --recurse-submodules unsupported mode" >&2',
' exit 128',
'fi',
`exec ${JSON.stringify(realGit)} "$@"`,
].join('\n'),
);
fs.chmodSync(shim, 0o755);
originalPath = process.env.PATH;
process.env.PATH = `${shimDir}:${originalPath ?? ''}`;
};
beforeEach(() => {
beforeEach(async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
vi.mocked(execFileSync).mockImplementation(actual.execFileSync);
tempDir = createTempDir();
});
afterEach(() => {
if (originalPath !== undefined) process.env.PATH = originalPath;
originalPath = undefined;
vi.mocked(execFileSync).mockReset();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('still honours includeIgnored when `ls-files --recurse-submodules` is unsupported', () => {
it('still honours includeIgnored when `ls-files --recurse-submodules` is unsupported', async () => {
const root = path.join(tempDir, 'root');
makeRepo(root, 'a');
// An embedded repo that .gitignore excludes but codegraph.json opts back in.
@@ -95,13 +76,27 @@ describe('Old git without `ls-files -s --recurse-submodules` support (#1549)', (
// Baseline: the real git resolves both files.
const withRealGit = scanDirectory(root);
expect(withRealGit).toContain('a.ts');
expect(withRealGit).toContain(path.join('dir_b', 'b.ts'));
expect(withRealGit).toContain('dir_b/b.ts');
installOldGitShim();
const actual = await vi.importActual<typeof import('child_process')>('child_process');
let rejected = 0;
let retried = 0;
vi.mocked(execFileSync).mockImplementation(((file, args, options) => {
if (file === 'git' && Array.isArray(args) && args[0] === 'ls-files' && args.includes('-s')) {
if (args.includes('--recurse-submodules')) {
rejected++;
throw new Error('fatal: ls-files --recurse-submodules unsupported mode');
}
retried++;
}
return actual.execFileSync(file, args, options);
}) as typeof execFileSync);
// The opted-in file must survive the unsupported-mode failure, not vanish.
const withOldGit = scanDirectory(root);
expect(withOldGit).toContain('a.ts');
expect(withOldGit).toContain(path.join('dir_b', 'b.ts'));
expect(withOldGit).toContain('dir_b/b.ts');
expect(rejected).toBeGreaterThan(0);
expect(retried).toBe(rejected);
});
});
+7 -6
View File
@@ -838,7 +838,10 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
describe('JVM FQN imports — end-to-end', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
@@ -856,7 +859,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n'
);
const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar');
@@ -872,8 +875,6 @@ describe('JVM FQN imports — end-to-end', () => {
.getIncomingEdges(bar!.id)
.find((e) => e.kind === 'imports');
expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined();
cg.close();
});
it('resolves a Kotlin top-level function import', async () => {
@@ -887,7 +888,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n'
);
const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util');
@@ -908,7 +909,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n'
);
const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar');
@@ -940,7 +941,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n'
);
const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar');
+51 -11
View File
@@ -41,6 +41,8 @@ import * as path from 'path';
import { CodeGraph } from '../src';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';
import { once } from 'events';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
@@ -51,7 +53,7 @@ interface SpawnedServer {
}
function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}, args: string[] = []): SpawnedServer {
const child = spawn(process.execPath, [BIN, 'serve', '--mcp', ...args], {
const child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', ...args], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
// #618: the daemon-attach log line is now off by default; opt the test
@@ -195,7 +197,14 @@ describe('Shared MCP daemon (issue #411)', () => {
});
afterEach(async () => {
killTree(...servers.map((s) => s.child));
// The runtime flags avoid an intermediate relaunch process. Wait for each
// actual server to exit before removing its Windows working directory.
await Promise.all(servers.map(async ({ child }) => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, 'exit');
child.kill('SIGKILL');
await exited;
}));
// The daemon is detached (not a tracked child) — reap it explicitly via the
// pid it recorded, so a test can't leak a background daemon. Guard against
// our own pid: the version-mismatch test plants `pid: process.pid` in the
@@ -203,10 +212,11 @@ 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));
servers.length = 0;
fs.rmSync(tempDir, { recursive: true, force: true });
await fs.promises.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
it.runIf(process.platform !== 'win32')('stops despite a socket still waiting for its client hello (#1963)', async () => {
@@ -495,8 +505,21 @@ describe('Shared MCP daemon (issue #411)', () => {
sendInitialize(first.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(first.stdout, 1), 10000);
await waitFor(() => countListeningLines(realRoot) >= 1, 10000);
// Listening precedes engine initialization. Do not kill SQLite while its
// initial connection is still being configured for this fixture.
sendMessage(first.child, {
jsonrpc: '2.0', id: 10, method: 'tools/call',
params: { name: 'codegraph_status', arguments: {} },
});
const ready = await waitFor(() => findResponse(first.stdout, 10), 10000);
expect(ready.result?.isError).not.toBe(true);
expect(JSON.stringify(ready.result)).toContain('CodeGraph Status');
const killedPid = readLockPid(realRoot)!;
// End the first proxy before simulating PID reuse. Otherwise it can switch
// to a fallback writer while this test prepares the replacement locks/DB.
first.child.stdin.end();
await waitFor(() => first.child.exitCode !== null, 5000);
process.kill(killedPid, 'SIGKILL');
expect(await waitProcessExit(killedPid, 8000)).toBe(true);
@@ -518,7 +541,10 @@ describe('Shared MCP daemon (issue #411)', () => {
fs.writeFileSync(daemonPath, staleDaemonLock);
fs.writeFileSync(writerPath, staleWriterLock);
const before = await staleIndex(realRoot);
// Make the index stale by changing the source, without opening a new
// SQLite writer against the database of the daemon we just killed.
const before = fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db'));
fs.writeFileSync(path.join(realRoot, 'app.ts'), 'export function changedSymbol() {}\n');
const second = spawnServer(tempDir, env);
servers.push(second);
sendInitialize(second.child, `file://${tempDir}`, 2);
@@ -528,7 +554,7 @@ describe('Shared MCP daemon (issue #411)', () => {
() => second.stderr.some((line) =>
line.includes('Attached to shared daemon') || line.includes('Shared daemon unavailable')
),
12000,
30000, // The nominal 6s retry loop takes up to 26s on the Windows VM.
25,
'the proxy to attach or fall back',
);
@@ -570,7 +596,7 @@ describe('Shared MCP daemon (issue #411)', () => {
() => server.stderr.some((line) =>
line.includes('Attached to shared daemon') || line.includes('Shared daemon unavailable')
),
12000,
30000, // The nominal 6s retry loop takes up to 26s on the Windows VM.
25,
'the proxy to attach or fall back',
);
@@ -590,7 +616,7 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(toolResponse).toMatchObject({
error: { message: expect.stringContaining('live legacy daemon') },
});
}, 30000);
}, 40000);
it('does not start a fallback writer when the daemon lock is unreadable', async () => {
const pidPath = path.join(realRoot, '.codegraph', 'daemon.pid');
@@ -601,7 +627,7 @@ describe('Shared MCP daemon (issue #411)', () => {
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(
() => server.stderr.some((line) => line.includes('Shared daemon unavailable')),
12000,
30000, // The nominal 6s retry loop takes up to 26s on the Windows VM.
25,
'the proxy to fall back',
);
@@ -616,7 +642,7 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(toolResponse).toMatchObject({
error: { message: expect.stringContaining('daemon lock could not be read') },
});
}, 30000);
}, 40000);
it.each([null, 'daemon', 'fallback'])('proxy falls back to read-only mode on a daemon version mismatch (writer: %s)', async (mode) => {
const before = await staleIndex(realRoot);
@@ -696,15 +722,29 @@ describe('Shared MCP daemon (issue #411)', () => {
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
// initialize is answered locally, and a PID file can belong to a daemon
// still starting. Establish a real live session before measuring silence.
const attached = await waitFor(
() => server.stderr.find((line) => line.includes('Attached to shared daemon')),
10000,
);
sendMessage(server.child, {
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'codegraph_status', arguments: {} },
});
const status = await waitFor(() => findResponse(server.stdout, 2), 10000);
expect(status.error).toBeUndefined();
expect(status.result?.isError).not.toBe(true);
expect(JSON.stringify(status.result)).toContain('CodeGraph Status');
const daemonPid = readLockPid(realRoot)!;
expect(attached).toContain(`(pid ${daemonPid},`);
expect(isAlive(daemonPid)).toBe(true);
// Stay silent well past several backstop windows. The live session's peer is
// provably alive, so the daemon must keep running (and never log a backstop
// shutdown), with its lockfile intact.
await new Promise((r) => setTimeout(r, 4000)); // > 3× maxIdle
expect(isAlive(daemonPid)).toBe(true);
expect(isAlive(daemonPid), readDaemonLog(realRoot) + '\n' + server.stderr.join('\n')).toBe(true);
expect(readDaemonLog(realRoot)).not.toContain('inactivity backstop');
expect(readLockPid(realRoot)).toBe(daemonPid);
}, 30000);
+10 -4
View File
@@ -16,11 +16,13 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { once } from 'events';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
// Pin to direct (in-process) mode. #172 is a contract about the in-process
@@ -107,11 +109,15 @@ describe('MCP initialize handshake (issue #172)', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-'));
});
afterEach(() => {
if (child && !child.killed) {
afterEach(async () => {
// Spawn with the runtime flags so this is the server, not a relauncher
// whose grandchild would retain the Windows cwd/database handles.
if (child && child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close');
child.kill('SIGKILL');
child = null;
await closed;
}
child = null;
fs.rmSync(tempDir, { recursive: true, force: true });
});
+2 -1
View File
@@ -357,6 +357,7 @@ describe('MCP explicit projectPath lifecycle (#1835)', () => {
}
});
// Builds and reconciles nine real indexes; a Windows VM exceeds the default 5s.
it('defers eviction and shutdown until an active catch-up finishes', async () => {
const roots = [serviceA, serviceB];
for (let i = roots.length; i <= MAX_CACHED_PROJECTS; i++) {
@@ -397,7 +398,7 @@ describe('MCP explicit projectPath lifecycle (#1835)', () => {
if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev;
}
});
}, 15_000);
it('drains a tool operation before closing its cached graph', async () => {
+10 -4
View File
@@ -21,12 +21,14 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { once } from 'events';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
// --no-watch keeps the test deterministic and avoids watcher startup noise.
return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--no-watch'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
@@ -84,11 +86,15 @@ describe('MCP project resolution via roots/list (issue #196)', () => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-'));
});
afterEach(() => {
if (child && !child.killed) {
afterEach(async () => {
// Spawn with the runtime flags so this is the server, not a relauncher
// whose grandchild would retain the Windows cwd/database handles.
if (child && child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close');
child.kill('SIGKILL');
child = null;
await closed;
}
child = null;
fs.rmSync(cwdDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
});
+10 -4
View File
@@ -19,13 +19,15 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { once } from 'events';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
// --no-watch keeps the test deterministic; CODEGRAPH_NO_DAEMON keeps the
// session in direct mode so no detached daemon outlives the test.
return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--no-watch'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
@@ -113,11 +115,15 @@ 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) {
afterEach(async () => {
// Spawn with the runtime flags so this is the server, not a relauncher
// whose grandchild would retain the Windows cwd/database handles.
if (child && child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close');
child.kill('SIGKILL');
child = null;
await closed;
}
child = null;
fs.rmSync(ws, { recursive: true, force: true });
});
+3 -1
View File
@@ -27,7 +27,9 @@ function docstrings(code: string): Map<string, string | undefined> {
return new Map(extractFromSource('ledger.py', code).nodes.map((n) => [n.name, n.docstring]));
}
const fixture = fs.readFileSync(path.join(__dirname, 'fixtures/kernel-parity/docstrings.py'), 'utf8');
// Git may check out the fixture as CRLF. Start from LF so the CRLF variant
// adds exactly one carriage return per newline on either platform.
const fixture = fs.readFileSync(path.join(__dirname, 'fixtures/kernel-parity/docstrings.py'), 'utf8').replace(/\r\n/g, '\n');
const kernelBuilt = fs.existsSync(path.join(__dirname, '../codegraph-kernel/prebuilds',
`${process.platform}-${process.arch}`, 'codegraph-kernel.node'));
+37 -6
View File
@@ -299,12 +299,7 @@ export class Daemon {
// writer lock prevents stale-artifact cleanup from racing this ownership
// check, and the exact snapshot prevents overwriting a replacement record.
try {
if (fs.readFileSync(this.pidPath, 'utf8') !== initialLockContents) {
throw new Error('Lost daemon lock ownership after binding.');
}
const tmpPid = `${this.pidPath}.${process.pid}.bound`;
fs.writeFileSync(tmpPid, encodeLockInfo(lock), { mode: 0o600 });
fs.renameSync(tmpPid, this.pidPath);
refreshDaemonLock(this.pidPath, initialLockContents, lock);
} catch (err) {
try { bound.server.close(); } catch { /* best-effort */ }
this.cleanupLockfile();
@@ -551,6 +546,42 @@ export class Daemon {
}
}
/**
* Publish the bound socket without abandoning a live daemon on a transient
* Windows sharing violation. Keep this startup step synchronous: accepting a
* client before ownership is refreshed could initialize an engine too early.
* Six attempts wait at most 375ms; permanent failures still abort startup.
*/
export function refreshDaemonLock(
pidPath: string,
initialContents: string,
lock: DaemonLockInfo,
platform: NodeJS.Platform = process.platform,
): void {
const tmpPid = `${pidPath}.${process.pid}.bound`;
try {
fs.writeFileSync(tmpPid, encodeLockInfo(lock), { mode: 0o600 });
for (let attempt = 0; ; attempt++) {
// A retry must never replace a record whose owner changed while waiting.
if (fs.readFileSync(pidPath, 'utf8') !== initialContents) {
throw new Error('Lost daemon lock ownership after binding.');
}
try {
fs.renameSync(tmpPid, pidPath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '') || attempt >= 5) {
throw error;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1));
}
}
} finally {
try { fs.unlinkSync(tmpPid); } catch { /* renamed, or best-effort cleanup */ }
}
}
/**
* Result of `tryAcquireDaemonLock`. Either we got the lockfile (caller becomes
* the daemon), or it already existed (caller should connect to the existing