daemon-bind-failure.test.ts 4.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. /**
  2. * Daemon bind-failure cleanup — issue #974.
  3. *
  4. * A detached daemon acquires the `.codegraph/daemon.pid` lock (via
  5. * `tryAcquireDaemonLock`) BEFORE it binds its socket. If the bind then fails —
  6. * e.g. AF_UNIX is unsupported/unreliable on the filesystem (the WSL2 DrvFs
  7. * hazard behind #974) — `Daemon.start()` must release that lockfile before it
  8. * propagates the error and exits. Otherwise the next launcher reads a stale lock
  9. * pointing at the now-dead pid and the process pileup the issue reported recurs.
  10. *
  11. * We force a deterministic bind failure by planting a *directory* at the socket
  12. * path: `unlinkSync` (the daemon's stale-socket clear) can't remove a directory,
  13. * so it survives and `listen()` fails with EADDRINUSE.
  14. */
  15. import { afterEach, describe, expect, it, vi } from 'vitest';
  16. import * as fs from 'fs';
  17. import * as os from 'os';
  18. import * as path from 'path';
  19. import { Daemon, tryAcquireDaemonLock, finalizeDaemonExit } from '../src/mcp/daemon';
  20. import { getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
  21. const tmpRoots: string[] = [];
  22. afterEach(() => {
  23. while (tmpRoots.length) {
  24. const root = tmpRoots.pop()!;
  25. try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
  26. }
  27. });
  28. describe('Daemon.start() bind failure (#974)', () => {
  29. it.runIf(process.platform !== 'win32')('releases the lockfile it acquired when the socket cannot bind', async () => {
  30. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bind-'));
  31. tmpRoots.push(root);
  32. // Acquire the lock exactly as the detached-daemon startup does.
  33. const lock = tryAcquireDaemonLock(root);
  34. expect(lock.kind).toBe('acquired');
  35. const pidPath = getDaemonPidPath(root);
  36. expect(fs.existsSync(pidPath)).toBe(true);
  37. // Make the socket path un-bindable: a directory can't be unlink'd by the
  38. // daemon's stale-socket clear, and listen() on it fails with EADDRINUSE.
  39. const sockPath = getDaemonSocketPath(root);
  40. fs.mkdirSync(sockPath, { recursive: true });
  41. // The tmpdir-fallback socket path can live outside `root`; clean it too.
  42. tmpRoots.push(sockPath);
  43. const daemon = new Daemon(root);
  44. await expect(daemon.start()).rejects.toThrow();
  45. // The lockfile must be gone so the next launcher doesn't spin on a stale lock.
  46. expect(fs.existsSync(pidPath)).toBe(false);
  47. });
  48. });
  49. /**
  50. * Windows shutdown must not force `process.exit()` while the recursive file
  51. * watcher is still tearing down — that aborts the daemon with a libuv
  52. * `UV_HANDLE_CLOSING` assertion (0xC0000409), reproducible when the indexed tree
  53. * contains a nested repo. `finalizeDaemonExit` drains on Windows and exits
  54. * immediately elsewhere; both branches are exercised here by injecting the
  55. * platform + exit fn (so it runs on any host).
  56. */
  57. describe('finalizeDaemonExit — Windows drains instead of aborting mid-watcher-close', () => {
  58. for (const platform of ['linux', 'darwin'] as const) {
  59. it(`exits immediately on ${platform}`, () => {
  60. const exit = vi.fn();
  61. const backstop = finalizeDaemonExit(platform, exit);
  62. expect(exit).toHaveBeenCalledTimes(1);
  63. expect(exit).toHaveBeenCalledWith(0);
  64. expect(backstop).toBeNull();
  65. });
  66. }
  67. it('on win32 defers exit (lets the loop drain), then force-exits via an unref\'d backstop', () => {
  68. vi.useFakeTimers();
  69. const prevExitCode = process.exitCode;
  70. const exit = vi.fn();
  71. try {
  72. const backstop = finalizeDaemonExit('win32', exit);
  73. // No synchronous exit — the process must drain its closing watch handles first.
  74. expect(exit).not.toHaveBeenCalled();
  75. expect(backstop).not.toBeNull();
  76. // Success code is set so a natural drain exits 0.
  77. expect(process.exitCode).toBe(0);
  78. // If a stray handle keeps the loop alive, the backstop still forces exit.
  79. vi.advanceTimersByTime(2_000);
  80. expect(exit).toHaveBeenCalledWith(0);
  81. } finally {
  82. vi.useRealTimers();
  83. process.exitCode = prevExitCode; // don't leak a 0 exit code into the runner
  84. }
  85. });
  86. });