daemon-socket-fallback.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /**
  2. * Daemon support on socket-incapable filesystems — issue #997 (and the adjacent
  3. * #974 WSL2 DrvFs hazard).
  4. *
  5. * A project on an ExFAT/FAT external volume (or some network mounts / WSL2 DrvFs)
  6. * breaks the daemon at TWO points, BOTH surfacing as ENOTSUP (verified on a real
  7. * macOS fskit ExFAT volume):
  8. *
  9. * 1. Lock acquisition `link()`s a temp file onto `.codegraph/daemon.pid` for
  10. * race-free exclusivity (#411). ExFAT has no hard links, so this throws
  11. * first — before the socket is ever reached. The fix falls back to an
  12. * O_EXCL create (`acquireLockViaExclusiveOpen`).
  13. * 2. The socket `listen()` then throws ENOTSUP regardless of path length, so
  14. * the old length-only tmpdir fallback never triggered. The fix makes the
  15. * socket path an ORDERED candidate list (in-project, then a deterministic
  16. * tmpdir path); the daemon binds the first that works and the proxy connects
  17. * the first that answers, so both converge on the fallback with zero
  18. * coordination.
  19. *
  20. * Both failures report a DIFFERENT errno per OS — ENOTSUP (macOS), EPERM (Linux),
  21. * EISDIR (Windows) — so the fix deliberately does NOT gate on an enumerated set:
  22. * the lock falls back on ANY non-EEXIST link error, the socket relocates on ANY
  23. * non-EADDRINUSE bind error. These tests pin that policy (incl. a deliberately
  24. * unanticipated errno), the candidate list, the candidate-walk binder, and the
  25. * exclusive-open lock primitive. (Throwaway scripts drove the full daemon end-to-
  26. * end on a real macOS ExFAT image, a Linux FAT loopback mount, and a Windows
  27. * exFAT VHD — relocate, serve a real client, rewrite the pidfile — none of which
  28. * can run in CI.)
  29. */
  30. import { afterEach, describe, expect, it } from 'vitest';
  31. import * as fs from 'fs';
  32. import * as net from 'net';
  33. import * as os from 'os';
  34. import * as path from 'path';
  35. import {
  36. getDaemonPidPath,
  37. getDaemonSocketCandidates,
  38. getDaemonSocketPath,
  39. } from '../src/mcp/daemon-paths';
  40. import type { DaemonLockInfo } from '../src/mcp/daemon-paths';
  41. import { decodeLockInfo } from '../src/mcp/daemon-paths';
  42. import {
  43. acquireLockViaExclusiveOpen,
  44. bindFirstUsableSocket,
  45. clearStaleDaemonLock,
  46. tryAcquireDaemonLock,
  47. } from '../src/mcp/daemon';
  48. const POSIX = process.platform !== 'win32';
  49. const tmpFiles: string[] = [];
  50. const tmpDirs: string[] = [];
  51. afterEach(() => {
  52. while (tmpFiles.length) {
  53. try { fs.rmSync(tmpFiles.pop()!, { force: true }); } catch { /* best-effort */ }
  54. }
  55. while (tmpDirs.length) {
  56. try { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* best-effort */ }
  57. }
  58. });
  59. /** A stand-in net.Server — bindFirstUsableSocket only ever passes it through. */
  60. const fakeServer = (tag: string): net.Server => ({ tag } as unknown as net.Server);
  61. /** Build an ErrnoException carrying a specific code, like a real listen() error. */
  62. function errno(code: string): NodeJS.ErrnoException {
  63. const e = new Error(`listen ${code}`) as NodeJS.ErrnoException;
  64. e.code = code;
  65. return e;
  66. }
  67. describe('getDaemonSocketCandidates (#997)', () => {
  68. it.runIf(POSIX)('returns [in-project, tmpdir] for a normal short path', () => {
  69. const root = path.join(os.tmpdir(), 'cg-cand-short');
  70. const candidates = getDaemonSocketCandidates(root);
  71. expect(candidates).toHaveLength(2);
  72. expect(candidates[0]).toBe(path.join(root, '.codegraph', 'daemon.sock'));
  73. expect(candidates[1]!.startsWith(os.tmpdir())).toBe(true);
  74. expect(path.basename(candidates[1]!)).toMatch(/^codegraph-[0-9a-f]{16}\.sock$/);
  75. });
  76. it.runIf(POSIX)('drops straight to [tmpdir] when the in-project path is too long', () => {
  77. // A deep root pushes `.codegraph/daemon.sock` past the POSIX socket limit.
  78. const root = path.join('/tmp', 'x'.repeat(120));
  79. const candidates = getDaemonSocketCandidates(root);
  80. expect(candidates).toHaveLength(1);
  81. expect(candidates[0]!.startsWith(os.tmpdir())).toBe(true);
  82. });
  83. it.runIf(POSIX)('is deterministic and project-scoped: same root → same tmpdir fallback', () => {
  84. const root = path.join(os.tmpdir(), 'cg-cand-determinism');
  85. const a = getDaemonSocketCandidates(root);
  86. const b = getDaemonSocketCandidates(root);
  87. expect(a).toEqual(b);
  88. // A different root yields a different (hashed) tmpdir fallback.
  89. const other = getDaemonSocketCandidates(root + '-other');
  90. expect(other[other.length - 1]).not.toBe(a[a.length - 1]);
  91. });
  92. it.runIf(!POSIX)('returns a single named pipe on Windows', () => {
  93. const candidates = getDaemonSocketCandidates('C:/dev/proj');
  94. expect(candidates).toHaveLength(1);
  95. expect(candidates[0]!.startsWith('\\\\.\\pipe\\codegraph-')).toBe(true);
  96. });
  97. it('getDaemonSocketPath returns the preferred candidate (index 0)', () => {
  98. const root = path.join(os.tmpdir(), 'cg-cand-primary');
  99. expect(getDaemonSocketPath(root)).toBe(getDaemonSocketCandidates(root)[0]);
  100. });
  101. });
  102. describe('bindFirstUsableSocket (#997)', () => {
  103. it('binds the first candidate when it works, without relocating', async () => {
  104. const tried: string[] = [];
  105. const relocations: string[] = [];
  106. const result = await bindFirstUsableSocket(
  107. ['/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
  108. (p) => { tried.push(p); return Promise.resolve(fakeServer(p)); },
  109. { onRelocate: (from, to) => relocations.push(`${from}->${to}`) },
  110. );
  111. expect(result.socketPath).toBe('/proj/.codegraph/daemon.sock');
  112. expect(tried).toEqual(['/proj/.codegraph/daemon.sock']); // never touched the fallback
  113. expect(relocations).toEqual([]);
  114. });
  115. it('relocates to the tmpdir fallback when the in-project bind throws ENOTSUP', async () => {
  116. const tried: string[] = [];
  117. const relocations: Array<[string, string, string]> = [];
  118. const result = await bindFirstUsableSocket(
  119. ['/exfat/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
  120. (p) => {
  121. tried.push(p);
  122. if (p.includes('/exfat/')) return Promise.reject(errno('ENOTSUP'));
  123. return Promise.resolve(fakeServer(p));
  124. },
  125. { onRelocate: (from, to, code) => relocations.push([from, to, code]) },
  126. );
  127. expect(result.socketPath).toBe('/tmp/fallback.sock');
  128. expect(tried).toEqual(['/exfat/proj/.codegraph/daemon.sock', '/tmp/fallback.sock']);
  129. expect(relocations).toEqual([
  130. ['/exfat/proj/.codegraph/daemon.sock', '/tmp/fallback.sock', 'ENOTSUP'],
  131. ]);
  132. });
  133. it('does NOT relocate on EADDRINUSE — it propagates even with a fallback present', async () => {
  134. const tried: string[] = [];
  135. await expect(
  136. bindFirstUsableSocket(
  137. ['/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
  138. (p) => { tried.push(p); return Promise.reject(errno('EADDRINUSE')); },
  139. ),
  140. ).rejects.toMatchObject({ code: 'EADDRINUSE' });
  141. expect(tried).toEqual(['/proj/.codegraph/daemon.sock']); // fallback never tried
  142. });
  143. it('propagates a capability error on the LAST candidate (nowhere left to go)', async () => {
  144. // When tmpdir itself can't host a socket, the single-candidate long-path list
  145. // (or the exhausted tail of a longer one) has no fallback — the daemon must
  146. // surface the error so the launcher drops to direct mode (#974).
  147. await expect(
  148. bindFirstUsableSocket(
  149. ['/tmp/only.sock'],
  150. () => Promise.reject(errno('ENOTSUP')),
  151. ),
  152. ).rejects.toMatchObject({ code: 'ENOTSUP' });
  153. });
  154. it('walks past multiple unusable candidates to the first that binds', async () => {
  155. const tried: string[] = [];
  156. const result = await bindFirstUsableSocket(
  157. ['/a.sock', '/b.sock', '/c.sock'],
  158. (p) => {
  159. tried.push(p);
  160. if (p === '/a.sock') return Promise.reject(errno('ENOTSUP'));
  161. if (p === '/b.sock') return Promise.reject(errno('EACCES'));
  162. return Promise.resolve(fakeServer(p));
  163. },
  164. );
  165. expect(result.socketPath).toBe('/c.sock');
  166. expect(tried).toEqual(['/a.sock', '/b.sock', '/c.sock']);
  167. });
  168. it('relocates on an UNEXPECTED errno too — the policy is "anything but EADDRINUSE", not a fixed list', async () => {
  169. // ExFAT/FAT report different bind errnos per OS (ENOTSUP macOS, EPERM Linux),
  170. // so we must NOT gate relocation on an enumerated set — a code we never
  171. // anticipated must still fall through to tmpdir. 'EWEIRD' stands in for any
  172. // such surprise.
  173. const result = await bindFirstUsableSocket(
  174. ['/odd/proj/.codegraph/daemon.sock', '/tmp/fallback.sock'],
  175. (p) => p.includes('/odd/') ? Promise.reject(errno('EWEIRD')) : Promise.resolve(fakeServer(p)),
  176. );
  177. expect(result.socketPath).toBe('/tmp/fallback.sock');
  178. });
  179. });
  180. describe('lock acquisition without hard links (#997)', () => {
  181. // The hard-link-FAILS path (link() → O_EXCL fallback) can't be forced on a
  182. // normal FS — fs.linkSync's namespace export is non-configurable, so it can't
  183. // be spied. It's proven instead end-to-end on real ExFAT/FAT/exFAT volumes
  184. // (macOS ENOTSUP, Linux EPERM, Windows EISDIR — all acquire via the fallback).
  185. // Here we just guard that the refactored catch block didn't break the normal
  186. // link path: a clean acquire, and a second caller correctly sees it held.
  187. it.runIf(POSIX)('tryAcquireDaemonLock still acquires on a normal FS, and a second caller is told it is taken', () => {
  188. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lock-'));
  189. tmpDirs.push(root);
  190. const first = tryAcquireDaemonLock(root);
  191. expect(first.kind).toBe('acquired');
  192. const pidPath = getDaemonPidPath(root);
  193. expect(fs.existsSync(pidPath)).toBe(true);
  194. expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))?.pid).toBe(process.pid);
  195. const second = tryAcquireDaemonLock(root); // link() → EEXIST → taken
  196. expect(second.kind).toBe('taken');
  197. if (second.kind === 'taken') expect(second.existing?.pid).toBe(process.pid);
  198. });
  199. it.runIf(POSIX)('acquireLockViaExclusiveOpen creates the pidfile with a complete, parseable record', () => {
  200. const pidPath = path.join(os.tmpdir(), `cg-excl-${process.pid}-${Date.now()}.pid`);
  201. tmpFiles.push(pidPath);
  202. const info: DaemonLockInfo = {
  203. pid: 4242,
  204. version: '9.9.9-test',
  205. socketPath: '/tmp/whatever.sock',
  206. startedAt: 1_700_000_000_000,
  207. };
  208. const acquired = acquireLockViaExclusiveOpen(pidPath, info);
  209. expect(acquired).toBe(true);
  210. // The file is non-empty and decodes back to exactly what we wrote — i.e. no
  211. // empty-file window left behind for a reader to mistake for a corrupt lock.
  212. expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(info);
  213. });
  214. it.runIf(POSIX)('acquireLockViaExclusiveOpen is exclusive: the second caller loses (EEXIST → false)', () => {
  215. const pidPath = path.join(os.tmpdir(), `cg-excl2-${process.pid}-${Date.now()}.pid`);
  216. tmpFiles.push(pidPath);
  217. const winner: DaemonLockInfo = { pid: 1, version: 'a', socketPath: '/s1', startedAt: 1 };
  218. const loser: DaemonLockInfo = { pid: 2, version: 'b', socketPath: '/s2', startedAt: 2 };
  219. expect(acquireLockViaExclusiveOpen(pidPath, winner)).toBe(true);
  220. expect(acquireLockViaExclusiveOpen(pidPath, loser)).toBe(false); // does not clobber
  221. // The winner's record is intact — the loser never overwrote it.
  222. expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(winner);
  223. });
  224. });
  225. describe('legacy daemon lock decoding', () => {
  226. it('decodes a plain decimal PID as a legacy lock record', () => {
  227. expect(decodeLockInfo('4242\n')).toEqual({
  228. pid: 4242,
  229. version: 'unknown',
  230. socketPath: '',
  231. startedAt: 0,
  232. });
  233. });
  234. it.each(['1e3', '0x3e8', '1000.0'])('rejects non-decimal PID syntax %s', (raw) => {
  235. expect(decodeLockInfo(raw)).toBeNull();
  236. });
  237. });
  238. describe('stale daemon lock snapshot validation', () => {
  239. it('does not delete a same-PID replacement whose identity was never probed', () => {
  240. const pidPath = path.join(os.tmpdir(), `cg-snapshot-${process.pid}-${Date.now()}.pid`);
  241. tmpFiles.push(pidPath);
  242. const original = JSON.stringify({
  243. pid: process.pid,
  244. version: '1.5.0',
  245. socketPath: '/old.sock',
  246. startedAt: 1,
  247. });
  248. const replacement = JSON.stringify({
  249. pid: process.pid,
  250. version: '1.5.0',
  251. socketPath: '/new.sock',
  252. startedAt: 2,
  253. });
  254. fs.writeFileSync(pidPath, replacement);
  255. expect(clearStaleDaemonLock(pidPath, process.pid, {
  256. allowLivePid: true,
  257. expectedLockContents: original,
  258. })).toBe(false);
  259. expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
  260. });
  261. });