Skip to content

[dotnet] [bidi] Additional Event streaming (breaking change)#17349

Open
nvborisenko wants to merge 55 commits intoSeleniumHQ:trunkfrom
nvborisenko:bidi-event-stream
Open

[dotnet] [bidi] Additional Event streaming (breaking change)#17349
nvborisenko wants to merge 55 commits intoSeleniumHQ:trunkfrom
nvborisenko:bidi-event-stream

Conversation

@nvborisenko
Copy link
Copy Markdown
Member

@nvborisenko nvborisenko commented Apr 15, 2026

Finally the events model is more cleaner. Each Subscription is backed by Channel, meaning all handlers want to be executed sequentially.
New API opens doors for consuming events via async LINQ - big features with minimal maintenance.

🔗 Related Issues

Contributes to #16095

💥 What does this PR do?

This pull request refactors and extends the BiDi event subscription and dispatching system to improve flexibility, reliability, and resource management. The most notable changes are the introduction of a more generic event subscription API in BiDi, significant reworking of event handling in Broker, and ensuring proper disposal of event subscriptions.

Event Subscription and Dispatching Improvements:

  • Added new generic SubscribeAsync and ReadAllAsync methods to the BiDi class, supporting both single and multiple event descriptors, and both synchronous and asynchronous handlers. This provides a more flexible and type-safe way for clients to subscribe to and consume events.
  • Refactored Broker to use a public EventDispatcher instance, initialized with delegates for subscribing and unsubscribing via the session, and removed the previous internal event subscription logic. This centralizes and simplifies event management. [1] [2]

Resource Management and Disposal:

  • Updated disposal logic in both BiDi and Broker to ensure event subscriptions are properly completed and disposed of before shutting down the transport and processing tasks, preventing resource leaks and ensuring clean shutdowns. [1] [2] [3]

Event Handling Logic:

  • Changed event message processing in Broker to delegate deserialization and dispatching to the EventDispatcher, removing the need for event metadata management and manual event argument construction. [1] [2]

These changes provide a more robust, extensible, and maintainable event system for BiDi clients.

🔄 Types of changes

  • New feature (non-breaking change which adds functionality and tests!)

Copilot AI review requested due to automatic review settings April 15, 2026 14:23
@selenium-ci selenium-ci added the C-dotnet .NET Bindings label Apr 15, 2026
@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented Apr 15, 2026

Review Summary by Qodo

(Agentic_describe updated until commit 42e8ef7)

[dotnet] [bidi] Refactor event subscription system with generic types and async streaming support

✨ Enhancement

Grey Divider

Walkthroughs

Description
  Comprehensive refactoring of the BiDi event subscription system to improve type safety, thread
  safety, and developer experience:
  * **Event Subscription Architecture**: Replaced EventDispatcher-based handler mechanism with a new
  per-event SubscriptionRegistry pattern, enabling more reliable and type-safe event handler
  management
  * **Generic Type-Safe API**: Transitioned from non-generic Subscription to generic
  Subscription<TEventArgs> throughout the API, providing better compile-time type checking and
  IntelliSense support
  * **Property-Based Event Sources**: Converted all OnXxxAsync methods to property-based
  EventSource<TEventArgs> properties with lazy initialization, simplifying the API surface
  * **Async Enumerable Support**: Introduced EventStream<TEventArgs> and IEventStream<TEventArgs>
  for consuming events via async LINQ queries, enabling modern async/await patterns
  * **Channel-Based Event Delivery**: Implemented channel-backed event delivery ensuring sequential
  handler execution within each subscription
  * **Centralized Event Descriptors**: Created EventDescriptor<TEventArgs> abstraction with static
  event definitions (BrowsingContextEvent, NetworkEvent, ScriptEvent, etc.) for consistent event
  metadata management
  * **Context-Filtered Events**: Added ContextEventSource<TEventArgs> wrapper for automatic
  context-specific event filtering in browsing context APIs
  * **Comprehensive Test Coverage**: Updated all existing tests and added new tests demonstrating
  single/multiple event subscriptions, async stream consumption, and LINQ-style event processing
  * **Module Updates**: Refactored all modules (BrowsingContext, Network, Script, Log,
  Input, Speculation) to use the new event source pattern with thread-safe lazy initialization

Grey Divider

File Changes

1. dotnet/src/webdriver/BiDi/EventDispatcher.cs ✨ Enhancement +188/-54

Event dispatcher refactored to subscription registry pattern

• Refactored from handler-based event dispatch to subscription registry pattern with per-event
 SubscriptionRegistry
• Added support for event metadata registration and deserialization with JSON type info
• Implemented TryDeserializeAndDispatch method to handle incoming events and deliver them to
 subscriptions
• Added CompleteAllAsync method for graceful shutdown of all subscriptions

dotnet/src/webdriver/BiDi/EventDispatcher.cs


2. dotnet/src/webdriver/BiDi/Subscription.cs ✨ Enhancement +112/-19

Subscription system refactored with generic types and channels

• Replaced old Subscription class with new generic Subscription<TEventArgs> implementing
 ISubscriptionSink
• Added EventStream<TEventArgs> class for async enumerable event consumption
• Implemented channel-based event delivery with proper error handling and disposal
• Added ISubscriptionSink interface for internal subscription management

dotnet/src/webdriver/BiDi/Subscription.cs


3. dotnet/src/webdriver/BiDi/EventSource.cs ✨ Enhancement +104/-0

New EventSource class for type-safe event subscriptions

• New file introducing EventSource<TEventArgs> for type-safe event subscriptions
• Supports both action and async task handlers with optional filtering
• Provides SubscribeAsync and ReadAllAsync methods for flexible event consumption
• Implements Where method for composable event filtering

dotnet/src/webdriver/BiDi/EventSource.cs


View more (46)
4. dotnet/src/webdriver/BiDi/EventStream.cs ✨ Enhancement +92/-0

New EventStream class for async enumerable events

• New file introducing EventStream<TEventArgs> for async enumerable event consumption
• Implements IAsyncEnumerable<TEventArgs> for LINQ-style event processing
• Provides channel-based event delivery with proper cleanup on disposal

dotnet/src/webdriver/BiDi/EventStream.cs


5. dotnet/src/webdriver/BiDi/IEventSource.cs ✨ Enhancement +31/-0

New IEventSource interface for event subscriptions

• New interface defining the contract for event sources
• Supports both synchronous and asynchronous handler subscriptions
• Provides ReadAllAsync for stream-based consumption and Where for filtering

dotnet/src/webdriver/BiDi/IEventSource.cs


6. dotnet/src/webdriver/BiDi/ContextEventSource.cs ✨ Enhancement +78/-0

New ContextEventSource for context-filtered events

• New file introducing ContextEventSource<TEventArgs> wrapper for context-specific events
• Automatically filters events to the associated browsing context
• Delegates to underlying EventSource with context filtering applied

dotnet/src/webdriver/BiDi/ContextEventSource.cs


7. dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextEvent.cs ✨ Enhancement +100/-0

New BrowsingContextEvent descriptors for all events

• New file defining static event descriptors for all browsing context events
• Each descriptor includes event name, factory function, and JSON type info
• Covers navigation, download, context lifecycle, and user prompt events

dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextEvent.cs


8. dotnet/src/webdriver/BiDi/Network/NetworkEvent.cs ✨ Enhancement +50/-0

New NetworkEvent descriptors for network events

• New file defining static event descriptors for network events
• Includes descriptors for request, response, fetch error, and auth events

dotnet/src/webdriver/BiDi/Network/NetworkEvent.cs


9. dotnet/src/webdriver/BiDi/Script/ScriptEvent.cs ✨ Enhancement +51/-0

New ScriptEvent descriptors for script events

• New file defining static event descriptors for script events
• Includes message and realm lifecycle event descriptors

dotnet/src/webdriver/BiDi/Script/ScriptEvent.cs


10. dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextModule.cs ✨ Enhancement +28/-201

BrowsingContextModule converted to EventSource properties

• Removed static Event<TEventArgs, TEventParams> field declarations
• Replaced OnXxxAsync methods with property-based EventSource<TEventArgs> properties
• Implemented lazy initialization using Interlocked.CompareExchange for thread-safe singleton
 pattern

dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextModule.cs


11. dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContext.cs ✨ Enhancement +45/-327

BrowsingContext converted to ContextEventSource properties

• Removed all OnXxxAsync method overloads and handler wrapper methods
• Replaced with property-based ContextEventSource<TEventArgs> properties
• Implemented CreateContextEventSource helper for context-filtered event sources

dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContext.cs


12. dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs ✨ Enhancement +14/-28

IBrowsingContextModule interface updated to EventSource properties

• Removed all OnXxxAsync method signatures
• Replaced with property declarations returning EventSource<TEventArgs>

dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs


13. dotnet/src/webdriver/BiDi/Network/NetworkModule.cs ✨ Enhancement +10/-70

NetworkModule converted to EventSource properties

• Removed static Event<TEventArgs, TEventParams> field declarations
• Replaced OnXxxAsync methods with property-based EventSource<TEventArgs> properties
• Implemented lazy initialization using Interlocked.CompareExchange

dotnet/src/webdriver/BiDi/Network/NetworkModule.cs


14. dotnet/src/webdriver/BiDi/Network/INetworkModule.cs ✨ Enhancement +5/-10

INetworkModule interface updated to EventSource properties

• Removed all OnXxxAsync method signatures
• Replaced with property declarations returning EventSource<TEventArgs>

dotnet/src/webdriver/BiDi/Network/INetworkModule.cs


15. dotnet/src/webdriver/BiDi/Script/ScriptModule.cs ✨ Enhancement +6/-53

ScriptModule converted to EventSource properties

• Removed static Event<TEventArgs, TEventParams> field declarations
• Replaced OnXxxAsync methods with property-based EventSource<TEventArgs> properties
• Implemented lazy initialization using Interlocked.CompareExchange

dotnet/src/webdriver/BiDi/Script/ScriptModule.cs


16. dotnet/src/webdriver/BiDi/Broker.cs ✨ Enhancement +15/-67

Broker refactored to use EventDispatcher directly

• Removed SubscribeAsync and UnsubscribeAsync methods
• Made EventDispatcher internal and exposed as property
• Updated disposal order to complete subscriptions before canceling receive loop
• Simplified event processing to delegate to EventDispatcher.TryDeserializeAndDispatch

dotnet/src/webdriver/BiDi/Broker.cs


17. dotnet/src/webdriver/BiDi/BiDi.cs ✨ Enhancement +46/-4

BiDi class enhanced with subscription and stream methods

• Added SubscribeAsync overloads for single and multiple event descriptors
• Added ReadAllAsync overloads for stream-based event consumption
• Changed _disposed from bool to int for atomic operations
• Updated disposal logic to use Interlocked.CompareExchange

dotnet/src/webdriver/BiDi/BiDi.cs


18. dotnet/test/webdriver/BiDi/Session/SessionTests.cs 🧪 Tests +109/-0

New tests for event subscription and streaming APIs

• Added test CanSubscribeToEvent demonstrating single event subscription
• Added test CanSubscribeToMultipleEvents for subscribing to multiple events
• Added test CanConsumeAsyncEventStream for async enumerable consumption
• Added test CanConsumeAsyncEventStreamViaLinq for LINQ-style event processing
• Added test CustomModuleShouldSubscribeToEvent for custom module events

dotnet/test/webdriver/BiDi/Session/SessionTests.cs


19. dotnet/test/webdriver/BiDi/Network/NetworkTests.cs 🧪 Tests +9/-9

Network tests updated to use EventSource properties

• Updated all event subscription calls from OnXxxAsync methods to Xxx.SubscribeAsync properties
• Changed from method-based to property-based event source access

dotnet/test/webdriver/BiDi/Network/NetworkTests.cs


20. dotnet/test/webdriver/BiDi/Network/NetworkEventsTests.cs 🧪 Tests +6/-6

Network event tests updated to use EventSource properties

• Updated all event subscription calls from OnXxxAsync methods to Xxx.SubscribeAsync properties
• Changed from method-based to property-based event source access

dotnet/test/webdriver/BiDi/Network/NetworkEventsTests.cs


21. dotnet/src/webdriver/BiDi/EventDescriptor.cs ✨ Enhancement +66/-0

New event descriptor abstraction for type-safe event registration

• New file introducing EventDescriptor and EventDescriptor<TEventArgs> classes for type-safe
 event metadata management
• Provides factory method Create<TEventParams> to register event metadata with JSON serialization
 support
• Enables lazy registration of events through EnsureRegistered method

dotnet/src/webdriver/BiDi/EventDescriptor.cs


22. dotnet/src/webdriver/BiDi/IEventStream.cs ✨ Enhancement +25/-0

New async event stream interface for event consumption

• New interface IEventStream<TEventArgs> combining IAsyncEnumerable<TEventArgs> and
 IAsyncDisposable
• Enables async iteration over events with proper resource cleanup

dotnet/src/webdriver/BiDi/IEventStream.cs


23. dotnet/src/webdriver/BiDi/ISubscription.cs ✨ Enhancement +24/-0

New subscription interface for resource management

• New interface ISubscription extending IAsyncDisposable for subscription lifecycle management

dotnet/src/webdriver/BiDi/ISubscription.cs


24. dotnet/src/webdriver/BiDi/FilteredEventStream.cs ✨ Enhancement +49/-0

New filtered event stream implementation for event filtering

• New internal class FilteredEventStream<TEventArgs> implementing IEventStream<TEventArgs>
• Provides filtering capability for event streams using predicates
• Delegates enumeration and disposal to inner stream

dotnet/src/webdriver/BiDi/FilteredEventStream.cs


25. dotnet/src/webdriver/BiDi/Log/LogEvent.cs ✨ Enhancement +36/-0

New centralized log event descriptor definition

• New file introducing LogEvent static class with EntryAdded event descriptor
• Moves event metadata from LogModule to centralized event definition
• Uses EventDescriptor<EntryAddedEventArgs>.Create factory with pattern matching for different log
 entry types

dotnet/src/webdriver/BiDi/Log/LogEvent.cs


26. dotnet/src/webdriver/BiDi/Input/InputEvent.cs ✨ Enhancement +30/-0

New centralized input event descriptor definition

• New file introducing InputEvent static class with FileDialogOpened event descriptor
• Moves event metadata from InputModule to centralized event definition
• Uses EventDescriptor<FileDialogOpenedEventArgs>.Create factory

dotnet/src/webdriver/BiDi/Input/InputEvent.cs


27. dotnet/src/webdriver/BiDi/Speculation/SpeculationEvent.cs ✨ Enhancement +30/-0

New centralized speculation event descriptor definition

• New file introducing SpeculationEvent static class with PrefetchStatusUpdated event descriptor
• Moves event metadata from SpeculationModule to centralized event definition
• Uses EventDescriptor<PrefetchStatusUpdatedEventArgs>.Create factory

dotnet/src/webdriver/BiDi/Speculation/SpeculationEvent.cs


28. dotnet/src/webdriver/BiDi/Module.cs ✨ Enhancement +4/-8

Refactored module event subscription to use event sources

• Replaces SubscribeAsync methods with new CreateEventSource method returning
 EventSource<TEventArgs>
• Adds EventDispatcher property for accessing broker's event dispatcher
• Simplifies event subscription API by delegating to event sources

dotnet/src/webdriver/BiDi/Module.cs


29. dotnet/src/webdriver/BiDi/IBiDi.cs ✨ Enhancement +12/-0

New event subscription and streaming methods in IBiDi interface

• Adds new SubscribeAsync overloads accepting EventDescriptor<TEventArgs> for single and
 multiple event descriptors
• Adds new ReadAllAsync methods for async streaming of events via IEventStream<TEventArgs>
• Supports both action and async function handlers

dotnet/src/webdriver/BiDi/IBiDi.cs


30. dotnet/src/webdriver/BiDi/Log/LogModule.cs ✨ Enhancement +2/-21

Refactored log module to use event source properties

• Removes static EntryAddedEvent field and old OnEntryAddedAsync method overloads
• Replaces with EntryAdded property returning lazy-initialized EventSource<EntryAddedEventArgs>
• Uses double-checked locking pattern with Interlocked.CompareExchange for thread-safe
 initialization

dotnet/src/webdriver/BiDi/Log/LogModule.cs


31. dotnet/src/webdriver/BiDi/Input/InputModule.cs ✨ Enhancement +2/-14

Refactored input module to use event source properties

• Removes static FileDialogOpenedEvent field and old OnFileDialogOpenedAsync method overloads
• Replaces with FileDialogOpened property returning lazy-initialized
 EventSource<FileDialogOpenedEventArgs>
• Uses double-checked locking pattern with Interlocked.CompareExchange for thread-safe
 initialization

