فهرست منبع

fix(sync,installer): time-bound the git/npm subprocess calls that had no timeout (#1139) (#1148)

extraction/index.ts bounds every git call it makes; worktree.ts,
git-hooks.ts, and the installer's npm install -g did not, so a stuck
subprocess blocked the caller indefinitely. Worst case was the daemon:
gitWorktreeRoot/gitCommonDir run (memoized) on the main event loop while
serving MCP clients, where an unbounded git hang would trip the 60s
liveness watchdog and SIGKILL a healthy daemon. git calls get 5s, the
interactive npm install 120s. Regression tests assert the option through
a mocked child_process plus a per-file call-site sweep.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 1 روز پیش
والد
کامیت
2f70eb3d32
5فایلهای تغییر یافته به همراه81 افزوده شده و 1 حذف شده
  1. 1 0
      CHANGELOG.md
  2. 70 0
      __tests__/subprocess-timeouts.test.ts
  3. 3 1
      src/installer/index.ts
  4. 2 0
      src/sync/git-hooks.ts
  5. 5 0
      src/sync/worktree.ts

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Lua and Luau method calls with capitalized names (`obj:Method()` — the standard Roblox convention) now link to the right method. Because Lua's method-call syntax looks identical to a Luau type annotation, a capitalized call like `lg:Log()` was misread as declaring the variable's type, so whenever two or more classes shared a method name (`Init`, `Update`, `Destroy`, …) the call was silently dropped from callers, impact/blast-radius, and flow traces. Lowercase method names were unaffected. Thanks @inth3shadows for the precise root-cause analysis and repro. (#1124)
 - Removed dead code left behind by the discontinued managed-reasoning feature. Its `codegraph login` flow was unplugged before ever shipping in a release, but the unused module still shipped inside the platform bundles, and a security review flagged its Windows browser-open step (it routed the login URL through `cmd`, which would have been unsafe had the flow ever been wired back up). The leftover module and its tests are now fully deleted. Thanks @inth3shadows for the report. (#1114)
 - The Claude Code context hook no longer treats ordinary English words that merely start with "call", "trace", "affect", or "connect" — callus, calligraphy, Connecticut, connective, affectionate, Tracey — as structural questions, which used to inject full CodeGraph context into prompts that had nothing to do with code structure. Genuinely structural forms (calls, callers, callbacks, call site, traced, tracing, affected, connections, connectivity, …) still fire exactly as before. Thanks @inth3shadows for the report. (#1138)
+- A stuck git command can no longer hang CodeGraph indefinitely. The git checks behind worktree detection and git-hook setup, and the installer's optional `npm install -g` step, now time out and fail gracefully instead of blocking forever — this matters most for the background MCP server, where an unbounded git hang (network filesystems, a wedged fsmonitor) could previously freeze it long enough for the safety watchdog to kill it. Thanks @inth3shadows for the report. (#1139)
 
 ## [1.2.0] - 2026-07-02
 

+ 70 - 0
__tests__/subprocess-timeouts.test.ts

@@ -0,0 +1,70 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+
+/**
+ * #1139: every git/npm subprocess call must be time-bounded. A stuck git
+ * (network filesystem, wedged fsmonitor daemon) otherwise blocks the caller
+ * forever — worst on the daemon's main event loop, where `gitWorktreeRoot`/
+ * `gitCommonDir` run (memoized) while serving MCP clients and an unbounded
+ * hang would trip the 60s liveness watchdog and SIGKILL a healthy daemon.
+ * `extraction/index.ts` already passes a timeout on every git call; these
+ * tests pin the same convention on the stragglers it flagged.
+ */
+
+vi.mock('child_process', () => ({
+  execFileSync: vi.fn(() => `${os.tmpdir()}\n`),
+  execSync: vi.fn(() => ''),
+}));
+
+import { execFileSync } from 'child_process';
+import { gitWorktreeRoot, gitCommonDir } from '../src/sync/worktree';
+import { isGitRepo } from '../src/sync/git-hooks';
+
+const timeoutOf = (): unknown => {
+  const call = vi.mocked(execFileSync).mock.calls.at(-1);
+  expect(call).toBeDefined();
+  return (call![2] as { timeout?: unknown }).timeout;
+};
+
+describe('git subprocess calls pass a timeout (#1139)', () => {
+  beforeEach(() => {
+    vi.mocked(execFileSync).mockClear();
+  });
+
+  it('gitWorktreeRoot bounds `git rev-parse --show-toplevel`', () => {
+    gitWorktreeRoot(os.tmpdir());
+    expect(timeoutOf()).toEqual(expect.any(Number));
+  });
+
+  it('gitCommonDir bounds `git rev-parse --git-common-dir`', () => {
+    gitCommonDir(os.tmpdir());
+    expect(timeoutOf()).toEqual(expect.any(Number));
+  });
+
+  it('isGitRepo bounds `git rev-parse --is-inside-work-tree`', () => {
+    isGitRepo(os.tmpdir());
+    expect(timeoutOf()).toEqual(expect.any(Number));
+  });
+});
+
+describe('no exec*Sync call site in these modules is unbounded (#1139)', () => {
+  // Source-level sweep: behavior tests above can only reach exported
+  // functions; this also covers the non-exported `gitHooksDir` and the
+  // installer's `npm install -g` (buried in an interactive prompt flow),
+  // and catches future call sites added to these files without a timeout.
+  it.each([
+    'src/sync/worktree.ts',
+    'src/sync/git-hooks.ts',
+    'src/installer/index.ts',
+  ])('%s passes a timeout at every exec*Sync call site', (rel) => {
+    const src = fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8');
+    const sites = src.split(/\bexec(?:File)?Sync\(/).slice(1);
+    expect(sites.length).toBeGreaterThan(0);
+    for (const site of sites) {
+      // The options object sits within a few hundred chars of the call.
+      expect(site.slice(0, 400)).toMatch(/\btimeout\s*:/);
+    }
+  });
+});

+ 3 - 1
src/installer/index.ts

@@ -115,7 +115,9 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
       const s = clack.spinner();
       s.start('Installing codegraph CLI...');
       try {
-        execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe', windowsHide: true });
+        // Generous bound (slow networks / cold npm cache) — but bounded, so a
+        // wedged npm can't hang the interactive installer forever (#1139).
+        execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe', windowsHide: true, timeout: 120_000 });
         s.stop('Installed codegraph CLI on PATH');
       } catch {
         s.stop('Could not install (permission denied)');

+ 2 - 0
src/sync/git-hooks.ts

@@ -45,6 +45,7 @@ export function isGitRepo(projectRoot: string): boolean {
       encoding: 'utf8',
       stdio: ['ignore', 'pipe', 'ignore'],
       windowsHide: true,
+      timeout: 5000, // fail fast instead of hanging init/sync on a stuck git (#1139)
     }).trim();
     return out === 'true';
   } catch {
@@ -63,6 +64,7 @@ function gitHooksDir(projectRoot: string): string | null {
       encoding: 'utf8',
       stdio: ['ignore', 'pipe', 'ignore'],
       windowsHide: true,
+      timeout: 5000, // same rationale as isGitRepo
     }).trim();
     if (!out) return null;
     return path.isAbsolute(out) ? out : path.resolve(projectRoot, out);

+ 5 - 0
src/sync/worktree.ts

@@ -36,6 +36,10 @@ export function gitWorktreeRoot(dir: string): string | null {
       encoding: 'utf8',
       stdio: ['ignore', 'pipe', 'ignore'],
       windowsHide: true,
+      // Bounded like every git call in extraction/: this runs (memoized) on
+      // the daemon's main event loop, where an unbounded hang would trip the
+      // 60s liveness watchdog and SIGKILL a healthy daemon (#1139).
+      timeout: 5000,
     }).trim();
     return out ? realpath(out) : null;
   } catch {
@@ -58,6 +62,7 @@ export function gitCommonDir(dir: string): string | null {
       encoding: 'utf8',
       stdio: ['ignore', 'pipe', 'ignore'],
       windowsHide: true,
+      timeout: 5000, // same rationale as gitWorktreeRoot
     }).trim();
     if (!out) return null;
     // `--git-common-dir` is relative to cwd unless already absolute.