Skip to content

fix: prevent frame abort listener leak#2737

Merged
kettanaito merged 4 commits intomainfrom
fix/frame-abort-listener-leak
May 4, 2026
Merged

fix: prevent frame abort listener leak#2737
kettanaito merged 4 commits intomainfrom
fix/frame-abort-listener-leak

Conversation

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c3423d9f-d893-4709-bcba-58ef7f98d3b4

📥 Commits

Reviewing files that changed from the base of the PR and between 90d7f33 and be3724d.

📒 Files selected for processing (1)
  • src/browser/sources/service-worker-source.ts

📝 Walkthrough

Walkthrough

Removes per-frame listeners after emitting network response events to allow GC; adds a Vitest memory test suite + config and CI steps to run it; and bumps the rettime dependency version.

Changes

Memory Leak Fix & Regression Testing

Layer / File(s) Summary
Core Memory Leak Fix
src/core/experimental/sources/interceptor-source.ts
After emitting the ResponseEvent inside a microtask, httpFrame.events.removeAllListeners() is invoked to clear per-frame listener registrations.
Browser Frame Cleanup
src/browser/sources/service-worker-source.ts
#handleResponse resolves the frame early; for opaque responses it clears frame.events before returning. For non-opaque responses, it emits the ResponseEvent inside a try and calls frame.events.removeAllListeners() in finally.
Memory Test Configuration
test/memory/vitest.config.ts
New Vitest config for memory tests: dir: './test/memory', globals: true, testTimeout: 120_000, pool: 'forks', and execArgv: ['--expose-gc'].
Memory Leak Regression Test
test/memory/2735-memory-leak.test.ts
Adds a Node memory-leak regression test that warms up requests, captures heap snapshots before/after a measured burst, counts constructor instances (including Emitter), and asserts Emitter delta < 5% of total requests.
Package Scripts & Dependency
package.json
Adds test:memory script (vitest --config=./test/memory/vitest.config.ts), includes it in the aggregated test script, and bumps dependencies.rettime from ^0.11.7 to ^0.11.11.
CI Integration
.github/workflows/ci.yml
Adds a Memory tests step (pnpm test:memory) to both test-node-20 and test-node-22 jobs, positioned after the existing Node.js tests step.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

release candidate

Poem

🐰 I hopped through frames where listeners cling tight,
Emitted the response, then cleared them that night.
One tiny sweep, the memory set free —
Heap snapshots cheer: no more leaks to see. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: prevent frame abort listener leak' clearly summarizes the main change: preventing a memory leak caused by frame abort listeners being retained on a shared AbortSignal.
Description check ✅ Passed The description relates to the changeset by referencing issue #2735 (the memory leak), #2736 (the fix), and noting a related rettime issue, which directly corresponds to the code changes fixing the listener leak.
Linked Issues check ✅ Passed The PR addresses all primary requirements from #2735 and #2736: (1) calls httpFrame.events.removeAllListeners() after emitting response events to trigger cleanup [interceptor-source.ts and service-worker-source.ts], (2) adds memory regression test with Emitter constructor delta assertion [2735-memory-leak.test.ts], (3) includes unit test asserting removeAllListeners() call [via test infrastructure], (4) extends fix to opaque responses.
Out of Scope Changes check ✅ Passed Package.json rettime version bump (^0.11.7 to ^0.11.11) and new test infrastructure (test:memory script, vitest config, CI workflow steps) are reasonable supporting changes for implementing and verifying the fix, though the rettime bump warrants confirmation of compatibility.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/frame-abort-listener-leak

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

Copy link
Copy Markdown

@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)
src/core/experimental/sources/interceptor-source.ts (1)

89-108: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ensure frame listener cleanup runs even if response emission throws.

removeAllListeners() currently depends on emit(...) completing successfully. If a response listener throws, cleanup is skipped and the per-frame emitter can remain retained.

