diff --git a/__tests__/arkts-resolution.test.ts b/__tests__/arkts-resolution.test.ts index b095a443..a43158d3 100644 --- a/__tests__/arkts-resolution.test.ts +++ b/__tests__/arkts-resolution.test.ts @@ -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'); diff --git a/__tests__/daemon-lock-refresh.test.ts b/__tests__/daemon-lock-refresh.test.ts new file mode 100644 index 00000000..215c2c22 --- /dev/null +++ b/__tests__/daemon-lock-refresh.test.ts @@ -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(); + 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('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); +}); diff --git a/__tests__/explore-factory-closure.test.ts b/__tests__/explore-factory-closure.test.ts index d0b08383..61fa1f59 100644 --- a/__tests__/explore-factory-closure.test.ts +++ b/__tests__/explore-factory-closure.test.ts @@ -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]); diff --git a/__tests__/explore-oversize-member.test.ts b/__tests__/explore-oversize-member.test.ts index 019d1ff9..d45c54db 100644 --- a/__tests__/explore-oversize-member.test.ts +++ b/__tests__/explore-oversize-member.test.ts @@ -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); diff --git a/__tests__/extraction-old-git.test.ts b/__tests__/extraction-old-git.test.ts index 38f62395..ac3e92bd 100644 --- a/__tests__/extraction-old-git.test.ts +++ b/__tests__/extraction-old-git.test.ts @@ -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(); + 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('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('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); }); }); diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 4e59a738..1bd0bf0d 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -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'); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index 94400fbe..eac53516 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -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); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 0a320773..7dc90ae2 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -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 }); }); diff --git a/__tests__/mcp-projectpath-lifecycle.test.ts b/__tests__/mcp-projectpath-lifecycle.test.ts index e8b302be..a8b5d07a 100644 --- a/__tests__/mcp-projectpath-lifecycle.test.ts +++ b/__tests__/mcp-projectpath-lifecycle.test.ts @@ -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 () => { diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts index 8e1d4520..5ae327d5 100644 --- a/__tests__/mcp-roots.test.ts +++ b/__tests__/mcp-roots.test.ts @@ -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 }); }); diff --git a/__tests__/mcp-subproject-adoption.test.ts b/__tests__/mcp-subproject-adoption.test.ts index 39abac03..e2e20372 100644 --- a/__tests__/mcp-subproject-adoption.test.ts +++ b/__tests__/mcp-subproject-adoption.test.ts @@ -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 }); }); diff --git a/__tests__/python-body-docstrings.test.ts b/__tests__/python-body-docstrings.test.ts index 909805ca..041d1ded 100644 --- a/__tests__/python-body-docstrings.test.ts +++ b/__tests__/python-body-docstrings.test.ts @@ -27,7 +27,9 @@ function docstrings(code: string): Map { 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')); diff --git a/src/mcp/daemon.ts b/src/mcp/daemon.ts index d173a573..706e3e97 100644 --- a/src/mcp/daemon.ts +++ b/src/mcp/daemon.ts @@ -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