mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
`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>
124 lines
4.5 KiB
TypeScript
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.
|
|
}
|
|
}
|
|
}
|