Skip to content

fix(studio): stop VideoFrameThumbnail error→cleanup infinite loop - #4037

Merged
miguel-heygen merged 1 commit into
heygen-com:mainfrom
CasbaL:fix/video-frame-thumbnail-error-loop
Sep 23, 2026
Merged

miguel-heygen merged 1 commit into
heygen-com:mainfrom
CasbaL:fix/video-frame-thumbnail-error-loop

Conversation

@CasbaL

@CasbaL CasbaL commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #4036

Problem

VideoFrameThumbnail's error handler called cleanup(), which sets video.src = "" and calls video.load(). Clearing src itself fires another error event on the element, re-entering the handler — an event-loop-speed infinite loop. Every pass allocates a React update object via setFailed(true).

The seeked path triggers the same loop: its cleanup() call provokes the synthetic error right after a successful frame extraction, so every thumbnail ends up spinning, whether the video loads or not.

Evidence (real project, embedded studio)

  • Instrumented HTMLMediaElement.src setter: ~475,000 assignments/second from the error → cleanup → error cycle.
  • Allocation sampling: ~8.6 MB/s of React update objects ({lane, revertLane, gesture, action, hasEagerState, eagerState, next}) inside dispatchSetState, attributed to this component's setFailed(true).
  • A heap snapshot after ~40 minutes: 24.6M update objects / 983 MB (93% of heap), renderer pinned at ~100% CPU; in our Electron embed the JS heap grew to 2.8 GB at ~7 MB/s until the page died.

Fix

  • Name the error handler and detach it inside cleanup() before clearing src.
  • Ignore the synthetic empty-src error (!video.getAttribute("src")) as a belt-and-braces guard.

Test

New VideoFrameThumbnail.test.tsx covers: fallback label on error, staying inert after repeated post-cleanup synthetic errors, keeping the extracted frame when the post-seek cleanup error fires, and re-arming a fresh video element when src changes.

The error handler called cleanup(), which sets video.src = "" and calls
load(). Clearing src itself fires another error event, re-entering the
handler at event-loop speed — every pass allocating a React update via
setFailed(true). The seeked path triggers the same loop: its cleanup
provokes the synthetic error right after a successful frame extract.

Detach the error listener inside cleanup() and ignore the synthetic
empty-src error. Instrumentation on a real session measured ~475k src
assignments/second and ~8.6 MB/s of update allocations before the fix.

Fixes heygen-com#4036
@CasbaL
CasbaL force-pushed the fix/video-frame-thumbnail-error-loop branch from 63e4954 to 348c9fc Compare September 17, 2026 08:41
@miguel-heygen
miguel-heygen enabled auto-merge (squash) September 18, 2026 16:07

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: @miguel-heygen already APPROVED at this exact head (348c9fc6) — this review is confirmatory, not a second gate.

Strengths

  • VideoFrameThumbnail.tsx:31-41 — the fix closes the loop by construction, not by luck. cleanup() (reached from unmount, src-change, and the direct error path — React always runs the prior effect's cleanup before a new one) removes the error listener as its first statement, before video.src=""/load() runs. No ref is used, so there's no stale-element-identity risk on src change either.
  • The new onError early-return guard (!video.getAttribute("src")) is real defense-in-depth, not decoration. I mutation-tested three ways: removing only removeEventListener (guard intact) → 4/4 still pass; removing only the guard (removeEventListener intact) → 4/4 still pass; removing both → 2/4 go red with loadCalls 5 and 4 instead of 2 (VideoFrameThumbnail.test.tsx:138,161). Each mechanism independently breaks the loop; only removing both reproduces the original bug. Confirms this is genuinely belt-and-braces, not one dead guard dressed up as two.
  • The OOM's two contributing factors (infinite trigger + unbounded per-iteration allocation) collapse to one fix here — blocking the first re-entry attempt bounds setFailed(true) to at most once per real error, by construction, not probabilistically.

Notes (non-blocking)

  • PR body's perf numbers (~475k src-assignments/sec, 24.6M update objects/983MB heap) are from the reporter's own production session — can't verify from this repo, not contradicted either.
  • Unrelated to this PR: this repo's default shell NODE_ENV=production breaks the whole VideoFrameThumbnail suite with TypeError: act is not a function (React resolves the production build, which drops act). Not this author's fault, just a footgun for anyone re-running these tests with default env.

Verdict: COMMENT (no blockers, no important findings — deferring to the standing human approval on this one; happy to flip to a formal approve if you want a second stamp on record).
Reasoning: Independently mutation-tested the loop-breaking mechanism myself (not just re-run the PR's own tests) and it holds under every partial-revert I tried; nothing else in the diff (10 net production lines) introduces new risk.

— Miga

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Thanks for the fix. Verified before merge with an undecodable video in a fresh test project, counting video.load() calls (one per error, cleanup, retry cycle) after opening the Assets tab:

seconds 0.5 2.5 4.5 6.5 8.5 10.5
main 5 4,882 21,453 36,442 52,325 68,878
this PR 4 5 5 5 5 5

The loop is gone and the thumbnail settles after its one real load.

@miguel-heygen
miguel-heygen merged commit e5eeeef into heygen-com:main Sep 23, 2026
48 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

3 participants