proxy-connect.test.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. /**
  2. * Proxy connect resilience — issue #974.
  3. *
  4. * `connectWithHello` returns a live socket to the caller, which then attaches
  5. * its own onDaemonLost handler. Before #974, `readHelloLine` attached an
  6. * 'error' listener and REMOVED it on success, leaving a window where the socket
  7. * had no 'error' listener — and a socket 'error' with no listener is re-thrown
  8. * by Node as an uncaughtException, which the global fatal handler turns into
  9. * process.exit(1). To an MCP client that is a bare "Transport closed". The fix
  10. * keeps a guard 'error' listener attached for the socket's whole life.
  11. *
  12. * AF_UNIX over WSL2/DrvFs makes that window common; here we just prove the
  13. * invariant on a normal socket: the returned socket always has an 'error'
  14. * listener, and emitting an error on it never throws.
  15. */
  16. import { afterEach, describe, expect, it } from 'vitest';
  17. import * as net from 'net';
  18. import * as fs from 'fs';
  19. import * as os from 'os';
  20. import * as path from 'path';
  21. import { connectWithHello } from '../src/mcp/proxy';
  22. import { CodeGraphPackageVersion } from '../src/mcp/version';
  23. const cleanups: Array<() => void> = [];
  24. afterEach(() => {
  25. while (cleanups.length) {
  26. try { cleanups.pop()!(); } catch { /* best-effort */ }
  27. }
  28. });
  29. /** Stand up a fake daemon that emits a valid hello line on connect. */
  30. async function fakeDaemon(version: string): Promise<{ sockPath: string; server: net.Server }> {
  31. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-proxy-'));
  32. const sockPath = path.join(dir, 'd.sock');
  33. const server = net.createServer((socket) => {
  34. const hello = { codegraph: version, pid: process.pid, socketPath: sockPath, protocol: 1 };
  35. socket.write(JSON.stringify(hello) + '\n');
  36. });
  37. await new Promise<void>((resolve) => server.listen(sockPath, resolve));
  38. cleanups.push(() => server.close());
  39. cleanups.push(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
  40. return { sockPath, server };
  41. }
  42. describe('connectWithHello — socket is never left without an error listener (#974)', () => {
  43. it.runIf(process.platform !== 'win32')('returns a socket that has an error listener and never throws on error', async () => {
  44. const { sockPath } = await fakeDaemon(CodeGraphPackageVersion);
  45. const result = await connectWithHello(sockPath);
  46. expect(result).not.toBeNull();
  47. expect(result).not.toBe('version-mismatch');
  48. const socket = result as net.Socket;
  49. cleanups.push(() => socket.destroy());
  50. // The invariant: a guard 'error' listener is attached for the socket's whole
  51. // life, so a stray socket error can't escalate to an uncaughtException.
  52. expect(socket.listenerCount('error')).toBeGreaterThanOrEqual(1);
  53. // Emitting an error must NOT throw. Without the guard this is exactly the
  54. // path that crashed the proxy with "Transport closed".
  55. expect(() => socket.emit('error', new Error('simulated ECONNRESET'))).not.toThrow();
  56. });
  57. it.runIf(process.platform !== 'win32')('still reports version-mismatch (and that path does not throw)', async () => {
  58. const { sockPath } = await fakeDaemon('0.0.0-not-our-version');
  59. const result = await connectWithHello(sockPath);
  60. expect(result).toBe('version-mismatch');
  61. });
  62. it.runIf(process.platform !== 'win32')('returns null when no daemon is listening', async () => {
  63. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-proxy-none-'));
  64. cleanups.push(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
  65. const result = await connectWithHello(path.join(dir, 'missing.sock'));
  66. expect(result).toBeNull();
  67. });
  68. });