// Regression guard: a fixture child must never outlive the job that owns it. // // The bug this pins down: /api/test/invalidate-cache used to drop the job // registry (globalThis.__yttJobRegistry__ = undefined) without cancelling // anything first. For yt-dlp-style jobs the registry's AbortController is the // ONLY kill path — runManagedFunction sets record.abortController but never // record.child (common/jobs/streamCommand.ts:306-319), so cancel()'s // child.kill() branch is a no-op for them. Dropping the registry therefore made // every in-flight child unkillable by the app. // // That route is called by resetData() and writeSettings() — 303 resetData() // calls across 83 of 87 spec files, many from inside test bodies while a job is // running. So the leak fired on essentially every full run, not (as was long // assumed) only on interrupted ones. A full run left 27 strays alive, each // spinning ~29% of a core; the suite went from 30 min / 1 failure to 1.3 h / 13 // failures, and all 13 read like real regressions in unrelated specs. // // A test suite that lies about what broke is the actual cost, which is why this // is worth a dedicated spec. import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, resetData, resolvePath, writeSettings, } from "./helpers"; import { listFixtureProcesses } from "./fixtureProcs"; async function makeTranscribeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(root, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "transcribe", name: slug, url: "https://odysee.com/@example", audioFormat: "mp3", }), ); for (const id of ids) { await mkdir(`${root}/data/${id}`, { recursive: true }); await writeFile(`${root}/data/${id}/audio.mp3`, `fake audio ${id}\n`); } } test("resetData cancels in-flight fixture children instead of orphaning them", async ({ page, }) => { test.setTimeout(90_000); await resetData("empty"); // parakeet is the right lever here: for a "slowop" dir the fake wrapper waits // on a 30s backstop before finishing (fixtures/bin/fake-parakeet-stitch.mjs). // That is a wide enough window that a stray is unambiguous — a fixture with a // sub-second sleep would "pass" simply by exiting on its own. await writeSettings({ workers: [ { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {}, }, ], }); await makeTranscribeChannel("orphan-chan", ["slowoporphan1"]); await generateReport(page, "orphan-chan"); await page.goto(channelStage("orphan-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait for the child to actually exist before trying to strand it, otherwise // the assertion below could pass because nothing had spawned yet. await expect .poll(async () => (await listFixtureProcesses()).length, { timeout: 20_000, message: "expected a fixture child to be running", }) .toBeGreaterThan(0); // The stranding event: this hits /api/test/invalidate-cache, which wipes the // registry singletons. Pre-fix, the running child survived this with nothing // left holding a reference that could kill it. await resetData("empty"); // Generous window: the app sends SIGTERM immediately (abort() fires execa's // cancelSignal synchronously) and execa escalates to SIGKILL after 5s, so a // correctly-cancelled child is gone well inside this. A child left orphaned // would still be sleeping on its 30s backstop when this expires. await expect .poll(async () => (await listFixtureProcesses()).length, { timeout: 15_000, message: "fixture child outlived the registry wipe — it was orphaned", }) .toBe(0); });