96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
/**
|
|
* Daemon bind-failure cleanup — issue #974.
|
|
*
|
|
* A detached daemon acquires the `.codegraph/daemon.pid` lock (via
|
|
* `tryAcquireDaemonLock`) BEFORE it binds its socket. If the bind then fails —
|
|
* e.g. AF_UNIX is unsupported/unreliable on the filesystem (the WSL2 DrvFs
|
|
* hazard behind #974) — `Daemon.start()` must release that lockfile before it
|
|
* propagates the error and exits. Otherwise the next launcher reads a stale lock
|
|
* pointing at the now-dead pid and the process pileup the issue reported recurs.
|
|
*
|
|
* We force a deterministic bind failure by planting a *directory* at the socket
|
|
* path: `unlinkSync` (the daemon's stale-socket clear) can't remove a directory,
|
|
* so it survives and `listen()` fails with EADDRINUSE.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { Daemon, tryAcquireDaemonLock, finalizeDaemonExit } from '../src/mcp/daemon';
|
|
import { getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
|
|
|
|
const tmpRoots: string[] = [];
|
|
afterEach(() => {
|
|
while (tmpRoots.length) {
|
|
const root = tmpRoots.pop()!;
|
|
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
}
|
|
});
|
|
|
|
describe('Daemon.start() bind failure (#974)', () => {
|
|
it.runIf(process.platform !== 'win32')('releases the lockfile it acquired when the socket cannot bind', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bind-'));
|
|
tmpRoots.push(root);
|
|
|
|
// Acquire the lock exactly as the detached-daemon startup does.
|
|
const lock = tryAcquireDaemonLock(root);
|
|
expect(lock.kind).toBe('acquired');
|
|
const pidPath = getDaemonPidPath(root);
|
|
expect(fs.existsSync(pidPath)).toBe(true);
|
|
|
|
// Make the socket path un-bindable: a directory can't be unlink'd by the
|
|
// daemon's stale-socket clear, and listen() on it fails with EADDRINUSE.
|
|
const sockPath = getDaemonSocketPath(root);
|
|
fs.mkdirSync(sockPath, { recursive: true });
|
|
// The tmpdir-fallback socket path can live outside `root`; clean it too.
|
|
tmpRoots.push(sockPath);
|
|
|
|
const daemon = new Daemon(root);
|
|
await expect(daemon.start()).rejects.toThrow();
|
|
|
|
// The lockfile must be gone so the next launcher doesn't spin on a stale lock.
|
|
expect(fs.existsSync(pidPath)).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Windows shutdown must not force `process.exit()` while the recursive file
|
|
* watcher is still tearing down — that aborts the daemon with a libuv
|
|
* `UV_HANDLE_CLOSING` assertion (0xC0000409), reproducible when the indexed tree
|
|
* contains a nested repo. `finalizeDaemonExit` drains on Windows and exits
|
|
* immediately elsewhere; both branches are exercised here by injecting the
|
|
* platform + exit fn (so it runs on any host).
|
|
*/
|
|
describe('finalizeDaemonExit — Windows drains instead of aborting mid-watcher-close', () => {
|
|
for (const platform of ['linux', 'darwin'] as const) {
|
|
it(`exits immediately on ${platform}`, () => {
|
|
const exit = vi.fn();
|
|
const backstop = finalizeDaemonExit(platform, exit);
|
|
expect(exit).toHaveBeenCalledTimes(1);
|
|
expect(exit).toHaveBeenCalledWith(0);
|
|
expect(backstop).toBeNull();
|
|
});
|
|
}
|
|
|
|
it('on win32 defers exit (lets the loop drain), then force-exits via an unref\'d backstop', () => {
|
|
vi.useFakeTimers();
|
|
const prevExitCode = process.exitCode;
|
|
const exit = vi.fn();
|
|
try {
|
|
const backstop = finalizeDaemonExit('win32', exit);
|
|
// No synchronous exit — the process must drain its closing watch handles first.
|
|
expect(exit).not.toHaveBeenCalled();
|
|
expect(backstop).not.toBeNull();
|
|
// Success code is set so a natural drain exits 0.
|
|
expect(process.exitCode).toBe(0);
|
|
// If a stray handle keeps the loop alive, the backstop still forces exit.
|
|
vi.advanceTimersByTime(2_000);
|
|
expect(exit).toHaveBeenCalledWith(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
process.exitCode = prevExitCode; // don't leak a 0 exit code into the runner
|
|
}
|
|
});
|
|
});
|