The client-facing MCP proxy could exit with "Transport closed" when its connection to the shared daemon hit a socket 'error' with no listener attached — common on WSL2 /mnt (DrvFs), where AF_UNIX is flaky. The global fatal handler turned that uncaughtException into process.exit(1), which the MCP client saw as a bare transport close even though the index was healthy. proxy.ts now keeps an 'error' listener on the daemon socket for its whole life (and skips a socket destroyed in the connect window), so a stray error degrades to the existing in-process fallback instead of crashing. daemon.ts releases the lockfile it acquired when it fails to bind, so the next launch doesn't spin on a stale lock (the duplicate serve --mcp pileup). No default behavior change for anyone; WSL /mnt users who still hit trouble can set CODEGRAPH_NO_DAEMON=1 to skip the shared daemon entirely. Validated on macOS (unit + live serve probe) and Linux (Docker, --init): 64/64 across the daemon/socket/lifecycle suites, incl. real AF_UNIX. Closes #974 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1e48861cfb
commit
7c6417ef8f
@@ -183,6 +183,19 @@ export class Daemon {
|
||||
this.server = server;
|
||||
resolve();
|
||||
});
|
||||
}).catch((err) => {
|
||||
// Bind failed — e.g. AF_UNIX is unsupported/unreliable on this filesystem
|
||||
// (the WSL2 DrvFs hazard behind #974), or a stale socket we couldn't clear.
|
||||
// We already hold the lockfile that `tryAcquireDaemonLock` wrote; release it
|
||||
// and any partial socket so the NEXT launcher doesn't spin respawning us on
|
||||
// a stale lock that points at our now-dying pid. Then re-throw so the caller
|
||||
// (the bin's try/catch) exits this detached daemon cleanly and every
|
||||
// launcher falls back to direct mode.
|
||||
this.cleanupLockfile();
|
||||
if (process.platform !== 'win32') {
|
||||
try { fs.unlinkSync(this.socketPath); } catch { /* may not exist */ }
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const lock: DaemonLockInfo = {
|
||||
|
||||
+14
-1
@@ -135,6 +135,16 @@ export async function connectWithHello(
|
||||
if (process.platform !== 'win32' && !fs.existsSync(socketPath)) return null;
|
||||
const socket = net.createConnection(socketPath);
|
||||
socket.setEncoding('utf8');
|
||||
// Keep an 'error' listener attached for the socket's ENTIRE life. readHelloLine
|
||||
// attaches its own and then REMOVES it on success (its cleanup()), which left a
|
||||
// window — from here until the caller attaches its onDaemonLost handler — where
|
||||
// a socket 'error' had NO listener. In Node an unhandled socket 'error' is
|
||||
// re-thrown as an uncaughtException, which the global fatal handler turns into
|
||||
// process.exit(1); to an MCP client that surfaces as a bare "Transport closed"
|
||||
// (#974). The window is rarely hit on a healthy FS but is common on flaky
|
||||
// AF_UNIX-over-DrvFs (WSL2 /mnt drives). A no-op guard makes the error
|
||||
// recoverable: the follow-up 'close' drives the caller's normal fallback.
|
||||
socket.on('error', () => { /* absorbed — see #974; 'close' drives the fallback */ });
|
||||
const hello = await readHelloLine(socket).catch(() => null);
|
||||
if (!hello) {
|
||||
socket.destroy();
|
||||
@@ -323,7 +333,10 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
|
||||
let socket: net.Socket | null = null;
|
||||
try { socket = await deps.getDaemonSocket(); } catch { socket = null; }
|
||||
|
||||
if (socket && !shuttingDown) {
|
||||
// `!socket.destroyed`: the connect-window error guard above can absorb an
|
||||
// 'error' that already destroyed the socket before we got here (#974) — treat
|
||||
// a dead socket as "no daemon" so we cleanly fall back to the in-process engine.
|
||||
if (socket && !socket.destroyed && !shuttingDown) {
|
||||
daemonSocket = socket;
|
||||
daemonStatus = 'ready';
|
||||
let sockBuf = '';
|
||||
|
||||
Reference in New Issue
Block a user