Files
iptvnator/apps/electron-backend-e2e/src/data-dir-reaper.ts
T
4grayandClaude Opus 5 72f8cebd2e fix(e2e): reap data directories abandoned by earlier runs (#1296)
`removeDataDir` tolerates a locked directory rather than failing the run, but
then abandons it, and nothing collects it on our behalf: Windows never clears
%TEMP% on process exit, and the Unix equivalents only run on a schedule. Every
teardown that lost that race leaked a database and user-data tree on developer
machines and long-lived runners, invisibly, while CI stayed green.

Sweeps leftover `iptvnator-electron-e2e-*` directories once per run, before the
first one is created. Ownership is settled by pid rather than age: each run
records its pid and the sweep asks the OS via `process.kill(pid, 0)`.

- A live owner is kept, so a concurrent suite is never collected — this repo is
  routinely checked out into several worktrees at once. Age cannot answer this:
  writes land under `databases/` and `user-data/`, which never refreshes the
  root's mtime, so a run paused in a debugger looks arbitrarily old.
- A dead owner is collected immediately.
- An undeterminable owner (missing, empty or malformed marker) falls back to a
  24h cutoff. The marker is published via rename so a half-written file cannot
  bypass that guard.
- A live-looking owner past a week is collected anyway, since the OS recycles
  pids and a stranger inheriting one would otherwise pin the directory forever.

Covered by a 10-test spec running on Linux, macOS and Windows, since
`process.kill(pid, 0)` semantics are platform-specific. Each behaviour was
verified to fail against the preceding implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:41:24 +02:00

124 lines
4.5 KiB
TypeScript

import {
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
export const dataDirPrefix = 'iptvnator-electron-e2e-';
export const dataDirOwnerMarker = '.e2e-owner-pid';
/** Fallback cutoff, used only for leftovers whose owner cannot be determined. */
export const orphanDataDirMaxAgeMs = 24 * 60 * 60 * 1000;
/**
* Backstop for pid reuse. A pid that looks alive is normally decisive, but the
* OS recycles pids — aggressively so on the long-lived Windows runners this
* sweep exists for — and an unrelated service inheriting an abandoned run's pid
* would otherwise pin that directory forever, defeating the whole point. No
* suite survives a week, so past this age a live-looking pid is a stranger.
*/
export const dataDirHardMaxAgeMs = 7 * 24 * 60 * 60 * 1000;
export type DataDirOwner = 'alive' | 'dead' | 'unknown';
/**
* Records the current process as the owner of `dataDir`.
*
* Written to a temporary name and renamed into place, because `writeFileSync`
* creates the file before its bytes land: a concurrent sweep could otherwise
* observe an empty marker and misjudge a starting run. `rename` is atomic
* within a filesystem, so the marker is either absent or complete.
*/
export function writeDataDirOwnerMarker(dataDir: string): void {
const markerPath = join(dataDir, dataDirOwnerMarker);
const pendingPath = `${markerPath}.pending`;
writeFileSync(pendingPath, String(process.pid));
renameSync(pendingPath, markerPath);
}
/**
* Resolves whether the run that created `dataDir` is still alive.
*
* Directory age cannot answer this. The suite writes beneath `databases/` and
* `user-data/`, which never refreshes the root's mtime, so a run paused in a
* debugger or blocked on a native process looks arbitrarily old while still
* using its data — and Unix would let a sweep unlink files the live Electron
* still has open. So the owner is asked of the OS directly.
*
* `unknown` covers a missing, unreadable, empty or malformed marker. Callers
* must treat it as "cannot tell", never as "dead".
*/
export function readDataDirOwner(dataDir: string): DataDirOwner {
let raw: string;
try {
raw = readFileSync(join(dataDir, dataDirOwnerMarker), 'utf8').trim();
} catch {
return 'unknown';
}
const pid = Number.parseInt(raw, 10);
if (!raw || !Number.isInteger(pid) || pid <= 0 || String(pid) !== raw) {
return 'unknown';
}
try {
// Signal 0 runs the existence/permission check without delivering.
process.kill(pid, 0);
return 'alive';
} catch (error) {
// EPERM means the pid exists but belongs to another user — still alive.
return (error as NodeJS.ErrnoException).code === 'EPERM'
? 'alive'
: 'dead';
}
}
/**
* Best-effort sweep of data directories abandoned by earlier runs.
*
* `removeDataDir` tolerates a locked directory rather than failing the run, but
* then abandons it, and nothing collects it on our behalf: Windows never clears
* %TEMP% on process exit, and the Unix equivalents only run on a schedule. So
* every teardown that loses that race leaks a database and user-data tree on a
* developer machine or a long-lived self-hosted runner, invisibly.
*
* A live owner is kept — this repo is routinely checked out into several
* worktrees at once — up to `dataDirHardMaxAgeMs`, past which the pid is
* assumed recycled rather than still ours. A dead owner is collected at once,
* since the pid settles what age only guesses at. An undeterminable owner
* falls back to `orphanDataDirMaxAgeMs`.
*/
export function reapOrphanedDataDirs(root: string = tmpdir()): void {
const now = Date.now();
let entries: string[];
try {
entries = readdirSync(root);
} catch {
return;
}
for (const entry of entries) {
if (!entry.startsWith(dataDirPrefix)) {
continue;
}
const candidate = join(root, entry);
try {
const owner = readDataDirOwner(candidate);
const age = now - statSync(candidate).mtimeMs;
if (owner === 'alive' && age < dataDirHardMaxAgeMs) {
continue;
}
if (owner === 'unknown' && age < orphanDataDirMaxAgeMs) {
continue;
}
rmSync(candidate, { force: true, recursive: true, maxRetries: 3 });
} catch {
// Still locked, or owned by another user — leave it for next time.
}
}
}