startup-handshake.test.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /**
  2. * Never-initialized backstop + early ppid capture (#1185).
  3. *
  4. * The orphan these guard against: an MCP host kills the launcher chain within
  5. * the server's first ~100ms and keeps the stdio pipes open. The server boots
  6. * already reparented (ppid baseline reads 1 → the divergence watchdog is
  7. * blind), stdin never EOFs, and pre-#1185 the process lived until the host
  8. * itself exited. The backstop reaps any server that never receives a single
  9. * byte of MCP traffic; early-ppid.ts shrinks the blind window itself.
  10. */
  11. import { describe, it, expect } from 'vitest';
  12. import { PassThrough } from 'stream';
  13. import {
  14. DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS,
  15. armStartupHandshakeTimeout,
  16. parseStartupHandshakeTimeoutMs,
  17. } from '../src/mcp/startup-handshake';
  18. import { EARLY_PPID } from '../src/mcp/early-ppid';
  19. const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
  20. describe('parseStartupHandshakeTimeoutMs', () => {
  21. it('defaults when unset or empty', () => {
  22. expect(parseStartupHandshakeTimeoutMs(undefined)).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
  23. expect(parseStartupHandshakeTimeoutMs('')).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
  24. });
  25. it('defaults on non-numeric garbage', () => {
  26. expect(parseStartupHandshakeTimeoutMs('abc')).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
  27. expect(parseStartupHandshakeTimeoutMs('NaN')).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
  28. });
  29. it('treats 0 and negatives as disabled', () => {
  30. expect(parseStartupHandshakeTimeoutMs('0')).toBe(0);
  31. expect(parseStartupHandshakeTimeoutMs('-5')).toBe(0);
  32. });
  33. it('floors fractional values', () => {
  34. expect(parseStartupHandshakeTimeoutMs('2500.7')).toBe(2500);
  35. });
  36. });
  37. describe('armStartupHandshakeTimeout', () => {
  38. it('fires exactly once when no data ever arrives', async () => {
  39. const stream = new PassThrough();
  40. let fired = 0;
  41. armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
  42. await sleep(140);
  43. expect(fired).toBe(1);
  44. });
  45. it('does not fire once any traffic arrives', async () => {
  46. const stream = new PassThrough();
  47. let fired = 0;
  48. armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
  49. stream.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n');
  50. await sleep(140);
  51. expect(fired).toBe(0);
  52. });
  53. it('a single early byte disarms it for good', async () => {
  54. const stream = new PassThrough();
  55. let fired = 0;
  56. armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
  57. stream.write('x');
  58. await sleep(140); // well past the 40ms window, with no further traffic
  59. expect(fired).toBe(0);
  60. });
  61. it('the returned disarm function cancels it', async () => {
  62. const stream = new PassThrough();
  63. let fired = 0;
  64. const disarm = armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
  65. disarm();
  66. disarm(); // idempotent
  67. await sleep(140);
  68. expect(fired).toBe(0);
  69. });
  70. it('timeout 0 disables (env convention shared with CODEGRAPH_PPID_POLL_MS)', async () => {
  71. const stream = new PassThrough();
  72. let fired = 0;
  73. const disarm = armStartupHandshakeTimeout(() => { fired++; }, stream, 0);
  74. await sleep(80);
  75. expect(fired).toBe(0);
  76. disarm(); // still callable
  77. });
  78. it('does not steal data from the real consumer', async () => {
  79. // The backstop attaches its own once('data') listener; the actual MCP
  80. // consumer on the same stream must still see every byte.
  81. const stream = new PassThrough();
  82. let seen = '';
  83. stream.on('data', (c: Buffer) => { seen += c.toString(); });
  84. armStartupHandshakeTimeout(() => { /* no-op */ }, stream, 1000);
  85. stream.write('hello');
  86. stream.write(' world');
  87. await sleep(20);
  88. expect(seen).toBe('hello world');
  89. });
  90. });
  91. describe('EARLY_PPID', () => {
  92. it('captured a plausible parent pid at module load', () => {
  93. expect(Number.isInteger(EARLY_PPID)).toBe(true);
  94. expect(EARLY_PPID).toBeGreaterThan(0);
  95. });
  96. });