fix(core-tests): plug kb-db-test-* leaks on SIGTERM fork recycle

Vitest's forks pool SIGTERMs a fork when a test times out, which skips
`beforeExit`/`exit` handlers and leaves `kb-db-test-*` dirs behind.
`holdWriteLock` child processes also kept WAL/SHM handles open, blocking
recursive removal on macOS. Both paths now run cleanup: SIGTERM/SIGINT/
SIGHUP handlers sweep tracked dirs and re-raise the signal, and active
lock-helper children are tracked and SIGKILLed during cleanup so the
parent dir can be removed.

These leaks tripped scripts/check-test-isolation.mjs during deterministic
merge verification, failing auto-merge with "Completion handoff limbo
recovery exhausted" (e.g. FN-5521, FN-5486).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-22 23:27:44 -07:00
parent 2a3a07a612
commit 380f8b8e5e

View File

@@ -47,6 +47,7 @@ async function removeTrackedTmpDir(dir: string | undefined): Promise<void> {
}
async function cleanupTmpDirsAsync(): Promise<void> {
killLockChildrenSync();
const cleanup = Array.from(createdTmpDirs);
await Promise.all(cleanup.map((dir) => removeTrackedTmpDir(dir)));
}
@@ -62,7 +63,26 @@ function removeTrackedTmpDirSync(dir: string | undefined): void {
}
}
// Lock-helper child processes hold open WAL/SHM file handles on the test db.
// If a test is force-killed (timeout → fork recycle → SIGTERM) before its
// `lock.release()` finally runs, those children outlive the test process and
// block recursive removal of the parent tmp dir on macOS, leaking
// `kb-db-test-*` directories. Track them so cleanup can kill stragglers.
const activeLockChildren = new Set<ChildProcessWithoutNullStreams>();
function killLockChildrenSync(): void {
for (const child of activeLockChildren) {
try {
if (child.exitCode === null && !child.killed) child.kill("SIGKILL");
} catch {
// best-effort
}
}
activeLockChildren.clear();
}
function cleanupTmpDirsSync(): void {
killLockChildrenSync();
const cleanup = Array.from(createdTmpDirs);
for (const dir of cleanup) {
removeTrackedTmpDirSync(dir);
@@ -71,12 +91,22 @@ function cleanupTmpDirsSync(): void {
// Full-suite worker shutdown can skip Vitest's normal afterAll timing if the worker
// is already draining, so keep a process-level sync cleanup backstop for kb-db-test-*.
// Vitest's "forks" pool also sends SIGTERM when a test times out and the fork is
// recycled — `beforeExit`/`exit` don't run in that path, so we also install
// signal handlers that perform the sync sweep before re-raising.
const processWithCleanupFlag = process as typeof process & {
[TMP_DIR_CLEANUP_HOOK_KEY]?: boolean;
};
if (!processWithCleanupFlag[TMP_DIR_CLEANUP_HOOK_KEY]) {
process.once("beforeExit", cleanupTmpDirsSync);
process.once("exit", cleanupTmpDirsSync);
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
process.once(signal, () => {
cleanupTmpDirsSync();
// Re-raise with default disposition so the runner still observes the signal.
process.kill(process.pid, signal);
});
}
processWithCleanupFlag[TMP_DIR_CLEANUP_HOOK_KEY] = true;
}
@@ -118,6 +148,10 @@ async function holdWriteLock(
const child = spawn(process.execPath, ["-e", script], {
stdio: ["pipe", "pipe", "pipe"],
});
activeLockChildren.add(child);
child.once("exit", () => {
activeLockChildren.delete(child);
});
const ready = new Promise<void>((resolve, reject) => {
let stderr = "";