Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions __tests__/daemon-socket-probe.test.ts
Original file line number Diff line number Diff line change
@@ -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-/);
});
});
62 changes: 59 additions & 3 deletions src/mcp/daemon-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<number, boolean>();

/**
* 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
Expand All @@ -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`);
}

Expand Down