Fail the export when ffmpeg wedges instead of hanging at 0% - #92
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sFFmpegclass tracks every request in#resolves/#rejectsand settles them only fromworker.onmessage.#registerHandlersinstalls noonerrorand noonmessageerror, so a worker that dies strands every in-flight call.terminate()is the only other thing that clears#rejects, and a caller stuck awaitingexec()never gets to call it.Sentry corroborates: the crash was arriving, as an unhandled
RuntimeError: memory access out of boundstagged to no stage. That is the tell — it never travelled through any of ourcatchblocks.But
worker.onerroralone 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 parentWorker. 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 synchronousexec.What changed
patches/@ffmpeg+ffmpeg+0.12.15.patch— adds#failAllPendingwired toworker.onerror/onmessageerror. Covers the class worker dying (browser OOM-kill, async rejection off the awaited path). Deliberately does notpreventDefault(), so crash reporting still sees the error. Worth upstreaming; it's a bug in any consumer.execWithWatchdoginlib/ffmpeg.ts— covers everything else. The core logs continuously while it works (the encoder status line alone lands several times a second), andpostMessagestill 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 pendingexecto 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 +faststartrewriting 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/ffmpegpinned to an exact version. Required for patched deps perpatches/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 buildall pass. Patch verified to apply on a clean install.Not covered here
The 1 GiB ceiling itself.
ffmpeg-core.wasmhard-declares(memory 16384 16384 shared)— min == max, so theINITIAL_MEMORYoverride inffmpeg-core.jsis 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 thetrim+concatfiltergraph for high cut counts are separate PRs.🤖 Generated with Claude Code