Files
codegraph/__tests__/daemon-lock-refresh.test.ts
T
Colby Mchenry c405ac5e6c fix(windows): make load-sensitive tests wait on real signals; longer pid-file retry (#1773) (#2078)
* 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>
2026-09-28 18:13:35 +00:00

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();
});
});