import { NextResponse } from "next/server"; import { revalidatePath } from "next/cache"; import { resetSnapshotScheduler } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { resetChannelSnapshotMemo } from "yt-dlp-transcript-common/controller/channels"; import { resetStorageProbeMemo } from "yt-dlp-transcript-common/controller/storageLocations"; import { resetVideoTitleMemo } from "yt-dlp-transcript-common/controller/videoTitles"; import { forgetChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { resetStorageHealth } from "yt-dlp-transcript-common/lib/storageHealth"; import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; // Cancel everything still live BEFORE the registry is dropped. // // The registry holds the only reachable AbortController for an in-flight child. // yt-dlp-style jobs run via runManagedFunction, which sets record.abortController // but never record.child (common/jobs/streamCommand.ts:306-319) — so abort() is // their ONLY kill path, and the moment the singleton is nulled the child is // unkillable by the app and reparents to init when the Next worker dies. // // Read the global DIRECTLY rather than calling getRegistry(): per this repo's // Next-16 lazy-singleton rule, getRegistry() *constructs* on read, so using it // here would build a fresh empty registry and dutifully cancel nothing. Reading // the (already globally-declared) var also means this route imports no registry // module, so it cannot construct one as an import side-effect either. function cancelLiveJobs() { const registry = globalThis.__yttJobRegistry__; if (!registry) return; for (const job of registry.list()) { if (job.status !== "running" && job.status !== "queued") continue; try { // Synchronous by design: abort() fires execa's cancelSignal inline, so // SIGTERM is delivered before this route returns. execa's default // forceKillAfterDelay escalates to SIGKILL, so we need not await the child. registry.cancel(job.id); // Then free the scheduler slot even if the record never settles (a child // that ignores SIGTERM would otherwise leave a phantom busy slot behind // for the next spec). Both calls are idempotent. registry.forceRelease(job.id); } catch { /* one wedged job must not stop us cancelling the rest */ } } } // E2E test harness only, and GUARDED BY `E2E_TEST_ROUTES` — which the // playwright config sets for its test server, so it is reachable from exactly the servers that // need it. It cancels every live job and drops four singletons; an // unauthenticated GET doing that is CSRF-able from any page the operator has // open, loopback or not. See _guard.ts. export async function POST() { const denied = testRouteDenied(); if (denied) return denied; return invalidate(); } export async function GET() { const denied = testRouteDenied(); if (denied) return denied; return invalidate(); } function invalidate() { // Clear any pending debounced snapshot regen FIRST (also clears its timer) so // it can't fire against the about-to-be-reset registry mid-spec. resetSnapshotScheduler(); // Kill live children while we still have the handle that can kill them. This // serves the same intent the wipe below was added for (4057f76: "tests don't // observe stale jobs from a prior spec") rather than fighting it — a job whose // process is gone is a great deal less observable than one that isn't. cancelLiveJobs(); // Reset the in-memory job registry too so tests don't observe stale jobs // from a prior spec in the same dev-server lifetime. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__yttJobRegistry__ = undefined; // Reset the global worker pool for the same reason: it's a process-wide // singleton whose slot accounting would otherwise carry across specs. A prior // spec that left a batch running (holding global slots) would starve the next // spec's batch. A detached transcription's lease.release() targets the old // pool object and is harmless; the fresh pool re-seeds from settings on first // use. (In production the pool legitimately persists; this is e2e-only.) // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__yttWorkerPool__ = undefined; // Reset the shared scheduler: it owns the per-queueKey ordering for the // (now-wiped) registry, so a prior spec's still-"running" queue entry would // otherwise make the next spec's same-named queue see a phantom busy slot and // queue forever. Cleared in lockstep with the registry it serves. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__yttScheduler__ = undefined; // Reset the auto-queue runner singleton too: its live entries reference job // ids in the (now-wiped) registry. A still-executing runner loop from a prior // spec detects its job is gone (registry.get -> undefined) and exits on its // next iteration; clearing this lets the next spec start fresh runners. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__yttAutoRunner__ = undefined; // And the ONE shared auto-queue state object the runners (and // recordDownloadBackoff) read and write through — it lives on its own global // in jobs/autoQueueState.ts. Kept, a reset corpus would inherit the previous // spec's picks, fairness weights and platform backoff. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__yttAutoQueueState__ = undefined; // Drop the shared snapshot parse memo. It is keyed on (mtime, size), so it is // self-invalidating in production — but resetData() rewrites the SAME fixture // paths from the same source tree, and two specs writing an identical // snapshot inside one filesystem timestamp tick would hand the second one the // first's parsed object. Cleared here with the other process-wide caches // rather than left to a coincidence. resetChannelSnapshotMemo(); // And the storage probe memo, for the snapshot memo's reason in a different // costume. It is keyed by location id and holds for ten seconds, so two specs // using the same id — "cold" is the obvious name and both storage specs // reach for it — could have the second answered with the first's reading of a // disk that no longer exists. Keyed by id and root, which a spec using // testInfo.outputPath varies by accident rather than on purpose; cleared here // so it is on purpose. resetStorageProbeMemo(); // And the two memories a stalled drive lives in: inspectChannelMedia's // five-second answers (keyed by channels dir, slug and the configured target, // which a reset fixture reproduces exactly) and each location's health, which // a previous spec's location id would otherwise carry into this one. forgetChannelMedia(); resetStorageHealth(); // And the video list's metadata.info.json title memo. It is keyed by the // channel's data/ mtime, which resetData() changes by recreating the dir — but // a spec that rewrites a title in place inside one mtime tick would otherwise // see the previous spec's title. resetVideoTitleMemo(); // And the auto-queue status poll's three-second memo of the snapshot-derived // pending counts (common/views/autoQueueStatus.ts): the next spec's first // poll must count ITS fixture, not the previous spec's. Dropped through the // global like the singletons above, so this route imports nothing that // reaches the runner; a computation still in flight lands in the dropped // object. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__yttAutoQueueStatusMemo__ = undefined; revalidatePath("/", "layout"); return NextResponse.json({ ok: true }); }