dotnet/src/webdriver/BiDi/Input/InputModule.cs


32. dotnet/src/webdriver/BiDi/Speculation/SpeculationModule.cs ✨ Enhancement +2/-15

Refactored speculation module to use event source properties

• Removes static PrefetchStatusUpdatedEvent field and old OnPrefetchStatusUpdatedAsync method
 overloads
• Replaces with PrefetchStatusUpdated property returning lazy-initialized
 EventSource<PrefetchStatusUpdatedEventArgs>
• Uses double-checked locking pattern with Interlocked.CompareExchange for thread-safe
 initialization

dotnet/src/webdriver/BiDi/Speculation/SpeculationModule.cs


33. dotnet/src/webdriver/BiDi/Log/ILogModule.cs ✨ Enhancement +1/-2

Updated log module interface to use event source property

• Removes OnEntryAddedAsync method overloads (both action and async function variants)
• Adds EntryAdded property returning EventSource<EntryAddedEventArgs>

dotnet/src/webdriver/BiDi/Log/ILogModule.cs


34. dotnet/src/webdriver/BiDi/Input/IInputModule.cs ✨ Enhancement +1/-2

Updated input module interface to use event source property

• Removes OnFileDialogOpenedAsync method overloads (both action and async function variants)
• Adds FileDialogOpened property returning EventSource<FileDialogOpenedEventArgs>

dotnet/src/webdriver/BiDi/Input/IInputModule.cs


35. dotnet/src/webdriver/BiDi/Speculation/ISpeculationModule.cs ✨ Enhancement +1/-2

Updated speculation module interface to use event source property

• Removes OnPrefetchStatusUpdatedAsync method overloads (both action and async function variants)
• Adds PrefetchStatusUpdated property returning EventSource<PrefetchStatusUpdatedEventArgs>

dotnet/src/webdriver/BiDi/Speculation/ISpeculationModule.cs


36. dotnet/src/webdriver/BiDi/Script/IScriptModule.cs ✨ Enhancement +3/-6

Updated script module interface to use event source properties

• Removes six OnMessageAsync, OnRealmCreatedAsync, and OnRealmDestroyedAsync method overloads
• Adds three properties: Message, RealmCreated, and RealmDestroyed returning
 EventSource<TEventArgs>

dotnet/src/webdriver/BiDi/Script/IScriptModule.cs


37. dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextNetworkModule.cs ✨ Enhancement +5/-10

Updated browsing context network module interface to use event source properties

• Removes ten OnAuthRequiredAsync, OnBeforeRequestSentAsync, OnFetchErrorAsync,
 OnResponseCompletedAsync, and OnResponseStartedAsync method overloads
• Adds five properties returning ContextEventSource<TEventArgs> for network events

dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextNetworkModule.cs


38. dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextLogModule.cs ✨ Enhancement +1/-2

Updated browsing context log module interface to use event source property

• Removes OnEntryAddedAsync method overloads (both action and async function variants)
• Adds EntryAdded property returning ContextEventSource<EntryAddedEventArgs>

dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextLogModule.cs


39. dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextInputModule.cs ✨ Enhancement +1/-2

Updated browsing context input module interface to use event source property

• Removes OnFileDialogOpenedAsync method overloads (both action and async function variants)
• Adds FileDialogOpened property returning ContextEventSource<FileDialogOpenedEventArgs>

dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextInputModule.cs


40. dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextLogModule.cs ✨ Enhancement +3/-35

Refactored browsing context log module to use filtered event sources

• Removes OnEntryAddedAsync method overloads and helper methods HandleEntryAddedAsync and
 HandleEntryAdded
• Replaces with EntryAdded property using lazy initialization and Where filtering on underlying
 log module events
• Simplifies context filtering by using LINQ-style event stream filtering

dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextLogModule.cs


41. dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextInputModule.cs ✨ Enhancement +3/-35

Refactored browsing context input module to use filtered event sources

• Removes OnFileDialogOpenedAsync method overloads and helper methods
 HandleFileDialogOpenedAsync and HandleFileDialogOpened
• Replaces with FileDialogOpened property using lazy initialization and Where filtering on
 underlying input module events
• Simplifies context filtering by using LINQ-style event stream filtering

dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextInputModule.cs


42. dotnet/src/webdriver/BiDi/EventArgs.cs Formatting +1/-9

Fixed copyright header filename

• Corrects copyright file header from "Event.cs" to "EventArgs.cs"

dotnet/src/webdriver/BiDi/EventArgs.cs


43. dotnet/test/webdriver/BiDi/Log/LogTests.cs 🧪 Tests +3/-3

Updated log tests to use new event source API

• Updates test calls from context.Log.OnEntryAddedAsync(handler) to
 context.Log.EntryAdded.SubscribeAsync(handler)
• Changes handler invocation from tcs.SetResult to e => tcs.TrySetResult(e) lambda
• Adds await using for subscription resource management

dotnet/test/webdriver/BiDi/Log/LogTests.cs


44. dotnet/test/webdriver/BiDi/Script/ScriptEventsTests.cs 🧪 Tests +3/-3

Updated script event tests to use new event source API

• Updates test calls from bidi.Script.OnMessageAsync, OnRealmCreatedAsync,
 OnRealmDestroyedAsync to property-based SubscribeAsync calls
• Changes handler invocation to lambda expressions with TrySetResult
• Adds await using for subscription resource management

dotnet/test/webdriver/BiDi/Script/ScriptEventsTests.cs


45. dotnet/test/webdriver/BiDi/BrowsingContext/BrowsingContextEventsTests.cs 🧪 Tests +2/-2

Updated browsing context event tests to use new event source API

• Updates test calls from context.OnDownloadWillBeginAsync and OnDownloadEndAsync to
 property-based SubscribeAsync calls
• Changes handler invocation to lambda expressions with TrySetResult

dotnet/test/webdriver/BiDi/BrowsingContext/BrowsingContextEventsTests.cs


46. dotnet/test/webdriver/BiDi/Input/InputEventsTests.cs 🧪 Tests +1/-1

Updated input event tests to use new event source API

• Updates test call from context.Input.OnFileDialogOpenedAsync(handler) to
 context.Input.FileDialogOpened.SubscribeAsync(handler)
• Changes handler invocation to lambda expression with TrySetResult

dotnet/test/webdriver/BiDi/Input/InputEventsTests.cs


47. dotnet/test/webdriver/BiDi/Script/ScriptCommandsTests.cs 🧪 Tests +1/-1

Updated script commands test to use new event source API

• Updates test call from context.Log.OnEntryAddedAsync(handler) to
 context.Log.EntryAdded.SubscribeAsync(handler)
• Changes handler invocation to lambda expression with TrySetResult
• Adds await using for subscription resource management

dotnet/test/webdriver/BiDi/Script/ScriptCommandsTests.cs


48. dotnet/test/webdriver/BiDi/Speculation/SpeculationTests.cs 🧪 Tests +1/-1

Updated speculation test to use new event source API

• Updates test call from speculation.OnPrefetchStatusUpdatedAsync(handler) to
 speculation.PrefetchStatusUpdated.SubscribeAsync(handler)
• Maintains handler lambda expression with TrySetResult

dotnet/test/webdriver/BiDi/Speculation/SpeculationTests.cs


49. dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextNetworkModule.cs Additional files +21/-172

...

dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextNetworkModule.cs


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented Apr 15, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (4) 📎 Requirement gaps (1)

Context used

Grey Divider


Action required

1. Added methods to IBiDi 📘 Rule violation ⚙ Maintainability ⭐ New
Description
New members were added to the public IBiDi interface, which is a breaking API/ABI change for any
downstream implementers of the interface. This violates the requirement to keep public interfaces
backward-compatible.
Code

dotnet/src/webdriver/BiDi/IBiDi.cs[R60-70]

