blob: 8ab5d7ea66aac31504baece0797882f4bef0e3a3 [file] [edit]
// <copyright file="Broker.cs" company="Selenium Committers">
// 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.
// </copyright>
using System.Buffers;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading.Channels;
using OpenQA.Selenium.Internal.Logging;
namespace OpenQA.Selenium.BiDi;
internal sealed class Broker : IAsyncDisposable
{
// Limits how many received messages can be buffered before backpressure is applied to the transport.
private const int ReceivedMessageQueueCapacity = 16;
// How long to wait for a command response before cancelling.
private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30);
private readonly ILogger _logger = Internal.Logging.Log.GetLogger<Broker>();
private readonly ITransport _transport;
private readonly BiDi _bidi;
private readonly ConcurrentDictionary<long, CommandInfo> _pendingCommands = new();
private long _currentCommandId;
private readonly Channel<PooledBufferWriter> _receivedMessages = Channel.CreateBounded<PooledBufferWriter>(
new BoundedChannelOptions(ReceivedMessageQueueCapacity) { SingleReader = true, SingleWriter = true, FullMode = BoundedChannelFullMode.Wait });
private readonly Channel<PooledBufferWriter> _bufferPool = Channel.CreateBounded<PooledBufferWriter>(
new BoundedChannelOptions(ReceivedMessageQueueCapacity) { SingleReader = false, SingleWriter = false });
private volatile Exception? _terminalReceiveException;
private readonly Task _receivingTask;
private readonly Task _processingTask;
private readonly CancellationTokenSource _receiveMessagesCancellationTokenSource;
public Broker(ITransport transport, BiDi bidi)
{
_transport = transport;
_bidi = bidi;
_receiveMessagesCancellationTokenSource = new CancellationTokenSource();
_receivingTask = Task.Run(() => ReceiveMessagesAsync(_receiveMessagesCancellationTokenSource.Token));
_processingTask = Task.Run(ProcessMessagesAsync);
}
public async Task<TResult> ExecuteAsync<TParameters, TResult>(Command<TParameters, TResult> descriptor, TParameters @params, CommandOptions? options, CancellationToken cancellationToken)
where TParameters : Parameters
where TResult : EmptyResult
{
if (_terminalReceiveException is { } terminalException)
{
throw new BiDiException("The broker is no longer processing messages due to a transport error.", terminalException);
}
var id = Interlocked.Increment(ref _currentCommandId);
var tcs = new TaskCompletionSource<EmptyResult>(TaskCreationOptions.RunContinuationsAsynchronously);
using var cts = cancellationToken.CanBeCanceled
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: new CancellationTokenSource();
var timeout = options?.Timeout ?? DefaultCommandTimeout;
cts.CancelAfter(timeout);
var sendBuffer = RentBuffer();
try
{
using (BiDiContext.Use(_bidi))
using (var writer = new Utf8JsonWriter(sendBuffer))
{
writer.WriteStartObject();
writer.WriteNumber("id"u8, id);
writer.WriteString("method"u8, descriptor.Method);
writer.WritePropertyName("params"u8);
if (options is { AdditionalData: { IsEmpty: false } additionalData })
{
// Cannot mutate the shared Parameters.Empty singleton; create a fresh instance to hold the extra data.
if (ReferenceEquals(@params, Parameters.Empty))
{
@params = (TParameters)(object)new Parameters();
}
@params.RawAdditionalData ??= [];
foreach (var prop in additionalData)
{
@params.RawAdditionalData[prop.Name] = prop.Value;
}
}
JsonSerializer.Serialize(writer, @params, descriptor.ParamsTypeInfo);
if (options is not null)
{
foreach (var prop in options.AdditionalMessageData)
{
writer.WritePropertyName(prop.Name);
prop.Value.WriteTo(writer);
}
}
writer.WriteEndObject();
}
}
catch
{
ReturnBuffer(sendBuffer);
throw;
}
var commandInfo = new CommandInfo(tcs, descriptor.ResultTypeInfo);
_pendingCommands[id] = commandInfo;
using var ctsRegistration = cts.Token.Register(() =>
{
tcs.TrySetCanceled(cts.Token);
_pendingCommands.TryRemove(id, out _);
});
try
{
if (_logger.IsEnabled(LogEventLevel.Trace))
{
#if NET8_0_OR_GREATER
_logger.Trace($"BiDi SND --> {System.Text.Encoding.UTF8.GetString(sendBuffer.WrittenMemory.Span)}");
#else
_logger.Trace($"BiDi SND --> {System.Text.Encoding.UTF8.GetString(sendBuffer.WrittenMemory.ToArray())}");
#endif
}
await _transport.SendAsync(sendBuffer.WrittenMemory, cts.Token).ConfigureAwait(false);
}
catch
{
_pendingCommands.TryRemove(id, out _);
throw;
}
finally
{
ReturnBuffer(sendBuffer);
}
return (TResult)await tcs.Task.ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
try
{
// Dispose subscriptions while transport and processing loop are still active,
// allowing wire unsubscribe commands to be sent and handler drain tasks to complete.
await _bidi.EventDispatcher.CompleteAllAsync(_terminalReceiveException).ConfigureAwait(false);
_receiveMessagesCancellationTokenSource.Cancel();
try
{
await _receivingTask.ConfigureAwait(false);
}
catch (OperationCanceledException) when (_receiveMessagesCancellationTokenSource.IsCancellationRequested)
{
// Expected when cancellation is requested, ignore.
}
await _processingTask.ConfigureAwait(false);
await _transport.DisposeAsync().ConfigureAwait(false);
}
finally
{
_receiveMessagesCancellationTokenSource.Dispose();
while (_bufferPool.Reader.TryRead(out var buffer))
{
buffer.Dispose();
}
}
}
private void ProcessReceivedMessage(ReadOnlySpan<byte> data)
{
using var scope = BiDiContext.Use(_bidi);
const int TypeSuccess = 1;
const int TypeEvent = 2;
const int TypeError = 3;
long? id = default;
int messageType = 0;
string? method = default;
string? error = default;
string? message = default;
Utf8JsonReader resultReader = default;
Utf8JsonReader paramsReader = default;
Dictionary<string, JsonElement>? additionalMessageData = null;
Utf8JsonReader reader = new(data);
reader.Read(); // "{"
reader.Read();
while (reader.TokenType == JsonTokenType.PropertyName)
{
if (reader.ValueTextEquals("id"u8))
{
reader.Read();
id = reader.GetInt64();
}
else if (reader.ValueTextEquals("type"u8))
{
reader.Read();
if (reader.ValueTextEquals("success"u8)) messageType = TypeSuccess;
else if (reader.ValueTextEquals("event"u8)) messageType = TypeEvent;
else if (reader.ValueTextEquals("error"u8)) messageType = TypeError;
}
else if (reader.ValueTextEquals("method"u8))
{
reader.Read();
method = reader.GetString();
}
else if (reader.ValueTextEquals("result"u8))
{
reader.Read();
resultReader = reader; // snapshot
}
else if (reader.ValueTextEquals("params"u8))
{
reader.Read();
paramsReader = reader; // snapshot
}
else if (reader.ValueTextEquals("error"u8))
{
reader.Read();
error = reader.GetString();
}
else if (reader.ValueTextEquals("message"u8))
{
reader.Read();
message = reader.GetString();
}
else
{
var propName = reader.GetString()!;
reader.Read();
additionalMessageData ??= [];
additionalMessageData[propName] = JsonElement.ParseValue(ref reader);
}
reader.Skip();
reader.Read();
}
switch (messageType)
{
case TypeSuccess:
if (id is null) throw new BiDiException("The remote end responded with 'success' message type, but missed required 'id' property.");
if (_pendingCommands.TryRemove(id.Value, out var command))
{
try
{
var commandResult = JsonSerializer.Deserialize(ref resultReader, command.JsonResultTypeInfo)
?? throw new BiDiException("Remote end returned null command result in the 'result' property.");
if (additionalMessageData is not null)
{
((EmptyResult)commandResult).AdditionalMessageData = AdditionalData.FromDictionary(additionalMessageData);
}
command.TaskCompletionSource.TrySetResult((EmptyResult)commandResult);
}
catch (Exception ex)
{
command.TaskCompletionSource.TrySetException(ex);
}
}
else
{
if (_logger.IsEnabled(LogEventLevel.Warn))
{
_logger.Warn($"The remote end responded with 'success' message type, but no pending command with id {id} was found. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}");
}
}
break;
case TypeEvent:
if (method is null) throw new BiDiException($"The remote end responded with 'event' message type, but missed required 'method' property. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}");
if (!_bidi.EventDispatcher.TryDeserializeAndDispatch(method, ref paramsReader, additionalMessageData))
{
if (_logger.IsEnabled(LogEventLevel.Warn))
{
_logger.Warn($"Received BiDi event with method '{method}', but no event type mapping was found. Event will be ignored. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}");
}
}
break;
case TypeError:
if (id is null) throw new BiDiException($"The remote end responded with 'error' message type, but missed required 'id' property. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}");
if (_pendingCommands.TryRemove(id.Value, out var errorCommand))
{
errorCommand.TaskCompletionSource.TrySetException(new BiDiException($"{error}: {message}"));
}
else
{
if (_logger.IsEnabled(LogEventLevel.Warn))
{
_logger.Warn($"The remote end responded with 'error' message type, but no pending command with id {id} was found. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}");
}
}
break;
default:
if (_logger.IsEnabled(LogEventLevel.Warn))
{
_logger.Warn($"The remote end responded with unknown message type. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}");
}
break;
}
}
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var buffer = RentBuffer();
try
{
await _transport.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
if (_logger.IsEnabled(LogEventLevel.Trace))
{
#if NET8_0_OR_GREATER
_logger.Trace($"BiDi RCV <-- {System.Text.Encoding.UTF8.GetString(buffer.WrittenMemory.Span)}");
#else
_logger.Trace($"BiDi RCV <-- {System.Text.Encoding.UTF8.GetString(buffer.WrittenMemory.ToArray())}");
#endif
}
await _receivedMessages.Writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
}
catch
{
ReturnBuffer(buffer);
throw;
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
if (_logger.IsEnabled(LogEventLevel.Error))
{
_logger.Error($"Unhandled error occurred while receiving remote messages: {ex}");
}
// Propagated via _terminalReceiveException; not rethrown to keep disposal orderly.
_terminalReceiveException = ex;
}
finally
{
_receivedMessages.Writer.TryComplete();
}
}
private async Task ProcessMessagesAsync()
{
var reader = _receivedMessages.Reader;
while (await reader.WaitToReadAsync().ConfigureAwait(false))
{
while (reader.TryRead(out var buffer))
{
try
{
ProcessReceivedMessage(buffer.WrittenMemory.Span);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogEventLevel.Error))
{
_logger.Error($"Unhandled error occurred while processing remote message: {ex}");
}
}
finally
{
ReturnBuffer(buffer);
}
}
}
// Channel is fully drained. Fail any commands that didn't get a response:
// either with the transport error or cancellation for clean shutdown.
var terminalException = _terminalReceiveException;
foreach (var id in _pendingCommands.Keys)
{
if (_pendingCommands.TryRemove(id, out var pendingCommand))
{
if (terminalException is not null)
{
pendingCommand.TaskCompletionSource.TrySetException(terminalException);
}
else
{
pendingCommand.TaskCompletionSource.TrySetCanceled();
}
}
}
}
private PooledBufferWriter RentBuffer()
{
return _bufferPool.Reader.TryRead(out var buffer) ? buffer : new PooledBufferWriter();
}
private void ReturnBuffer(PooledBufferWriter buffer)
{
buffer.Reset();
if (!_bufferPool.Writer.TryWrite(buffer))
{
buffer.Dispose();
}
}
private readonly record struct CommandInfo(TaskCompletionSource<EmptyResult> TaskCompletionSource, JsonTypeInfo JsonResultTypeInfo);
private sealed class PooledBufferWriter : IBufferWriter<byte>, IDisposable
{
private const int DefaultBufferSize = 1024 * 8;
private byte[]? _buffer = ArrayPool<byte>.Shared.Rent(DefaultBufferSize);
private int _written;
public ReadOnlyMemory<byte> WrittenMemory => new(_buffer, 0, _written);
public void Reset()
{
var buffer = _buffer ?? throw new ObjectDisposedException(nameof(PooledBufferWriter));
if (buffer.Length > DefaultBufferSize)
{
ArrayPool<byte>.Shared.Return(buffer);
_buffer = ArrayPool<byte>.Shared.Rent(DefaultBufferSize);
}
_written = 0;
}
public void Advance(int count)
{
if (count < 0) throw new ArgumentOutOfRangeException(nameof(count));
if (_written + count > (_buffer?.Length ?? 0)) throw new InvalidOperationException("Cannot advance past the end of the buffer.");
_written += count;
}
public Memory<byte> GetMemory(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsMemory(_written);
}
public Span<byte> GetSpan(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsSpan(_written);
}
private void EnsureCapacity(int sizeHint)
{
var buffer = _buffer ?? throw new ObjectDisposedException(nameof(PooledBufferWriter));
if (sizeHint <= 0)
{
sizeHint = Math.Max(1, buffer.Length - _written);
}
if (_written + sizeHint > buffer.Length)
{
var newSize = Math.Max(buffer.Length * 2, _written + sizeHint);
var newBuffer = ArrayPool<byte>.Shared.Rent(newSize);
buffer.AsSpan(0, _written).CopyTo(newBuffer);
ArrayPool<byte>.Shared.Return(buffer);
_buffer = newBuffer;
}
}
public void Dispose()
{
var buffer = _buffer;
if (buffer is not null)
{
_buffer = null;
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
}