fix(mcp): drain the event loop on Windows daemon shutdown instead of aborting mid-watcher-close (#1041)

On Windows, calling process.exit() while a recursive fs.watch handle is still
tearing down aborts the daemon with a libuv UV_HANDLE_CLOSING assertion
(0xC0000409) — reproducible whenever the indexed tree contains a nested repo
(submodule / embedded clone), since that's what keeps a watch active at shutdown.
A small exit delay doesn't help; only letting the loop drain is clean (verified
on a real Windows VM: close()+exit() and close()+setTimeout(exit) both abort,
while letting the loop drain exits 0).

finalizeDaemonExit() now exits immediately on POSIX (unchanged) but on Windows
marks success (exitCode=0) and lets the loop drain to a natural exit, with an
unref'd backstop that force-exits only if a stray handle would otherwise hang
shutdown. The daemon's own timers are already unref'd and its PPID watchdog lives
in the proxy, so nothing keeps the loop alive past the closing watch handles —
natural drain is fast. Pure + platform-injected so both branches unit-test off-Windows.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-28 20:11:05 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f73227d2f5
commit cfa293539f
2 changed files with 85 additions and 3 deletions
+42 -2
View File
@@ -13,11 +13,11 @@
* so it survives and `listen()` fails with EADDRINUSE.
*/
import { afterEach, describe, expect, it } from 'vitest';
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 } from '../src/mcp/daemon';
import { Daemon, tryAcquireDaemonLock, finalizeDaemonExit } from '../src/mcp/daemon';
import { getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
const tmpRoots: string[] = [];
@@ -53,3 +53,43 @@ describe('Daemon.start() bind failure (#974)', () => {
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
}
});
});