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:
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user