|
|
@@ -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*:/);
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|