mcp-writer-lock.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /**
  2. * Issue #1740 — concurrent direct-mode serve --mcp must fail fast on the
  3. * second writer instead of silently degrading auto-sync.
  4. */
  5. import { afterEach, beforeEach, describe, expect, it } from 'vitest';
  6. import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
  7. import * as fs from 'fs';
  8. import * as os from 'os';
  9. import * as path from 'path';
  10. import { CodeGraph } from '../src';
  11. import { getWriterPidPath } from '../src/mcp/writer-lock';
  12. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  13. function sleep(ms: number): Promise<void> {
  14. return new Promise((r) => setTimeout(r, ms));
  15. }
  16. function spawnMcp(
  17. cwd: string,
  18. env: NodeJS.ProcessEnv,
  19. ): { child: ChildProcessWithoutNullStreams; getStderr: () => string } {
  20. const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], {
  21. cwd,
  22. stdio: ['pipe', 'pipe', 'pipe'],
  23. env: { ...process.env, ...env },
  24. }) as ChildProcessWithoutNullStreams;
  25. child.on('error', () => {});
  26. child.stdin.on('error', () => {});
  27. let stderr = '';
  28. child.stderr.on('data', (c: Buffer) => { stderr += c.toString('utf8'); });
  29. child.stdout.on('data', () => {});
  30. return { child, getStderr: () => stderr };
  31. }
  32. describe('issue #1740 — direct-mode writer lock', () => {
  33. let tempDir: string;
  34. let realRoot: string;
  35. const children: ChildProcessWithoutNullStreams[] = [];
  36. beforeEach(async () => {
  37. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg1740-mcp-'));
  38. realRoot = fs.realpathSync(tempDir);
  39. fs.mkdirSync(path.join(realRoot, 'src'));
  40. fs.writeFileSync(path.join(realRoot, 'src/a.ts'), 'export function a() { return 1; }\n');
  41. const cg = await CodeGraph.init(realRoot);
  42. await cg.indexAll();
  43. cg.close();
  44. });
  45. afterEach(async () => {
  46. for (const c of children) {
  47. try { c.kill('SIGTERM'); } catch { /* ignore */ }
  48. }
  49. children.length = 0;
  50. await sleep(300);
  51. try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
  52. });
  53. it('second CODEGRAPH_NO_DAEMON serve --mcp exits with writer-lock error', async () => {
  54. const env = {
  55. CODEGRAPH_NO_DAEMON: '1',
  56. CODEGRAPH_MCP_DEBUG: '1',
  57. CODEGRAPH_NO_WATCHDOG: '1',
  58. CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: '0',
  59. // Avoid wasm --liftoff-only re-exec so lock.pid matches the spawned pid.
  60. CODEGRAPH_NO_RELAUNCH: '1',
  61. CODEGRAPH_WASM_RELAUNCHED: '1',
  62. };
  63. const first = spawnMcp(realRoot, env);
  64. children.push(first.child);
  65. const lockPath = getWriterPidPath(realRoot);
  66. const deadline = Date.now() + 10000;
  67. while (Date.now() < deadline && !fs.existsSync(lockPath)) {
  68. await sleep(50);
  69. }
  70. expect(fs.existsSync(lockPath)).toBe(true);
  71. expect(first.child.exitCode).toBeNull();
  72. const second = spawnMcp(realRoot, env);
  73. children.push(second.child);
  74. const code = await new Promise<number | null>((resolve) => {
  75. const timer = setTimeout(() => resolve(second.child.exitCode), 10000);
  76. second.child.on('close', (c) => {
  77. clearTimeout(timer);
  78. resolve(c);
  79. });
  80. });
  81. expect(code).toBe(1);
  82. expect(second.getStderr()).toMatch(/writer lock held/i);
  83. expect(second.getStderr()).toMatch(/CODEGRAPH_NO_DAEMON/);
  84. expect(first.child.exitCode).toBeNull();
  85. const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { pid: number };
  86. expect(lock.pid).toBe(first.child.pid);
  87. }, 20000);
  88. it('default daemon mode still allows two proxies to share one writer', async () => {
  89. const env = {
  90. CODEGRAPH_MCP_LOG_ATTACH: '1',
  91. CODEGRAPH_NO_WATCHDOG: '1',
  92. CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: '0',
  93. CODEGRAPH_NO_RELAUNCH: '1',
  94. CODEGRAPH_WASM_RELAUNCHED: '1',
  95. };
  96. const a = spawnMcp(realRoot, env);
  97. const b = spawnMcp(realRoot, env);
  98. children.push(a.child, b.child);
  99. const lockPath = getWriterPidPath(realRoot);
  100. const deadline = Date.now() + 15000;
  101. while (Date.now() < deadline && !fs.existsSync(lockPath)) {
  102. await sleep(50);
  103. }
  104. expect(fs.existsSync(lockPath)).toBe(true);
  105. await sleep(1000);
  106. expect(a.child.exitCode).toBeNull();
  107. expect(b.child.exitCode).toBeNull();
  108. const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { pid: number; mode: string };
  109. expect(lock.mode).toBe('daemon');
  110. expect(lock.pid).not.toBe(a.child.pid);
  111. expect(lock.pid).not.toBe(b.child.pid);
  112. }, 25000);
  113. });