Skip to content

fix: stop leaking PromiseReactions in consumer loops#1400

Open
skirsten wants to merge 1 commit into
moq-dev:mainfrom
skirsten:fix/consumer-promise-reactions-leak
Open

fix: stop leaking PromiseReactions in consumer loops#1400
skirsten wants to merge 1 commit into
moq-dev:mainfrom
skirsten:fix/consumer-promise-reactions-leak

Conversation

@skirsten
Copy link
Copy Markdown
Contributor

Promise.race against a never-settling Promise (Effect#closed, Effect#cancel) attaches a .then reaction to that long-lived promise on every call. Because the promise never settles, those reactions never run and never get GC'd. Each leaked reaction's closure retains the awaiter state, which keeps the resolved {frame, group} alive.

Over a multi-minute playback session this accumulates one retained Frame per consumed video frame (~400MB after 9 minutes at 60fps) and grows the PromiseReaction chain to tens of thousands of nodes, which inflates major GC pauses to 100-200ms. That's the cause of the periodic ~700ms stalls.

Two changes, no API impact:

  • @moq/hang Consumer.next() waits via an AbortSignal listener registered with { once: true } and explicitly removed when notify wins, instead of Promise.race([wait, signals.closed]).

  • @moq/watch video and audio decoder loops drop the outer Promise.race([consumer.next(), effect.cancel]). When the effect closes, effect.cleanup runs consumer.close(), which makes next() return undefined naturally and the loop exits.

Promise.race against a never-settling Promise (Effect#closed,
Effect#cancel) attaches a .then reaction to that long-lived promise on
every call. Because the promise never settles, those reactions never run
and never get GC'd. Each leaked reaction's closure retains the awaiter
state, which keeps the resolved {frame, group} alive.

Over a multi-minute playback session this accumulates one retained Frame
per consumed video frame (~400MB after 9 minutes at 60fps) and grows the
PromiseReaction chain to tens of thousands of nodes, which inflates major
GC pauses to 100-200ms. That's the cause of the periodic ~700ms stalls.

Two changes, no API impact:

* @moq/hang Consumer.next() waits via an AbortSignal listener registered
  with { once: true } and explicitly removed when notify wins, instead of
  Promise.race([wait, signals.closed]).

* @moq/watch video and audio decoder loops drop the outer
  Promise.race([consumer.next(), effect.cancel]). When the effect closes,
  effect.cleanup runs consumer.close(), which makes next() return
  undefined naturally and the loop exits.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Review Change Stack

Walkthrough

This PR refactors cancellation handling in frame consumption. Consumer.next() now includes explicit abort signal checking: it returns early if already aborted, otherwise waits for either an abort event or a notify event, cleaning up listeners afterward. The audio and video decoders simplify their consumer loops by removing explicit Promise.race calls against effect.cancel, delegating cancellation responsibility to Consumer.next()'s built-in abort signal handling instead.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: stop leaking PromiseReactions in consumer loops' clearly and concisely summarizes the main change—fixing a memory leak caused by PromiseReactions in consumer loop implementations.
Description check ✅ Passed The description is directly related to the changeset, explaining the root cause of the PromiseReactions leak and detailing the two specific changes made to fix the issue across the hang and watch modules.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/watch/src/video/decoder.ts (1)

245-247: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The Promise.race against effect.cancel pattern in the decoder output callback should be migrated to an abort-listener pattern.

The PR commit explicitly identifies this leak mechanism: every invocation attaches a never-settling reaction on effect.cancel, which accumulates over time as frames are decoded. The callback fires ~60Hz, so during buffering or when sync.wait() delays, hundreds of leaked reactions accumulate with the same memory impact described in the PR (tens of thousands of PromiseReaction nodes inflating GC pauses).

The PR successfully fixed this pattern in Consumer.next() using an AbortSignal listener with { once: true }. The same approach can be applied here to eliminate the accumulated reaction overhead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/watch/src/video/decoder.ts` around lines 245 - 247, The decoder output
callback currently races this.source.sync.wait(timestamp) against effect.cancel,
which attaches accumulating never-settling reactions; replace that pattern with
an AbortSignal listener: create an AbortController/derive a signal tied to
effect.cancel, make a promise that resolves when signal.aborted via
signal.addEventListener('abort', () => resolve(false), { once: true }), then
race that abort-promise against this.source.sync.wait(timestamp) (or use
Promise.race with the two promises) and ensure the listener is registered with {
once: true } so no persistent reactions accumulate; update the code paths around
this.source.sync.wait, effect.cancel, and the decoder output callback to use the
abort-signal approach and clean up any created controller/listener references.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@js/watch/src/video/decoder.ts`:
- Around line 245-247: The decoder output callback currently races
this.source.sync.wait(timestamp) against effect.cancel, which attaches
accumulating never-settling reactions; replace that pattern with an AbortSignal
listener: create an AbortController/derive a signal tied to effect.cancel, make
a promise that resolves when signal.aborted via signal.addEventListener('abort',
() => resolve(false), { once: true }), then race that abort-promise against
this.source.sync.wait(timestamp) (or use Promise.race with the two promises) and
ensure the listener is registered with { once: true } so no persistent reactions
accumulate; update the code paths around this.source.sync.wait, effect.cancel,
and the decoder output callback to use the abort-signal approach and clean up
any created controller/listener references.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 371a57f7-0340-497d-a2bb-51ee2a4ccc30

📥 Commits

Reviewing files that changed from the base of the PR and between 6adf3c3 and 4ffb0cc.

📒 Files selected for processing (3)
  • js/hang/src/container/consumer.ts
  • js/watch/src/audio/decoder.ts
  • js/watch/src/video/decoder.ts

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