Skip to content

Fail the export when ffmpeg wedges instead of hanging at 0% - #92

Merged
wassgha merged 1 commit into
mainfrom
fix/export-hang-watchdog
Sep 3, 2026
Merged

Fail the export when ffmpeg wedges instead of hanging at 0%#92
wassgha merged 1 commit into
mainfrom
fix/export-hang-watchdog

Conversation

@wassgha

@wassgha wassgha commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Two Discord reports of export failing: one stuck at "Rendering in your browser… 0%" forever (Snapdragon ARM Windows), one erroring out (Apple M2). This PR fixes the hang. The underlying memory pressure is tracked separately.

Why it hangs

@ffmpeg/ffmpeg@0.12.15's FFmpeg class tracks every request in #resolves/#rejects and settles them only from worker.onmessage. #registerHandlers installs no onerror and no onmessageerror, so a worker that dies strands every in-flight call. terminate() is the only other thing that clears #rejects, and a caller stuck awaiting exec() never gets to call it.

Sentry corroborates: the crash was arriving, as an unhandled RuntimeError: memory access out of bounds tagged to no stage. That is the tell — it never travelled through any of our catch blocks.

But worker.onerror alone doesn't cover the case actually biting here. The multi-threaded core runs the filtergraph and x264 on real pthreads, which live in nested workers; their traps never bubble to the parent Worker. When one dies, the main ffmpeg thread blocks on a futex it will never be woken from, and the class worker is still perfectly healthy — it's just blocked inside a synchronous exec.

What changed

patches/@ffmpeg+ffmpeg+0.12.15.patch — adds #failAllPending wired to worker.onerror / onmessageerror. Covers the class worker dying (browser OOM-kill, async rejection off the awaited path). Deliberately does not preventDefault(), so crash reporting still sees the error. Worth upstreaming; it's a bug in any consumer.

execWithWatchdog in lib/ffmpeg.ts — covers everything else. The core logs continuously while it works (the encoder status line alone lands several times a second), and postMessage still reaches us while the worker's message thread is blocked, so silence is a reliable wedge signal. 120s with no log and no progress → terminate, which frees the gigabyte and forces the pending exec to reject.

It's a liveness budget, not a time limit — a genuinely slow 4K export runs for hours and resets the timer the whole way. The budget only has to clear the longest legitimate silence, which is -movflags +faststart rewriting the output after the last frame.

Applied to audio extraction too, where the same wedge hangs import at "Extracting audio…".

Sentry reporting on handled export failures. The export path was invisible in Sentry except for crashes that escaped the catch, so the graceful failures — ffmpeg exiting non-zero, which is the M2 report — showed up nowhere at all.

@ffmpeg/ffmpeg pinned to an exact version. Required for patched deps per patches/README.md: on a version mismatch patch-package only warns, so a caret range would let a minor bump silently drop the fix.

Testing

tests/ffmpeg-watchdog-test.ts (new, wired into CI) drives the watchdog against a stub that can hang on demand — a real core can't be wedged to order. Covers: healthy exec untouched, stall terminates and reports, heartbeats keep a slow export alive well past the budget, ffmpeg's own errors pass through unmasked, and the timer doesn't outlive the call (a leak would kill the next export).

Lint, typecheck, i18n, timeline tests and next build all pass. Patch verified to apply on a clean install.

Not covered here

The 1 GiB ceiling itself. ffmpeg-core.wasm hard-declares (memory 16384 16384 shared) — min == max, so the INITIAL_MEMORY override in ffmpeg-core.js is a dead end and it can't be raised without rebuilding the core. Falling back to the single-threaded @ffmpeg/core (growable, 32 MiB → 2 GiB) and reworking the trim+concat filtergraph for high cut counts are separate PRs.

🤖 Generated with Claude Code

Users report export sitting at "Rendering in your browser… 0%" forever
with no error. Two gaps let that happen.

`@ffmpeg/ffmpeg`'s FFmpeg class settles its pending promises only from
`worker.onmessage` — it installs no `onerror`, so a worker that dies
strands every in-flight call. Patched to reject them instead. The
crashes were reaching Sentry as unhandled `RuntimeError: memory access
out of bounds`, which is the tell: they never passed through a catch.

That covers the class worker, but not the case actually biting here. The
multi-threaded core runs the filtergraph and x264 on real pthreads, and
those live in nested workers whose traps never reach the parent. When
one dies — reliably, when the core's fixed 1 GiB heap runs out — the
main ffmpeg thread blocks on a futex forever. So `exec` now runs under a
liveness watchdog: the core logs several times a second while it works,
so two minutes of silence means it stopped, whatever the cause.
Terminating both frees the gigabyte and forces the pending `exec` to
reject.

The watchdog covers audio extraction too, where a wedge hangs import at
"Extracting audio…" the same way.

Also report handled export failures to Sentry. Until now the export path
was invisible there except for the crashes that escaped the catch, so
the graceful failures — ffmpeg exiting non-zero — showed up nowhere.

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

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
app.rescript Ready Ready Preview Sep 3, 2026 4:42am UTC

@wassgha
wassgha merged commit 4b70912 into main Sep 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant