1
0

subprocess-timeouts.test.ts 2.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as path from 'node:path';
  4. import * as os from 'node:os';
  5. /**
  6. * #1139: every git/npm subprocess call must be time-bounded. A stuck git
  7. * (network filesystem, wedged fsmonitor daemon) otherwise blocks the caller
  8. * forever — worst on the daemon's main event loop, where `gitWorktreeRoot`/
  9. * `gitCommonDir` run (memoized) while serving MCP clients and an unbounded
  10. * hang would trip the 60s liveness watchdog and SIGKILL a healthy daemon.
  11. * `extraction/index.ts` already passes a timeout on every git call; these
  12. * tests pin the same convention on the stragglers it flagged.
  13. */
  14. vi.mock('child_process', () => ({
  15. execFileSync: vi.fn(() => `${os.tmpdir()}\n`),
  16. execSync: vi.fn(() => ''),
  17. }));
  18. import { execFileSync } from 'child_process';
  19. import { gitWorktreeRoot, gitCommonDir } from '../src/sync/worktree';
  20. import { isGitRepo } from '../src/sync/git-hooks';
  21. const timeoutOf = (): unknown => {
  22. const call = vi.mocked(execFileSync).mock.calls.at(-1);
  23. expect(call).toBeDefined();
  24. return (call![2] as { timeout?: unknown }).timeout;
  25. };
  26. describe('git subprocess calls pass a timeout (#1139)', () => {
  27. beforeEach(() => {
  28. vi.mocked(execFileSync).mockClear();
  29. });
  30. it('gitWorktreeRoot bounds `git rev-parse --show-toplevel`', () => {
  31. gitWorktreeRoot(os.tmpdir());
  32. expect(timeoutOf()).toEqual(expect.any(Number));
  33. });
  34. it('gitCommonDir bounds `git rev-parse --git-common-dir`', () => {
  35. gitCommonDir(os.tmpdir());
  36. expect(timeoutOf()).toEqual(expect.any(Number));
  37. });
  38. it('isGitRepo bounds `git rev-parse --is-inside-work-tree`', () => {
  39. isGitRepo(os.tmpdir());
  40. expect(timeoutOf()).toEqual(expect.any(Number));
  41. });
  42. });
  43. describe('no exec*Sync call site in these modules is unbounded (#1139)', () => {
  44. // Source-level sweep: behavior tests above can only reach exported
  45. // functions; this also covers the non-exported `gitHooksDir` and the
  46. // installer's `npm install -g` (buried in an interactive prompt flow),
  47. // and catches future call sites added to these files without a timeout.
  48. it.each([
  49. 'src/sync/worktree.ts',
  50. 'src/sync/git-hooks.ts',
  51. 'src/installer/index.ts',
  52. ])('%s passes a timeout at every exec*Sync call site', (rel) => {
  53. const src = fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8');
  54. const sites = src.split(/\bexec(?:File)?Sync\(/).slice(1);
  55. expect(sites.length).toBeGreaterThan(0);
  56. for (const site of sites) {
  57. // The options object sits within a few hundred chars of the call.
  58. expect(site.slice(0, 400)).toMatch(/\btimeout\s*:/);
  59. }
  60. });
  61. });