Skip to content

[dotnet] [bidi] Split event stream backed subscription and enumeration - #17705

Merged
nvborisenko merged 7 commits into
SeleniumHQ:trunkfrom
nvborisenko:bidi-event-stream
Jun 28, 2026
Merged

[dotnet] [bidi] Split event stream backed subscription and enumeration#17705
nvborisenko merged 7 commits into
SeleniumHQ:trunkfrom
nvborisenko:bidi-event-stream

Conversation

@nvborisenko

@nvborisenko nvborisenko commented Jun 22, 2026

Copy link
Copy Markdown
Member

StreamAsync() is disposable only, meaning explicit subscription lifecycle management. To get enumeration use stream.ReadAllAsync().

🔗 Related Issues

Implicitly resolves #17696

💥 What does this PR do?

This pull request refactors the event stream handling in the BiDi WebDriver .NET implementation to simplify cancellation token management. It introduces a new ReadAllAsync method for consuming event streams, updates the interface and all usage sites. The changes also include corresponding updates to tests to reflect the new usage pattern.

Event stream API changes:

  • Replaced direct implementation of IAsyncEnumerable<TEventArgs> in IEventStream<TEventArgs> with a new ReadAllAsync method, and removed the GetAsyncEnumerator method from EventStream<TEventArgs>. This simplifies cancellation token handling and standardizes stream consumption. [1] [2] [3]
  • Updated all usages of event stream enumeration in tests and code to use ReadAllAsync, ensuring cancellation tokens are passed explicitly where needed. [1] [2] [3] [4] [5] [6] [7] [8]

Code cleanup and improvements:

  • Removed unused _cancellationToken field and related logic from EventStream<TEventArgs>, streamlining resource management.
  • Added System.Runtime.CompilerServices import to support [EnumeratorCancellation] attribute usage.
  • Minor code cleanup in async disposal logic.

🔧 Implementation Notes

Good to simplify user-facing API.

🤖 AI assistance

  • No substantial AI assistance used
  • AI assisted (complete below)
    • Tool(s):
    • What was generated:
    • I reviewed all AI output and can explain the change

💡 Additional Considerations

Low level API, amazing simplification.

🔄 Types of changes

  • Cleanup (formatting, renaming)
  • Breaking change (fix or feature that would cause existing functionality to change)

@selenium-ci selenium-ci added the C-dotnet .NET Bindings label Jun 22, 2026
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

[dotnet][bidi] Use IAsyncEnumerable for event streams with implicit unsubscribe
✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

Description

• Replace custom BiDi event stream interface with standard IAsyncEnumerable for consumption.
• Implicitly unsubscribe when enumeration ends by disposing the async enumerator.
• Update BiDi event source APIs and adjust tests for the new streaming pattern.
Diagram

graph TD
  A([Consumer test/app]) --> B[["IBiDi / IEventSource" API]] --> C([EventDispatcher]) --> D([EventStream<T>]) --> E[("Channel<T>")]
  C --> F{{"BiDi wire subscribe/unsubscribe"}}
  F --> G{{"Remote end events"}} --> C
  subgraph Legend
    direction LR
    _c([Consumer/code]) ~~~ _i[[Interface/API]] ~~~ _e{{External/wire}} ~~~ _q[(Queue/channel)]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep IEventStream as an alias type
  • ➕ Avoids breaking changes for existing consumers
  • ➕ Retains explicit IAsyncDisposable on the returned type
  • ➖ Continues a custom abstraction instead of idiomatic IAsyncEnumerable
  • ➖ Still encourages explicit disposal rather than natural end-of-enumeration semantics
2. Return (IAsyncEnumerable, IAsyncDisposable) wrapper object
  • ➕ Preserves an obvious explicit-disposal path
  • ➕ Can add future metadata (subscription id, diagnostics) without API churn
  • ➖ More complex API surface than returning IAsyncEnumerable directly
  • ➖ Consumers still need to learn a custom wrapper type
3. Expose explicit UnsubscribeAsync token instead of disposal-driven unsubscribe
  • ➕ Makes wire unsubscribe explicit and deterministic from the API perspective
  • ➕ Avoids relying on enumerator disposal behavior
  • ➖ More cumbersome for typical foreach/linq consumption
  • ➖ Easy to leak subscriptions if callers forget to unsubscribe

Recommendation: The PR’s approach (return IAsyncEnumerable and unsubscribe on enumerator disposal) is the most idiomatic .NET streaming model and aligns with how await foreach naturally scopes lifetimes. Keeping a custom interface or wrapper would reduce breakage but would also preserve unnecessary abstraction and complexity; the current design is a good tradeoff for a low-level API that aims to be modern and easy to use.

Files changed (9) +53 / -21

Bug fix (1) +39 / -3
EventStream.csImplement IAsyncEnumerable with implicit unsubscribe on enumeration end +39/-3

Implement IAsyncEnumerable with implicit unsubscribe on enumeration end

• Makes EventStream implement IAsyncEnumerable directly and ensures disposal/unsubscribe happens when the async enumerator is disposed. Adds an internal enumerator wrapper to dispose linked cancellation token sources and then dispose the owning EventStream.

dotnet/src/webdriver/BiDi/EventStream.cs

Refactor (5) +7 / -7
BiDi.csReturn IAsyncEnumerable from BiDi StreamAsync APIs +2/-2

Return IAsyncEnumerable from BiDi StreamAsync APIs

• Changes StreamAsync overloads to return IAsyncEnumerable<TEventArgs> rather than a custom stream interface. Keeps existing subscription behavior via EventDispatcher.SubscribeReaderAsync.

dotnet/src/webdriver/BiDi/BiDi.cs

ContextEventSource.csUpdate context-scoped event source streaming signature +1/-1

Update context-scoped event source streaming signature

• Updates IEventSource implementation for context-bound event sources to return IAsyncEnumerable<TEventArgs>. Continues to delegate stream creation to the dispatcher with context and filter.

dotnet/src/webdriver/BiDi/ContextEventSource.cs

