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