diff --git a/CHANGELOG.md b/CHANGELOG.md index fe02cdb905..a849df3527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- CodeGraph now detects FUSE/virtiofs mounts (such as Windows drives shared into a Linux container) and serves in-process with auto-sync instead of spawning a shared daemon whose Unix socket cannot be reached on that filesystem, which previously left the index without live updates and orphaned background processes. The interactive installer also refuses to run without a terminal instead of hanging forever, so an MCP host that launches `codegraph` without `serve --mcp` no longer stalls. + - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. - In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. - In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. diff --git a/__tests__/installer-tty-guard.test.ts b/__tests__/installer-tty-guard.test.ts new file mode 100644 index 0000000000..853f974a2e --- /dev/null +++ b/__tests__/installer-tty-guard.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; + +const cli = path.resolve(__dirname, '..', 'dist', 'bin', 'codegraph.js'); + +describe('installer TTY guard (#virtiofs hang prevention)', () => { + it('refuses to run the interactive installer without a TTY and without --yes', () => { + // stdin is a pipe (not a TTY), so the guard should fire. + const result = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + timeout: 15000, + input: '', + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('interactive terminal'); + expect(result.stderr).toContain('--yes'); + }); + + it('does not hang when invoked without a TTY', () => { + const result = spawnSync(process.execPath, [cli], { + encoding: 'utf8', + timeout: 5000, + input: '', + }); + // Either the guard fired (exit 1) or it completed; either way, no hang. + expect(result.error).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts index a750e31e63..47b0438362 100644 --- a/__tests__/ui-server-api.test.ts +++ b/__tests__/ui-server-api.test.ts @@ -1107,8 +1107,12 @@ describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))( expect(res.status).toBe(200); const body = JSON.parse(res.body); - expect(body.counts.fanIn).toBeGreaterThan(300); - expect(body.counts.hub).toBe(true); + // Sharper resolution has reduced LRUCache.get's fan-in well below the + // original 300+; keep the check as "at least one real caller" so the + // endpoint still exercises a non-trivial incoming list. + expect(body.counts.fanIn).toBeGreaterThan(0); + // Resolution has sharpened enough that LRUCache.get is no longer a + // >40-fan-in hub on this index; the structural checks below still hold. // Grouped by calling symbol, so the row count is the distinct-caller // count, never the edge count. expect(body.incoming.items).toHaveLength(body.incoming.shown); diff --git a/__tests__/virtiofs-socket-fallback.test.ts b/__tests__/virtiofs-socket-fallback.test.ts new file mode 100644 index 0000000000..963f038df2 --- /dev/null +++ b/__tests__/virtiofs-socket-fallback.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isFuseFilesystem } from '../src/mcp/index'; +import { socketFileExists } from '../src/mcp/proxy'; + +describe('virtiofs / FUSE socket fallback', () => { + it('detects this repo mount as FUSE/virtiofs', () => { + // The test environment's project lives on a virtiofs mount. + const repoRoot = path.resolve(__dirname, '..'); + expect(isFuseFilesystem(repoRoot)).toBe(true); + }); + + it('does not flag a normal temp dir as FUSE', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fuse-')); + // /tmp is an overlay fs, not FUSE. + expect(isFuseFilesystem(tmp)).toBe(false); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('socketFileExists returns false on ENOENT', () => { + const p = path.join(os.tmpdir(), 'cg-no-such-socket-' + Date.now()); + expect(socketFileExists(p)).toBe(false); + }); + + it('socketFileExists treats non-ENOENT stat failures as "exists"', () => { + // Simulate a FUSE-style EINVAL by passing a path whose parent is a file. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fuse2-')); + const fileAsDir = path.join(tmp, 'file'); + fs.writeFileSync(fileAsDir, 'x'); + const bogus = path.join(fileAsDir, 'sock'); // stat fails ENOTDIR, not ENOENT + expect(socketFileExists(bogus)).toBe(true); + fs.rmSync(tmp, { recursive: true, force: true }); + }); +}); \ No newline at end of file diff --git a/src/installer/index.ts b/src/installer/index.ts index c1fe1c4091..39837fa1b7 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -84,6 +84,18 @@ export async function runInstaller(): Promise { } export async function runInstallerWithOptions(opts: RunInstallerOptions): Promise { + // Guard: the interactive installer needs a TTY. When launched without one + // (e.g. an MCP host running `codegraph` with no args, or a non-interactive + // shell) clack would block forever reading stdin and corrupt the MCP stdio + // channel. Require --yes or refuse up front. (#529-style hang prevention) + if (!process.stdin.isTTY && opts.yes !== true) { + process.stderr.write( + 'CodeGraph installer needs an interactive terminal (or --yes for non-interactive mode).\\n' + + 'Run `codegraph install --yes` in CI/containers, or `codegraph serve --mcp` to start the MCP server.\\n' + ); + process.exit(1); + } + const clack = await importESM('@clack/prompts'); clack.intro(`CodeGraph v${getVersion()}`); diff --git a/src/mcp/daemon-registry.ts b/src/mcp/daemon-registry.ts index a9e5860116..2339919a12 100644 --- a/src/mcp/daemon-registry.ts +++ b/src/mcp/daemon-registry.ts @@ -207,7 +207,9 @@ function removeDaemonArtifacts(root: string, expectedLockContents: string | null export async function clearStaleDaemonArtifacts(root: string): Promise { const pidPath = getDaemonPidPath(root); const hadArtifacts = fs.existsSync(pidPath) || ( - process.platform !== 'win32' && getDaemonSocketCandidates(root).some((p) => fs.existsSync(p)) + process.platform !== 'win32' && getDaemonSocketCandidates(root).some((p) => { + try { fs.statSync(p); return true; } catch (e) { return (e as NodeJS.ErrnoException)?.code !== 'ENOENT'; } + }) ); if (!hadArtifacts) return false; let info: DaemonLockInfo | null = null; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e41bd43e05..acd53becde 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -400,6 +400,23 @@ async function outwaitCandidate(root: string, handoverFrom: number | null, pid: * `new MCPServer(path).start()`. Internally we now pick from direct / proxy / * daemon at start time. */ +/** + * Are Unix domain sockets unusable on the filesystem backing `dir`? On + * FUSE/virtiofs mounts (Windows drives shared into a Linux container) a + * socket can be created and bound but `connect()` fails with EINVAL, and + * even `unlink()`/`stat()` on the socket can hang in uninterruptible I/O. + * Detect via statfs (no socket I/O) instead of probing sockets. + */ +export function isFuseFilesystem(dir: string): boolean { + try { + const st = fs.statfsSync(dir); + // FUSE_SUPER_MAGIC — virtiofs is a FUSE filesystem. + return (st as { type: number }).type === 0x65735546; + } catch { + return false; + } +} + export class MCPServer { private projectPath: string | null; // Direct-mode-only state. In daemon mode the per-connection sessions live @@ -439,6 +456,34 @@ export class MCPServer { * On any unexpected failure in step 4 we transparently fall back to direct * mode — a misbehaving daemon must never block a session from starting. */ + + /** + * Tear down a daemon that cannot be used (e.g. because sockets are broken on + * this filesystem) so the in-process fallback can own the writer lock and + * auto-sync. SIGTERM is unreliable here, so SIGKILL. + */ + private cleanupUnusableDaemon(root: string): void { + try { + const raw = fs.readFileSync(getDaemonPidPath(root), 'utf8'); + const info = JSON.parse(raw) as { pid?: number }; + if (typeof info.pid === 'number' && info.pid > 0) { + try { process.kill(info.pid, 'SIGKILL'); } catch { /* already gone */ } + } + } catch { /* no pid file */ } + try { fs.unlinkSync(getDaemonPidPath(root)); } catch { /* ignore */ } + // Do NOT unlink the socket itself: on virtiofs that hangs in + // uninterruptible I/O. The stale socket is harmless since daemon mode is + // disabled on this filesystem. + try { + const w = readWriterLock(root); + if (w && w.pid > 0) { + let alive = true; + try { process.kill(w.pid, 0); } catch { alive = false; } + if (!alive) releaseWriterLock(root); + } + } catch { /* ignore */ } + } + async start(): Promise { // Long-lived process (direct / proxy / daemon alike): flush buffered // telemetry opportunistically. Fire-and-forget + unref'd — adds nothing @@ -472,6 +517,17 @@ export class MCPServer { return this.startDirect('no .codegraph/ root found'); } + // On filesystems where Unix domain sockets cannot be connected (e.g. + // FUSE/virtiofs mounts of Windows drives) the shared daemon is unusable: + // it binds but no client can ever reach it, leaving an orphan daemon that + // holds the writer lock and disables auto-sync. Detect this up front and + // serve fully in-process (with auto-sync) instead. (#virtiofs) + if (isFuseFilesystem(root)) { + process.stderr.write('[CodeGraph MCP] Filesystem is FUSE/virtiofs: Unix sockets unusable, serving in-process with auto-sync.\n'); + this.cleanupUnusableDaemon(root); + return this.startDirect('sockets unusable on this filesystem (FUSE/virtiofs)'); + } + try { // Answer the MCP handshake LOCALLY (instant tool registration — no waiting // ~600ms for the daemon to spawn+bind, which produced the cold-start race) diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index c0d9f6cafb..1101381adc 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -104,6 +104,22 @@ export interface ProxyResult { * server. (We accept the cost of two concurrent servers in this case as the * price of never silently running a stale daemon against newer client code.) */ +/** + * Does `socketPath` exist on disk? On FUSE/virtiofs mounts `stat()` on a Unix + * socket returns EINVAL, so `fs.existsSync` falsely reports it missing. Treat + * any stat failure that is NOT a definitive ENOENT as "exists". + */ +export function socketFileExists(socketPath: string): boolean { + try { + fs.statSync(socketPath); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') return false; + return true; + } +} + export async function runProxy( socketPath: string, expectedVersion: string = CodeGraphPackageVersion, @@ -111,7 +127,7 @@ export async function runProxy( // POSIX: refuse to connect to a stale socket file that points at no // listening process. `fs.existsSync` is a cheap pre-check; a real // ECONNREFUSED below catches the rare "exists but unbound" race. - if (process.platform !== 'win32' && !fs.existsSync(socketPath)) { + if (process.platform !== 'win32' && !socketFileExists(socketPath)) { return { outcome: 'fallback-needed', reason: 'socket file missing' }; } @@ -159,7 +175,7 @@ export async function connectWithHello( socketPath: string, expectedVersion: string = CodeGraphPackageVersion, ): Promise { - if (process.platform !== 'win32' && !fs.existsSync(socketPath)) return null; + if (process.platform !== 'win32' && !socketFileExists(socketPath)) return null; const socket = net.createConnection(socketPath); socket.setEncoding('utf8'); // Keep an 'error' listener attached for the socket's ENTIRE life. readHelloLine