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
+50 -9
View File
@@ -16,6 +16,16 @@
* an absolute-path hash under `os.tmpdir()`. The pidfile always stays in the
* project (it doesn't have a length limit) — and acts as the authoritative
* pointer to the socket path the daemon chose.
*
* Second special-case (#997, #974): some filesystems can't host an AF_UNIX node
* AT ALL — ExFAT/FAT external volumes, certain network mounts, WSL2 DrvFs — so
* `listen()` throws ENOTSUP/EACCES regardless of path length. We can't cheaply
* tell those apart from a normal volume up front, so instead of guessing we
* expose an ORDERED candidate list (`getDaemonSocketCandidates`): the in-project
* path first, the deterministic tmpdir path as the fallback of last resort. The
* daemon binds the first that works (relocating past a capability error); the
* proxy connects the first that answers. Both walk the SAME list, so they still
* converge on whichever the daemon bound with zero coordination.
*/
import * as crypto from 'crypto';
@@ -32,19 +42,50 @@ function projectHash(projectRoot: string): string {
}
/**
* Compute the socket / named-pipe path the daemon should listen on (and the
* proxy should connect to) for `projectRoot`. Deterministic given a project
* root, so independent processes converge without coordination.
* The deterministic tmpdir socket path for `projectRoot` — the fallback used
* when the in-project location can't host a socket (too long, or an FS that
* doesn't support AF_UNIX). Hash keeps it project-scoped, and being purely a
* function of the root means the daemon and the proxy compute the identical
* path without talking to each other.
*/
export function getDaemonSocketPath(projectRoot: string): string {
function tmpdirSocketPath(projectRoot: string): string {
return path.join(os.tmpdir(), `codegraph-${projectHash(projectRoot)}.sock`);
}
/**
* Ordered socket / named-pipe path candidates the daemon should try to bind (and
* the proxy should try to connect) for `projectRoot`, most-preferred first.
* Deterministic given a project root, so independent processes converge without
* coordination — even when the preferred candidate is unusable and both fall
* through to the same fallback.
*
* - Windows: a single named pipe (lives in the kernel pipe namespace, not on
* the project FS, so neither the length nor the ExFAT hazard applies).
* - Short in-project path: `[ .codegraph/daemon.sock , <tmpdir> ]` — try the
* project first, fall back to tmpdir if its FS can't host a socket (#997).
* - Long in-project path (deep monorepos, Bazel out dirs): `[ <tmpdir> ]` only
* — bind would throw ENAMETOOLONG, so we skip straight to tmpdir.
*/
export function getDaemonSocketCandidates(projectRoot: string): string[] {
if (process.platform === 'win32') {
return `\\\\.\\pipe\\codegraph-${projectHash(projectRoot)}`;
return [`\\\\.\\pipe\\codegraph-${projectHash(projectRoot)}`];
}
const inProject = path.join(getCodeGraphDir(projectRoot), 'daemon.sock');
if (inProject.length <= POSIX_SOCKET_PATH_LIMIT) return inProject;
// Long project paths (deep monorepos, Bazel out dirs) need tmpdir fallback
// or `bind` returns EADDRINUSE / ENAMETOOLONG. Hash keeps it project-scoped.
return path.join(os.tmpdir(), `codegraph-${projectHash(projectRoot)}.sock`);
const tmp = tmpdirSocketPath(projectRoot);
if (inProject.length > POSIX_SOCKET_PATH_LIMIT) return [tmp];
return [inProject, tmp];
}
/**
* The PREFERRED (primary) socket path — candidate 0. Use this only where a
* single representative path is wanted (the lockfile's informational
* `socketPath` field, status display). For binding/connecting, walk the full
* {@link getDaemonSocketCandidates} list — the daemon may bind a fallback when
* candidate 0 is unusable.
*/
export function getDaemonSocketPath(projectRoot: string): string {
// The candidate list is never empty (≥1 on every platform), so [0] is safe.
return getDaemonSocketCandidates(projectRoot)[0]!;
}
/** Absolute path to the daemon pid lockfile for `projectRoot`. */