writer-lock.test.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /**
  2. * Project writer lock (#1740) — unit coverage for acquire / re-entrant /
  3. * stale-dead-pid / live-holder refusal.
  4. */
  5. import { afterEach, describe, expect, it } from 'vitest';
  6. import { spawn, type ChildProcess } from 'child_process';
  7. import * as fs from 'fs';
  8. import * as os from 'os';
  9. import * as path from 'path';
  10. import { MCPEngine } from '../src/mcp/engine';
  11. import {
  12. decodeWriterLockInfo,
  13. getWriterPidPath,
  14. releaseWriterLock,
  15. tryAcquireWriterLock,
  16. writerLockHeldMessage,
  17. } from '../src/mcp/writer-lock';
  18. describe('writer lock (#1740)', () => {
  19. let dir: string;
  20. let holder: ChildProcess | null = null;
  21. afterEach(() => {
  22. try { holder?.kill('SIGKILL'); } catch { /* already gone */ }
  23. holder = null;
  24. if (dir) {
  25. releaseWriterLock(dir);
  26. try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
  27. }
  28. });
  29. function makeProject(): string {
  30. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg1740-lock-'));
  31. fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
  32. return dir;
  33. }
  34. it('acquires and releases writer.pid', () => {
  35. const root = makeProject();
  36. const r = tryAcquireWriterLock(root, 'direct');
  37. expect(r.kind).toBe('acquired');
  38. expect(fs.existsSync(getWriterPidPath(root))).toBe(true);
  39. const info = decodeWriterLockInfo(fs.readFileSync(getWriterPidPath(root), 'utf8'));
  40. expect(info?.pid).toBe(process.pid);
  41. expect(info?.mode).toBe('direct');
  42. releaseWriterLock(root);
  43. expect(fs.existsSync(getWriterPidPath(root))).toBe(false);
  44. });
  45. it('is re-entrant for the same pid', () => {
  46. const root = makeProject();
  47. expect(tryAcquireWriterLock(root, 'daemon').kind).toBe('acquired');
  48. const again = tryAcquireWriterLock(root, 'fallback');
  49. expect(again.kind).toBe('acquired');
  50. releaseWriterLock(root);
  51. });
  52. it('reports taken when a live foreign pid holds the lock', () => {
  53. const root = makeProject();
  54. // Use our own pid first, then overwrite with a fake live-looking pid by
  55. // writing a pid that is alive: process.pid of this test — simulate foreign
  56. // by writing a different alive pid. On Linux, PID 1 is almost always alive.
  57. fs.writeFileSync(
  58. getWriterPidPath(root),
  59. JSON.stringify({ pid: 1, mode: 'direct', startedAt: Date.now() }) + '\n',
  60. { flag: 'wx' },
  61. );
  62. const r = tryAcquireWriterLock(root, 'direct');
  63. expect(r.kind).toBe('taken');
  64. if (r.kind === 'taken') {
  65. expect(r.existing?.pid).toBe(1);
  66. const msg = writerLockHeldMessage(r.existing, r.pidPath);
  67. expect(msg).toMatch(/writer lock held/i);
  68. expect(msg).toMatch(/CODEGRAPH_NO_DAEMON/);
  69. expect(msg).toMatch(/daemon stop/);
  70. }
  71. });
  72. it('clears a stale dead-pid lock and acquires', () => {
  73. const root = makeProject();
  74. // Pick a pid that is extremely unlikely to be alive.
  75. const deadPid = 2147483646;
  76. fs.writeFileSync(
  77. getWriterPidPath(root),
  78. JSON.stringify({ pid: deadPid, mode: 'direct', startedAt: Date.now() }) + '\n',
  79. );
  80. const r = tryAcquireWriterLock(root, 'direct');
  81. expect(r.kind).toBe('acquired');
  82. releaseWriterLock(root);
  83. });
  84. it('lets a fallback engine atomically claim and release writer ownership', () => {
  85. const root = makeProject();
  86. const engine = new MCPEngine({ writerLockRoot: root });
  87. expect(decodeWriterLockInfo(fs.readFileSync(getWriterPidPath(root), 'utf8'))).toMatchObject({
  88. pid: process.pid,
  89. mode: 'fallback',
  90. });
  91. engine.stop();
  92. expect(fs.existsSync(getWriterPidPath(root))).toBe(false);
  93. });
  94. it('rejects a fallback engine before opening when another process owns writer.pid', () => {
  95. const root = makeProject();
  96. holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
  97. if (!holder.pid) throw new Error('Failed to spawn writer-lock holder');
  98. fs.writeFileSync(
  99. getWriterPidPath(root),
  100. JSON.stringify({ pid: holder.pid, mode: 'daemon', startedAt: Date.now() }) + '\n',
  101. );
  102. expect(() => new MCPEngine({ writerLockRoot: root })).toThrow(/writer lock held/i);
  103. });
  104. });