| // <copyright file="WebSocketConnection.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; |
| using System.Net.WebSockets; |
| using System.Text; |
| using System.Threading; |
| using System.Threading.Tasks; |
| |
| namespace OpenQA.Selenium.DevTools; |
| |
| /// <summary> |
| /// Represents a connection to a WebDriver Bidi remote end. |
| /// </summary> |
| public class WebSocketConnection |
| { |
| private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); |
| private readonly CancellationTokenSource clientTokenSource = new CancellationTokenSource(); |
| private readonly TimeSpan startupTimeout; |
| private readonly TimeSpan shutdownTimeout; |
| private Task? dataReceiveTask; |
| private ClientWebSocket client = new ClientWebSocket(); |
| private readonly SemaphoreSlim sendMethodSemaphore = new SemaphoreSlim(1, 1); |
| |
| /// <summary> |
| /// Initializes a new instance of the <see cref="WebSocketConnection" /> class. |
| /// </summary> |
| public WebSocketConnection() |
| : this(DefaultTimeout) |
| { |
| } |
| |
| /// <summary> |
| /// Initializes a new instance of the <see cref="WebSocketConnection" /> class with a given startup timeout. |
| /// </summary> |
| /// <param name="startupTimeout">The timeout before throwing an error when starting up the connection.</param> |
| public WebSocketConnection(TimeSpan startupTimeout) |
| : this(startupTimeout, DefaultTimeout) |
| { |
| } |
| |
| /// <summary> |
| /// Initializes a new instance of the <see cref="WebSocketConnection" /> class with a given startup and shutdown timeout. |
| /// </summary> |
| /// <param name="startupTimeout">The timeout before throwing an error when starting up the connection.</param> |
| /// <param name="shutdownTimeout">The timeout before throwing an error when shutting down the connection.</param> |
| public WebSocketConnection(TimeSpan startupTimeout, TimeSpan shutdownTimeout) |
| { |
| this.startupTimeout = startupTimeout; |
| this.shutdownTimeout = shutdownTimeout; |
| } |
| |
| /// <summary> |
| /// Occurs when data is received from this connection. |
| /// </summary> |
| public event EventHandler<WebSocketConnectionDataReceivedEventArgs>? DataReceived; |
| |
| /// <summary> |
| /// Occurs when a log message is emitted from this connection. |
| /// </summary> |
| public event EventHandler<DevToolsSessionLogMessageEventArgs>? LogMessage; |
| |
| /// <summary> |
| /// Gets a value indicating whether this connection is active. |
| /// </summary> |
| public bool IsActive { get; private set; } = false; |
| |
| /// <summary> |
| /// Gets the buffer size for communication used by this connection. |
| /// </summary> |
| public int BufferSize { get; } = 4096; |
| |
| /// <summary> |
| /// Asynchronously starts communication with the remote end of this connection. |
| /// </summary> |
| /// <param name="url">The URL used to connect to the remote end.</param> |
| /// <returns>The task object representing the asynchronous operation.</returns> |
| /// <exception cref="TimeoutException">Thrown when the connection is not established within the startup timeout.</exception> |
| public virtual async Task Start(string url) |
| { |
| if (url is null) |
| { |
| throw new ArgumentNullException(nameof(url)); |
| } |
| |
| this.Log($"Opening connection to URL {url}", DevToolsSessionLogLevel.Trace); |
| bool connected = false; |
| DateTime timeout = DateTime.Now.Add(this.startupTimeout); |
| while (!connected && DateTime.Now <= timeout) |
| { |
| try |
| { |
| await this.client.ConnectAsync(new Uri(url), this.clientTokenSource.Token).ConfigureAwait(false); |
| connected = true; |
| } |
| catch (WebSocketException) |
| { |
| // If the server-side socket is not yet ready, it leaves the client socket in a closed state, |
| // which sees the object as disposed, so we must create a new one to try again |
| await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false); |
| this.client = new ClientWebSocket(); |
| } |
| } |
| |
| if (!connected) |
| { |
| throw new TimeoutException($"Could not connect to browser within {this.startupTimeout.TotalSeconds} seconds"); |
| } |
| |
| this.dataReceiveTask = Task.Run(async () => await this.ReceiveData()); |
| this.IsActive = true; |
| this.Log($"Connection opened", DevToolsSessionLogLevel.Trace); |
| } |
| |
| /// <summary> |
| /// Asynchronously stops communication with the remote end of this connection. |
| /// </summary> |
| /// <returns>The task object representing the asynchronous operation.</returns> |
| public virtual async Task Stop() |
| { |
| this.Log($"Closing connection", DevToolsSessionLogLevel.Trace); |
| if (this.client.State != WebSocketState.Open) |
| { |
| this.Log($"Socket already closed (Socket state: {this.client.State})"); |
| } |
| else |
| { |
| await this.CloseClientWebSocket().ConfigureAwait(false); |
| } |
| |
| // Whether we closed the socket or timed out, we cancel the token causing ReceiveAsync to abort the socket. |
| // The finally block at the end of the processing loop will dispose of the ClientWebSocket object. |
| this.clientTokenSource.Cancel(); |
| if (this.dataReceiveTask != null) |
| { |
| await this.dataReceiveTask.ConfigureAwait(false); |
| } |
| |
| this.client.Dispose(); |
| } |
| |
| /// <summary> |
| /// Asynchronously sends data to the remote end of this connection. |
| /// </summary> |
| /// <param name="data">The data to be sent to the remote end of this connection.</param> |
| /// <returns>The task object representing the asynchronous operation.</returns> |
| public virtual async Task SendData(string data) |
| { |
| if (data is null) |
| { |
| throw new ArgumentNullException(nameof(data)); |
| } |
| |
| ArraySegment<byte> messageBuffer = new ArraySegment<byte>(Encoding.UTF8.GetBytes(data)); |
| this.Log($"SEND >>> {data}"); |
| |
| await sendMethodSemaphore.WaitAsync().ConfigureAwait(false); |
| |
| try |
| { |
| await this.client.SendAsync(messageBuffer, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None).ConfigureAwait(false); |
| } |
| finally |
| { |
| sendMethodSemaphore.Release(); |
| } |
| } |
| |
| /// <summary> |
| /// Asynchronously closes the client WebSocket. |
| /// </summary> |
| /// <returns>The task object representing the asynchronous operation.</returns> |
| protected virtual async Task CloseClientWebSocket() |
| { |
| // Close the socket first, because ReceiveAsync leaves an invalid socket (state = aborted) when the token is cancelled |
| CancellationTokenSource timeout = new CancellationTokenSource(this.shutdownTimeout); |
| try |
| { |
| // After this, the socket state which change to CloseSent |
| await this.client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing", timeout.Token).ConfigureAwait(false); |
| |
| // Now we wait for the server response, which will close the socket |
| while (this.client.State != WebSocketState.Closed && this.client.State != WebSocketState.Aborted && !timeout.Token.IsCancellationRequested) |
| { |
| // The loop may be too tight for the cancellation token to get triggered, so add a small delay |
| await Task.Delay(TimeSpan.FromMilliseconds(10)).ConfigureAwait(false); |
| } |
| |
| this.Log($"Client state is {this.client.State}", DevToolsSessionLogLevel.Trace); |
| } |
| catch (OperationCanceledException) |
| { |
| // An OperationCanceledException is normal upon task/token cancellation, so disregard it |
| } |
| catch (WebSocketException e) |
| { |
| this.Log($"Unexpected error during attempt at close: {e.Message}", DevToolsSessionLogLevel.Error); |
| } |
| } |
| |
| /// <summary> |
| /// Raises the DataReceived event. |
| /// </summary> |
| /// <param name="e">The event args used when raising the event.</param> |
| protected virtual void OnDataReceived(WebSocketConnectionDataReceivedEventArgs e) |
| { |
| if (this.DataReceived != null) |
| { |
| this.DataReceived(this, e); |
| } |
| } |
| |
| /// <summary> |
| /// Raises the LogMessage event. |
| /// </summary> |
| /// <param name="e">The event args used when raising the event.</param> |
| protected virtual void OnLogMessage(DevToolsSessionLogMessageEventArgs e) |
| { |
| if (this.LogMessage != null) |
| { |
| this.LogMessage(this, e); |
| } |
| } |
| |
| private async Task ReceiveData() |
| { |
| CancellationToken cancellationToken = this.clientTokenSource.Token; |
| try |
| { |
| StringBuilder messageBuilder = new StringBuilder(); |
| ArraySegment<byte> buffer = WebSocket.CreateClientBuffer(this.BufferSize, this.BufferSize); |
| while (this.client.State != WebSocketState.Closed && !cancellationToken.IsCancellationRequested) |
| { |
| WebSocketReceiveResult receiveResult = await this.client.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); |
| |
| // If the token is cancelled while ReceiveAsync is blocking, the socket state changes to aborted and it can't be used |
| if (!cancellationToken.IsCancellationRequested) |
| { |
| // The server is notifying us that the connection will close, and we did |
| // not initiate the close; send acknowledgement |
| if (receiveResult.MessageType == WebSocketMessageType.Close && this.client.State != WebSocketState.Closed && this.client.State != WebSocketState.CloseSent) |
| { |
| this.Log($"Acknowledging Close frame received from server (client state: {this.client.State})", DevToolsSessionLogLevel.Trace); |
| await this.client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Acknowledge Close frame", CancellationToken.None).ConfigureAwait(false); |
| } |
| |
| // Display text or binary data |
| if (this.client.State == WebSocketState.Open && receiveResult.MessageType != WebSocketMessageType.Close) |
| { |
| messageBuilder.Append(Encoding.UTF8.GetString(buffer.Array!, 0, receiveResult.Count)); |
| if (receiveResult.EndOfMessage) |
| { |
| string message = messageBuilder.ToString(); |
| messageBuilder = new StringBuilder(); |
| if (message.Length > 0) |
| { |
| this.Log($"RECV <<< {message}"); |
| this.OnDataReceived(new WebSocketConnectionDataReceivedEventArgs(message)); |
| } |
| } |
| } |
| } |
| } |
| |
| this.Log($"Ending processing loop in state {this.client.State}", DevToolsSessionLogLevel.Trace); |
| } |
| catch (OperationCanceledException) |
| { |
| // An OperationCanceledException is normal upon task/token cancellation, so disregard it |
| } |
| catch (WebSocketException e) |
| { |
| this.Log($"Unexpected error during receive of data: {e.Message}", DevToolsSessionLogLevel.Error); |
| } |
| finally |
| { |
| this.IsActive = false; |
| } |
| } |
| |
| private void Log(string message) |
| { |
| this.Log(message, DevToolsSessionLogLevel.Trace); |
| } |
| |
| private void Log(string message, DevToolsSessionLogLevel level) |
| { |
| this.OnLogMessage(new DevToolsSessionLogMessageEventArgs(level, "[{0}] {1}", "Connection", message)); |
| } |
| } |