EventSource.csUpdate global event source streaming signature +1/-1

Update global event source streaming signature

• Updates non-context event source streaming to return IAsyncEnumerable<TEventArgs>. Delegates stream creation to the dispatcher with the existing filter behavior.

dotnet/src/webdriver/BiDi/EventSource.cs

IBiDi.csChange IBiDi StreamAsync return types to IAsyncEnumerable +2/-2

Change IBiDi StreamAsync return types to IAsyncEnumerable

• Updates the public IBiDi interface so StreamAsync returns IAsyncEnumerable<TEventArgs> for both single and multi-descriptor overloads. Removes the custom stream interface dependency from the public surface.

dotnet/src/webdriver/BiDi/IBiDi.cs

IEventSource.csChange IEventSource StreamAsync to return IAsyncEnumerable +1/-1

Change IEventSource StreamAsync to return IAsyncEnumerable

• Updates the event source interface to return IAsyncEnumerable<TEventArgs> for streaming consumption. Aligns event source contracts with standard async enumeration patterns.

dotnet/src/webdriver/BiDi/IEventSource.cs

Tests (3) +7 / -11
NetworkTests.csStop relying on await using for event stream lifetime +1/-1

Stop relying on await using for event stream lifetime

• Updates network tests to treat StreamAsync results as IAsyncEnumerable without explicit await using. Relies on enumeration completion/disposal semantics to end subscriptions.

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

SessionTests.csUpdate session streaming tests to use IAsyncEnumerable patterns +4/-4

Update session streaming tests to use IAsyncEnumerable patterns

• Replaces await using stream variables with plain variables and continues enumerating via GetAsyncEnumerator. Keeps cancellation-focused assertions while matching the new implicit-unsubscribe behavior.

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

SessionUnitTests.csAdjust unit tests to avoid explicit stream disposal and ensure unsubscribe response +2/-6

Adjust unit tests to avoid explicit stream disposal and ensure unsubscribe response

• Removes explicit DisposeAsync calls and instead ensures unsubscribe is observed via WithResponse during enumeration operations. Updates FirstAsync flows so the unsubscribe wire response is exercised under the new disposal model.

dotnet/test/webdriver/BiDi/SessionUnitTests.cs

@qodo-code-review

