Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a941598c8e5cc09ad29068ba12821efd6577cbde
parent b074693ee29971bae1b78ecc7f4657b194121c6c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 18 Jun 2026 09:11:04 -0400

Pause audio-integrity downloads during the integrity probe

In audio-check mode the snapshot's SIGSTOP window only covered the .part
copy: snapshotPart SIGCONT'd yt-dlp the instant the copy finished, so it
kept downloading throughout the (longer) ffmpeg probe. On a malformed
verdict every byte pulled during the probe — plus everything back to the
last good checkpoint — was discarded and re-fetched, which is a prime
driver of HTTP 429 responses.

Keep yt-dlp suspended across the whole probe by default: resume (SIGCONT)
only on a clean/partial verdict; on malformed, kill it while still stopped
and roll back, having downloaded zero throwaway bytes. snapshotPart gains a
holdStopped param (failure paths still always resume, preserving the
never-wedge guarantee) and checkpoint resumes the held child in a finally
unless it is intentionally killed.

A per-channel resumeDuringProbe opt-out (default false) restores the legacy
resume-immediately behavior for A/B comparison, surfaced as a channel-form
checkbox and overridable per-run via AUDIO_CHECK_RESUME_DURING_PROBE. The
legacy path is byte-for-byte unchanged when enabled.

Existing audio-check e2e scenarios now exercise the new paused default;
added two differential tests (slow-probe fixture instrumentation) proving
the download freezes during the probe by default and keeps flowing under
the opt-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/channelConfig.ts | 9+++++++++
Mcommon/ytdlp/audioCheckedDownload.ts | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/components/ChannelForm.tsx | 19+++++++++++++++++++
Meditor/app/channels/components/parseChannelForm.ts | 5+++++
Meditor/e2e/audio-check-scenarios.spec.ts | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/e2e/fixtures/bin/fake-ffmpeg.mjs | 24+++++++++++++++++++-----
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 18++++++++++++++++++
8 files changed, 288 insertions(+), 65 deletions(-)

diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts @@ -9,6 +9,12 @@ export type AudioCheckConfig = { intervalSeconds?: number; maxRollbacks?: number; copyTimeoutSeconds?: number; + // When false (default), yt-dlp stays SIGSTOPped across each integrity + // probe, so it never downloads bytes that a malformed verdict would + // discard and force a re-fetch — minimising HTTP 429 risk. Set true for + // the legacy behavior: resume immediately after the snapshot copy and + // probe concurrently while the download keeps running. + resumeDuringProbe?: boolean; }; export type ChannelConfig = { @@ -189,6 +195,9 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null { AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS, ); } + if (typeof a.resumeDuringProbe === "boolean") { + ac.resumeDuringProbe = a.resumeDuringProbe; + } config.audioCheck = ac; } } diff --git a/common/ytdlp/audioCheckedDownload.ts b/common/ytdlp/audioCheckedDownload.ts @@ -53,6 +53,15 @@ function envIntOverride(name: string): number | null { return Number.isFinite(n) && n >= 0 ? n : null; } +// Tri-state boolean env override: unset -> null (use config), "1"/"true" -> +// true, anything else -> false. Lets comparison runs flip the resume-during- +// probe behavior without editing channel config. +function envBoolOverride(name: string): boolean | null { + const raw = process.env[name]; + if (raw == null || raw === "") return null; + return raw === "1" || raw.toLowerCase() === "true"; +} + export type CheckpointAction = | "advance" | "rollback" @@ -118,6 +127,10 @@ type Knobs = { copyTimeoutMs: number; debugPauseMs: number; sizeGateBytes: number; + // Legacy behavior when true: SIGCONT immediately after the snapshot copy + // and probe concurrently. Default false: hold the child stopped across the + // probe so no would-be-discarded bytes are downloaded. + resumeDuringProbe: boolean; }; function resolveKnobs(opts: AudioCheckedOpts): Knobs { @@ -125,6 +138,9 @@ function resolveKnobs(opts: AudioCheckedOpts): Knobs { const intervalMsOverride = envIntOverride("AUDIO_CHECK_INTERVAL_MS_OVERRIDE"); const sizeGateOverride = envIntOverride("AUDIO_CHECK_SIZE_GATE_OVERRIDE"); const debugPauseOverride = envIntOverride("AUDIO_CHECK_DEBUG_PAUSE_MS"); + const resumeDuringProbeOverride = envBoolOverride( + "AUDIO_CHECK_RESUME_DURING_PROBE", + ); return { intervalMs: intervalMsOverride ?? @@ -135,6 +151,8 @@ function resolveKnobs(opts: AudioCheckedOpts): Knobs { 1000, debugPauseMs: debugPauseOverride ?? opts.debugPauseMs ?? 0, sizeGateBytes: sizeGateOverride ?? DEFAULT_SIZE_GATE_BYTES, + resumeDuringProbe: + resumeDuringProbeOverride ?? cfg?.resumeDuringProbe ?? false, }; } @@ -323,6 +341,23 @@ async function prepareDataTree( return resumeDir; } +// SIGCONT a (possibly already-exited) child, swallowing ESRCH. Used by the +// caller to resume a child that snapshotPart left stopped via holdStopped. +function resumeChild( + pid: number | undefined, + onLog: (s: string) => void, +): void { + if (!pid) return; + try { + process.kill(pid, "SIGCONT"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ESRCH") { + onLog(`WARN: failed to SIGCONT pid ${pid}: ${(err as Error).message}\n`); + } + } +} + async function snapshotPart( partFile: string, testingFile: string, @@ -330,6 +365,11 @@ async function snapshotPart( knobs: Knobs, onLog: (s: string) => void, signal: AbortSignal, + // When true, leave the child SIGSTOPped on a successful snapshot so the + // caller can run the integrity probe before any further bytes download. + // The caller then owns resuming (or killing) it. Failure paths always + // resume — the never-wedge guarantee is preserved. + holdStopped = false, ): Promise<{ ok: true; bytes: number } | { ok: false; reason: "size-gate" | "copy-timeout" | "missing-part" }> { // Stat first; the .part may have been renamed away (yt-dlp finished). let preSize: number; @@ -345,6 +385,10 @@ async function snapshotPart( return { ok: false, reason: "missing-part" }; } let stopped = false; + // Default: resume the child when we leave this function. Flipped to false + // only on a successful snapshot when holdStopped is set, handing the resume + // responsibility to the caller. + let resumeOnExit = true; try { try { process.kill(pid, "SIGSTOP"); @@ -406,9 +450,12 @@ async function snapshotPart( signal.addEventListener("abort", onAbort, { once: true }); }); } + // Successful snapshot. When holding stopped, the caller resumes/kills the + // child after probing; leave it suspended. + resumeOnExit = !holdStopped; return { ok: true, bytes: preSize }; } finally { - if (stopped) { + if (stopped && resumeOnExit) { try { process.kill(pid, "SIGCONT"); } catch (err) { @@ -751,6 +798,10 @@ export async function runAudioCheckedYtdlp( return; } const testing = testingPath(partFile); + // Default: keep the child SIGSTOPped across the probe below so it never + // downloads bytes a malformed verdict would discard (429 minimisation). + // resumeDuringProbe restores the legacy resume-immediately behavior. + const holdStopped = !knobs.resumeDuringProbe; const snap = await snapshotPart( partFile, testing, @@ -758,6 +809,7 @@ export async function runAudioCheckedYtdlp( knobs, opts.onLog, opts.signal, + holdStopped, ); if (!snap.ok) { checkpoints.push({ @@ -772,74 +824,88 @@ export async function runAudioCheckedYtdlp( return; } lastCheckedBytes = snap.bytes; - // Classify the snapshot. - const probe = await probeAudioStream({ - ffmpegBin: opts.paths.ffmpegBin, - file: testing, - signal: opts.signal, - onLog: opts.onLog, - }); - if (opts.signal.aborted) { - await rm(testing, { force: true }); - return; - } - if (probe.verdict === "clean" || probe.verdict === "partial") { - // Advance: rename testing → good, reset rollback counter. - const good = goodPath(partFile); - await rename(testing, good).catch(async () => { - // If rename failed (e.g. cross-device on weird setups), try - // copyFile+rm fallback. - await copyFile(testing, good).catch(() => {}); - await rm(testing, { force: true }); + // When holdStopped, the child is suspended for the whole probe below. + // Resume it on every exit path EXCEPT the malformed branch, which + // intentionally kills it (SIGCONT + SIGTERM). `killed` gates that. + let killed = false; + try { + // Classify the snapshot. + const probe = await probeAudioStream({ + ffmpegBin: opts.paths.ffmpegBin, + file: testing, + signal: opts.signal, + onLog: opts.onLog, }); - consecutiveRollbacks = 0; + if (opts.signal.aborted) { + await rm(testing, { force: true }); + return; + } + if (probe.verdict === "clean" || probe.verdict === "partial") { + // Advance: rename testing → good, reset rollback counter. The + // finally resumes the held child, so yt-dlp continues from where it + // paused. + const good = goodPath(partFile); + await rename(testing, good).catch(async () => { + // If rename failed (e.g. cross-device on weird setups), try + // copyFile+rm fallback. + await copyFile(testing, good).catch(() => {}); + await rm(testing, { force: true }); + }); + consecutiveRollbacks = 0; + checkpoints.push({ + at: new Date().toISOString(), + bytes: snap.bytes, + verdict: probe.verdict, + action: "advance", + }); + opts.onLog( + `Checkpoint OK at ${snap.bytes} bytes (verdict=${probe.verdict}).\n`, + ); + return; + } + // Malformed. Decide rollback vs restart depending on whether we have + // a .good baseline. + const good = goodPath(partFile); + const haveGood = await pathExists(good); + pendingDecision = haveGood ? "rollback" : "restart"; checkpoints.push({ at: new Date().toISOString(), bytes: snap.bytes, verdict: probe.verdict, - action: "advance", + action: pendingDecision, }); opts.onLog( - `Checkpoint OK at ${snap.bytes} bytes (verdict=${probe.verdict}).\n`, + `Checkpoint MALFORMED at ${snap.bytes} bytes. ${haveGood ? "Rolling back to .good." : "No .good baseline; restarting from 0."}\n`, ); - return; - } - // Malformed. Decide rollback vs restart depending on whether we have - // a .good baseline. - const good = goodPath(partFile); - const haveGood = await pathExists(good); - pendingDecision = haveGood ? "rollback" : "restart"; - checkpoints.push({ - at: new Date().toISOString(), - bytes: snap.bytes, - verdict: probe.verdict, - action: pendingDecision, - }); - opts.onLog( - `Checkpoint MALFORMED at ${snap.bytes} bytes. ${haveGood ? "Rolling back to .good." : "No .good baseline; restarting from 0."}\n`, - ); - // Drop the (failed) snapshot. - await rm(testing, { force: true }); - // Stop the watcher to avoid concurrent decisions. - watcherStop = true; - // Terminate yt-dlp, await exit, then mutate files. - if (!childExited && child.pid) { - try { - process.kill(child.pid, "SIGCONT"); - } catch {} + // Drop the (failed) snapshot. + await rm(testing, { force: true }); + // Stop the watcher to avoid concurrent decisions. + watcherStop = true; + // Terminate yt-dlp, await exit, then mutate files. SIGCONT first so a + // held-stopped child can actually process the SIGTERM. + killed = true; + if (!childExited && child.pid) { + try { + process.kill(child.pid, "SIGCONT"); + } catch {} + try { + child.kill("SIGTERM"); + } catch {} + } try { - child.kill("SIGTERM"); + await exitPromise; } catch {} - } - try { - await exitPromise; - } catch {} - // Now safe to manipulate .part. - if (haveGood) { - await rm(partFile, { force: true }); - await rename(good, partFile); - } else { - await rm(partFile, { force: true }); + // Now safe to manipulate .part. + if (haveGood) { + await rm(partFile, { force: true }); + await rename(good, partFile); + } else { + await rm(partFile, { force: true }); + } + } finally { + // Resume a child snapshotPart left stopped, unless we just killed it. + // No-op in resumeDuringProbe mode (snapshotPart already resumed). + if (holdStopped && !killed) resumeChild(child.pid, opts.onLog); } } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Audio-integrity checks now pause the download while they run, cutting re-downloaded bytes and HTTP 429 risk.** With audio-integrity checking enabled, the downloader periodically snapshots the in-progress `.part` and validates it with ffmpeg. Previously yt-dlp was only paused for the brief *copy* of that snapshot and then resumed immediately, so it kept downloading throughout the (longer) ffmpeg probe — and if the probe came back malformed, every byte pulled during the probe, plus everything back to the last good checkpoint, was discarded and had to be re-fetched. That wasted, repeated fetching is a prime driver of rate-limit (429) responses. Now yt-dlp stays suspended (SIGSTOP) across the whole probe and only resumes on a clean verdict; on a corrupt verdict it's killed while still stopped and rolled back, having downloaded zero throwaway bytes. The trade-off is a briefly idle source connection during each probe (probes are seconds; if a held connection is ever dropped, yt-dlp's own `-c` resume recovers on the next launch). This is the new default; a per-channel **Resume during probe (legacy)** checkbox (channel editor → Audio-integrity checking, `audioCheck.resumeDuringProbe` in `config.json`) restores the old resume-immediately behavior for comparison, and the `AUDIO_CHECK_RESUME_DURING_PROBE` env var overrides it for one-off runs. - **Channels can sync automatically on a schedule.** Each channel gained an **Auto-sync** setting (channel editor → Source): *Default* (inherit the global cadence), *Off*, or a concrete interval (every 10m / 30m / hourly / 6h / 12h / daily / weekly), stored as `syncIntervalMinutes` in `config.json`. Inspired by the Laravel scheduler, a single lightweight cron heartbeat (`pnpm sync:tick`, an ~30-line client) POSTs to the editor's new `/api/scheduler/tick`, and the **server** decides which channels are due — a channel is due when `now − lastSyncedAt ≥ its interval`, so a missed tick (server down, machine asleep) simply runs at the next one with no catch-up storm. All work runs **inside the editor** through the existing job queue and per-channel lock, so a scheduled sync can't collide with a manual **Sync** click, shows up live on `/jobs`, and feeds the same transcription worker pool — no second process, no new file locks. Global controls live in **Settings → Sync scheduler**: a master **enable** (off by default), a **default interval**, a **max concurrent syncs** cap (a tick queues at most `cap − running` channels, most-overdue first, rolling the rest to the next tick — which both bounds load and staggers a large due-batch so it doesn't hit the source all at once), an optional **quiet-hours** window, and **failure backoff** (after N consecutive failures a channel waits `base·2^(N-1)` minutes, capped, before retrying). Channels already marked **Exclude from sync** never auto-sync. A new **Schedule** page (`/scheduler`) shows each channel's interval, last sync, next-due time, last outcome, and any active backoff, plus a recent-ticks log and a **Run scheduler now** button; the same data is at `GET /api/scheduler/status`. The cron client targets the editor's port (3001) by default and is hardenable with a `SYNC_TICK_TOKEN` bearer token for installs that expose the editor — see `SCHEDULED_SYNC.md`. - **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders. - **The editor refreshes itself on a timer so its data stays live without a manual reload.** Every page now passively re-fetches its own server-rendered data on a configurable interval — so the sidebar badges (active/running job counts, changelog dot), channel reports, and any other on-screen figures keep up to date on their own. It uses Next's `router.refresh()` (the same mechanism the jobs list already used) mounted once globally in the root layout, so it covers every page and the shared sidebar with no per-page wiring. To avoid wasting work when you're not looking, it **pauses entirely while the browser tab is hidden** and does **one immediate refresh the moment you return** to the tab (rather than waiting out the interval); it also skips a tick while a previous refresh is still settling, so refreshes can't pile up. The cadence is set in **Settings → Auto-refresh interval (seconds)**: default **5s** (clamped 1–600), or **0 to disable** passive refresh completely. This replaces the jobs page's old bespoke 2.5s auto-refresh (the `/jobs/active` page keeps its faster 1s progress-bar polling, which animates per-task bars without a full re-render). diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -387,6 +387,25 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { keepalive; the check skips itself rather than holding longer. </span> </label> + <label className="flex items-start gap-2 text-sm pl-6"> + <input + type="checkbox" + name="audioCheckResumeDuringProbe" + defaultChecked={ac?.resumeDuringProbe ?? false} + aria-label="audio-check resume during probe" + className="mt-1" + /> + <span className="flex flex-col gap-0.5"> + <span className="font-medium">Resume during probe (legacy)</span> + <span className="text-xs text-zinc-500"> + Keep downloading while each integrity probe runs. Leave off + (default) to pause yt-dlp until the probe finishes — it then never + downloads bytes a corrupt verdict would discard and re-fetch, + lowering HTTP 429 risk. The trade-off is a briefly idle connection + during each probe. + </span> + </span> + </label> </div> ); } diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts @@ -160,6 +160,11 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm { } audioCheck.copyTimeoutSeconds = n; } + // Opt-out: checked = legacy resume-immediately. Absent (unchecked) leaves + // the field off, which defaults to the safer pause-during-probe behavior. + if (formData.get("audioCheckResumeDuringProbe") != null) { + audioCheck.resumeDuringProbe = true; + } } const config: ChannelConfig = { diff --git a/editor/e2e/audio-check-scenarios.spec.ts b/editor/e2e/audio-check-scenarios.spec.ts @@ -1,6 +1,7 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { baseUrl } from "./baseUrl"; import type { DownloadOutcomeRecord, } from "../../common/lib/downloadOutcome"; @@ -18,6 +19,9 @@ type FakeSidecar = { chunkDelayMs?: number; corruptAfterChunk?: number; partExtension?: string; + // Embed the slow-probe marker in chunk 0 (so fake-ffmpeg's probe sleeps) and + // log each chunk's write time to fake-ytdlp.chunks. Used by the pause test. + slowProbe?: boolean; }; async function writeFakeConfig(sidecar: FakeSidecar) { @@ -27,6 +31,38 @@ async function writeFakeConfig(sidecar: FakeSidecar) { ); } +// Patch the channel's audioCheck.resumeDuringProbe after resetData restored the +// fixture default (which omits it → paused-during-probe). Re-invalidates the +// editor's config cache so the next download job reads the new value. +async function setResumeDuringProbe(value: boolean) { + const p = resolvePath(`${CHANNEL_ROOT}/config.json`); + const cfg = JSON.parse(await readFile(p, "utf8")); + cfg.audioCheck = { ...(cfg.audioCheck ?? {}), resumeDuringProbe: value }; + await writeFile(p, JSON.stringify(cfg, null, 2)); + await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); +} + +// Largest gap (ms) between consecutive chunk writes recorded by the slowProbe +// fake. A gap near the ~600ms probe means yt-dlp was frozen during the probe; +// a gap near the chunk cadence means it kept downloading through it. +async function maxChunkGapMs(): Promise<number> { + const raw = await readFile( + resolvePath(`${CHANNEL_ROOT}/fake-ytdlp.chunks`), + "utf8", + ); + const times = raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => Number.parseInt(l.split(/\s+/)[1], 10)) + .filter((n) => Number.isFinite(n)); + let max = 0; + for (let i = 1; i < times.length; i++) { + max = Math.max(max, times[i] - times[i - 1]); + } + return max; +} + async function triggerDownload(page: import("@playwright/test").Page) { await page.goto(`/channels/${CHANNEL}`); await page.getByRole("button", { name: "Download videos" }).click(); @@ -360,4 +396,59 @@ test.describe("audio-checked download scenarios", () => { expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)) .toBe(true); }); + + test("default mode pauses the download while the integrity probe runs", async ({ + page, + }) => { + // Fast chunk cadence + a deliberately slow (~600ms) probe. In the default + // (paused) mode yt-dlp is held SIGSTOPped across the probe, so a large gap + // opens between two chunk writes — proving it downloads zero bytes that a + // malformed verdict could discard and force a re-fetch (the 429 win). + await resetData("audio-check-channel"); + await writeFakeConfig({ + mode: "happy", + slowProbe: true, + totalChunks: 24, + chunkDelayMs: 30, + }); + await triggerDownload(page); + + await waitForOutcome( + (o) => o.status === "ok-audio-checked", + "paused-mode outcome", + ); + const gap = await maxChunkGapMs(); + expect(gap).toBeGreaterThan(300); + expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe( + true, + ); + }); + + test("resumeDuringProbe keeps the download flowing through the probe (legacy)", async ({ + page, + }) => { + // Same slow probe, but legacy resume-during-probe is opted back in. yt-dlp + // keeps downloading while the probe runs, so the largest inter-chunk gap + // stays near the 30ms cadence — nowhere near the ~600ms probe. Also covers + // that the legacy path still finalizes correctly. + await resetData("audio-check-channel"); + await setResumeDuringProbe(true); + await writeFakeConfig({ + mode: "happy", + slowProbe: true, + totalChunks: 24, + chunkDelayMs: 30, + }); + await triggerDownload(page); + + await waitForOutcome( + (o) => o.status === "ok-audio-checked", + "resume-mode outcome", + ); + const gap = await maxChunkGapMs(); + expect(gap).toBeLessThan(250); + expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe( + true, + ); + }); }); diff --git a/editor/e2e/fixtures/bin/fake-ffmpeg.mjs b/editor/e2e/fixtures/bin/fake-ffmpeg.mjs @@ -11,6 +11,12 @@ import { readFile, writeFile } from "node:fs/promises"; const CORRUPT_MARKER = "__CORRUPT__"; +// When the probed file contains this marker, the probe (and transcode) sleeps +// SLOW_PROBE_MS before finishing. The audio-check pause test embeds it so the +// integrity probe takes meaningfully longer than the download's chunk cadence, +// making the "download is frozen during the probe" behaviour observable. +const SLOW_PROBE_MARKER = "__SLOWPROBE__"; +const SLOW_PROBE_MS = 600; const argv = process.argv.slice(2); @@ -20,12 +26,15 @@ function arg(flag) { return argv[i + 1]; } -async function fileHasCorruptMarker(file) { +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readSrc(file) { try { - const buf = await readFile(file); - return buf.includes(CORRUPT_MARKER); + return await readFile(file); } catch { - return false; + return null; } } @@ -36,7 +45,12 @@ if (!out || out.startsWith("-")) { process.exit(2); } -if (await fileHasCorruptMarker(src)) { +const srcBuf = await readSrc(src); +if (srcBuf?.includes(SLOW_PROBE_MARKER)) { + await sleep(SLOW_PROBE_MS); +} + +if (srcBuf?.includes(CORRUPT_MARKER)) { process.stderr.write( `[aac @ 0x1234] Sample rate index in program config element does not match the sample rate index configured by the container.\n`, ); diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs @@ -19,6 +19,10 @@ import path from "node:path"; // Used in audio-check scenarios. The fake-ffmpeg companion treats files // containing this string as malformed when probing. const CORRUPT_MARKER = "__CORRUPT__"; +// When the `slowProbe` sidecar option is set, chunk 0 carries this marker so +// the fake-ffmpeg probe sleeps. Combined with the per-chunk timestamp log +// below, the pause test can prove the download is frozen while the probe runs. +const SLOW_PROBE_MARKER = "__SLOWPROBE__"; // Tests use small chunks so the size gate (defaults to 1 MiB; tests override // via AUDIO_CHECK_SIZE_GATE_OVERRIDE) can be exceeded each chunk. ~16 KiB. const CHUNK_BYTES = 16 * 1024; @@ -231,6 +235,9 @@ async function modeAudioCheckOneUrl(url) { // (Odysee "original" video). Tests can set this to "mp3" to simulate the // bestaudio-already-in-target-format case. const partExtension = String(sidecar.partExtension ?? "mp4"); + // Pause test: embed the slow-probe marker in chunk 0 and record each chunk + // write's wall-clock time so the test can detect the probe-induced freeze. + const slowProbe = sidecar.slowProbe === true; const id = urlIdYouTube(url) ?? "ackid"; const videoDir = path.join("data", id); @@ -267,6 +274,8 @@ async function modeAudioCheckOneUrl(url) { // Open .part for append (resume) or create. const fd = openSync(partFile, isResume ? "a" : "w"); + // Append-only log of chunk write times (pause test only). + const chunksLogFd = slowProbe ? openSync("fake-ytdlp.chunks", "a") : null; // Resume support for SIGCONT logging — install a no-op handler so that // SIGCONT delivery flips a flag that we log on each chunk. @@ -311,9 +320,18 @@ async function modeAudioCheckOneUrl(url) { ) { chunk = `${CORRUPT_MARKER} ${chunk}`.padEnd(CHUNK_BYTES, "x"); } + if (slowProbe && i === 0) { + chunk = `${SLOW_PROBE_MARKER} ${chunk}`.padEnd(CHUNK_BYTES, "x"); + } writeSync(fd, chunk); + if (chunksLogFd != null) { + // Record wall-clock at each chunk write so the pause test can measure + // the gap that opens while yt-dlp is held SIGSTOPped during a probe. + writeSync(chunksLogFd, `${i} ${Date.now()}\n`); + } } closeSync(fd); + if (chunksLogFd != null) closeSync(chunksLogFd); // Final-corrupt: appended marker only into the renamed file. if (mode === "corrupt-final" && shouldEmitMarker) {