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:
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) {