qodo-code-review Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (3) 📎 Requirement gaps (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 15 rules

Grey Divider


Action required

1. No unsubscribe on enumeration end 🐞 Bug ☼ Reliability
Description
EventStream.ReadAllCoreAsync yields from the channel but never disposes/unsubscribes the owning
EventStream when enumeration completes or is disposed early (e.g., FirstAsync/break/cancellation).
This leaves the subscription registered in EventDispatcher and can keep buffering events into an
unbounded channel with no reader, causing resource leaks and unnecessary event processing.
Code

dotnet/src/webdriver/BiDi/EventStream.cs[R70-82]

+    private async IAsyncEnumerable<TEventArgs> ReadAllCoreAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
-        while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+        if (Interlocked.CompareExchange(ref _enumerating, 1, 0) != 0)
+        {
+            throw new InvalidOperationException("This event stream can only be enumerated once; create a new stream to read again.");
+        }
+
+        while (await _channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
        {
-            while (reader.TryRead(out var item))
+            while (_channel.Reader.TryRead(out var item))
            {
                yield return item;
            }
Evidence
The stream is added to EventDispatcher slots and removed only during wire-unsubscribe.
ReadAllCoreAsync does not invoke DisposeAsync (and has no finally), so ending or abandoning
enumeration does not unsubscribe or remove the stream from the dispatcher; event deliveries can
continue into an unbounded channel.

dotnet/src/webdriver/BiDi/EventDispatcher.cs[92-112]
dotnet/src/webdriver/BiDi/EventDispatcher.cs[219-231]
dotnet/src/webdriver/BiDi/EventStream.cs[31-37]
dotnet/src/webdriver/BiDi/EventStream.cs[70-84]
dotnet/src/webdriver/BiDi/EventStream.cs[86-103]

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

### Issue description
`EventStream<TEventArgs>.ReadAllCoreAsync` returns an async iterator over an unbounded channel, but it never calls `DisposeAsync()` (wire unsubscribe + slot removal) when enumeration ends or is abandoned early. As a result, common consumption patterns like `await stream.ReadAllAsync().FirstAsync()` can stop reading without actually unsubscribing the remote subscription.

### Issue Context
- The stream is registered into `EventDispatcher` slots on subscribe.
- The only code path that removes the stream from slots is `UnsubscribeAsync`, which is only invoked via `EventStream.DisposeAsync()`.
- The channel is unbounded, so continued deliveries after the consumer stops reading can accumulate.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/EventStream.cs[70-84]

### Suggested change
Wrap the iterator body in a `try/finally` and trigger cleanup in the `finally` so it runs when:
- the channel completes (normal end)
- the enumerator is disposed early (break/FirstAsync/Take)
- the cancellation token aborts the enumeration

In the `finally`, call `await DisposeAsync().ConfigureAwait(false)` (or call `_unsubscribe` directly) and consider catching/logging unsubscribe errors so they don't unexpectedly surface from enumerator disposal.

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


2. IEventStream no longer enumerable 📘 Rule violation ≡ Correctness
Description
Removing IAsyncEnumerable<TEventArgs> from IEventStream<TEventArgs> is a backward-incompatible
public API change that breaks consumers that used await foreach / LINQ directly on the stream.
This violates the backward-compatible public API/ABI requirement.
Code

dotnet/src/webdriver/BiDi/IEventStream.cs[22]

+public interface IEventStream<out TEventArgs> : IAsyncDisposable
Evidence
PR Compliance ID 389266 prohibits breaking changes to existing public symbols.
IEventStream<TEventArgs> is public and was changed to no longer be an
IAsyncEnumerable<TEventArgs>, which breaks existing enumeration-based usages.

Rule 389266: Maintain backward-compatible public API and ABI
dotnet/src/webdriver/BiDi/IEventStream.cs[22-25]

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

## Issue description
A public interface changed in a breaking way by removing `IAsyncEnumerable<T>` from its inheritance list.

## Issue Context
Existing consumer code treating `IEventStream<T>` as an `IAsyncEnumerable<T>` (e.g., `await foreach (var e in stream)`) will no longer compile.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/IEventStream.cs[22-22]

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


3. Multiple enumerators allowed ✓ Resolved 🐞 Bug ≡ Correctness
Description
EventStream.ReadAllAsync only blocks multiple ReadAllAsync calls, but the returned IAsyncEnumerable
can still be enumerated multiple times (multiple GetAsyncEnumerator calls), which can create
multiple readers against a Channel configured with SingleReader=true and lead to undefined
behavior/lost events.
Code

dotnet/src/webdriver/BiDi/EventStream.cs[R64-83]

+    public IAsyncEnumerable<TEventArgs> ReadAllAsync(CancellationToken cancellationToken = default)
    {
-        var effectiveToken = (_cancellationToken.CanBeCanceled, cancellationToken.CanBeCanceled) switch
+        if (_disposed != 0) throw new ObjectDisposedException(GetType().FullName);
+
+        if (Interlocked.CompareExchange(ref _enumerating, 1, 0) != 0)
        {
-            (true, true) => CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken, cancellationToken).Token,
+            throw new InvalidOperationException("The stream can only be enumerated once.");
+        }
+
+        CancellationTokenSource? linkedTokenSource = null;
+
+        CancellationToken effectiveToken = (_cancellationToken.CanBeCanceled, cancellationToken.CanBeCanceled) switch
+        {
+            (true, true) => (linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken, cancellationToken)).Token,
            (true, false) => _cancellationToken,
            (false, true) => cancellationToken,
            _ => default
        };

-        return ReadChannelAsync(_channel.Reader, effectiveToken);
+        return ReadChannelAsync(_channel.Reader, effectiveToken, linkedTokenSource);
Evidence
The stream uses an unbounded channel configured for a single reader, but ReadAllAsync returns an
async-iterator enumerable which can produce multiple enumerators (multiple reader instances) without
any guard at enumerator creation time.

dotnet/src/webdriver/BiDi/EventStream.cs[35-36]
dotnet/src/webdriver/BiDi/EventStream.cs[64-105]

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

### Issue description
`ReadAllAsync` returns an async-iterator `IAsyncEnumerable<T>` that can be enumerated multiple times even though the underlying channel is configured with `SingleReader = true`. The current `_enumerating` guard only prevents calling `ReadAllAsync` twice, not calling `GetAsyncEnumerator` twice on the same returned enumerable.

### Issue Context
This can lead to multiple concurrent reads from the same `ChannelReader<T>` (violating the single-reader optimization/assumption), potentially causing missed events or other undefined behavior.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/EventStream.cs[64-105]

### Suggested fix approach
- Return a custom `IAsyncEnumerable<TEventArgs>` implementation (not an async-iterator directly) whose `GetAsyncEnumerator(...)`:
 - Uses an `Interlocked.CompareExchange` guard to allow only a single enumerator to be created.
 - Creates any linked cancellation token source inside `GetAsyncEnumerator` and ensures it is disposed when the enumerator is disposed.
- Alternatively (if intended), change semantics to allow multiple enumerations by removing `SingleReader = true` and making the channel multi-reader safe (but that likely changes stream semantics).

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


View more (4)
4. Missing IEventStream ConfigureAwait 📎 Requirement gap ≡ Correctness
Description
IEventStream<TEventArgs> no longer provides a way for consumers to call
eventStream.ConfigureAwait(false) directly, contrary to the required unambiguous ConfigureAwait
experience for BiDi event streams. Consumers must now switch APIs (e.g.,
ReadAllAsync().ConfigureAwait(false)), which does not satisfy the stated requirement.
Code

dotnet/src/webdriver/BiDi/IEventStream.cs[R22-25]

+public interface IEventStream<out TEventArgs> : IAsyncDisposable
    where TEventArgs : EventArgs
{
+    IAsyncEnumerable<TEventArgs> ReadAllAsync(CancellationToken cancellationToken = default);
Evidence
PR Compliance ID 389276 requires a dedicated ConfigureAwait<T>(IEventStream<T>, bool) to ensure
eventStream.ConfigureAwait(false) compiles unambiguously. The updated IEventStream<TEventArgs>
interface only exposes ReadAllAsync(...) and does not provide (or inherit) a ConfigureAwait
surface for IEventStream<TEventArgs> itself.

Provide an unambiguous ConfigureAwait extension for IEventStream<T> in .NET BiDi event streams
dotnet/src/webdriver/BiDi/IEventStream.cs[22-25]

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

## Issue description
The checklist requires an unambiguous `ConfigureAwait(bool)` extension callable directly on `IEventStream<T>`.

## Issue Context
`IEventStream<T>` no longer derives from `IAsyncEnumerable<T>`, and the interface does not define any `ConfigureAwait` API. This prevents `eventStream.ConfigureAwait(false)` from compiling and breaks the intended ergonomic usage.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/IEventStream.cs[22-25]

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


5. IBiDi.StreamAsync return type changed ✓ Resolved 📘 Rule violation ≡ Correctness
Description
The public StreamAsync APIs now return Task<IAsyncEnumerable<TEventArgs>> instead of
Task<IEventStream<TEventArgs>>, which is a source/binary breaking change for existing consumers.
The prior event-stream abstraction is removed/invalidated without a deprecation phase and
replacement guidance.
Code

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

+    Task<IAsyncEnumerable<TEventArgs>> StreamAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;

-    Task<IEventStream<TEventArgs>> StreamAsync<TEventArgs>(ImmutableArray<EventDescriptor> descriptors, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+    Task<IAsyncEnumerable<TEventArgs>> StreamAsync<TEventArgs>(ImmutableArray<EventDescriptor> descriptors, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
Evidence
PR Compliance ID 389266 forbids incompatible changes to existing public API signatures;
StreamAsync changed its return type in IBiDi, and the implementation in BiDi was updated
accordingly. PR Compliance ID 389271 requires a deprecation-with-guidance phase before
removing/breaking public APIs, but there is no deprecated compatibility API present for the previous
IEventStream<TEventArgs> return type.

Rule 389266: Maintain backward-compatible public API and ABI
Rule 389271: Deprecate public APIs with guidance before removal
dotnet/src/webdriver/BiDi/IBiDi.cs[68-70]
dotnet/src/webdriver/BiDi/BiDi.cs[120-130]

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 BiDi streaming APIs were changed incompatibly (`IEventStream<TEventArgs>` -> `IAsyncEnumerable<TEventArgs>`), and the old surface is not preserved with a deprecation phase.

## Issue Context
This is a user-visible .NET API change that will break existing callers at compile-time and can break binaries. The compliance policy requires backward-compatible public API/ABI, and separately requires deprecating public APIs with guidance before removal.

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

## Implementation guidance (high level)
- Reintroduce a compatibility surface for existing callers, e.g. keep `StreamAsync` returning `Task<IEventStream<TEventArgs>>` (or add a differently-named method for the new `IAsyncEnumerable<TEventArgs>` return type, since return-type-only overloads are not possible in C#).
- Mark the legacy API with `[Obsolete("Use <replacement API> instead.")]` and keep it for at least one release cycle per project policy.
- Ensure the replacement API is clearly discoverable (docs + IntelliSense) and tests cover both paths during the deprecation period.

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


6. Undisposable stream leaks subscription ✓ Resolved 🐞 Bug ☼ Reliability
Description
StreamAsync now returns IAsyncEnumerable<TEventArgs>, so callers cannot DisposeAsync the underlying
EventStream even though SubscribeReaderAsync subscribes on the wire immediately. If a stream is
created but never enumerated, the remote subscription remains registered (and keeps dispatching)
until BiDi shutdown, potentially accumulating unused subscriptions.
Code

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

+    Task<IAsyncEnumerable<TEventArgs>> StreamAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;

-    Task<IEventStream<TEventArgs>> StreamAsync<TEventArgs>(ImmutableArray<EventDescriptor> descriptors, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
+    Task<IAsyncEnumerable<TEventArgs>> StreamAsync<TEventArgs>(ImmutableArray<EventDescriptor> descriptors, CancellationToken cancellationToken = default) where TEventArgs : EventArgs;
Evidence
The dispatcher performs wire subscription and registers the stream immediately, and the only code
path that removes the subscription is EventStream.DisposeAsync (wire unsubscribe). After the API
change, callers only receive an IAsyncEnumerable<T> and thus cannot call DisposeAsync unless
they enumerate (which triggers enumerator disposal) or downcast.

dotnet/src/webdriver/BiDi/IBiDi.cs[68-70]
dotnet/src/webdriver/BiDi/BiDi.cs[120-130]
dotnet/src/webdriver/BiDi/EventDispatcher.cs[92-113]
dotnet/src/webdriver/BiDi/EventDispatcher.cs[220-233]
dotnet/src/webdriver/BiDi/EventStream.cs[89-107]

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

## Issue description
`StreamAsync` returns `IAsyncEnumerable<T>` (not disposable), but `SubscribeReaderAsync` performs an eager wire subscribe and registers the subscription in dispatcher slots. If the returned stream is never enumerated, there's no way for callers to trigger unsubscription, so the subscription stays active until the whole `IBiDi` instance is disposed.

## Issue Context
- `EventDispatcher.SubscribeReaderAsync` eagerly subscribes and adds the `EventStream` to slots.
- `EventStream` only unsubscribes in `DisposeAsync`, which is no longer reachable via the public return type.

## Fix Focus Areas
- Implement a lazy `IAsyncEnumerable<T>` wrapper so *no wire subscribe happens until the first enumeration* (and dispose/unsubscribe happens when the enumerator is disposed).
- Ensure the wrapper disposes the underlying `EventStream` if enumeration starts, and does nothing if it never starts.

### Suggested approach
- Change `BiDi.StreamAsync(...)` (and `EventSource/ContextEventSource.StreamAsync`) to return an `IAsyncEnumerable<T>` that performs the `SubscribeReaderAsync(...)` call inside the enumerator (e.g., lazy-init on first `MoveNextAsync`).
- Keep `EventDispatcher.SubscribeReaderAsync` and `EventStream` mostly as-is; the key is to avoid calling them before enumeration.

## Fix Focus Areas (files/lines)
- dotnet/src/webdriver/BiDi/BiDi.cs[120-130]
- dotnet/src/webdriver/BiDi/EventSource.cs[47-50]
- dotnet/src/webdriver/BiDi/ContextEventSource.cs[51-54]
- dotnet/src/webdriver/BiDi/EventDispatcher.cs[92-113]
- dotnet/src/webdriver/BiDi/EventStream.cs[89-107]

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


7. BiDi.StreamAsync missing XML docs 📘 Rule violation ✧ Quality
Description
The modified public StreamAsync members on BiDi, IBiDi, and IEventSource<TEventArgs> have no
/// XML documentation block with a non-empty <summary>. This violates the PR’s public API XML
documentation requirement and reduces API discoverability for changed members.
Code

dotnet/src/webdriver/BiDi/BiDi.cs[R120-130]

+    public Task<IAsyncEnumerable<TEventArgs>> StreamAsync<TEventArgs>(EventDescriptor<TEventArgs> descriptor, CancellationToken cancellationToken = default) where TEventArgs : EventArgs
    {
        ArgumentNullException.ThrowIfNull(descriptor);

        return StreamAsync<TEventArgs>([descriptor], cancellationToken);
    }

-    public async Task<IEventStream<TEventArgs>> StreamAsync<TEventArgs>(ImmutableArray<EventDescriptor> descriptors, CancellationToken cancellationToken = default) where TEventArgs : EventArgs
+    public async Task<IAsyncEnumerable<TEventArgs>> StreamAsync<TEventArgs>(ImmutableArray<EventDescriptor> descriptors, CancellationToken cancellationToken = default) where TEventArgs : EventArgs
    {
        return await EventDispatcher.SubscribeReaderAsync<TEventArgs>(descriptors, cancellationToken: cancellationToken).ConfigureAwait(false);
    }
Evidence
PR Compliance ID 389245 requires XML documentation with a <summary> for all public API members
included in the diff. The cited BiDi.StreamAsync methods, the IBiDi.StreamAsync declarations,
and IEventSource<TEventArgs>.StreamAsync are public members whose signatures were changed in this
PR, and each appears without any preceding /// XML documentation comment block containing a
non-empty <summary>, demonstrating non-compliance with the requirement.

Rule 389245: Require XML documentation with <summary> for all public API members
dotnet/src/webdriver/BiDi/BiDi.cs[120-130]
dotnet/src/webdriver/BiDi/IBiDi.cs[68-70]
dotnet/src/webdriver/BiDi/IEventSource.cs[22-29]

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

## Issue description
Modified public API members in the diff must include XML documentation with a non-empty `<summary>`.

## Issue Context
The `StreamAsync` signatures were changed in this PR across `BiDi`, `IBiDi`, and `IEventSource<TEventArgs>`, but the affected public method/member declarations do not have XML doc comments immediately preceding them. Where behavior/semantics are relevant (for example, implicit unsubscribe on end of enumeration, what the async stream yields, and how cancellation/disposal affects subscription/unsubscription), the documentation should reflect those semantics.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/BiDi.cs[120-130]
- dotnet/src/webdriver/BiDi/IBiDi.cs[68-70]
- dotnet/src/webdriver/BiDi/IEventSource.cs[28-28]

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



Remediation recommended

8. Uncancelable stream unsubscribe 🐞 Bug ☼ Reliability
Description
EventStream<TEventArgs>.DisposeAsync always calls the wire-unsubscribe delegate with
CancellationToken.None, so disposing a stream cannot be shortened/cancelled by any caller-provided
CancellationToken and may block teardown until the broker command timeout when the remote end is
slow/unresponsive.
Code

dotnet/src/webdriver/BiDi/EventStream.cs[R39-43]

+    internal EventStream(Func<CancellationToken, ValueTask> unsubscribe, Func<TEventArgs, bool>? filter = null)
    {
        _unsubscribe = unsubscribe;
        _filter = filter;
-        _cancellationToken = cancellationToken;
    }
Evidence
The PR removes the stored cancellation token from EventStream construction, and DisposeAsync uses
CancellationToken.None when invoking the unsubscribe delegate. The unsubscribe delegate ultimately
forwards its token to the wire command, and the broker applies a default timeout when no
cancellation token is provided—so disposal can block until that timeout with no way for callers to
supply a shorter cancellation window.

dotnet/src/webdriver/BiDi/EventStream.cs[31-92]
dotnet/src/webdriver/BiDi/EventDispatcher.cs[219-232]
dotnet/src/webdriver/BiDi/Broker.cs[34-87]

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

### Issue description
`EventStream<TEventArgs>` no longer retains any cancellation token from `StreamAsync(...)` creation, and `DisposeAsync()` always calls `_unsubscribe(default)`. This removes the ability to bound/shorten disposal-time wire unsubscribe (e.g., in test teardown or app shutdown), so disposal can stall until the broker’s command timeout.

### Issue Context
- `EventDispatcher.UnsubscribeAsync(...)` forwards the provided token into the wire unsubscribe command.
- `Broker.ExecuteAsync(...)` applies a default 30s command timeout even when the token is `CancellationToken.None`, meaning `DisposeAsync()` can block up to that timeout.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/EventStream.cs[31-92]
- dotnet/src/webdriver/BiDi/EventDispatcher.cs[219-232]
- dotnet/src/webdriver/BiDi/Broker.cs[34-87]

### Suggested fix
Re-introduce a disposal/unsubscribe cancellation token for `EventStream` (separate from `ReadAllAsync` enumeration cancellation):
- Store a `CancellationToken _disposeToken` in `EventStream`, passed from `SubscribeReaderAsync(..., cancellationToken)` (or from a configured timeout token).
- In `DisposeAsync()`, call `await _unsubscribe(_disposeToken)` (or link `_disposeToken` with a short internal timeout via `CancellationTokenSource.CancelAfter`).
- Keep `ReadAllAsync(CancellationToken)` semantics unchanged (token only controls enumeration).

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


9. WaitAsync timeout doesn't cancel 🐞 Bug ☼ Reliability
Description
StreamCanBeReadSequentially uses WaitAsync(TimeSpan) around FirstAsync without passing a
CancellationToken into ReadAllAsync, so if the wait times out the underlying async enumeration can
keep running and consuming events. This can leave lingering background work and make subsequent
steps/tests flaky when timeouts occur.
Code

dotnet/test/webdriver/BiDi/SessionUnitTests.cs[R202-203]

+        var first = await stream.ReadAllAsync().FirstAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
+        var second = await stream.ReadAllAsync().FirstAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
Evidence
The test currently wraps FirstAsync with WaitAsync(TimeSpan) but does not pass any cancellation
token to the enumerator, while ReadAllAsync explicitly accepts a cancellation token that should be
used to cancel enumeration on timeout.

dotnet/test/webdriver/BiDi/SessionUnitTests.cs[194-209]
dotnet/src/webdriver/BiDi/EventStream.cs[62-73]

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

### Issue description
`StreamCanBeReadSequentially` applies a timeout via `WaitAsync(TimeSpan)` to `FirstAsync().AsTask()`, but it does not provide a cancellation token to the underlying async enumeration (`ReadAllAsync`). If the timeout fires, `WaitAsync` stops waiting but does not cancel the enumeration, which may continue running.

### Issue Context
`EventStream.ReadAllAsync` supports a `CancellationToken` parameter; tests should use that to enforce timeouts/cancellation on the actual enumeration.

### Fix Focus Areas
- dotnet/test/webdriver/BiDi/SessionUnitTests.cs[202-203]
- dotnet/src/webdriver/BiDi/EventStream.cs[62-62]

### Suggested fix
Replace the `WaitAsync(TimeSpan.FromSeconds(5))` timeout pattern with a `CancellationTokenSource(TimeSpan.FromSeconds(5))` and pass the token into `ReadAllAsync` (and/or `FirstAsync` if an overload exists). Example:

```csharp
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var first = await stream.ReadAllAsync(cts.Token).FirstAsync();
var second = await stream.ReadAllAsync(cts.Token).FirstAsync();
```

This makes the timeout actually cancel the enumeration instead of only timing out the wait.

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


10. Linked CTS can leak ✓ Resolved 🐞 Bug ☼ Reliability
Description
ReadAllAsync eagerly creates a linked CancellationTokenSource but only disposes it inside the async
iterator's finally; if the caller obtains the returned IAsyncEnumerable but never starts
enumeration, the linked CTS (and its token registrations) is never disposed.
Code

dotnet/src/webdriver/BiDi/EventStream.cs[R73-104]

+        CancellationTokenSource? linkedTokenSource = null;
+
+        CancellationToken effectiveToken = (_cancellationToken.CanBeCanceled, cancellationToken.CanBeCanceled) switch
+        {
+            (true, true) => (linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken, cancellationToken)).Token,
            (true, false) => _cancellationToken,
            (false, true) => cancellationToken,
            _ => default
        };

-        return ReadChannelAsync(_channel.Reader, effectiveToken);
+        return ReadChannelAsync(_channel.Reader, effectiveToken, linkedTokenSource);
    }

-    private static async IAsyncEnumerator<TEventArgs> ReadChannelAsync(ChannelReader<TEventArgs> reader, CancellationToken cancellationToken)
+    private static async IAsyncEnumerable<TEventArgs> ReadChannelAsync(
+        ChannelReader<TEventArgs> reader,
+        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken,
+        CancellationTokenSource? linkedTokenSource)
    {
-        while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+        try
        {
-            while (reader.TryRead(out var item))
+            while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
            {
-                yield return item;
+                while (reader.TryRead(out var item))
+                {
+                    yield return item;
+                }
            }
        }
+        finally
+        {
+            linkedTokenSource?.Dispose();
+        }
Evidence
The linked CTS is created in ReadAllAsync and only disposed in ReadChannelAsync's finally block,
which runs only if the iterator is actually enumerated.

dotnet/src/webdriver/BiDi/EventStream.cs[73-84]
dotnet/src/webdriver/BiDi/EventStream.cs[86-104]

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

### Issue description
`ReadAllAsync` allocates a linked `CancellationTokenSource` immediately, but disposal occurs only in the async-iterator `finally`. If the returned `IAsyncEnumerable` is never enumerated, the iterator never runs and the CTS is not disposed.

### Issue Context
This leaks token registrations and can keep objects alive longer than necessary. It is especially relevant because `IAsyncEnumerable` is commonly used in deferred-execution pipelines.

### Fix Focus Areas
- dotnet/src/webdriver/BiDi/EventStream.cs[73-104]

### Suggested fix approach
- Defer `CancellationTokenSource.CreateLinkedTokenSource(...)` until enumeration actually begins (e.g., inside `GetAsyncEnumerator` of a custom enumerable, or at the start of the iterator body).
- Ensure the CTS is disposed deterministically when enumeration ends or the enumerator is disposed.
- If you implement the custom single-use enumerable from the other finding, fold this fix into that implementation so the CTS is only created when the enumerator is created.

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


View more (2)
11. IEventStream missing XML docs 📘 Rule violation ✧ Quality
Description
The public IEventStream<TEventArgs> interface and its new public method ReadAllAsync lack XML
documentation with a non-empty <summary>. This reduces API discoverability and violates the
requirement for documented public members.
Code

dotnet/src/webdriver/BiDi/IEventStream.cs[R22-25]

+public interface IEventStream<out TEventArgs> : IAsyncDisposable
    where TEventArgs : EventArgs
{
+    IAsyncEnumerable<TEventArgs> ReadAllAsync(CancellationToken cancellationToken = default);
Evidence
PR Compliance ID 389245 requires /// XML documentation with a non-empty <summary> for all public
members in the diff. IEventStream<TEventArgs> and ReadAllAsync(...) are public but have no XML
doc block preceding them.

Rule 389245: Require XML documentation with <summary> for all public API members
dotnet/src/webdriver/BiDi/IEventStream.cs[22-25]

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 API members must have XML documentation with a non-empty `<summary>`.

## Issue Context
`IEventStream<TEventArgs>` is public and the newly introduced `ReadAllAsync(...)` method is part of the public surface, but neither has XML docs.

## Fix Focus Areas
- dotnet/src/webdriver/BiDi/IEventStream.cs[22-25]

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


12. Stream silently completes on reuse ✓ Resolved 🐞 Bug ≡ Correctness
Description
UnsubscribingAsyncEnumerator.DisposeAsync always disposes the owning EventStream (wire unsubscribe +
channel completion), so the returned IAsyncEnumerable becomes single-use. Subsequent calls to
GetAsyncEnumerator will read from a completed channel and terminate immediately, which can hide
consumer mistakes and contradicts the Channel’s SingleReader optimization.
Code

dotnet/src/webdriver/BiDi/EventStream.cs[R110-141]

+    private sealed class UnsubscribingAsyncEnumerator : IAsyncEnumerator<TEventArgs>
+    {
+        private readonly EventStream<TEventArgs> _owner;
+        private readonly IAsyncEnumerator<TEventArgs> _inner;
+        private readonly CancellationTokenSource? _linkedTokenSource;
+
+        internal UnsubscribingAsyncEnumerator(EventStream<TEventArgs> owner, IAsyncEnumerator<TEventArgs> inner, CancellationTokenSource? linkedTokenSource)
+        {
+            _owner = owner;
+            _inner = inner;
+            _linkedTokenSource = linkedTokenSource;
+        }
+
+        public TEventArgs Current => _inner.Current;
+
+        public ValueTask<bool> MoveNextAsync()
+        {
+            return _inner.MoveNextAsync();
+        }
+
+        public async ValueTask DisposeAsync()
+        {
+            try
+            {
+                await _inner.DisposeAsync().ConfigureAwait(false);
+            }
+            finally
+            {
+                _linkedTokenSource?.Dispose();
+                await _owner.DisposeAsync().ConfigureAwait(false);
+            }
+        }
Evidence
The wrapper enumerator disposes the owner on enumerator disposal, and owner disposal completes the
channel. Since GetAsyncEnumerator has no disposed/started guard, later enumerations will iterate
over a completed channel and end without producing events.

dotnet/src/webdriver/BiDi/EventStream.cs[63-76]
dotnet/src/webdriver/BiDi/EventStream.cs[78-87]
dotnet/src/webdriver/BiDi/EventStream.cs[89-107]
dotnet/src/webdriver/BiDi/EventStream.cs[110-141]

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

## Issue description
The new enumerator wrapper disposes the owning `EventStream` whenever an enumerator is disposed. This makes the returned `IAsyncEnumerable<T>` single-use; re-enumeration completes immediately (because the channel is completed) rather than failing fast, which can silently mask bugs.

## Issue Context
- `UnsubscribingAsyncEnumerator.DisposeAsync()` calls `_owner.DisposeAsync()`.
- `EventStream.DisposeAsync()` completes the channel.
- `GetAsyncEnumerator()` does not check `_disposed` or enforce single enumeration.

## Fix Focus Areas
- Fail fast on misuse (e.g., throw `ObjectDisposedException` if `_disposed != 0`).
- Optionally enforce "single active enumerator" (since the channel is configured `SingleReader = true`), e.g., track an `_enumeratorCreated` flag and throw if called twice.

## Fix Focus Areas (files/lines)
- dotnet/src/webdriver/BiDi/EventStream.cs[34-36]
- dotnet/src/webdriver/BiDi/EventStream.cs[63-76]
- dotnet/src/webdriver/BiDi/EventStream.cs[89-107]
- dotnet/src/webdriver/BiDi/EventStream.cs[110-141]

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


Grey Divider

Qodo Logo

Comment thread dotnet/src/webdriver/BiDi/IBiDi.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/BiDi.cs Outdated
Comment thread dotnet/src/webdriver/BiDi/IBiDi.cs Outdated
@kevinoid

kevinoid commented Jun 22, 2026

Copy link
Copy Markdown

I really like this simplification too! It makes things more understandable for consumers by using only a well-known interface and reduces casting by avoiding the ConfigureAwait ambiguity, which are both big wins!

The main drawback I see is that if the IAsyncEnumerable<> returned by CreateStream() isn't used (i.e. GetAsyncEnumerator isn't called), the subscription won't be cleaned up until its finalizer runs. Is that correct? Is there much harm in delaying subscription cleanup? I'd hope that would be rare (why call CreateStream() if you aren't going to use it?) and could be worked around by casting to IAsyncDisposable for using in those rare cases. Just confirming it isn't a sharp edge which users may hurt themselves on.

Another small drawback is that if enumeration is attempted after the first is disposed (i.e. .GetAsyncEnumerator() is called after .GetAsyncEnumerator().DisposeAsync()) it will fail. It's not clear to me whether it would fail gracefully. (Probably by throwing from ChannelReader? It doesn't look like .GetAsyncEnumerator() checks whether it has been disposed?) I don't think this is a big problem since, like IEnumerable, the contract only guarantees it can be enumerated once. (Hopefully an analyzer like CA1851 will be added for IAsyncEnumerable in the future to catch this.) Perhaps it would make sense to throw ObjectDisposedException explicitly? Or, perhaps it would make sense to throw InvalidOperationException if .GetAsyncEnumerator() is called a second time (since multiple enumerators would compete to read from the same channel if neither is disposed?)?

Thanks for exploring it @nvborisenko, I really like it!

@nvborisenko

Copy link
Copy Markdown
Member Author

the IAsyncEnumerable<> returned by CreateStream() isn't used

This is the same issue as before. All data, got from remote end, is buffered. This is "intentional". Users might forgot to call Dispose(). Should we resolve it?.. - Yes. How? I guess via transport backpressure mechanism: kind of "hold on buffering until already 1024 buffered messages/events are not processed". But this is different story.

.GetAsyncEnumerator() is called after .GetAsyncEnumerator().DisposeAsync()) it will fail.

Good call, we should throw with self-explainable exception.

Thanks for your review, appreciate earlier feedback.

Comment thread dotnet/src/webdriver/BiDi/EventStream.cs Outdated
Comment thread dotnet/test/webdriver/BiDi/SessionUnitTests.cs Outdated
@nvborisenko

nvborisenko commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

Now the following case doesn't work, event collection pattern:

var stream = await bidi...StreamAsync();

// await stream.DisposeAsync();

stream.ToListAsync(); // hang because the stream never completes

Might be fixed via: await stream.CompleteAsync() (or UnsubscribeAsync())

@kevinoid

Copy link
Copy Markdown

Good catch! I would have thought that enumerating a stream after disposing it would throw. Having a way to unsubscribe from future events while still enumerating buffered events seems like a great idea.

Relatedly: It would be great to have a way to enumerate only buffered events (i.e. via TryRead). Perhaps exposing the ChannelReader<T> would make sense? (Allowing users to call ReadAllAsync if they want an IAsyncEnumerable<T>.)

@nvborisenko

Copy link
Copy Markdown
Member Author

I would have thought that enumerating a stream after disposing it would throw.

This is good observation. Enumeration after disposal does look strange. stream.CompleteAsync() seems reasonable.

At the same time, honestly, I prefer explicit IAsyncDisposable because there is underline subscription, kind of managed resource.

stream.Reader is nice proposal!

Current state in my head:

await using var stream = await bidi....StreamAsync(); // disposable, because remote-end subscription

DoSomething();

await stream.CompleteAsync(); // signal to stop receiving events (should we unsubscribe on remote-end?)

stream.Reader // -> ChannelReader<>, new api surface, providing some great capabilities

If we will introduce stream.Reader, then should IEventStream implement IAsyncEnumerable? 2 api surfaces are doing the same, not good.

@kevinoid

Copy link
Copy Markdown

I agree; the redundancy would be confusing and having explicit IAsyncDisposable for the resource would be preferable.

What if StreamAsync() returned IEventStream<T> which implements IAsyncDisposable where DisposeAsync unsubscribes/completes the stream, similar to the current implementation, but instead of implementing IAsyncEnumerable<T>, IEventStream<T> could have a Reader property to get the ChannelReader<T>?

@nvborisenko

Copy link
Copy Markdown
Member Author

True, this is exactly what is in my head. Previously I thought throght how to make IEventStream richer, like stream.Completion - a task; Within stream.Reader we are getting it for free.

OK, sold! Next step is to understand what we are loosing and what we are bringing for end users.

@nvborisenko

Copy link
Copy Markdown
Member Author

Concern: exposing ChannelReader<> seems leaking implementation details. But good news is ChannelReader<> is abstract, looks not a criminal.

@nvborisenko

Copy link
Copy Markdown
Member Author

I don't see use cases why stream.Reader is better than await foreach (stream). @kevinoid please help me.

If we really want to bring stream.Reader (which makes api harder), then we should understand why we do it. From low-level perspective it might be clear, but I don't see cases.

@kevinoid

Copy link
Copy Markdown

I agree; it's a trade-off that's important to weigh and understand. The big features that ChannelReader<T> would bring for me:

  1. TryRead (and TryPeek) allows reading (or checking for) buffered events without waiting to read more items if none are buffered.
  2. ReadAsync makes it possible to cancel a read. (Since MoveNextAsync doesn't take a CancellationToken.)

Features that might be useful for other users:

  1. Completion might be useful to know when unsubscribe has occurred. The CancellationToken passed to StreamAsync can be used to determine when unsubscribe has been requested, rather than completed, which covers my current use cases. Are there cases where unsubscribe occurs due to other reasons than cancellation (e.g. stream errors) which users might care about?
  2. Count to determine how many events are buffered. Again, I don't have a current use for this, but perhaps other users might?

The big ones for me are checking for buffered events and cancelling individual reads. Providing an interface with just these methods would be sufficient for most of the use cases I see and would keep the API a bit smaller and easier to change in the future, with the slight understandability cost of using a custom interface instead of the well-known ChannelReader<T> .

Comment thread dotnet/src/webdriver/BiDi/IEventStream.cs
Comment thread dotnet/src/webdriver/BiDi/IEventStream.cs
Comment thread dotnet/src/webdriver/BiDi/EventStream.cs Outdated
@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 8cd2fee

@nvborisenko

Copy link
Copy Markdown
Member Author

@kevinoid , I am looking at:

await using var stream = await bidi....StreamAsync(); // disposable resource, starts buffering
var enumerable = await stream.ReadAllAsync(); // IAsyncEnumerable
DoSomething();
enumerable.ToList(); // still hang, not yet completed

stream.Reader is missing, stream is already a reader by nature.

Anyway puzzle is incomplete:

  • Dispose versus Complete (seems resolved because enumeration over explicit enumerator instead of stream?)
  • ReadAllAsync() twice/concurrent?

I don't see complete picture how it should work.

@kevinoid

Copy link
Copy Markdown

I like separating IAsyncEnumerable<T> from IAsyncDisposable.

I'm disappointed that it won't have either of the features I mentioned previously (a way to read buffered items without waiting (TryRead) or a way to cancel a single read (ReadAsync)) but perhaps those can be considered for addition later?

Dispose vs Complete is tricky. It feels unsafe to continue enumerating after disposal, but as long as it's clearly documented that stream disposal doesn't invalidate the read enumerable, I think it would be ok. I'd probably lean toward separate methods with Complete unsubscribing and Dispose both unsubscribing and releasing all buffered events and invalidating readers, but I see it as subjective and wouldn't object to either approach.

Calling ReadAllAsync multiple times seems like a footgun to me, but there are probably reasonable use cases (e.g. if the first enumerable is disposed before the second call, or distributing events across threads). I think I've changed my mind on it: Since ChannelReader allows it, and the risks should be obvious to callers, I'd allow it.

@nvborisenko

nvborisenko commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

TryRead later for sure. design should allow it.

@RenderMichael @YevgeniyShunevych your opinion is highly appreciated here. Recently we added streaming of events. This feature should open the world for IAsyncEnumerable, which is amazing for Async LINQ. Please read the history and express your opinion, keeping in mind potential "simple use cases".

This is not easy. please be careful.

UPD: seems allowing only one single enumerator might resolve all concerns. Enumerator finishes - we are stopping buffering following events (unsubscribe on remote-end). This contract also implicitly resolves the issue with memory consumption: user forgot to unsubscribe/dispose. But how to require user to consume buffered events?.. it is his responsibility? - yes.

UPD: if enumerator finish means to unssubcribe, then it should mean starting of enumeration is to subscribe.

UPD: personally I wish to create extension method like stream.Doing(() => DoSomething()).AllAsync(). Seems not a big problem, I can do it over IEventSource object. Thus the question here is what capabilities we should allow over the stream, and how to make the stream/reader/enumerable finite.

Comment thread dotnet/src/webdriver/BiDi/EventStream.cs Outdated
@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 616a694

This was referenced Jul 11, 2026
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.

[🚀 Feature]: [dotnet][bidi] IEventStream<>.ConfigureAwait() call is ambiguous

3 participants