1
0

ppid-watchdog.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. /**
  2. * Unit coverage for the PPID-watchdog decision logic (#277, #692).
  3. *
  4. * The live watchdog timers in `proxy.ts` / `index.ts` are integration-tested on
  5. * POSIX in `mcp-ppid-watchdog.test.ts`, but that test is skipped on Windows
  6. * (`process.kill(pid, 'SIGKILL')` and reparenting are POSIX-specific). That gap
  7. * is exactly how the Windows leak (#692) shipped: on Windows `process.ppid`
  8. * never changes when the parent dies, so the old change-only check could never
  9. * fire. These pure-function tests exercise the Windows branch on any OS by
  10. * stubbing `isAlive` and `platform`.
  11. */
  12. import { describe, it, expect } from 'vitest';
  13. import { supervisionLostReason } from '../src/mcp/ppid-watchdog';
  14. const alive = () => true;
  15. const dead = () => false;
  16. /** Alive for everyone except the listed pids. */
  17. const deadOnly = (...pids: number[]) => (pid: number) => !pids.includes(pid);
  18. describe('supervisionLostReason', () => {
  19. describe('POSIX (parent death reparents → ppid changes)', () => {
  20. it('returns null while the parent is unchanged', () => {
  21. expect(
  22. supervisionLostReason({
  23. originalPpid: 100,
  24. currentPpid: 100,
  25. hostPpid: null,
  26. isAlive: alive,
  27. platform: 'linux',
  28. }),
  29. ).toBeNull();
  30. });
  31. it('detects a reparent (ppid divergence) as the death signal', () => {
  32. const reason = supervisionLostReason({
  33. originalPpid: 100,
  34. currentPpid: 1, // reparented to init
  35. hostPpid: null,
  36. isAlive: alive,
  37. platform: 'linux',
  38. });
  39. expect(reason).toBe('ppid 100 -> 1');
  40. });
  41. it('does NOT use liveness on POSIX — a dead original ppid is not orphaning', () => {
  42. // A double-forked grandparent can die while we stay correctly parented.
  43. // POSIX must rely on the change-check only, or it would false-positive.
  44. expect(
  45. supervisionLostReason({
  46. originalPpid: 100,
  47. currentPpid: 100,
  48. hostPpid: null,
  49. isAlive: dead,
  50. platform: 'linux',
  51. }),
  52. ).toBeNull();
  53. });
  54. });
  55. describe('Windows (ppid is stable across parent death → poll liveness)', () => {
  56. it('returns null while the original parent is still alive', () => {
  57. expect(
  58. supervisionLostReason({
  59. originalPpid: 100,
  60. currentPpid: 100,
  61. hostPpid: null,
  62. isAlive: alive,
  63. platform: 'win32',
  64. }),
  65. ).toBeNull();
  66. });
  67. it('detects parent death by liveness even though ppid is unchanged (the #692 fix)', () => {
  68. const reason = supervisionLostReason({
  69. originalPpid: 100,
  70. currentPpid: 100, // Windows never reparents
  71. hostPpid: null,
  72. isAlive: deadOnly(100),
  73. platform: 'win32',
  74. });
  75. expect(reason).toBe('parent pid 100 exited');
  76. });
  77. it('ignores pid 0/1 — never a real Windows parent, must not trigger shutdown', () => {
  78. for (const ppid of [0, 1]) {
  79. expect(
  80. supervisionLostReason({
  81. originalPpid: ppid,
  82. currentPpid: ppid,
  83. hostPpid: null,
  84. isAlive: dead,
  85. platform: 'win32',
  86. }),
  87. ).toBeNull();
  88. }
  89. });
  90. });
  91. describe('threaded host pid (reached past an intermediate launcher shim)', () => {
  92. it('shuts down when the host pid is gone, on either platform', () => {
  93. for (const platform of ['linux', 'win32'] as const) {
  94. const reason = supervisionLostReason({
  95. originalPpid: 100,
  96. currentPpid: 100,
  97. hostPpid: 42,
  98. isAlive: deadOnly(42), // shim 100 alive, host 42 dead
  99. platform,
  100. });
  101. expect(reason).toBe('host pid 42 exited');
  102. }
  103. });
  104. it('stays supervised while the host pid is alive', () => {
  105. expect(
  106. supervisionLostReason({
  107. originalPpid: 100,
  108. currentPpid: 100,
  109. hostPpid: 42,
  110. isAlive: alive,
  111. platform: 'linux',
  112. }),
  113. ).toBeNull();
  114. });
  115. });
  116. describe('signal precedence', () => {
  117. it('reports the ppid change ahead of a host-gone reason', () => {
  118. const reason = supervisionLostReason({
  119. originalPpid: 100,
  120. currentPpid: 1,
  121. hostPpid: 42,
  122. isAlive: dead,
  123. platform: 'linux',
  124. });
  125. expect(reason).toBe('ppid 100 -> 1');
  126. });
  127. });
  128. });