From f9feac02ff06dbf384051364ec81abe405dac62d Mon Sep 17 00:00:00 2001 From: Zengwenliang0416 Date: Fri, 26 Jun 2026 22:40:17 +0800 Subject: [PATCH] fix(mcp): fall back to tmpdir socket on filesystems without AF_UNIX support (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExFAT, NTFS-3G, and some FUSE-backed volumes do not support Unix domain sockets — `listen()` fails with ENOTSUP. `getDaemonSocketPath` already had a tmpdir fallback for long paths; this extends it to also probe socket support via a short-lived child process, cached per device. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/daemon-socket-probe.test.ts | 68 +++++++++++++++++++++++++++ src/mcp/daemon-paths.ts | 62 ++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 __tests__/daemon-socket-probe.test.ts diff --git a/__tests__/daemon-socket-probe.test.ts b/__tests__/daemon-socket-probe.test.ts new file mode 100644 index 000000000..f51c97107 --- /dev/null +++ b/__tests__/daemon-socket-probe.test.ts @@ -0,0 +1,68 @@ +/** + * Socket-support probe — issue #997. + * + * ExFAT, NTFS-3G, and some FUSE-backed volumes don't support AF_UNIX sockets. + * `getDaemonSocketPath` must detect this and fall back to `os.tmpdir()` instead + * of returning an in-project path that will fail on `listen()`. + * + * These tests validate `canSocketInDir` on the local tmpdir (which always + * supports sockets on any standard OS) and verify the cache is effective. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + canSocketInDir, + clearSocketSupportCache, + getDaemonSocketPath, +} from '../src/mcp/daemon-paths'; + +afterEach(() => { + clearSocketSupportCache(); +}); + +describe('canSocketInDir (#997)', () => { + it.runIf(process.platform !== 'win32')('returns true for a directory on a socket-capable filesystem', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sock-probe-')); + try { + expect(canSocketInDir(dir)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform !== 'win32')('caches the result per device — second call is free', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sock-probe-')); + try { + const first = canSocketInDir(dir); + const second = canSocketInDir(dir); + expect(first).toBe(second); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns true when the directory does not exist (optimistic fallback)', () => { + expect(canSocketInDir('/nonexistent-dir-cg-probe')).toBe(true); + }); +}); + +describe('getDaemonSocketPath (#997)', () => { + it.runIf(process.platform !== 'win32')('returns in-project path on a socket-capable filesystem', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-path-')); + try { + const sockPath = getDaemonSocketPath(root); + expect(sockPath).toContain('.codegraph'); + expect(sockPath).toContain('daemon.sock'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform === 'win32')('returns a named pipe on Windows', () => { + const sockPath = getDaemonSocketPath('/some/project'); + expect(sockPath).toMatch(/^\\\\.\\pipe\\codegraph-/); + }); +}); diff --git a/src/mcp/daemon-paths.ts b/src/mcp/daemon-paths.ts index b1afc40df..1ca469754 100644 --- a/src/mcp/daemon-paths.ts +++ b/src/mcp/daemon-paths.ts @@ -18,7 +18,9 @@ * pointer to the socket path the daemon chose. */ +import { execFileSync } from 'child_process'; import * as crypto from 'crypto'; +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCodeGraphDir } from '../directory'; @@ -31,6 +33,58 @@ function projectHash(projectRoot: string): string { return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16); } +/** + * Per-device cache for AF_UNIX socket support. Keyed by `fs.statSync().dev` + * so the probe runs at most once per mounted filesystem. + */ +const socketSupportCache = new Map(); + +/** + * Probe whether `dir` lives on a filesystem that supports AF_UNIX sockets. + * ExFAT, NTFS-3G, some FUSE mounts, and network shares don't — `listen()` + * fails with ENOTSUP / EOPNOTSUPP. The result is cached per device so + * subsequent calls for the same mount are free. + * + * Exported for testing. + */ +export function canSocketInDir(dir: string): boolean { + let dev: number; + try { + dev = fs.statSync(dir).dev; + } catch { + return true; // can't stat → optimistic, let listen() fail naturally + } + const cached = socketSupportCache.get(dev); + if (cached !== undefined) return cached; + + const probe = path.join(dir, `.sock-probe-${process.pid}`); + try { + // getDaemonSocketPath is synchronous but net.Server.listen is async. + // Bridge with execFileSync: spawn a short-lived child that attempts to + // bind a Unix socket and exits 0 (success) or 1 (ENOTSUP / similar). + execFileSync(process.execPath, [ + '-e', + `const n=require("net"),f=require("fs"),p=${JSON.stringify(probe)};` + + 'const s=n.createServer();' + + 's.on("error",()=>{try{f.unlinkSync(p)}catch{};process.exit(1)});' + + 's.listen(p,()=>{s.close();try{f.unlinkSync(p)}catch{};process.exit(0)})', + ], { timeout: 3000, stdio: 'ignore' }); + socketSupportCache.set(dev, true); + return true; + } catch { + try { fs.unlinkSync(probe); } catch { /* probe may not exist */ } + socketSupportCache.set(dev, false); + return false; + } +} + +/** + * Clear the socket-support cache. Exported for testing only. + */ +export function clearSocketSupportCache(): void { + socketSupportCache.clear(); +} + /** * Compute the socket / named-pipe path the daemon should listen on (and the * proxy should connect to) for `projectRoot`. Deterministic given a project @@ -41,9 +95,11 @@ export function getDaemonSocketPath(projectRoot: string): string { 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. + if (inProject.length <= POSIX_SOCKET_PATH_LIMIT && canSocketInDir(path.dirname(inProject))) { + return inProject; + } + // Long project paths, or filesystem doesn't support AF_UNIX sockets + // (ExFAT, NTFS-3G, etc. — #997). Hash keeps it project-scoped. return path.join(os.tmpdir(), `codegraph-${projectHash(projectRoot)}.sock`); }