diff --git a/dotnet/src/webdriver/BiDi/EventStreamExtensions.cs b/dotnet/src/webdriver/BiDi/EventStreamExtensions.cs
new file mode 100644
index 0000000000000..9036d9ba7be89
--- /dev/null
+++ b/dotnet/src/webdriver/BiDi/EventStreamExtensions.cs
@@ -0,0 +1,51 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Runtime.CompilerServices;
+
+namespace OpenQA.Selenium.BiDi;
+
+public static class EventStreamExtensions
+{
+ ///
+ /// Configures how awaits on the tasks returned from an iteration of the event stream are performed.
+ ///
+ ///
+ ///
+ /// implements both and
+ /// , which makes a plain .ConfigureAwait(bool) call ambiguous
+ /// (CS0121) because TaskAsyncEnumerableExtensions provides an overload for each interface.
+ /// This extension method resolves the ambiguity by explicitly routing to the
+ /// overload, which is the behavior callers need when using
+ /// await foreach.
+ ///
+ ///
+ /// The event-args type produced by the stream.
+ /// The event stream to configure.
+ ///
+ /// to capture and marshal continuation back to the original context;
+ /// to continue on a thread-pool thread.
+ ///
+ /// A configured enumerable that applies the specified context-capture behavior.
+ public static ConfiguredCancelableAsyncEnumerable ConfigureAwait(
+ this IEventStream stream,
+ bool continueOnCapturedContext)
+ where TEventArgs : EventArgs
+ => ((IAsyncEnumerable)stream).ConfigureAwait(continueOnCapturedContext);
+}
diff --git a/dotnet/test/webdriver/BiDi/EventStreamExtensionsTests.cs b/dotnet/test/webdriver/BiDi/EventStreamExtensionsTests.cs
new file mode 100644
index 0000000000000..7a98bfcf714c2
--- /dev/null
+++ b/dotnet/test/webdriver/BiDi/EventStreamExtensionsTests.cs
@@ -0,0 +1,84 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Runtime.CompilerServices;
+using OpenQA.Selenium.BiDi;
+
+namespace OpenQA.Selenium.Tests.BiDi;
+
+[Parallelizable(ParallelScope.All)]
+[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
+class EventStreamExtensionsTests
+{
+ private IBiDi _bidi;
+ private FakeTransport _transport;
+
+ [SetUp]
+ public async Task SetUp()
+ {
+ _transport = new FakeTransport();
+ _bidi = await Selenium.BiDi.BiDi.ConnectAsync(new Uri("ws://fake"), opts => opts.UseTransport(() => _transport));
+ }
+
+ [TearDown]
+ public async Task TearDown()
+ {
+ await _bidi.DisposeAsync();
+ }
+
+ [Test]
+ public async Task ConfigureAwait_ReturnsConfiguredCancelableAsyncEnumerable()
+ {
+ var stream = await _bidi.Script.RealmDestroyed.StreamAsync()
+ .WithResponse(_transport, """{"subscription":"sub-1"}""");
+
+ // This line previously failed to compile (CS0121) because IEventStream implements
+ // both IAsyncEnumerable and IAsyncDisposable and both have a matching ConfigureAwait overload.
+ // EventStreamExtensions.ConfigureAwait disambiguates toward IAsyncEnumerable.
+ var configured = stream.ConfigureAwait(false);
+
+ Assert.That(configured, Is.InstanceOf>());
+
+ await stream.DisposeAsync().WithResponse(_transport);
+ }
+
+ [Test]
+ public async Task ConfigureAwait_DeliverEventsThroughConfiguredEnumerable()
+ {
+ var stream = await _bidi.Script.RealmDestroyed.StreamAsync()
+ .WithResponse(_transport, """{"subscription":"sub-1"}""");
+
+ _transport.EnqueueEvent("script.realmDestroyed", """{"realm":"r-1"}""");
+
+ var received = new List();
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+
+ await foreach (var e in stream.ConfigureAwait(false).WithCancellation(cts.Token))
+ {
+ received.Add(e);
+ break;
+ }
+
+ Assert.That(received, Has.Count.EqualTo(1));
+ Assert.That(received[0].Realm.Id, Is.EqualTo("r-1"));
+
+ await stream.DisposeAsync().WithResponse(_transport);
+ }
+}
diff --git a/rb/lib/selenium/webdriver/common/websocket_connection.rb b/rb/lib/selenium/webdriver/common/websocket_connection.rb
index 0774a46918009..0b636eb489b5b 100644
--- a/rb/lib/selenium/webdriver/common/websocket_connection.rb
+++ b/rb/lib/selenium/webdriver/common/websocket_connection.rb
@@ -34,8 +34,13 @@ class WebSocketConnection
RESPONSE_WAIT_INTERVAL = 0.1
MAX_LOG_MESSAGE_SIZE = 9999
+ MAX_FRAME_SIZE = 100 * 1024 * 1024 # 100 MB; DevTools payloads can be large
def initialize(url:)
+ # websocket-ruby exposes max_frame_size only as a global; bump it for devtools use
+ # only when the current limit is lower so user-configured values are not overridden
+ WebSocket.max_frame_size = MAX_FRAME_SIZE if WebSocket.max_frame_size < MAX_FRAME_SIZE
+
@callback_threads = ThreadGroup.new
@callbacks_mtx = Mutex.new
@@ -142,6 +147,10 @@ def attach_socket_listener
@callback_threads.add(callback_thread(message['params'], &callback))
end
end
+
+ # websocket-ruby rescues TooLong internally and returns nil from next;
+ # raise the stored error so the loop exits instead of spinning at 100% cpu
+ raise incoming_frame.error if incoming_frame.error?
end
rescue *CONNECTION_ERRORS, WebSocket::Error => e
WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws
diff --git a/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs b/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs
index 5d763fc23542c..3d0bab56c9adf 100644
--- a/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs
+++ b/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs
@@ -35,6 +35,8 @@ module Selenium
MAX_LOG_MESSAGE_SIZE: Integer
+ MAX_FRAME_SIZE: Integer
+
def initialize: (url: String) -> void
def add_callback: (untyped event) { () -> void } -> Integer
diff --git a/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb b/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb
new file mode 100644
index 0000000000000..dcce93c351f5f
--- /dev/null
+++ b/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+# Licensed to the Software Freedom Conservancy (SFC) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The SFC licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+require File.expand_path('../spec_helper', __dir__)
+
+module Selenium
+ module WebDriver
+ describe WebSocketConnection do
+ # Stub network I/O so we can test initialize without a real socket.
+ before do
+ allow_any_instance_of(described_class).to receive(:process_handshake)
+ allow_any_instance_of(described_class).to receive(:attach_socket_listener).and_return(nil)
+ end
+
+ describe 'MAX_FRAME_SIZE' do
+ it 'is 100 MB' do
+ expect(described_class::MAX_FRAME_SIZE).to eq(100 * 1024 * 1024)
+ end
+ end
+
+ describe '#initialize' do
+ it 'raises WebSocket.max_frame_size to MAX_FRAME_SIZE when current value is lower' do
+ original = WebSocket.max_frame_size
+ WebSocket.max_frame_size = 1024
+
+ described_class.new(url: 'ws://localhost:4444')
+
+ expect(WebSocket.max_frame_size).to eq(described_class::MAX_FRAME_SIZE)
+ ensure
+ WebSocket.max_frame_size = original
+ end
+
+ it 'does not lower WebSocket.max_frame_size when already above MAX_FRAME_SIZE' do
+ original = WebSocket.max_frame_size
+ higher = described_class::MAX_FRAME_SIZE * 2
+ WebSocket.max_frame_size = higher
+
+ described_class.new(url: 'ws://localhost:4444')
+
+ expect(WebSocket.max_frame_size).to eq(higher)
+ ensure
+ WebSocket.max_frame_size = original
+ end
+ end
+ end
+ end
+end