mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
c405ac5e6c
* test: make Windows load-sensitive tests wait on the tree, not the clock (#1773) Each failure from the loaded Windows run was traced to its mechanism and reproduced deterministically before changing it. No assertion is weakened. - daemon-lock-refresh: the PowerShell holder slept 200ms against a 375ms retry budget. Windows refuses a replace-rename while any handle to the target is open, even a Node read handle, so hold one in-process and close it inside the rename mock after the first real failure. Also asserts the first rename really failed with a retryable code. - mcp-projectpath-lifecycle: the owner daemon armed a 500ms idle timer at start and exited (code 0) before a starved engine reached it. It now starts with idle exit off and is armed over IPC once the engines hold a session. The file waits for catch-up instead of the gate's 3s serve-anyway deadline, and its index-heavy hooks and eviction case get explicit timeouts. - mcp-daemon / mcp-writer-lock teardown: racing launchers spawn detached daemon candidates whose cwd is the fixture; a loser still starting at teardown pinned it (EBUSY) or took over after the winner was stopped. A --require preload records candidate pids, and teardown waits for the losers before stopping the winner. - mcp-writer-lock: writer.pid appears before the daemon binds and rewrites daemon.pid, so cleanup could probe a daemon mid-start ('unverified'); wait for both proxies to attach. Removal uses fs.promises.rm, because fs.rmSync gives up on the first Windows access-denied error despite maxRetries. - mcp-daemon #1963: connect only after the proxy attached (lock before bind). - sync-rebuild-convergence / multi-repo-workspace: explicit timeouts scoped to the cases that build real indexes; the global timeout is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(daemon): give the Windows pid-file replace a longer retry budget (#1773) Windows refuses to replace a file while any handle to it is open, including a plain Node read handle, so an antivirus scan, an indexer, or another session reading daemon.pid can each block the daemon's startup refresh for a moment, and for longer on a loaded machine. The retry waited 375ms in all; it now backs off 25, 50, 100, 200, then 400ms three times, about 1.6s over eight attempts, still well inside a launcher's ~6s connect window. Permanent failures and non-Windows errors still abort at once, and an ownership change still stops the retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
4.5 KiB
TypeScript
110 lines
4.5 KiB
TypeScript
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 { 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(8);
|
|
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 on the pid file to close', () => {
|
|
// Windows will not replace a file while any handle to it is open, even
|
|
// one that shares delete access, which is how a scanner, an indexer or
|
|
// another process reading the lock trips this path. Hold a real handle,
|
|
// let the first real rename fail, and close it only then, so the retry
|
|
// follows the release by order rather than racing a timer against the
|
|
// backoff (#1773).
|
|
const handle = fs.openSync(pidPath, 'r');
|
|
const held: NodeJS.ErrnoException[] = [];
|
|
vi.mocked(fs.renameSync).mockImplementationOnce((from, to) => {
|
|
try {
|
|
realRename(from, to);
|
|
} catch (error) {
|
|
held.push(error as NodeJS.ErrnoException);
|
|
fs.closeSync(handle);
|
|
throw error;
|
|
}
|
|
});
|
|
try {
|
|
refreshDaemonLock(pidPath, initial, lock);
|
|
} finally {
|
|
if (held.length === 0) fs.closeSync(handle);
|
|
}
|
|
expect(held).toHaveLength(1);
|
|
expect(['EPERM', 'EACCES', 'EBUSY']).toContain(held[0]!.code);
|
|
expect(vi.mocked(fs.renameSync).mock.calls.length).toBeGreaterThan(1);
|
|
expect(JSON.parse(fs.readFileSync(pidPath, 'utf8'))).toEqual(lock);
|
|
noTemporaryFile();
|
|
});
|
|
});
|