+    Task<ISubscription> SubscribeAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, Action<TEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<ISubscription> SubscribeAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, Func<TEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<ISubscription> SubscribeAsync<TEventArgs>(IEnumerable<EventDescriptor> descriptors, Action<TEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<ISubscription> SubscribeAsync<TEventArgs>(IEnumerable<EventDescriptor> descriptors, Func<TEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<IEventStream<TEventArgs>> ReadAllAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, EventStreamOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<IEventStream<TEventArgs>> ReadAllAsync<TEventArgs>(IEnumerable<EventDescriptor> descriptors, EventStreamOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
Evidence
PR Compliance ID 3 forbids breaking changes to public APIs/ABIs; adding methods to a public
interface breaks compilation for external implementers. The diff adds multiple new
SubscribeAsync/ReadAllAsync members to IBiDi.

AGENTS.md
dotnet/src/webdriver/BiDi/IBiDi.cs[60-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`IBiDi` gained new members, which is a breaking change for any external implementers.

## Issue Context
Adding methods to a public interface is an ABI/API breaking change in .NET.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/IBiDi.cs[60-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. SubscriptionOptions.Timeout removed 📘 Rule violation ⚙ Maintainability ⭐ New
Description
The Timeout property was removed from the public SubscriptionOptions type (and related context
options), which is a breaking API/ABI change for existing callers. The removal is not preceded by
deprecation with guidance to replacement behavior.
Code

dotnet/src/webdriver/BiDi/Subscription.cs[57]

-    public TimeSpan? Timeout { get; init; }
+    public IEnumerable<Browser.UserContext>? UserContexts { get; init; }
Evidence
PR Compliance ID 3 disallows breaking public API changes, and PR Compliance ID 7 requires
deprecation before removal. The diff deletes public TimeSpan? Timeout { get; init; } from
SubscriptionOptions.

AGENTS.md
AGENTS.md
dotnet/src/webdriver/BiDi/Subscription.cs[141-163]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SubscriptionOptions.Timeout` was removed, which breaks existing consumers.

## Issue Context
Public options types are part of the API contract; removals must be deprecated first and/or supported via a compatibility layer.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/Subscription.cs[141-163]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. IBiDi takes IEnumerable 📎 Requirement gap ☼ Reliability ⭐ New
Description
New public subscription/stream APIs accept IEnumerable<EventDescriptor> inputs, which can be lazy
or mutable and can throw or behave nondeterministically if modified during
enumeration/serialization. This violates the guideline to prefer materialized/immutable collection
inputs for command/options-like APIs.
Code

dotnet/src/webdriver/BiDi/IBiDi.cs[R64-70]

+    Task<ISubscription> SubscribeAsync<TEventArgs>(IEnumerable<EventDescriptor> descriptors, Action<TEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<ISubscription> SubscribeAsync<TEventArgs>(IEnumerable<EventDescriptor> descriptors, Func<TEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<IEventStream<TEventArgs>> ReadAllAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, EventStreamOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+
+    Task<IEventStream<TEventArgs>> ReadAllAsync<TEventArgs>(IEnumerable<EventDescriptor> descriptors, EventStreamOptions? options = null, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
Evidence
PR Compliance ID 2 requires avoiding mutable IEnumerable inputs; the diff introduces multiple new
public APIs on IBiDi that accept IEnumerable<EventDescriptor> (and similarly in the BiDi
implementation), enabling mutation-related runtime failures during enumeration.

Command APIs must avoid accepting mutable IEnumerable inputs; prefer materialized/immutable collections (e.g., ImmutableArray)
dotnet/src/webdriver/BiDi/IBiDi.cs[64-70]
dotnet/src/webdriver/BiDi/BiDi.cs[97-125]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New public APIs accept `IEnumerable<EventDescriptor>` inputs, which can be mutated/lazy and cause runtime failures during enumeration.

## Issue Context
Compliance requires using materialized/immutable collection types (e.g., `ImmutableArray`) for collection inputs to avoid mutation-related exceptions.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/IBiDi.cs[64-70]
- dotnet/src/webdriver/BiDi/BiDi.cs[97-125]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (5)
4. SingleWriter channel race 🐞 Bug ☼ Reliability ⭐ New
Description
Subscription<TEventArgs> creates a channel with SingleWriter=true but calls
Writer.TryWrite/TryComplete from multiple threads (dispatcher delivery, DispatchEventsAsync task,
and disposal/shutdown). This violates Channel’s single-writer contract and can cause dropped events
or runtime failures under load or during shutdown.
Code

dotnet/src/webdriver/BiDi/Subscription.cs[R44-45]

+    private readonly Channel<TEventArgs> _channel = Channel.CreateUnbounded<TEventArgs>(
+        new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
Evidence
The subscription channel is configured for a single writer, but writes/completions occur from (1)
the broker processing path via EventDispatcher.TryDeserializeAndDispatch -> subscription.Deliver ->
TryWrite, (2) the background dispatch task started with Task.Run which calls TryComplete on handler
exception, and (3) shutdown code calling CompleteAllAsync before canceling the receive/processing
loops. These code paths can run concurrently, meaning multiple threads can touch the writer despite
SingleWriter=true.

dotnet/src/webdriver/BiDi/Subscription.cs[44-54]
dotnet/src/webdriver/BiDi/Subscription.cs[56-69]
dotnet/src/webdriver/BiDi/Subscription.cs[105-122]
dotnet/src/webdriver/BiDi/EventDispatcher.cs[109-147]
dotnet/src/webdriver/BiDi/Broker.cs[151-172]
dotnet/src/webdriver/BiDi/EventStream.cs[33-54]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Subscription<TEventArgs>` (and `EventStream<TEventArgs>`) create channels with `SingleWriter = true`, but the channel writer is used from multiple threads (dispatcher delivery thread, the subscription’s dispatch task, and dispose/shutdown). This violates `Channel`’s single-writer contract and can lead to race conditions (dropped events or runtime failures).

### Issue Context
- `Subscription<TEventArgs>` starts a background task (`Task.Run(DispatchEventsAsync)`) which may call `_channel.Writer.TryComplete(ex)`.
- Event delivery calls `_channel.Writer.TryWrite(...)` from `EventDispatcher.TryDeserializeAndDispatch`.
- Shutdown (`Broker.DisposeAsync`) calls `EventDispatcher.CompleteAllAsync(...)` before canceling/awaiting the processing loop, so `Complete()`/`DisposeAsync()` can run concurrently with delivery.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/Subscription.cs[44-45]
- dotnet/src/webdriver/BiDi/Subscription.cs[105-122]
- dotnet/src/webdriver/BiDi/EventStream.cs[33-34]

### Suggested change
- Remove the `SingleWriter = true` optimization (set it to `false` or omit it) in both `Subscription<TEventArgs>` and `EventStream<TEventArgs>`.
- If you want to keep `SingleWriter = true`, refactor so that **only one thread** ever calls into the channel writer (e.g., ensure `TryComplete` happens on the same thread that performs delivery, and prevent dispose/shutdown from racing with delivery).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Task<Subscription> overloads removed 📘 Rule violation ⚙ Maintainability
Description
Public BiDi module interfaces changed from returning Task<Subscription> to
Task<Subscription<TEventArgs>>, which is a breaking API/ABI change for existing consumers.
Upgrading would require code changes beyond version bump, violating the compatibility requirement.
Code

dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs[L32-59]

-    Task<Subscription> OnContextCreatedAsync(Func<ContextCreatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnContextCreatedAsync(Action<ContextCreatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnContextDestroyedAsync(Func<ContextDestroyedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnContextDestroyedAsync(Action<ContextDestroyedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDomContentLoadedAsync(Func<DomContentLoadedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDomContentLoadedAsync(Action<DomContentLoadedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadEndAsync(Func<DownloadEndEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadEndAsync(Action<DownloadEndEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadWillBeginAsync(Func<DownloadWillBeginEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadWillBeginAsync(Action<DownloadWillBeginEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnFragmentNavigatedAsync(Func<FragmentNavigatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnFragmentNavigatedAsync(Action<FragmentNavigatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnHistoryUpdatedAsync(Func<HistoryUpdatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnHistoryUpdatedAsync(Action<HistoryUpdatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnLoadAsync(Func<LoadEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnLoadAsync(Action<LoadEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationAbortedAsync(Func<NavigationAbortedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationAbortedAsync(Action<NavigationAbortedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationCommittedAsync(Func<NavigationCommittedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationCommittedAsync(Action<NavigationCommittedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationFailedAsync(Func<NavigationFailedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationFailedAsync(Action<NavigationFailedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationStartedAsync(Func<NavigationStartedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationStartedAsync(Action<NavigationStartedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptClosedAsync(Func<UserPromptClosedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptClosedAsync(Action<UserPromptClosedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptOpenedAsync(Func<UserPromptOpenedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptOpenedAsync(Action<UserPromptOpenedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextCreatedEventArgs>> OnContextCreatedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextCreatedEventArgs>> OnContextCreatedAsync(Func<ContextCreatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextCreatedEventArgs>> OnContextCreatedAsync(Action<ContextCreatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextDestroyedEventArgs>> OnContextDestroyedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextDestroyedEventArgs>> OnContextDestroyedAsync(Func<ContextDestroyedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextDestroyedEventArgs>> OnContextDestroyedAsync(Action<ContextDestroyedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DomContentLoadedEventArgs>> OnDomContentLoadedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DomContentLoadedEventArgs>> OnDomContentLoadedAsync(Func<DomContentLoadedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DomContentLoadedEventArgs>> OnDomContentLoadedAsync(Action<DomContentLoadedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadEndEventArgs>> OnDownloadEndAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadEndEventArgs>> OnDownloadEndAsync(Func<DownloadEndEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadEndEventArgs>> OnDownloadEndAsync(Action<DownloadEndEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadWillBeginEventArgs>> OnDownloadWillBeginAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadWillBeginEventArgs>> OnDownloadWillBeginAsync(Func<DownloadWillBeginEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadWillBeginEventArgs>> OnDownloadWillBeginAsync(Action<DownloadWillBeginEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<FragmentNavigatedEventArgs>> OnFragmentNavigatedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<FragmentNavigatedEventArgs>> OnFragmentNavigatedAsync(Func<FragmentNavigatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<FragmentNavigatedEventArgs>> OnFragmentNavigatedAsync(Action<FragmentNavigatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<HistoryUpdatedEventArgs>> OnHistoryUpdatedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<HistoryUpdatedEventArgs>> OnHistoryUpdatedAsync(Func<HistoryUpdatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<HistoryUpdatedEventArgs>> OnHistoryUpdatedAsync(Action<HistoryUpdatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<LoadEventArgs>> OnLoadAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<LoadEventArgs>> OnLoadAsync(Func<LoadEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<LoadEventArgs>> OnLoadAsync(Action<LoadEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationAbortedEventArgs>> OnNavigationAbortedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationAbortedEventArgs>> OnNavigationAbortedAsync(Func<NavigationAbortedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationAbortedEventArgs>> OnNavigationAbortedAsync(Action<NavigationAbortedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationCommittedEventArgs>> OnNavigationCommittedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationCommittedEventArgs>> OnNavigationCommittedAsync(Func<NavigationCommittedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationCommittedEventArgs>> OnNavigationCommittedAsync(Action<NavigationCommittedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationFailedEventArgs>> OnNavigationFailedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationFailedEventArgs>> OnNavigationFailedAsync(Func<NavigationFailedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationFailedEventArgs>> OnNavigationFailedAsync(Action<NavigationFailedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationStartedEventArgs>> OnNavigationStartedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationStartedEventArgs>> OnNavigationStartedAsync(Func<NavigationStartedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationStartedEventArgs>> OnNavigationStartedAsync(Action<NavigationStartedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptClosedEventArgs>> OnUserPromptClosedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptClosedEventArgs>> OnUserPromptClosedAsync(Func<UserPromptClosedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptClosedEventArgs>> OnUserPromptClosedAsync(Action<UserPromptClosedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptOpenedEventArgs>> OnUserPromptOpenedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptOpenedEventArgs>> OnUserPromptOpenedAsync(Func<UserPromptOpenedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptOpenedEventArgs>> OnUserPromptOpenedAsync(Action<UserPromptOpenedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
Evidence
PR Compliance ID 4 requires public API/ABI changes to be backward-compatible. The diff removes
multiple Task<Subscription> method signatures from the public IBrowsingContextModule interface
and replaces them with new generic Task<Subscription<TEventArgs>> overloads, forcing downstream
breaking changes.

AGENTS.md
dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs[32-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public interfaces removed `Task<Subscription>` overloads and replaced them with `Task<Subscription<TEventArgs>>`, which breaks API/ABI compatibility.

## Issue Context
Compatibility rules require upgrades to be possible by changing only the package version. Introduce a transition path by keeping the prior signatures and forwarding to the new generic implementation.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs[32-73]
- dotnet/src/webdriver/BiDi/Network/INetworkModule.cs[31-45]
- dotnet/src/webdriver/BiDi/Script/IScriptModule.cs[33-41]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. DrainAsync swallows cancellation 📘 Rule violation ☼ Reliability
Description
Subscription<TEventArgs>.DrainAsync catches Exception and logs it, which will also swallow
OperationCanceledException and can break cooperative cancellation. This can prevent
shutdown/cancel flows from propagating correctly and leave tasks running unexpectedly.
Code

dotnet/src/webdriver/BiDi/Subscription.cs[R124-136]

+                if (_handler is not null)
+                {
+                    try
+                    {
+                        await _handler(args).ConfigureAwait(false);
+                    }
+                    catch (Exception ex)
+                    {
+                        if (Logger.IsEnabled(LogEventLevel.Error))
+                        {
+                            Logger.Error($"Unhandled error processing BiDi event handler: {ex}");
+                        }
+                    }
Evidence
PR Compliance ID 14 requires preserving cancellation signals when catching broad exceptions. The
added catch (Exception ex) block in DrainAsync does not special-case
OperationCanceledException, so cancellation can be suppressed instead of being
propagated/preserved.

dotnet/src/webdriver/BiDi/Subscription.cs[124-136]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DrainAsync` catches all exceptions from user handlers, which also catches `OperationCanceledException` and suppresses cancellation.

## Issue Context
Cancellation should remain observable and should not be unintentionally converted into a logged-and-ignored error.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/Subscription.cs[118-137]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Enumerator child-channel leak 🐞 Bug ☼ Reliability
Description
Subscription<TEventArgs>.GetAsyncEnumerator() adds a per-enumerator child Channel to _children but
never removes it when enumeration ends or is cancelled, so DrainAsync keeps writing events into
abandoned channels for the lifetime of the subscription. This can cause unbounded memory growth and
retain references even after consumers stop iterating.
Code

dotnet/src/webdriver/BiDi/Subscription.cs[R91-107]

+    public IAsyncEnumerator<TEventArgs> GetAsyncEnumerator(CancellationToken cancellationToken = default)
+    {
+        var child = Channel.CreateUnbounded<TEventArgs>();
+        lock (_children) { _children.Add(child); }
+        return ReadChannelAsync(child.Reader, cancellationToken);
+    }
+
+    private static async IAsyncEnumerator<TEventArgs> ReadChannelAsync(ChannelReader<TEventArgs> reader, CancellationToken cancellationToken)
+    {
+        while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+        {
+            while (reader.TryRead(out var item))
+            {
+                yield return item;
+            }
+        }
+    }
Evidence
GetAsyncEnumerator creates an unbounded child channel and only ever adds it to the _children list;
ReadChannelAsync has no cleanup path on cancellation/completion, and DrainAsync writes every
incoming event to every child in _children. Children are only completed when the *subscription*
finishes draining, not when an individual enumerator is disposed/cancelled, so abandoned enumerators
remain writable targets indefinitely.

dotnet/src/webdriver/BiDi/Subscription.cs[91-107]
dotnet/src/webdriver/BiDi/Subscription.cs[118-157]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Subscription<TEventArgs>.GetAsyncEnumerator()` registers a per-enumerator child `Channel<TEventArgs>` in `_children` but never unregisters it when the consumer stops iterating (natural completion, `DisposeAsync`, or cancellation). As a result, `DrainAsync()` continues to write every event into channels that no longer have active readers, leading to unbounded buffering/memory growth and retained references.

### Issue Context
- Child channels are stored in `_children` and written-to on every event.
- Cancellation of the returned async iterator does not trigger any code that removes the child from `_children`.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/Subscription.cs[91-107]
- dotnet/src/webdriver/BiDi/Subscription.cs[118-157]

### Suggested change
Implement enumeration via an instance `async IAsyncEnumerable<TEventArgs>` that:
1) creates and registers the child channel,
2) yields items from `child.Reader.ReadAllAsync(cancellationToken)`,
3) **in a `finally` block** removes the child from `_children` and completes the child writer.

Example shape:
```csharp
public IAsyncEnumerator<TEventArgs> GetAsyncEnumerator(CancellationToken ct = default)
 => Enumerate(ct).GetAsyncEnumerator(ct);

private async IAsyncEnumerable<TEventArgs> Enumerate([EnumeratorCancellation] CancellationToken ct)
{
 var child = Channel.CreateUnbounded<TEventArgs>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
 lock (_children) _children.Add(child);
 try
 {
   await foreach (var item in child.Reader.ReadAllAsync(ct).ConfigureAwait(false))
     yield return item;
 }
 finally
 {
   lock (_children) _children.Remove(child);
   child.Writer.TryComplete();
 }
}
```
This ensures abandoned enumerators do not keep accumulating writes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Unsubscribe cleanup not exception-safe 🐞 Bug ☼ Reliability
Description
Broker.UnsubscribeAsync only removes the subscription from the local registry after the remote
UnsubscribeAsync succeeds, so if the transport call throws the subscription remains registered and
still traversed for every event. Subscription.DisposeAsync also sets its disposed flag before
awaiting UnsubscribeAsync, preventing retry after a transient failure and increasing the chance of a
permanent local leak.
Code

dotnet/src/webdriver/BiDi/Broker.cs[R93-101]

    public async ValueTask UnsubscribeAsync(Subscription subscription, CancellationToken cancellationToken)
    {
        await _bidi.Session.UnsubscribeAsync([subscription.SubscriptionId], null, cancellationToken).ConfigureAwait(false);
-        _eventDispatcher.RemoveHandler(subscription.EventName, subscription.Handler);
+
+        if (_subscriptions.TryGetValue(subscription.EventName, out var registry))
+        {
+            registry.Remove(subscription);
+        }
    }
Evidence
Broker.UnsubscribeAsync has no try/finally, so local registry removal is skipped on exceptions from
the remote unsubscribe call. Separately, Subscription.DisposeAsync marks the subscription disposed
before awaiting UnsubscribeAsync; if that await throws, subsequent DisposeAsync calls will not
attempt cleanup again.

dotnet/src/webdriver/BiDi/Broker.cs[93-101]
dotnet/src/webdriver/BiDi/Subscription.cs[50-57]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Broker.UnsubscribeAsync` performs local cleanup (removing from `_subscriptions`) only after the remote unsubscribe completes successfully. If the remote call throws, the local subscription remains in the registry, and the broker continues iterating it on every event.

Additionally, `Subscription.DisposeAsync` sets `_disposed` before awaiting `UnsubscribeAsync()`. If `UnsubscribeAsync()` throws, the subscription becomes non-retryable for disposal/unsubscribe, making the local leak harder to recover from.

### Issue Context
- Local cleanup should be resilient to transport/remote failures.
- Even if the remote unsubscribe fails, local detachment should happen to stop further event delivery and avoid retaining stale subscriptions.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/Broker.cs[93-101]
- dotnet/src/webdriver/BiDi/Subscription.cs[50-57]

### Suggested change
1) Wrap the remote unsubscribe in `try/finally` so local registry removal always runs:
```csharp
public async ValueTask UnsubscribeAsync(Subscription subscription, CancellationToken cancellationToken)
{
 try
 {
   await _bidi.Session.UnsubscribeAsync([subscription.SubscriptionId], null, cancellationToken).ConfigureAwait(false);
 }
 finally
 {
   if (_subscriptions.TryGetValue(subscription.EventName, out var registry))
     registry.Remove(subscription);
 }
}
```

2) Consider making `Subscription.DisposeAsync` retry-safe by only setting `_disposed` after a successful unsubscribe, or resetting `_disposed` on failure (so callers can retry), e.g. `try { ... } catch { Interlocked.Exchange(ref _disposed, 0); throw; }`.

This prevents permanent local leaks when the transport is flaky or shutting down.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 42e8ef7

Results up to commit a74af07


Details

🐞 Bugs (2)   📘 Rule violations (2)   📎 Requirement gaps (0)
🐞\ ☼ Reliability (2)
📘\ ☼ Reliability (1) ⚙ Maintainability (1)

Action required
1. Task<Subscription> overloads removed 📘
Description
Public BiDi module interfaces changed from returning Task<Subscription> to
Task<Subscription<TEventArgs>>, which is a breaking API/ABI change for existing consumers.
Upgrading would require code changes beyond version bump, violating the compatibility requirement.
Code

dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs[L32-59]

-    Task<Subscription> OnContextCreatedAsync(Func<ContextCreatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnContextCreatedAsync(Action<ContextCreatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnContextDestroyedAsync(Func<ContextDestroyedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnContextDestroyedAsync(Action<ContextDestroyedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDomContentLoadedAsync(Func<DomContentLoadedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDomContentLoadedAsync(Action<DomContentLoadedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadEndAsync(Func<DownloadEndEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadEndAsync(Action<DownloadEndEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadWillBeginAsync(Func<DownloadWillBeginEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnDownloadWillBeginAsync(Action<DownloadWillBeginEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnFragmentNavigatedAsync(Func<FragmentNavigatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnFragmentNavigatedAsync(Action<FragmentNavigatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnHistoryUpdatedAsync(Func<HistoryUpdatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnHistoryUpdatedAsync(Action<HistoryUpdatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnLoadAsync(Func<LoadEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnLoadAsync(Action<LoadEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationAbortedAsync(Func<NavigationAbortedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationAbortedAsync(Action<NavigationAbortedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationCommittedAsync(Func<NavigationCommittedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationCommittedAsync(Action<NavigationCommittedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationFailedAsync(Func<NavigationFailedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationFailedAsync(Action<NavigationFailedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationStartedAsync(Func<NavigationStartedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnNavigationStartedAsync(Action<NavigationStartedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptClosedAsync(Func<UserPromptClosedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptClosedAsync(Action<UserPromptClosedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptOpenedAsync(Func<UserPromptOpenedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
-    Task<Subscription> OnUserPromptOpenedAsync(Action<UserPromptOpenedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextCreatedEventArgs>> OnContextCreatedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextCreatedEventArgs>> OnContextCreatedAsync(Func<ContextCreatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextCreatedEventArgs>> OnContextCreatedAsync(Action<ContextCreatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextDestroyedEventArgs>> OnContextDestroyedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextDestroyedEventArgs>> OnContextDestroyedAsync(Func<ContextDestroyedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<ContextDestroyedEventArgs>> OnContextDestroyedAsync(Action<ContextDestroyedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DomContentLoadedEventArgs>> OnDomContentLoadedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DomContentLoadedEventArgs>> OnDomContentLoadedAsync(Func<DomContentLoadedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DomContentLoadedEventArgs>> OnDomContentLoadedAsync(Action<DomContentLoadedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadEndEventArgs>> OnDownloadEndAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadEndEventArgs>> OnDownloadEndAsync(Func<DownloadEndEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadEndEventArgs>> OnDownloadEndAsync(Action<DownloadEndEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadWillBeginEventArgs>> OnDownloadWillBeginAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadWillBeginEventArgs>> OnDownloadWillBeginAsync(Func<DownloadWillBeginEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<DownloadWillBeginEventArgs>> OnDownloadWillBeginAsync(Action<DownloadWillBeginEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<FragmentNavigatedEventArgs>> OnFragmentNavigatedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<FragmentNavigatedEventArgs>> OnFragmentNavigatedAsync(Func<FragmentNavigatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<FragmentNavigatedEventArgs>> OnFragmentNavigatedAsync(Action<FragmentNavigatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<HistoryUpdatedEventArgs>> OnHistoryUpdatedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<HistoryUpdatedEventArgs>> OnHistoryUpdatedAsync(Func<HistoryUpdatedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<HistoryUpdatedEventArgs>> OnHistoryUpdatedAsync(Action<HistoryUpdatedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<LoadEventArgs>> OnLoadAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<LoadEventArgs>> OnLoadAsync(Func<LoadEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<LoadEventArgs>> OnLoadAsync(Action<LoadEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationAbortedEventArgs>> OnNavigationAbortedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationAbortedEventArgs>> OnNavigationAbortedAsync(Func<NavigationAbortedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationAbortedEventArgs>> OnNavigationAbortedAsync(Action<NavigationAbortedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationCommittedEventArgs>> OnNavigationCommittedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationCommittedEventArgs>> OnNavigationCommittedAsync(Func<NavigationCommittedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationCommittedEventArgs>> OnNavigationCommittedAsync(Action<NavigationCommittedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationFailedEventArgs>> OnNavigationFailedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationFailedEventArgs>> OnNavigationFailedAsync(Func<NavigationFailedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationFailedEventArgs>> OnNavigationFailedAsync(Action<NavigationFailedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationStartedEventArgs>> OnNavigationStartedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationStartedEventArgs>> OnNavigationStartedAsync(Func<NavigationStartedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<NavigationStartedEventArgs>> OnNavigationStartedAsync(Action<NavigationStartedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptClosedEventArgs>> OnUserPromptClosedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptClosedEventArgs>> OnUserPromptClosedAsync(Func<UserPromptClosedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptClosedEventArgs>> OnUserPromptClosedAsync(Action<UserPromptClosedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptOpenedEventArgs>> OnUserPromptOpenedAsync(SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptOpenedEventArgs>> OnUserPromptOpenedAsync(Func<UserPromptOpenedEventArgs, Task> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
+    Task<Subscription<UserPromptOpenedEventArgs>> OnUserPromptOpenedAsync(Action<UserPromptOpenedEventArgs> handler, SubscriptionOptions? options = null, CancellationToken cancellationToken = default);
Evidence
PR Compliance ID 4 requires public API/ABI changes to be backward-compatible. The diff removes
multiple Task<Subscription> method signatures from the public IBrowsingContextModule interface
and replaces them with new generic Task<Subscription<TEventArgs>> overloads, forcing downstream
breaking changes.

AGENTS.md
dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs[32-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public interfaces removed `Task<Subscription>` overloads and replaced them with `Task<Subscription<TEventArgs>>`, which breaks API/ABI compatibility.

## Issue Context
Compatibility rules require upgrades to be possible by changing only the package version. Introduce a transition path by keeping the prior signatures and forwarding to the new generic implementation.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs[32-73]
- dotnet/src/webdriver/BiDi/Network/INetworkModule.cs[31-45]
- dotnet/src/webdriver/BiDi/Script/IScriptModule.cs[33-41]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. DrainAsync swallows cancellation 📘
Description
Subscription<TEventArgs>.DrainAsync catches Exception and logs it, which will also swallow
OperationCanceledException and can break cooperative cancellation. This can prevent
shutdown/cancel flows from propagating correctly and leave tasks running unexpectedly.
Code

dotnet/src/webdriver/BiDi/Subscription.cs[R124-136]

+                if (_handler is not null)
+                {
+                    try
+                    {
+                        await _handler(args).ConfigureAwait(false);
+                    }
+                    catch (Exception ex)
+                    {
+                        if (Logger.IsEnabled(LogEventLevel.Error))
+                        {
+                            Logger.Error($"Unhandled error processing BiDi event handler: {ex}");
+                        }
+                    }
Evidence
PR Compliance ID 14 requires preserving cancellation signals when catching broad exceptions. The
added catch (Exception ex) block in DrainAsync does not special-case
OperationCanceledException, so cancellation can be suppressed instead of being
propagated/preserved.

dotnet/src/webdriver/BiDi/Subscription.cs[124-136]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DrainAsync` catches all exceptions from user handlers, which also catches `OperationCanceledException` and suppresses cancellation.

## Issue Context
Cancellation should remain observable and should not be unintentionally converted into a logged-and-ignored error.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/Subscription.cs[118-137]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Enumerator child-channel leak 🐞
Description
Subscription<TEventArgs>.GetAsyncEnumerator() adds a per-enumerator child Channel to _children but
never removes it when enumeration ends or is cancelled, so DrainAsync keeps writing events into
abandoned channels for the lifetime of the subscription. This can cause unbounded memory growth and
retain references even after consumers stop iterating.
Code

dotnet/src/webdriver/BiDi/Subscription.cs[R91-107]

+    public IAsyncEnumerator<TEventArgs> GetAsyncEnumerator(CancellationToken cancellationToken = default)
+    {
+        var child = Channel.CreateUnbounded<TEventArgs>();
+        lock (_children) { _children.Add(child); }
+        return ReadChannelAsync(child.Reader, cancellationToken);
+    }
+
+    private static async IAsyncEnumerator<TEventArgs> ReadChannelAsync(ChannelReader<TEventArgs> reader, CancellationToken cancellationToken)
+    {
+        while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+        {
+            while (reader.TryRead(out var item))
+            {
+                yield return item;
+            }
+        }
+    }
Evidence
GetAsyncEnumerator creates an unbounded child channel and only ever adds it to the _children list;
ReadChannelAsync has no cleanup path on cancellation/completion, and DrainAsync writes every
incoming event to every child in _children. Children are only completed when the *subscription*
finishes draining, not when an individual enumerator is disposed/cancelled, so abandoned enumerators
remain writable targets indefinitely.

dotnet/src/webdriver/BiDi/Subscription.cs[91-107]
dotnet/src/webdriver/BiDi/Subscription.cs[118-157]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Subscription<TEventArgs>.GetAsyncEnumerator()` registers a per-enumerator child `Channel<TEventArgs>` in `_children` but never unregisters it when the consumer stops iterating (natural completion, `DisposeAsync`, or cancellation). As a result, `DrainAsync()` continues to write every event into channels that no longer have active readers, leading to unbounded buffering/memory growth and retained references.

### Issue Context
- Child channels are stored in `_children` and written-to on every event.
- Cancellation of the returned async iterator does not trigger any code that removes the child from `_children`.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/Subscription.cs[91-107]
- dotnet/src/webdriver/BiDi/Subscription.cs[118-157]

### Suggested change
Implement enumeration via an instance `async IAsyncEnumerable<TEventArgs>` that:
1) creates and registers the child channel,
2) yields items from `child.Reader.ReadAllAsync(cancellationToken)`,
3) **in a `finally` block** removes the child from `_children` and completes the child writer.

Example shape:
```csharp
public IAsyncEnumerator<TEventArgs> GetAsyncEnumerator(CancellationToken ct = default)
 => Enumerate(ct).GetAsyncEnumerator(ct);

private async IAsyncEnumerable<TEventArgs> Enumerate([EnumeratorCancellation] CancellationToken ct)
{
 var child = Channel.CreateUnbounded<TEventArgs>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
 lock (_children) _children.Add(child);
 try
 {
   await foreach (var item in child.Reader.ReadAllAsync(ct).ConfigureAwait(false))
     yield return item;
 }
 finally
 {
   lock (_children) _children.Remove(child);
   child.Writer.TryComplete();
 }
}
```
This ensures abandoned enumerators do not keep accumulating writes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Comment thread dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Broker.cs Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors the .NET BiDi event subscription pipeline to support strongly-typed subscriptions and optional event streaming, replacing the prior centralized dispatcher approach with per-event subscription registries.

Changes:

  • Introduces Subscription<TEventArgs> backed by Channel<T> and implements IAsyncEnumerable<TEventArgs> for streaming consumption.
  • Replaces EventDispatcher with a per-event SubscriptionRegistry inside Broker, and propagates terminal exceptions to active subscriptions on shutdown.
  • Updates BiDi module and BrowsingContext event APIs to return Subscription<TEventArgs> and adds handlerless overloads.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 19 comments.

Show a summary per file
File Description
dotnet/src/webdriver/BiDi/Subscription.cs Adds generic, channel-backed subscriptions with streaming support and sequential handler draining.
dotnet/src/webdriver/BiDi/Broker.cs Replaces dispatcher with per-event registries and direct delivery/completion to subscriptions.
dotnet/src/webdriver/BiDi/Module.cs Updates subscribe helper to return Subscription<TEventArgs> and accept ValueTask handlers.
dotnet/src/webdriver/BiDi/EventDispatcher.cs Removes the previous centralized queued dispatcher implementation.
dotnet/src/webdriver/BiDi/Speculation/SpeculationModule.cs Updates speculation event subscription APIs to typed subscriptions + handlerless overload.
dotnet/src/webdriver/BiDi/Speculation/ISpeculationModule.cs Updates public speculation interface signatures to typed subscriptions and adds handlerless overload.
dotnet/src/webdriver/BiDi/Script/ScriptModule.cs Updates script event subscription APIs to typed subscriptions + handlerless overload.
dotnet/src/webdriver/BiDi/Script/IScriptModule.cs Updates public script interface signatures to typed subscriptions and adds handlerless overloads.
dotnet/src/webdriver/BiDi/Network/NetworkModule.cs Updates network event subscription APIs to typed subscriptions + handlerless overloads.
dotnet/src/webdriver/BiDi/Network/INetworkModule.cs Updates public network interface signatures to typed subscriptions and adds handlerless overloads.
dotnet/src/webdriver/BiDi/Log/LogModule.cs Updates log event subscription APIs to typed subscriptions + handlerless overload.
dotnet/src/webdriver/BiDi/Log/ILogModule.cs Updates public log interface signatures to typed subscriptions and adds handlerless overload.
dotnet/src/webdriver/BiDi/Input/InputModule.cs Updates input event subscription APIs to typed subscriptions + handlerless overload.
dotnet/src/webdriver/BiDi/Input/IInputModule.cs Updates public input interface signatures to typed subscriptions and adds handlerless overload.
dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextModule.cs Updates browsing-context module event subscription APIs to typed subscriptions + handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs Updates public browsing-context interface signatures to typed subscriptions and adds handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContext.cs Updates BrowsingContext facade methods to use typed subscriptions and adds handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextNetworkModule.cs Updates context-scoped network event subscription wrappers to typed subscriptions + handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextNetworkModule.cs Updates public context-scoped network interface signatures to typed subscriptions and adds handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextLogModule.cs Updates context-scoped log event subscription wrappers to typed subscriptions + handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextLogModule.cs Updates public context-scoped log interface signatures to typed subscriptions and adds handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextInputModule.cs Updates context-scoped input event subscription wrappers to typed subscriptions + handlerless overloads.
dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextInputModule.cs Updates public context-scoped input interface signatures to typed subscriptions and adds handlerless overloads.

Comment thread dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextInputModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/BrowsingContext/IBrowsingContextModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Log/LogModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Speculation/SpeculationModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContextModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Broker.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Broker.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Network/INetworkModule.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/Log/ILogModule.cs Outdated
@nvborisenko
Copy link
Copy Markdown
Member Author

Need more design decisions..

@nvborisenko nvborisenko marked this pull request as draft April 15, 2026 19:54
@nvborisenko
Copy link
Copy Markdown
Member Author

Perfect, I understood how to design events.

Before:

// Push
await using var sub = await network.OnBeforeRequestSentAsync(e => Console.WriteLine(e));

After:

// Push
await using var sub = await network.BeforeRequestSent.OnAsync(e => Console.WriteLine(e));

// Pull
await using var sub = await network.BeforeRequestSent.SubscribeAsync();
await foreach (var args in sub) { ... }

2 separate clean concepts. Why this way...

  • resolves method overloading problem, when one method behaves differently based on parameters
  • property is an accessor, opening a door for new possibilities

Thus example of usage becomes very simple (purely native C#):

await using var sub = await network.BeforeRequestSent.SubscribeAsync();
await context.NavigateAsync("example.com");
await sub.Where(req => req.Url.Contains("/api")).FirstAsync();

Or if somebody wants to develop extension method:

var request = await network.BeforeRequestSent
    .During(() => context.NavigateAsync("example.com"))
    .Where(req => req.Url.Contains("/api")).FirstAsync()

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 49 out of 49 changed files in this pull request and generated 3 comments.

Comment thread dotnet/src/webdriver/BiDi/Log/ILogModule.cs Outdated
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/EventDispatcher.cs Outdated
Copilot AI review requested due to automatic review settings May 1, 2026 11:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 50 out of 50 changed files in this pull request and generated 5 comments.

Comment thread dotnet/src/webdriver/BiDi/Broker.cs
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs Outdated
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContext.cs
@nvborisenko
Copy link
Copy Markdown
Member Author

Checkpoint

Redesigns BiDi event subscription with two clean patterns — push and pull — exposed through properties on each module.

// Push — subscribe with a handler
await using var sub = await context.Network.BeforeRequestSent.SubscribeAsync(async e =>
{
    await bidi.Network.ContinueRequestAsync(e.Request.Request);
});

// Pull — consume as async stream + LINQ
await using var stream = await bidi.Log.EntryAdded.ReadAllAsync();
var error = await stream.Where(e => e.Level == Level.Error).FirstAsync(cts.Token);

// Filter before subscribing
await using var sub = await bidi.Network.ResponseCompleted
    .Where(e => e.Response.Url.Contains("/api"))
    .SubscribeAsync(e => Console.WriteLine(e.Response.Status));

// Context-scoped — auto-filters to this tab
await using var sub = await context.Log.EntryAdded.SubscribeAsync(e =>
    Console.WriteLine(e.Text));

// Multiple events in a single wire subscription
await using var sub = await bidi.SubscribeAsync(
    [NetworkEvent.ResponseStarted, NetworkEvent.ResponseCompleted],
    (EventArgs e) =>
    {
        switch (e)
        {
            case ResponseStartedEventArgs started:
                Console.WriteLine($"Started: {started.Request.Url}");
                break;
            case ResponseCompletedEventArgs completed:
                Console.WriteLine($"Completed: {completed.Response.Status}");
                break;
        }
    });

Copilot AI review requested due to automatic review settings May 1, 2026 12:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 49 out of 49 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

dotnet/src/webdriver/BiDi/Subscription.cs:113

  • SubscriptionOptions is a public type and the previous Timeout option has been removed, which is a breaking API change (and conflicts with the PR description claiming non-breaking changes). If this is intended, it should be handled via deprecation (keep the old member, mark [Obsolete], and map it to the new behavior) or documented as a breaking change.

Comment thread dotnet/src/webdriver/BiDi/IEventSource.cs
Copilot AI review requested due to automatic review settings May 1, 2026 13:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 49 out of 49 changed files in this pull request and generated 10 comments.

Comments suppressed due to low confidence (8)

dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:123

  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. It should be disposed (e.g., await using) after the test to ensure the wire subscription and dispatch task don’t leak into later tests.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:175
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. Dispose it (e.g., await using) to avoid leaking the subscription/background task across tests.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:242
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. Dispose it to avoid leaving a subscription active beyond the test, which can lead to cross-test interference.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:260
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. Dispose it (e.g., await using) after the test to avoid leaking the subscription and its background processing task.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:145
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. Dispose it (e.g., via await using) so the intercept handler doesn’t remain active beyond this test.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:203
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. It should be disposed to avoid leaving an auth intercept handler active after the test completes.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:223
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. Dispose it (e.g., await using) to prevent the subscription/background task from leaking into later tests.
    dotnet/test/webdriver/BiDi/Network/NetworkTests.cs:100
  • This SubscribeAsync call returns an IAsyncDisposable subscription but the result is ignored. With the new subscription implementation, it should be disposed to avoid leaking a live intercept handler/background task beyond the test. Capture it in await using var subscription = await ... and keep it alive until after navigation/assertions.

Comment thread dotnet/test/webdriver/BiDi/Script/ScriptEventsTests.cs
Comment thread dotnet/test/webdriver/BiDi/Script/ScriptCommandsTests.cs
Comment thread dotnet/src/webdriver/BiDi/EventDispatcher.cs
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/EventDispatcher.cs
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs Outdated
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs Outdated
Comment thread dotnet/test/webdriver/BiDi/Session/SessionTests.cs
Copilot AI review requested due to automatic review settings May 1, 2026 13:49
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 49 out of 49 changed files in this pull request and generated 8 comments.

Comment thread dotnet/src/webdriver/BiDi/Subscription.cs
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs
Comment thread dotnet/src/webdriver/BiDi/EventStream.cs
Comment thread dotnet/src/webdriver/BiDi/Network/INetworkModule.cs
Comment thread dotnet/src/webdriver/BiDi/Script/IScriptModule.cs
Comment thread dotnet/src/webdriver/BiDi/EventDispatcher.cs
Comment thread dotnet/src/webdriver/BiDi/BrowsingContext/BrowsingContext.cs
@nvborisenko nvborisenko marked this pull request as ready for review May 1, 2026 14:03
@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 1, 2026

Persistent review updated to latest commit 42e8ef7

Comment thread dotnet/src/webdriver/BiDi/IBiDi.cs
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs
Comment thread dotnet/src/webdriver/BiDi/IBiDi.cs
Comment thread dotnet/src/webdriver/BiDi/Subscription.cs
@nvborisenko nvborisenko changed the title [dotnet] [bidi] Optional Event streaming [dotnet] [bidi] Additional Event streaming (breaking change) May 1, 2026
@YevgeniyShunevych
Copy link
Copy Markdown
Contributor

@nvborisenko, in the latest checkpoint example there are:

bidi.Log.EntryAdded...

context.Log.EntryAdded...

Are bidi and context actually the same IBiDi object?

@nvborisenko
Copy link
Copy Markdown
Member Author

bidi is global object (browser). context is tab.

bidi.Log.EntryAdded.... // getting all events for browser
context.Log.EntryAdded..... // only for this tab

Context-aware scope is additional abstraction over specification.

Raw modeling:

bidi.BrowsingContext.NavigateAsync(BrowsingContext ctx, "https://example.com");

We support simpler built-in variant:

ctx.NavigateAsync("https://example.com");

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-dotnet .NET Bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants