daemon-client-liveness.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /**
  2. * Unit coverage for the daemon-side client-liveness primitives (#692, Layer 2).
  3. *
  4. * These back the daemon's defense against a phantom client — one whose process
  5. * died without the socket ever signalling close (a Windows named-pipe hazard).
  6. * The wire parsing and the liveness decision are pure, so they're tested here;
  7. * the full handshake + sweep is exercised end-to-end in `mcp-daemon.test.ts`.
  8. */
  9. import { describe, it, expect } from 'vitest';
  10. import { Daemon, parseClientHelloLine, peerIsDead } from '../src/mcp/daemon';
  11. describe('parseClientHelloLine', () => {
  12. it('parses a well-formed client-hello', () => {
  13. expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234,"hostPid":56}'))
  14. .toEqual({ pid: 1234, hostPid: 56 });
  15. });
  16. it('accepts a null host pid and a missing host pid', () => {
  17. expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234,"hostPid":null}'))
  18. .toEqual({ pid: 1234, hostPid: null });
  19. expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234}'))
  20. .toEqual({ pid: 1234, hostPid: null });
  21. });
  22. it('returns null for a JSON-RPC message (no marker) so it is treated as data', () => {
  23. expect(parseClientHelloLine('{"jsonrpc":"2.0","id":1,"method":"initialize"}')).toBeNull();
  24. });
  25. it('rejects a wrong-typed marker, a non-numeric pid, and a non-integer marker', () => {
  26. expect(parseClientHelloLine('{"codegraph_client":true,"pid":1}')).toBeNull();
  27. expect(parseClientHelloLine('{"codegraph_client":2,"pid":1}')).toBeNull();
  28. expect(parseClientHelloLine('{"codegraph_client":1,"pid":"1"}')).toBeNull();
  29. });
  30. it('returns null for invalid / empty / non-object JSON', () => {
  31. expect(parseClientHelloLine('not json')).toBeNull();
  32. expect(parseClientHelloLine('')).toBeNull();
  33. expect(parseClientHelloLine('42')).toBeNull();
  34. expect(parseClientHelloLine('null')).toBeNull();
  35. });
  36. });
  37. describe('peerIsDead', () => {
  38. const aliveAll = () => true;
  39. const deadAll = () => false;
  40. const deadOnly = (...pids: number[]) => (pid: number) => !pids.includes(pid);
  41. it('never reaps a client with an unknown pid (no client-hello)', () => {
  42. expect(peerIsDead({ pid: null, hostPid: null }, deadAll)).toBe(false);
  43. expect(peerIsDead({ pid: null, hostPid: 99 }, deadAll)).toBe(false);
  44. });
  45. it('keeps a client whose proxy is alive', () => {
  46. expect(peerIsDead({ pid: 100, hostPid: null }, aliveAll)).toBe(false);
  47. });
  48. it('reaps a client whose proxy process is gone', () => {
  49. expect(peerIsDead({ pid: 100, hostPid: null }, deadOnly(100))).toBe(true);
  50. });
  51. it('reaps when the proxy is alive but its host is gone', () => {
  52. // proxy 100 alive, host 42 dead
  53. expect(peerIsDead({ pid: 100, hostPid: 42 }, deadOnly(42))).toBe(true);
  54. });
  55. it('keeps a client when both proxy and host are alive', () => {
  56. expect(peerIsDead({ pid: 100, hostPid: 42 }, aliveAll)).toBe(false);
  57. });
  58. });
  59. describe('Daemon.reapDeadClients', () => {
  60. // Construct with idleTimeoutMs:0 so dropping the last client doesn't arm a real
  61. // idle timer. The constructor opens no sockets/DB, so this stays a fast unit test.
  62. const makeDaemon = () => new Daemon('/tmp/codegraph-reap-unit-test', { idleTimeoutMs: 0 }) as any;
  63. const fakeSession = () => ({ stopped: false, stop() { this.stopped = true; } });
  64. it('drops clients with a dead peer and leaves live ones attached', () => {
  65. const d = makeDaemon();
  66. const dead = fakeSession();
  67. const live = fakeSession();
  68. d.clients.add(dead); d.clientPeers.set(dead, { pid: 111, hostPid: null });
  69. d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
  70. const reaped = d.reapDeadClients((pid: number) => pid !== 111); // 111 dead, 222 alive
  71. expect(reaped).toBe(1);
  72. expect(dead.stopped).toBe(true);
  73. expect(d.clients.has(dead)).toBe(false);
  74. expect(d.clientPeers.has(dead)).toBe(false); // peer record cleaned up too
  75. expect(d.clients.has(live)).toBe(true);
  76. });
  77. it('never reaps a client with an unknown pid (no client-hello)', () => {
  78. const d = makeDaemon();
  79. const s = fakeSession();
  80. d.clients.add(s); d.clientPeers.set(s, { pid: null, hostPid: null });
  81. expect(d.reapDeadClients(() => false)).toBe(0); // everything "dead", but pid unknown
  82. expect(d.clients.has(s)).toBe(true);
  83. });
  84. it('reaps a client whose host pid is gone even if its proxy pid is alive', () => {
  85. const d = makeDaemon();
  86. const s = fakeSession();
  87. d.clients.add(s); d.clientPeers.set(s, { pid: 100, hostPid: 42 });
  88. expect(d.reapDeadClients((pid: number) => pid !== 42)).toBe(1); // proxy 100 alive, host 42 dead
  89. expect(d.clients.has(s)).toBe(false);
  90. });
  91. });
  92. // The inactivity backstop (#692) must reap a phantom daemon but NEVER a
  93. // live-but-quiet session — reaping the latter silently degraded that session
  94. // (and any others sharing the daemon) to an in-process engine, and on a real
  95. // machine it fired far more often on live sessions than on actual phantoms.
  96. describe('Daemon.backstopShouldExit', () => {
  97. // maxIdleMs small; idleTimeoutMs:0 so a sweep that empties the set doesn't arm
  98. // a real timer. Force the inactivity window open by backdating lastActivityAt.
  99. const makeDaemon = () => {
  100. const d = new Daemon('/tmp/codegraph-backstop-unit-test', { idleTimeoutMs: 0, maxIdleMs: 1000 }) as any;
  101. d.lastActivityAt = Date.now() - 60_000; // long past the 1000ms window
  102. return d;
  103. };
  104. const fakeSession = () => ({ stopped: false, stop() { this.stopped = true; } });
  105. it('does NOT reap while a provably-alive client stays connected (the fix)', () => {
  106. const d = makeDaemon();
  107. const live = fakeSession();
  108. d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
  109. expect(d.backstopShouldExit(() => true)).toBe(false); // 222 alive → keep the daemon
  110. expect(d.clients.has(live)).toBe(true);
  111. });
  112. it('reaps when only an unknown-pid client remains (the phantom the sweep cannot catch)', () => {
  113. const d = makeDaemon();
  114. const phantom = fakeSession();
  115. d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
  116. // Unknown pid → the sweep leaves it, and after the window it's a probable phantom.
  117. expect(d.backstopShouldExit(() => false)).toBe(true);
  118. });
  119. it('protects a live session even when a phantom is also connected', () => {
  120. const d = makeDaemon();
  121. const live = fakeSession();
  122. const phantom = fakeSession();
  123. d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
  124. d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
  125. // 222 alive, phantom unknown → ANY alive keeps the daemon; the live one wins.
  126. expect(d.backstopShouldExit((pid: number) => pid === 222)).toBe(false);
  127. expect(d.clients.has(live)).toBe(true);
  128. });
  129. it('sweeps a dead-peer client first; if that empties the set it does not exit', () => {
  130. const d = makeDaemon();
  131. const dead = fakeSession();
  132. d.clients.add(dead); d.clientPeers.set(dead, { pid: 111, hostPid: null });
  133. // 111 dead → swept by backstopShouldExit; empty set → idle timer owns it, no backstop exit.
  134. expect(d.backstopShouldExit(() => false)).toBe(false);
  135. expect(d.clients.has(dead)).toBe(false);
  136. expect(dead.stopped).toBe(true);
  137. });
  138. it('does not exit before the inactivity window elapses', () => {
  139. const d = makeDaemon();
  140. d.lastActivityAt = Date.now(); // fresh — inside the 1000ms window
  141. const phantom = fakeSession();
  142. d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
  143. expect(d.backstopShouldExit(() => false)).toBe(false);
  144. expect(d.clients.has(phantom)).toBe(true); // not even swept yet
  145. });
  146. it('does not exit with zero clients (the idle timer owns that case)', () => {
  147. const d = makeDaemon();
  148. expect(d.backstopShouldExit(() => false)).toBe(false);
  149. });
  150. });