Skip to content

fix(defineNetwork): prevent event forwarding manually#2740

Merged
kettanaito merged 4 commits into
mainfrom
fix/manual-event-forwarding-prevention
May 11, 2026
Merged

fix(defineNetwork): prevent event forwarding manually#2740
kettanaito merged 4 commits into
mainfrom
fix/manual-event-forwarding-prevention

Conversation

@kettanaito
Copy link
Copy Markdown
Member

@kettanaito kettanaito commented May 7, 2026

Changes

  • defineNetwork no longer introduces an AbortController to nuke all the * listeners attached to individual network frames. Instead, it adds a manual check on readyState to prevent the forwarding of frame events after the network has been disabled.

Motivation

While working on @msw/cloudflare, I've encountered the following issue:

Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: RefcountedCanceler)

It was caused by MSW reading listenersController.signal.aborted in defineNetwork. The error originates from the guard workerd has against using abort controllers created in different request contexts:

        listenersController // <- test context

        source.on('frame', async ({ frame }) => {
          const options = {
            signal: listenersController.signal, // <- request context
          }

History

Previously, the abort controller was introduced to prevent listener leaks during enable->disable->enable chains. Now, every frame calls this.events.removeAllListeners() by itself upon finish.

Frames don't survive enable->disable->enable chain so there's no leak. Sources do, but sources remove all their listeners on dispose already.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Centralizes per-frame listener cleanup in a Disposable, replaces AbortController/session cancellation with enable-scoped session.active gating for event forwarding, updates configure()'s invariant message to require DISABLED, and makes Disposable.dispose() aggregate and report subscription errors.

Changes

State Management Refactor

Layer / File(s) Summary
Dependencies / Imports
src/core/experimental/define-network.ts, src/core/utils/internal/Disposable.ts
Add Disposable import in define-network and devUtils import in Disposable implementation.
Lifecycle Owner
src/core/experimental/define-network.ts
Create a disposable instance inside defineNetwork to own frame-related subscriptions.
Invariant Validation
src/core/experimental/define-network.ts
configure() now throws a descriptive error requiring the network to be DISABLED when called.
Enable Session Setup
src/core/experimental/define-network.ts
.enable() creates an enable-scoped session object and registers a disposable subscription that deactivates session.active for lifecycle termination (replaces AbortController creation).
Event Forwarding & Cleanup
src/core/experimental/define-network.ts
Frame event listener forwards events only while session.active is true; forwarding no longer depends on an AbortController signal.
Disable Path
src/core/experimental/define-network.ts
disable() sets readyState to DISABLED and calls disposable.dispose() for cleanup.
Disposable Implementation
src/core/utils/internal/Disposable.ts
dispose() now catches errors from each subscription, aggregates Error instances, and logs an AggregateError with a formatted diagnostic via devUtils.

Sequence Diagram(s)

sequenceDiagram
  participant Frame as frame.events
  participant Network as defineNetwork.events
  participant Session as enable session
  Frame->>Network: emit('*', event)
  Network->>Session: check session.active
  alt session.active === true
    Network->>Network: events.emit(event)
  else session.active !== true
    Network->>Network: suppress forwarding
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit hops through code tonight,
Sessions set, listeners held tight,
Disposable tidies every thread,
Errors gathered, neatly said,
readyState guards the event light. 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: replacing AbortController-based listener cleanup with manual readyState checks for event forwarding prevention.
Description check ✅ Passed The description clearly explains the changes made, the motivation (Cloudflare Workers I/O context issue), and why the AbortController approach is no longer needed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/manual-event-forwarding-prevention

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

@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 (1)
src/core/experimental/define-network.ts (1)

175-187: ⚡ Quick win

Scope event forwarding to the current enable cycle.

This guard only suppresses forwarding while the network is disabled. If a frame listener survives into a later enable() call, it becomes active again because it closes over the shared mutable readyState. The old AbortController behavior invalidated those listeners permanently for that session, so a generation/token check here would preserve that isolation without crossing request contexts.

Suggested direction
let enableGeneration = 0

enable() {
  // ...
  const currentGeneration = ++enableGeneration

  source.on('frame', async ({ frame }) => {
    frame.events.on('*', (event) => {
      if (
        readyState !== NetworkReadyState.ENABLED ||
        enableGeneration !== currentGeneration
      ) {
        return
      }

      events.emit(event)
    })

    // ...
  })
}
🤖 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 `@src/core/experimental/define-network.ts` around lines 175 - 187, The frame
event handler currently only checks readyState and can be reactivated across
subsequent enable() calls because it closes over the shared mutable readyState;
introduce a generation/token check (e.g., an enableGeneration counter
incremented inside enable() and captured as currentGeneration for that enable
cycle) and additionally verify enableGeneration === currentGeneration (alongside
readyState === NetworkReadyState.ENABLED) before forwarding via events.emit in
the frame.events.on('*'...) callback so listeners from prior enable() cycles are
inert; update enable() to increment the generation and capture currentGeneration
for each installed listener.
🤖 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.

Nitpick comments:
In `@src/core/experimental/define-network.ts`:
- Around line 175-187: The frame event handler currently only checks readyState
and can be reactivated across subsequent enable() calls because it closes over
the shared mutable readyState; introduce a generation/token check (e.g., an
enableGeneration counter incremented inside enable() and captured as
currentGeneration for that enable cycle) and additionally verify
enableGeneration === currentGeneration (alongside readyState ===
NetworkReadyState.ENABLED) before forwarding via events.emit in the
frame.events.on('*'...) callback so listeners from prior enable() cycles are
inert; update enable() to increment the generation and capture currentGeneration
for each installed listener.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 393ba05a-e744-4c40-a86f-9e94fe62d23a

📥 Commits

Reviewing files that changed from the base of the PR and between d697485 and a9fe03c.

📒 Files selected for processing (1)
  • src/core/experimental/define-network.ts

@kettanaito kettanaito force-pushed the fix/manual-event-forwarding-prevention branch from 6fc97e6 to b79c2e1 Compare May 11, 2026 08:46
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.

Actionable comments posted: 1

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

Inline comments:
In `@src/core/utils/internal/Disposable.ts`:
- Around line 15-19: The catch block only pushes instances of Error into the
errors array, so non-Error throws are ignored; update the catch in
src/core/utils/internal/Disposable.ts to convert any non-Error throw into an
Error before pushing (e.g., if (error instanceof Error) errors.push(error); else
errors.push(new Error(String(error)) ) ), and optionally preserve the original
value as the cause/property when supported so the original thrown value is still
accessible; target the same catch block that populates the errors array.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ddcd77f7-68cd-4a80-9fef-197a9eeeca70

📥 Commits

Reviewing files that changed from the base of the PR and between a9fe03c and 6fc97e6.

📒 Files selected for processing (2)
  • src/core/experimental/define-network.ts
  • src/core/utils/internal/Disposable.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/experimental/define-network.ts

Comment thread src/core/utils/internal/Disposable.ts
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

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

commit: bb488ac

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/core/experimental/define-network.ts (1)

173-175: ⚡ Quick win

Add a public add() method to Disposable to expose the subscription registration API.

disposable['subscriptions'] at line 173 couples defineNetwork to a protected implementation detail. A public method like add(subscription: DisposableSubscription): void on Disposable would make this lifecycle contract explicit and eliminate the need for bracket notation access.

Suggested refactor
// src/core/utils/internal/Disposable.ts
 export class Disposable {
   protected subscriptions: Array<DisposableSubscription> = []
+
+  public add(subscription: DisposableSubscription): void {
+    this.subscriptions.push(subscription)
+  }
 
   public dispose() {
     // ...
   }
 }

// src/core/experimental/define-network.ts
-      disposable['subscriptions'].push(() => {
+      disposable.add(() => {
         session.active = false
       })
🤖 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 `@src/core/experimental/define-network.ts` around lines 173 - 175, The code in
defineNetwork accesses Disposable's internal array via
disposable['subscriptions'].push(...), coupling to a protected implementation
detail; add a public method add(subscription: DisposableSubscription): void to
the Disposable class that internally pushes the subscription onto its
subscriptions array, then replace the bracket access in defineNetwork (where
disposable['subscriptions'].push(() => { session.active = false })) with a call
to disposable.add(() => { session.active = false }); this makes the lifecycle
contract explicit and avoids direct access to the internal subscriptions
storage.
🤖 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.

Inline comments:
In `@src/core/experimental/define-network.ts`:
- Around line 187-200: The per-frame listener registered via
frame.events.on('*', ...) is never disposed and accumulates across
enable/disable cycles; modify the code that registers the listener (inside
defineNetwork/session setup) to capture the returned AbortController from
frame.events.on(...) into a variable (e.g., perFrameAbort) and ensure that when
the session/disable() path runs you call perFrameAbort.abort(); alternatively,
mirror the removal pattern used elsewhere (e.g., removeAllListeners in
interceptor-source) by removing that frame listener during disable — update the
registration site (frame.events.on) and the corresponding dispose/disable logic
to abort/remove the listener so events no longer accumulate while keeping the
existing session.active guard.

---

Nitpick comments:
In `@src/core/experimental/define-network.ts`:
- Around line 173-175: The code in defineNetwork accesses Disposable's internal
array via disposable['subscriptions'].push(...), coupling to a protected
implementation detail; add a public method add(subscription:
DisposableSubscription): void to the Disposable class that internally pushes the
subscription onto its subscriptions array, then replace the bracket access in
defineNetwork (where disposable['subscriptions'].push(() => { session.active =
false })) with a call to disposable.add(() => { session.active = false }); this
makes the lifecycle contract explicit and avoids direct access to the internal
subscriptions storage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4bb0b048-1d48-4424-b1c4-7dc4d2ded2ea

📥 Commits

Reviewing files that changed from the base of the PR and between b79c2e1 and bb488ac.

📒 Files selected for processing (1)
  • src/core/experimental/define-network.ts

Comment thread src/core/experimental/define-network.ts
@kettanaito kettanaito merged commit ccb40e0 into main May 11, 2026
45 of 48 checks passed
@kettanaito kettanaito deleted the fix/manual-event-forwarding-prevention branch May 11, 2026 09:34
@kettanaito
Copy link
Copy Markdown
Member Author

Released: v2.14.6 🎉

This has been released in v2.14.6.

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.

1 participant