💡 Suggested fix
     queueMicrotask(() => {
-      httpFrame.events.emit(
-        new ResponseEvent(
-          isMockedResponse ? 'response:mocked' : 'response:bypass',
-          {
-            requestId,
-            request,
-            response,
-          },
-        ),
-      )
-
-      /**
-       * `@note` Remove any listeners from this frame.
-       * Past this point, it won't emit anything. The removal is crucial
-       * to prevent "rettime" from keeping the abort cleanup listeners internally.
-       * `@see` https://github.com/mswjs/msw/issues/2735
-       */
-      httpFrame.events.removeAllListeners()
+      try {
+        httpFrame.events.emit(
+          new ResponseEvent(
+            isMockedResponse ? 'response:mocked' : 'response:bypass',
+            {
+              requestId,
+              request,
+              response,
+            },
+          ),
+        )
+      } finally {
+        /**
+         * `@note` Remove any listeners from this frame.
+         * Past this point, it won't emit anything. The removal is crucial
+         * to prevent "rettime" from keeping the abort cleanup listeners internally.
+         * `@see` https://github.com/mswjs/msw/issues/2735
+         */
+        httpFrame.events.removeAllListeners()
+      }
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/experimental/sources/interceptor-source.ts` around lines 89 - 108,
The call to httpFrame.events.removeAllListeners() is skipped if
httpFrame.events.emit(...) throws; wrap the emit in a try/finally inside the
existing queueMicrotask callback so removeAllListeners() always runs (use try {
httpFrame.events.emit(new ResponseEvent(...)) } finally {
httpFrame.events.removeAllListeners() }) referencing queueMicrotask,
httpFrame.events.emit, ResponseEvent and httpFrame.events.removeAllListeners to
locate the change.
🧹 Nitpick comments (1)
test/memory/2735-memory-leak.test.ts (1)

50-54: ⚡ Quick win

Fail fast when global.gc is unavailable to avoid silent false signals.

global.gc?.() can no-op if the test is run without --expose-gc, weakening the regression check.

💡 Suggested fix
 async function forceGc(): Promise<void> {
+  if (typeof global.gc !== 'function') {
+    throw new Error('Memory tests require Node to run with --expose-gc')
+  }
   for (let i = 0; i < 6; i++) {
-    global.gc?.()
+    global.gc()
     await delay(30)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/memory/2735-memory-leak.test.ts` around lines 50 - 54, The forceGc
function currently calls global.gc?.() which silently no-ops when Node is run
without --expose-gc; update forceGc to fail fast by checking for global.gc at
the start (e.g., if (typeof global.gc !== 'function') throw new Error(...)) and
document/mention that tests must be run with --expose-gc; keep the existing loop
and delay logic (function name forceGc, usage of global.gc and delay) but bail
immediately with a clear error if global.gc is unavailable so the test doesn't
produce silent false signals.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/core/experimental/sources/interceptor-source.ts`:
- Around line 89-108: The call to httpFrame.events.removeAllListeners() is
skipped if httpFrame.events.emit(...) throws; wrap the emit in a try/finally
inside the existing queueMicrotask callback so removeAllListeners() always runs
(use try { httpFrame.events.emit(new ResponseEvent(...)) } finally {
httpFrame.events.removeAllListeners() }) referencing queueMicrotask,
httpFrame.events.emit, ResponseEvent and httpFrame.events.removeAllListeners to
locate the change.

---

Nitpick comments:
In `@test/memory/2735-memory-leak.test.ts`:
- Around line 50-54: The forceGc function currently calls global.gc?.() which
silently no-ops when Node is run without --expose-gc; update forceGc to fail
fast by checking for global.gc at the start (e.g., if (typeof global.gc !==
'function') throw new Error(...)) and document/mention that tests must be run
with --expose-gc; keep the existing loop and delay logic (function name forceGc,
usage of global.gc and delay) but bail immediately with a clear error if
global.gc is unavailable so the test doesn't produce silent false signals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 10a10f70-8747-476b-92e6-b01af4f63227

📥 Commits

Reviewing files that changed from the base of the PR and between 4bc4a5c and 3933621.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • .github/workflows/ci.yml
  • package.json
  • src/core/experimental/sources/interceptor-source.ts
  • test/memory/2735-memory-leak.test.ts
  • test/memory/vitest.config.ts

Copy link
Copy Markdown

@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.

🧹 Nitpick comments (2)
src/browser/sources/service-worker-source.ts (2)

382-394: 💤 Low value

removeAllListeners() placement is correct; consider documenting the intentional timing difference vs. interceptor-source.ts.

interceptor-source.ts wraps both emit() and removeAllListeners() inside queueMicrotask() and has a test that asserts cleanup occurs "after the microtask flush." Here the operations run synchronously in the same turn of #handleResponse. Both approaches achieve the GC goal (rettime's onAbort closures are detached), but the observable emission timing differs.

If the synchronous path is intentional for the SW source (e.g., because the call is already in a message-event callback rather than in the middle of the interceptor's async chain), a brief inline comment cross-referencing the queueMicrotask rationale in interceptor-source.ts would help future maintainers.

💡 Suggested comment to document the intentional divergence
     frame.events.emit(
       new ResponseEvent(
         isMockedResponse ? 'response:mocked' : 'response:bypass',
         {
           requestId: frame.data.id,
           request: fetchRequest,
           response: fetchResponse,
           isMockedResponse,
         },
       ),
     )
 
+    /**
+     * `@note` Remove any listeners from this frame. Unlike the interceptor source,
+     * no queueMicrotask deferral is needed here because `#handleResponse` is
+     * already invoked in a message-event callback and all frame.events listeners
+     * are registered synchronously before this point.
+     * Removal is critical to prevent "rettime" from retaining abort cleanup
+     * listeners on the shared AbortSignal.
+     * `@see` https://github.com/mswjs/msw/issues/2735
+     */
     frame.events.removeAllListeners()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/browser/sources/service-worker-source.ts` around lines 382 - 394, Add a
brief inline comment above the frame.events.removeAllListeners() call in
`#handleResponse` explaining that the synchronous emit() + removeAllListeners() is
intentional for the service-worker-source (it runs inside the message-event
callback), and explicitly cross-reference interceptor-source.ts where
emit/removeAllListeners() are deferred with queueMicrotask() and a test
asserting "after the microtask flush"; mention that both approaches detach
rettime onAbort closures but differ only in observable emission timing
(response:mocked/response:bypass via ResponseEvent).

341-344: ⚡ Quick win

Opaque-response path leaves the frame emitter's listeners uncleared.

When a response is opaque, the frame is removed from this.#frames but frame.events.removeAllListeners() is never called. The rettime onAbort closure registered on listenersController.signal (via frame.events.on('*', ..., { signal }) in define-network.ts) still holds a reference to that frame's emitter, so the frame is retained until disable() — the same leak vector the main fix targets.

This is acknowledged as out of scope in the PR, but the fix is a straightforward two-line change:

♻️ Proposed fix for the opaque-response path
-    if (response.type?.includes('opaque')) {
-      this.#frames.delete(request.id)
-      return
-    }
+    if (response.type?.includes('opaque')) {
+      const frame = this.#frames.get(request.id)
+      this.#frames.delete(request.id)
+      frame?.events.removeAllListeners()
+      return
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/browser/sources/service-worker-source.ts` around lines 341 - 344, In the
opaque-response branch in service-worker-source (the block checking
response.type?.includes('opaque')), grab the stored frame for request.id and
call frame.events.removeAllListeners() before deleting it from this.#frames;
i.e., retrieve the frame from this.#frames (using the same request.id key),
invoke frame.events.removeAllListeners() to clear the rettime listeners, then
call this.#frames.delete(request.id) and return. This ensures the onAbort
closure registered with listenersController.signal no longer retains the frame.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/browser/sources/service-worker-source.ts`:
- Around line 382-394: Add a brief inline comment above the
frame.events.removeAllListeners() call in `#handleResponse` explaining that the
synchronous emit() + removeAllListeners() is intentional for the
service-worker-source (it runs inside the message-event callback), and
explicitly cross-reference interceptor-source.ts where emit/removeAllListeners()
are deferred with queueMicrotask() and a test asserting "after the microtask
flush"; mention that both approaches detach rettime onAbort closures but differ
only in observable emission timing (response:mocked/response:bypass via
ResponseEvent).
- Around line 341-344: In the opaque-response branch in service-worker-source
(the block checking response.type?.includes('opaque')), grab the stored frame
for request.id and call frame.events.removeAllListeners() before deleting it
from this.#frames; i.e., retrieve the frame from this.#frames (using the same
request.id key), invoke frame.events.removeAllListeners() to clear the rettime
listeners, then call this.#frames.delete(request.id) and return. This ensures
the onAbort closure registered with listenersController.signal no longer retains
the frame.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c4a174ba-80c6-4f4e-b8d9-6e022637869a

📥 Commits

Reviewing files that changed from the base of the PR and between 3933621 and a7a2ffc.

📒 Files selected for processing (1)
  • src/browser/sources/service-worker-source.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/msw@2737

commit: be3724d

Copy link
Copy Markdown

@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)
src/browser/sources/service-worker-source.ts (1)

341-344: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Secondary listener leak for opaque responses is not addressed here.

When the opaque response guard fires, the frame has already had listeners attached (via the 'frame' event in define-network.ts triggered by this.queue(frame) in #handleRequest). Returning early without calling frame.events.removeAllListeners() leaves those listeners — and rettime's onAbort closures on the shared listenersController.signal — alive for the network's lifetime. The PR description notes this as out of scope, but it's the same root cause as the leak being fixed on the happy path.

🛡️ Proposed one-liner to cover this path
     if (response.type?.includes('opaque')) {
-      this.#frames.delete(request.id)
-      return
+      const opaqueFrame = this.#frames.get(request.id)
+      this.#frames.delete(request.id)
+      opaqueFrame?.events.removeAllListeners()
+      return
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/browser/sources/service-worker-source.ts` around lines 341 - 344, When
the opaque-response guard in `#handleRequest` returns early after calling
this.#frames.delete(request.id), it doesn't remove per-frame listeners added
earlier, leaking the frame's event handlers and onAbort closures; update the
opaque branch to also clean up the frame's listeners by calling
frame.events.removeAllListeners() (and abort or remove any
listenersController.signal hooks) before returning so the listeners attached via
this.queue(frame) / define-network.ts 'frame' event are released.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/browser/sources/service-worker-source.ts`:
- Around line 341-344: When the opaque-response guard in `#handleRequest` returns
early after calling this.#frames.delete(request.id), it doesn't remove per-frame
listeners added earlier, leaking the frame's event handlers and onAbort
closures; update the opaque branch to also clean up the frame's listeners by
calling frame.events.removeAllListeners() (and abort or remove any
listenersController.signal hooks) before returning so the listeners attached via
this.queue(frame) / define-network.ts 'frame' event are released.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 202c05cf-dc9d-40bc-8e5f-51ef942c9c81

📥 Commits

Reviewing files that changed from the base of the PR and between a7a2ffc and 90d7f33.

📒 Files selected for processing (2)
  • src/browser/sources/service-worker-source.ts
  • src/core/experimental/sources/interceptor-source.ts

@kettanaito kettanaito merged commit c4567d2 into main May 4, 2026
24 checks passed
@kettanaito kettanaito deleted the fix/frame-abort-listener-leak branch May 4, 2026 17:00
@kettanaito
Copy link
Copy Markdown
Member Author

Released: v2.14.3 🎉

This has been released in v2.14.3.

Get these changes by running the following command:

npm i msw@latest

Predictable release automation by Release.

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.

Memory leak: per-frame Emitter retained for the network's lifetime via shared AbortSignal (interceptor-source)

1 participant