diff --git a/apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts b/apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts new file mode 100644 index 000000000..767b8c72b --- /dev/null +++ b/apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts @@ -0,0 +1,204 @@ +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); + }); +}); diff --git a/apps/electron-backend-e2e/src/data-dir-reaper.ts b/apps/electron-backend-e2e/src/data-dir-reaper.ts new file mode 100644 index 000000000..1ec460035 --- /dev/null +++ b/apps/electron-backend-e2e/src/data-dir-reaper.ts @@ -0,0 +1,123 @@ +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. + } + } +} diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 12424acd7..80f4f119a 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -22,6 +22,11 @@ import { } from 'fs'; import { tmpdir } from 'os'; import { dirname, join, resolve } from 'path'; +import { + dataDirPrefix, + reapOrphanedDataDirs, + writeDataDirOwnerMarker, +} from './data-dir-reaper'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -154,10 +159,17 @@ function removeDataDir(dataDir: string): void { } } +let reapedOrphanedDataDirs = false; + export const test = base.extend({ dataDir: async ({ browserName }, use) => { void browserName; - const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-electron-e2e-')); + if (!reapedOrphanedDataDirs) { + reapedOrphanedDataDirs = true; + reapOrphanedDataDirs(); + } + const dataDir = mkdtempSync(join(tmpdir(), dataDirPrefix)); + writeDataDirOwnerMarker(dataDir); await use(dataDir);