Files
thedotmack__claude-mem/tests/shared/kill-process-tree-cross-platform.test.ts
T
Alex Newman 285a65ef77 test: close the Windows coverage gap; drop one production test seam
Two exposures I flagged in the round-7 report, closed rather than left
listed.

(1) Coverage. My claim that "the reuse branches are exercised by no CI on
any platform" was WRONG and overstated: ci.yml's build job runs
`bun test tests` on ubuntu, which picks up the whole reuse and identity
suites, and on Linux every describe executes. The real gap was narrower
and Windows-only — the Windows job ran the identity file but not the
reuse suite, and the reuse suite is describe.if(isPosix) throughout, so
adding it there would have skipped rather than covered.

That left three Windows mechanisms with no executing coverage anywhere,
all of them things we cannot verify locally: the CIM process-table read,
taskkill exit-code classification, and the root identity gate
short-circuiting before `taskkill /T /F`. A difference in any of those
would silently skip every descendant as "reused" and bring back #2313
while the code still looked guarded.

Adds tests/shared/kill-process-tree-cross-platform.test.ts — the same
guarantees expressed with a platform-appropriate fixture (cmd.exe/ping on
Windows, sh/sleep on POSIX) so both implementations actually execute,
wired into BOTH jobs so the platforms are held to one contract. Verified
as real gates: removing the root gate fails the no-op case; breaking
descendant enumeration fails four of five.

(2) Test seams. Assessed individually rather than removed reflexively.

  - waitForUnexpectedCloseCleanupForTesting REMOVED from
    ChromaMcpManager. It was a public method existing only for one
    assertion, and the assertion survives unchanged by polling the
    observable side effect (the recorded killProcessTree call) with the
    suite's existing waitForCondition helper. Zero coverage lost, one
    method off the production surface.

  - __identityProbeCountForTesting KEPT, now documented as a deliberate
    seam. It is a monotonic read-only counter with no way to mutate
    state or influence a kill decision, nothing in src/ imports it, and
    it is the only way to observe "isSameProcess re-reads the OS on
    every authorization" — the property that stops a cached verdict
    certifying a reused PID. Removing it would delete a real gate to
    save no risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:34:33 -07:00

143 lines
5.5 KiB
TypeScript

import { describe, it, expect, afterAll } from 'bun:test';
import { spawn, type ChildProcess } from 'child_process';
import { killProcessTree, collectDescendantIdentities } from '../../src/shared/kill-process-tree.js';
import { captureProcessStartToken } from '../../src/shared/process-identity.js';
import { isPidAlive } from '../../src/supervisor/process-registry.js';
/**
* The parts of tree-kill that are genuinely runnable on BOTH platforms.
*
* Most of the reuse suite is `describe.if(isPosix)` — its fixtures depend on
* `/bin/sh`, `pgrep` and SIGTERM semantics that have no Windows equivalent —
* so it runs on ubuntu only. That left three Windows-specific mechanisms with
* no executing coverage anywhere, and they are exactly the ones that cannot be
* verified locally:
*
* 1. the CIM process-table read (descendant discovery),
* 2. taskkill exit-code classification (not-found tolerated, real failures
* surfaced),
* 3. the root identity gate short-circuiting before `taskkill /T /F`.
*
* A format or behaviour difference in any of those would silently skip every
* descendant as "reused" and bring back #2313 while the code still looked
* guarded. Everything here therefore uses a platform-appropriate fixture and
* asserts through the PRODUCTION helpers, so the Windows job exercises the
* Windows implementations rather than skipping.
*/
const isWindows = process.platform === 'win32';
const strays: number[] = [];
function settle(ms = 600): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await settle(50);
}
return predicate();
}
/**
* A two-level tree on either platform: a shell that outlives a long-running
* child, so a single-PID kill would leave the child behind.
*/
function spawnTwoLevelTree(): ChildProcess {
const child = isWindows
? spawn('cmd.exe', ['/c', 'ping -n 120 127.0.0.1 > NUL'], { stdio: 'ignore', windowsHide: true })
: spawn('/bin/sh', ['-c', 'sleep 120 & wait'], { stdio: 'ignore' });
if (child.pid) strays.push(child.pid);
return child;
}
afterAll(() => {
for (const pid of strays) {
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
});
describe('killProcessTree end-to-end on this platform', () => {
it('discovers descendants through the production enumeration', async () => {
const root = spawnTwoLevelTree();
await settle();
// On Windows this is the CIM read; on POSIX the ps/proc read. Either way
// it must actually see the child, or every guard downstream is inert.
const descendants = await collectDescendantIdentities(root.pid!);
expect(descendants.length).toBeGreaterThan(0);
try { process.kill(root.pid!, 'SIGKILL'); } catch { /* fine */ }
}, 60_000);
it('kills the root AND its descendant', async () => {
const root = spawnTwoLevelTree();
await settle();
const descendants = await collectDescendantIdentities(root.pid!);
expect(descendants.length).toBeGreaterThan(0);
const childPid = descendants[0]!.pid;
await killProcessTree(root.pid!);
expect(await waitUntil(() => !isPidAlive(root.pid!), 20_000)).toBe(true);
expect(await waitUntil(() => !isPidAlive(childPid), 20_000)).toBe(true);
}, 60_000);
it('treats an already-dead target as success, not failure', async () => {
// Windows: taskkill exits 128 / "not found". POSIX: ESRCH. Both are the
// tolerated case — a throw here would make `server stop` report a failed
// stop for a server that had already exited.
const root = spawnTwoLevelTree();
await settle();
const pid = root.pid!;
await killProcessTree(pid);
expect(await waitUntil(() => !isPidAlive(pid), 20_000)).toBe(true);
// Second call against the corpse must resolve, not reject.
await killProcessTree(pid);
}, 60_000);
it('is a complete no-op when the root identity does not match', async () => {
const root = spawnTwoLevelTree();
await settle();
const descendants = await collectDescendantIdentities(root.pid!);
expect(descendants.length).toBeGreaterThan(0);
const childPid = descendants[0]!.pid;
// A token that cannot belong to this process: the gate must short-circuit
// BEFORE taskkill /T /F, leaving the subtree untouched.
await killProcessTree(root.pid!, { expectedStartToken: 'not-this-processes-start-token' });
await settle(1_000);
expect(isPidAlive(root.pid!)).toBe(true);
expect(isPidAlive(childPid)).toBe(true);
try { process.kill(root.pid!, 'SIGKILL'); } catch { /* fine */ }
try { process.kill(childPid, 'SIGKILL'); } catch { /* fine */ }
}, 60_000);
it('still kills when the supplied root identity matches', async () => {
// The other half: without this, the no-op case above would pass for a
// build where the gate rejected everything.
const root = spawnTwoLevelTree();
await settle();
const descendants = await collectDescendantIdentities(root.pid!);
expect(descendants.length).toBeGreaterThan(0);
const childPid = descendants[0]!.pid;
const token = captureProcessStartToken(root.pid!);
expect(token).not.toBeNull();
await killProcessTree(root.pid!, { expectedStartToken: token });
expect(await waitUntil(() => !isPidAlive(root.pid!), 20_000)).toBe(true);
expect(await waitUntil(() => !isPidAlive(childPid), 20_000)).toBe(true);
}, 60_000);
});