Files
iptvnator/apps/electron-backend-e2e/src/data-dir-reaper.e2e.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

205 lines
6.4 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { spawn, ChildProcess } from 'child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
utimesSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
dataDirHardMaxAgeMs,
dataDirOwnerMarker,
dataDirPrefix,
orphanDataDirMaxAgeMs,
readDataDirOwner,
reapOrphanedDataDirs,
writeDataDirOwnerMarker,
} from './data-dir-reaper';
/**
* Coverage for the orphaned-data-directory sweep.
*
* These are filesystem-level rather than app-level, but they belong in the
* Electron suite: it runs on Linux, macOS and Windows, and the sweep turns on
* `process.kill(pid, 0)` semantics that differ per platform.
*/
const roots: string[] = [];
const children: ChildProcess[] = [];
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'reaper-spec-'));
roots.push(root);
return root;
}
/** A data directory shaped like a real one, optionally aged and/or owned. */
function makeDataDir(
root: string,
name: string,
options: { ageHours?: number; pid?: number | string } = {}
): string {
const dir = join(root, `${dataDirPrefix}${name}`);
mkdirSync(join(dir, 'databases'), { recursive: true });
writeFileSync(join(dir, 'databases', 'iptvnator.db'), 'db');
if (options.pid !== undefined) {
writeFileSync(join(dir, dataDirOwnerMarker), String(options.pid));
}
if (options.ageHours !== undefined) {
const when = new Date(Date.now() - options.ageHours * 3600_000);
utimesSync(dir, when, when);
}
return dir;
}
function spawnLiveProcess(): ChildProcess {
const child =
process.platform === 'win32'
? spawn('cmd', ['/c', 'ping -n 30 127.0.0.1 > NUL'], {
stdio: 'ignore',
})
: spawn('sleep', ['30'], { stdio: 'ignore' });
children.push(child);
return child;
}
test.afterAll(() => {
for (const child of children) {
child.kill();
}
for (const root of roots) {
rmSync(root, { force: true, recursive: true });
}
});
test.describe('orphaned data directory reaper', () => {
test('keeps a live owner well past the unmarked cutoff', () => {
const root = makeRoot();
const child = spawnLiveProcess();
// Age is deliberately past the cutoff: writes land under databases/ and
// never refresh the root's mtime, so a running suite can look this old.
const dir = makeDataDir(root, 'live', {
ageHours: 48,
pid: child.pid,
});
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(true);
});
test('reaps a live-looking owner once past the hard cap, since the pid must be recycled', () => {
const root = makeRoot();
// A pid that resolves to a live process, but on a directory far older
// than any suite could run — so the pid belongs to a stranger now.
const child = spawnLiveProcess();
const dir = makeDataDir(root, 'recycled', {
ageHours: 24 * 8,
pid: child.pid,
});
expect(readDataDirOwner(dir)).toBe('alive');
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(false);
});
test('reaps a dead owner immediately, without waiting out the cutoff', async () => {
const root = makeRoot();
const child = spawnLiveProcess();
const dir = makeDataDir(root, 'dead', {
ageHours: 0,
pid: child.pid,
});
child.kill('SIGKILL');
// The pid only leaves the table once the parent reaps the exit status,
// so wait for that rather than assuming kill() is synchronous.
await expect
.poll(() => readDataDirOwner(dir), { timeout: 10_000 })
.toBe('dead');
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(false);
});
test('reaps an unmarked directory once it is past the cutoff', () => {
const root = makeRoot();
const dir = makeDataDir(root, 'legacy', { ageHours: 48 });
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(false);
});
test('keeps a fresh unmarked directory', () => {
const root = makeRoot();
const dir = makeDataDir(root, 'fresh', { ageHours: 1 });
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(true);
});
test('treats an empty or malformed marker as undeterminable, not dead', () => {
const root = makeRoot();
// A half-written marker must not bypass the age guard and take out a
// suite that is still starting up.
const empty = makeDataDir(root, 'empty', { ageHours: 1, pid: '' });
const partial = makeDataDir(root, 'partial', {
ageHours: 1,
pid: 'not-a-pid',
});
expect(readDataDirOwner(empty)).toBe('unknown');
expect(readDataDirOwner(partial)).toBe('unknown');
reapOrphanedDataDirs(root);
expect(existsSync(empty)).toBe(true);
expect(existsSync(partial)).toBe(true);
});
test('publishes the owner marker atomically', () => {
const root = makeRoot();
const dir = makeDataDir(root, 'atomic', { ageHours: 0 });
writeDataDirOwnerMarker(dir);
// No `.pending` residue, and the marker resolves to this live process.
expect(existsSync(`${join(dir, dataDirOwnerMarker)}.pending`)).toBe(
false
);
expect(readDataDirOwner(dir)).toBe('alive');
});
test('ignores directories that do not carry the suite prefix', () => {
const root = makeRoot();
const foreign = join(root, 'some-other-tool-XYZ');
mkdirSync(foreign, { recursive: true });
const when = new Date(Date.now() - 99 * 3600_000);
utimesSync(foreign, when, when);
reapOrphanedDataDirs(root);
expect(existsSync(foreign)).toBe(true);
});
test('survives a missing root instead of throwing', () => {
expect(() =>
reapOrphanedDataDirs(join(tmpdir(), 'reaper-spec-does-not-exist'))
).not.toThrow();
});
test('cutoffs are a day and a week, and the hard cap is the looser one', () => {
expect(orphanDataDirMaxAgeMs).toBe(24 * 60 * 60 * 1000);
expect(dataDirHardMaxAgeMs).toBe(7 * 24 * 60 * 60 * 1000);
expect(dataDirHardMaxAgeMs).toBeGreaterThan(orphanDataDirMaxAgeMs);
});
});