fix(mcp): start the daemon on ExFAT/FAT/network filesystems (#997) (#1022)

A project kept on an ExFAT/FAT external volume (or some network mounts /
WSL2 DrvFs) broke the background auto-sync daemon at two points, both
because the filesystem lacks POSIX features the daemon relied on:

1. Lock acquisition hard-links a temp file onto .codegraph/daemon.pid for
   race-free exclusivity (#411) — these filesystems have no hard links.
2. The Unix-domain socket listen() fails regardless of path length, so the
   old length-only tmpdir fallback never triggered.

Both surface as a capability error, but each OS reports a DIFFERENT errno
for the same gap (macOS ENOTSUP, Linux EPERM, Windows EISDIR), so the fix
is policy-based rather than an enumerated code-set:

- Lock: fall back to an O_EXCL create on any non-EEXIST link error. The
  temp write already proved the directory is writable, so the fallback
  either succeeds (still atomic + exclusive, "first writer wins") or
  surfaces its own genuine error.
- Socket: an ordered candidate list [in-project, tmpdir] walked by BOTH
  the daemon (binds) and the proxy (connects) — they converge on the
  fallback with zero coordination. Relocate past any non-EADDRINUSE bind
  error; EADDRINUSE still rethrows, preserving the #974 contract.

Normal repos are unaffected: the in-project candidate binds first, and the
hard-link lock path is unchanged.

Validated end-to-end on real removable-drive filesystems: macOS ExFAT
(hdiutil image), Linux FAT32 (Docker loop mount), Windows exFAT (diskpart
VHD) — each acquires the lock, relocates (or binds a named pipe on
Windows), and serves a real client.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-27 13:47:29 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7a361ef16e
commit f83a1ecc8e
6 changed files with 487 additions and 44 deletions
+22 -5
View File
@@ -48,7 +48,7 @@ import {
tryAcquireDaemonLock,
} from './daemon';
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
import { getDaemonSocketPath } from './daemon-paths';
import { getDaemonSocketCandidates } from './daemon-paths';
import { getTelemetry } from '../telemetry';
import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from './ppid-watchdog';
import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
@@ -376,17 +376,34 @@ export class MCPServer {
* never wedges a session.
*/
private async runProxyWithLocalHandshake(root: string): Promise<void> {
const socketPath = getDaemonSocketPath(root);
// The daemon may relocate its socket past an in-project filesystem that can't
// host one (ExFAT/FAT volumes, WSL2 DrvFs; #997) to the deterministic tmpdir
// fallback. We don't read the bound path from the lockfile — both sides walk
// the SAME ordered candidate list, so we converge on whichever the daemon
// bound with zero coordination. The in-project candidate is tried first, so a
// normal repo pays nothing extra (it connects on the very first probe).
const candidates = getDaemonSocketCandidates(root);
const connectAnyCandidate = async (): Promise<Awaited<ReturnType<typeof connectWithHello>>> => {
for (const candidate of candidates) {
const s = await connectWithHello(candidate);
// A wrong-version daemon IS up — definitive; propagate so the caller
// serves in-process instead of spawning + polling for 6s. Don't keep
// probing fallbacks past it.
if (s === 'version-mismatch') return s;
if (s) return s;
}
return null;
};
const getDaemonSocket = async () => {
// Fast path: a daemon may already be listening.
const probe = await connectWithHello(socketPath);
// Fast path: a daemon may already be listening (on either candidate).
const probe = await connectAnyCandidate();
if (probe === 'version-mismatch') return null; // definitive — serve in-process, don't poll for 6s
if (probe) return probe;
// None reachable — spawn one (detached) and poll for its bind.
spawnDetachedDaemon(root);
for (let attempt = 0; attempt < DAEMON_CONNECT_MAX_RETRIES; attempt++) {
await sleep(DAEMON_CONNECT_RETRY_DELAY_MS);
const s = await connectWithHello(socketPath);
const s = await connectAnyCandidate();
if (s === 'version-mismatch') return null;
if (s) return s;
}