Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
seleniumhq
GitHub Repository: seleniumhq/selenium
Path: blob/trunk/dotnet/src/webdriver/DevTools/DevToolsSession.cs
2885 views
// <copyright file="DevToolsSession.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 OpenQA.Selenium.Internal.Logging;
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;

namespace OpenQA.Selenium.DevTools;

/// <summary>
/// Represents a WebSocket connection to a running DevTools instance that can be used to send
/// commands and receive events.
///</summary>
[RequiresUnreferencedCode(CDP_AOTIncompatibilityMessage)]
[RequiresDynamicCode(CDP_AOTIncompatibilityMessage)]
public class DevToolsSession : IDevToolsSession
{
    internal const string CDP_AOTIncompatibilityMessage = "CDP is not compatible with trimming or AOT.";

    /// <summary>
    /// A value indicating that the version of the DevTools protocol in use
    /// by the browser should be automatically detected.
    /// </summary>
    public const int AutoDetectDevToolsProtocolVersion = 0;

    private readonly string debuggerEndpoint;
    private readonly TimeSpan openConnectionWaitTimeSpan = TimeSpan.FromSeconds(30);
    private readonly TimeSpan closeConnectionWaitTimeSpan = TimeSpan.FromSeconds(2);

    private bool isDisposed = false;
    private string? attachedTargetId;

    private WebSocketConnection? connection;
    private ConcurrentDictionary<long, DevToolsCommandData> pendingCommands = new ConcurrentDictionary<long, DevToolsCommandData>();
    private readonly BlockingCollection<string> messageQueue = new BlockingCollection<string>();
    private readonly Task messageQueueMonitorTask;
    private long currentCommandId = 0;
    private readonly DevToolsOptions options;

    private readonly static ILogger logger = Internal.Logging.Log.GetLogger<DevToolsSession>();

    /// <summary>
    /// Initializes a new instance of the DevToolsSession class, using the specified WebSocket endpoint.
    /// </summary>
    /// <param name="endpointAddress"></param>
    [Obsolete("Use DevToolsSession(string endpointAddress, DevToolsOptions options)")]
    public DevToolsSession(string endpointAddress) : this(endpointAddress, new DevToolsOptions()) { }

    /// <summary>
    /// Initializes a new instance of the DevToolsSession class, using the specified WebSocket endpoint and specified DevTools options.
    /// </summary>
    /// <param name="endpointAddress"></param>
    /// <param name="options"></param>
    /// <exception cref="ArgumentNullException">If <paramref name="options"/> is <see langword="null"/>, or <paramref name="endpointAddress"/> is <see langword="null"/> or <see cref="string.Empty"/>.</exception>
    public DevToolsSession(string endpointAddress, DevToolsOptions options)
    {
        if (string.IsNullOrWhiteSpace(endpointAddress))
        {
            throw new ArgumentNullException(nameof(endpointAddress));
        }

        this.options = options ?? throw new ArgumentNullException(nameof(options));
        this.CommandTimeout = TimeSpan.FromSeconds(30);
        this.debuggerEndpoint = endpointAddress;
        if (endpointAddress.StartsWith("ws", StringComparison.InvariantCultureIgnoreCase))
        {
            this.EndpointAddress = endpointAddress;
        }
        this.messageQueueMonitorTask = Task.Run(() => this.MonitorMessageQueue());
    }

    /// <summary>
    /// Event raised when the DevToolsSession logs informational messages.
    /// </summary>
    public event EventHandler<DevToolsSessionLogMessageEventArgs>? LogMessage;

    /// <summary>
    /// Event raised an event notification is received from the DevTools session.
    /// </summary>
    public event EventHandler<DevToolsEventReceivedEventArgs>? DevToolsEventReceived;

    /// <summary>
    /// Gets or sets the time to wait for a command to complete. Default is 30 seconds.
    /// </summary>
    public TimeSpan CommandTimeout { get; set; }

    /// <summary>
    /// Gets or sets the active session ID of the connection.
    /// </summary>
    public string? ActiveSessionId { get; private set; }

    /// <summary>
    /// Gets the endpoint address of the session.
    /// </summary>
    public string? EndpointAddress { get; private set; }

    /// <summary>
    /// Gets the version-independent domain implementation for this Developer Tools connection
    /// </summary>
    public DevToolsDomains Domains { get; private set; } = null!; // TODO handle nullability for this

    /// <summary>
    /// Gets the version-specific implementation of domains for this DevTools session.
    /// </summary>
    /// <typeparam name="T">
    /// A <see cref="DevToolsSessionDomains"/> object containing the version-specific DevTools Protocol domain implementations.</typeparam>
    /// <returns>The version-specific DevTools Protocol domain implementation.</returns>
    /// <exception cref="InvalidOperationException">If the provided <typeparamref name="T"/> is not the right protocol version which is running.</exception>
    public T GetVersionSpecificDomains<T>() where T : DevToolsSessionDomains
    {
        if (this.Domains.VersionSpecificDomains is not T versionSpecificDomains)
        {
            string errorTemplate = "The type is invalid for conversion. You requested domains of type '{0}', but the version-specific domains for this session are '{1}'";
            string exceptionMessage = string.Format(CultureInfo.InvariantCulture, errorTemplate, typeof(T).ToString(), this.Domains.GetType().ToString());
            throw new InvalidOperationException(exceptionMessage);
        }

        return versionSpecificDomains;
    }

    /// <summary>
    /// Sends the specified command and returns the associated command response.
    /// </summary>
    /// <typeparam name="TCommand">A command object implementing the <see cref="ICommand"/> interface.</typeparam>
    /// <param name="command">The command to be sent.</param>
    /// <param name="cancellationToken">A CancellationToken object to allow for cancellation of the command.</param>
    /// <param name="millisecondsTimeout">The execution timeout of the command in milliseconds.</param>
    /// <param name="throwExceptionIfResponseNotReceived"><see langword="true"/> to throw an exception if a response is not received; otherwise, <see langword="false"/>.</param>
    /// <returns>The command response object implementing the <see cref="ICommandResponse{T}"/> interface.</returns>
    /// <exception cref="ArgumentNullException">If <paramref name="command"/> is <see langword="null"/>.</exception>
    public async Task<ICommandResponse<TCommand>?> SendCommand<TCommand>(TCommand command, CancellationToken cancellationToken = default, int? millisecondsTimeout = null, bool throwExceptionIfResponseNotReceived = true)
        where TCommand : ICommand
    {
        if (command == null)
        {
            throw new ArgumentNullException(nameof(command));
        }

        JsonNode commandParameters = JsonSerializer.SerializeToNode(command) ?? throw new InvalidOperationException("Command serialized to \"null\".");
        var result = await SendCommand(command.CommandName, commandParameters, cancellationToken, millisecondsTimeout, throwExceptionIfResponseNotReceived).ConfigureAwait(false);

        if (result == null)
        {
            return null;
        }

        if (!this.Domains.VersionSpecificDomains.ResponseTypeMap.TryGetCommandResponseType<TCommand>(out Type? commandResponseType))
        {
            throw new InvalidOperationException($"Type {command.GetType()} does not correspond to a known command response type.");
        }

        return result.Value.Deserialize(commandResponseType) as ICommandResponse<TCommand>;
    }

    /// <summary>
    /// Sends the specified command and returns the associated command response.
    /// </summary>
    /// <typeparam name="TCommand">A command object implementing the <see cref="ICommand"/> interface.</typeparam>
    /// <param name="command">The command to be sent.</param>
    /// <param name="sessionId">The target session of the command</param>
    /// <param name="cancellationToken">A CancellationToken object to allow for cancellation of the command.</param>
    /// <param name="millisecondsTimeout">The execution timeout of the command in milliseconds.</param>
    /// <param name="throwExceptionIfResponseNotReceived"><see langword="true"/> to throw an exception if a response is not received; otherwise, <see langword="false"/>.</param>
    /// <returns>The command response object implementing the <see cref="ICommandResponse{T}"/> interface.</returns>
    public async Task<ICommandResponse<TCommand>?> SendCommand<TCommand>(TCommand command, string sessionId, CancellationToken cancellationToken = default, int? millisecondsTimeout = null, bool throwExceptionIfResponseNotReceived = true)
        where TCommand : ICommand
    {
        if (command == null)
        {
            throw new ArgumentNullException(nameof(command));
        }

        JsonNode commandParameters = JsonSerializer.SerializeToNode(command) ?? throw new InvalidOperationException("Command serialized to \"null\".");
        var result = await SendCommand(command.CommandName, sessionId, commandParameters, cancellationToken, millisecondsTimeout, throwExceptionIfResponseNotReceived).ConfigureAwait(false);

        if (result == null)
        {
            return null;
        }

        if (!this.Domains.VersionSpecificDomains.ResponseTypeMap.TryGetCommandResponseType(command, out Type? commandResponseType))
        {
            throw new InvalidOperationException($"Type {typeof(TCommand)} does not correspond to a known command response type.");
        }

        return result.Value.Deserialize(commandResponseType) as ICommandResponse<TCommand>;
    }

    /// <summary>
    /// Sends the specified command and returns the associated command response.
    /// </summary>
    /// <typeparam name="TCommand">A command object implementing the <see cref="ICommand"/> interface.</typeparam>
    /// <typeparam name="TCommandResponse">A response object implementing the <see cref="ICommandResponse"/> interface.</typeparam>
    /// <param name="command">The command to send.</param>
    /// <param name="cancellationToken">A CancellationToken object to allow for cancellation of the command.</param>
    /// <param name="millisecondsTimeout">The execution timeout of the command in milliseconds.</param>
    /// <param name="throwExceptionIfResponseNotReceived"><see langword="true"/> to throw an exception if a response is not received; otherwise, <see langword="false"/>.</param>
    /// <returns>The command response object implementing the <see cref="ICommandResponse{T}"/> interface.</returns>
    /// <exception cref="ArgumentNullException">If <paramref name="command"/> is <see langword="null"/>.</exception>
    public async Task<TCommandResponse?> SendCommand<TCommand, TCommandResponse>(TCommand command, CancellationToken cancellationToken = default, int? millisecondsTimeout = null, bool throwExceptionIfResponseNotReceived = true)
        where TCommand : ICommand
        where TCommandResponse : ICommandResponse<TCommand>
    {
        if (command == null)
        {
            throw new ArgumentNullException(nameof(command));
        }

        JsonNode commandParameters = JsonSerializer.SerializeToNode(command) ?? throw new InvalidOperationException("Command serialized to \"null\".");
        var result = await SendCommand(command.CommandName, commandParameters, cancellationToken, millisecondsTimeout, throwExceptionIfResponseNotReceived).ConfigureAwait(false);

        if (result == null)
        {
            return default;
        }

        return result.Value.Deserialize<TCommandResponse>();
    }

    /// <summary>
    /// Returns a JsonNode based on a command created with the specified command name and params.
    /// </summary>
    /// <param name="commandName">The name of the command to send.</param>
    /// <param name="commandParameters">The parameters of the command as a JsonNode object</param>
    /// <param name="cancellationToken">A CancellationToken object to allow for cancellation of the command.</param>
    /// <param name="millisecondsTimeout">The execution timeout of the command in milliseconds.</param>
    /// <param name="throwExceptionIfResponseNotReceived"><see langword="true"/> to throw an exception if a response is not received; otherwise, <see langword="false"/>.</param>
    /// <returns>The command response object implementing the <see cref="ICommandResponse{T}"/> interface.</returns>
    /// <exception cref="ArgumentNullException">If <paramref name="commandName"/> is <see langword="null"/>.</exception>
    public async Task<JsonElement?> SendCommand(string commandName, JsonNode commandParameters, CancellationToken cancellationToken = default, int? millisecondsTimeout = null, bool throwExceptionIfResponseNotReceived = true)
    {
        if (this.attachedTargetId == null)
        {
            LogTrace("Session not currently attached to a target; reattaching");
            await this.InitializeSession().ConfigureAwait(false);
        }

        return await SendCommand(commandName, this.ActiveSessionId, commandParameters, cancellationToken, millisecondsTimeout, throwExceptionIfResponseNotReceived);
    }

    /// <summary>
    /// Returns a JsonNode based on a command created with the specified command name and params.
    /// </summary>
    /// <param name="commandName">The name of the command to send.</param>
    /// <param name="sessionId">The sessionId of the command.</param>
    /// <param name="commandParameters">The parameters of the command as a JsonNode object</param>
    /// <param name="cancellationToken">A CancellationToken object to allow for cancellation of the command.</param>
    /// <param name="millisecondsTimeout">The execution timeout of the command in milliseconds.</param>
    /// <param name="throwExceptionIfResponseNotReceived"><see langword="true"/> to throw an exception if a response is not received; otherwise, <see langword="false"/>.</param>
    /// <returns>The command response object implementing the <see cref="ICommandResponse{T}"/> interface.</returns>
    /// <exception cref="ArgumentNullException">If <paramref name="commandName"/> is <see langword="null"/>.</exception>
    public async Task<JsonElement?> SendCommand(string commandName, string? sessionId, JsonNode commandParameters, CancellationToken cancellationToken = default, int? millisecondsTimeout = null, bool throwExceptionIfResponseNotReceived = true)
    {
        millisecondsTimeout ??= Convert.ToInt32(CommandTimeout.TotalMilliseconds);

        var message = new DevToolsCommandData(Interlocked.Increment(ref this.currentCommandId), sessionId, commandName, commandParameters);

        if (this.connection != null && this.connection.IsActive)
        {
            if (logger.IsEnabled(LogEventLevel.Trace))
            {
                logger.Trace($"CDP SND >> {message.CommandId} {message.CommandName}: {commandParameters.ToJsonString()}");
            }

            LogTrace("Sending {0} {1}: {2}", message.CommandId, message.CommandName, commandParameters.ToString());

            string contents = JsonSerializer.Serialize(message);
            this.pendingCommands.TryAdd(message.CommandId, message);
            await this.connection.SendData(contents).ConfigureAwait(false);

            var responseWasReceived = message.SyncEvent.Wait(millisecondsTimeout.Value, cancellationToken);

            if (!responseWasReceived && throwExceptionIfResponseNotReceived)
            {
                throw new InvalidOperationException($"A command response was not received: {commandName}, timeout: {millisecondsTimeout.Value}ms");
            }

            if (this.pendingCommands.TryRemove(message.CommandId, out DevToolsCommandData? modified))
            {
                if (modified.IsError)
                {
                    var errorMessage = modified.Result.GetProperty("message").GetString();
                    var errorData = modified.Result.TryGetProperty("data", out var data) ? data.GetString() : null;

                    var exceptionMessage = $"{commandName}: {errorMessage}";
                    if (!string.IsNullOrWhiteSpace(errorData))
                    {
                        exceptionMessage = $"{exceptionMessage} - {errorData}";
                    }

                    LogTrace("Received Error Response {0}: {1} {2}", modified.CommandId, message, errorData);
                    throw new CommandResponseException(exceptionMessage)
                    {
                        Code = modified.Result.TryGetProperty("code", out var code) ? code.GetInt64() : -1
                    };
                }

                return modified.Result;
            }
        }
        else
        {
            LogTrace("WebSocket is not connected; not sending {0}", message.CommandName);
        }

        return null;
    }

    /// <summary>
    /// Releases all resources associated with this <see cref="DevToolsSession"/>.
    /// </summary>
    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Asynchronously starts the session.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    internal async Task StartSession()
    {
        int requestedProtocolVersion = options.ProtocolVersion ?? AutoDetectDevToolsProtocolVersion;
        int protocolVersion = await InitializeProtocol(requestedProtocolVersion).ConfigureAwait(false);
        this.Domains = DevToolsDomains.InitializeDomains(protocolVersion, this);

        await this.InitializeSocketConnection().ConfigureAwait(false);
        await this.InitializeSession().ConfigureAwait(false);
        try
        {
            // Wrap this in a try-catch, because it's not the end of the
            // world if clearing the log doesn't work.
            await this.Domains.Log.Clear().ConfigureAwait(false);
            LogTrace("Log cleared.", this.attachedTargetId);
        }
        catch (WebDriverException)
        {
        }
    }

    /// <summary>
    /// Asynchronously stops the session.
    /// </summary>
    /// <param name="manualDetach"><see langword="true"/> to manually detach the session
    /// from its attached target; otherwise <see langword="false"/>.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    internal async Task StopSession(bool manualDetach)
    {
        if (this.attachedTargetId != null)
        {
            this.Domains.Target.TargetDetached -= this.OnTargetDetached;
            string? sessionId = this.ActiveSessionId;
            this.ActiveSessionId = null;
            if (manualDetach)
            {
                await this.Domains.Target.DetachFromTarget(sessionId, this.attachedTargetId).ConfigureAwait(false);
            }

            this.attachedTargetId = null;
        }
    }

    /// <summary>
    /// Releases all resources associated with this <see cref="DevToolsSession"/>.
    /// </summary>
    /// <param name="disposing"><see langword="true"/> if the Dispose method was explicitly called; otherwise, <see langword="false"/>.</param>
    protected void Dispose(bool disposing)
    {
        if (!this.isDisposed)
        {
            if (disposing)
            {
                this.Domains.Target.TargetDetached -= this.OnTargetDetached;
                this.pendingCommands.Clear();
                Task.Run(async () => await this.TerminateSocketConnection()).GetAwaiter().GetResult();
            }

            this.isDisposed = true;
        }
    }

    private async Task<int> InitializeProtocol(int requestedProtocolVersion)
    {
        int protocolVersion = requestedProtocolVersion;
        if (this.EndpointAddress == null)
        {
            string debuggerUrl = string.Format(CultureInfo.InvariantCulture, "http://{0}", this.debuggerEndpoint);
            string rawVersionInfo;
            using (HttpClient client = new HttpClient())
            {
                client.BaseAddress = new Uri(debuggerUrl);
                rawVersionInfo = await client.GetStringAsync("/json/version").ConfigureAwait(false);
            }

            var versionInfo = JsonSerializer.Deserialize<DevToolsVersionInfo>(rawVersionInfo) ?? throw new JsonException("/json/version endpoint returned null response");
            this.EndpointAddress = versionInfo.WebSocketDebuggerUrl;

            if (requestedProtocolVersion == AutoDetectDevToolsProtocolVersion)
            {
                if (!int.TryParse(versionInfo.BrowserMajorVersion, out protocolVersion))
                {
                    throw new WebDriverException(string.Format(CultureInfo.InvariantCulture, "Unable to parse version number received from browser. Reported browser version string is '{0}'", versionInfo.Browser));
                }
            }
        }
        else
        {
            if (protocolVersion == AutoDetectDevToolsProtocolVersion)
            {
                throw new WebDriverException("A WebSocket address for DevTools protocol has been detected, but the protocol version cannot be automatically detected. You must specify a protocol version.");
            }
        }

        return protocolVersion;
    }

    private async Task InitializeSession()
    {
        LogTrace("Creating session");
        if (this.attachedTargetId == null)
        {
            // Set the attached target ID to a "pending connection" value
            // (any non-null will do, so we choose the empty string), so
            // that when getting the available targets, we won't
            // recursively try to call InitializeSession.
            this.attachedTargetId = "";
            var targets = await this.Domains.Target.GetTargets().ConfigureAwait(false);
            foreach (var target in targets)
            {
                if (target.Type == "page")
                {
                    this.attachedTargetId = target.TargetId;
                    LogTrace("Found Target ID {0}.", this.attachedTargetId);
                    break;
                }
            }
        }

        if (this.attachedTargetId == "")
        {
            this.attachedTargetId = null;
            throw new WebDriverException("Unable to find target to attach to, no targets of type 'page' available");
        }

        string sessionId = await this.Domains.Target.AttachToTarget(this.attachedTargetId).ConfigureAwait(false);
        LogTrace("Target ID {0} attached. Active session ID: {1}", this.attachedTargetId, sessionId);
        this.ActiveSessionId = sessionId;

        await this.Domains.Target.SetAutoAttach().ConfigureAwait(false);
        LogTrace("AutoAttach is set.", this.attachedTargetId);

        // The Target domain needs to send Session-less commands! Else the waitForDebugger setting in setAutoAttach won't work!
        if (options.WaitForDebuggerOnStart)
        {
            var setAutoAttachCommand = Domains.Target.CreateSetAutoAttachCommand(true);
            var setDiscoverTargetsCommand = Domains.Target.CreateDiscoverTargetsCommand();

            await SendCommand(setAutoAttachCommand, string.Empty, default, null, true).ConfigureAwait(false);
            await SendCommand(setDiscoverTargetsCommand, string.Empty, default, null, true).ConfigureAwait(false);
        }

        this.Domains.Target.TargetDetached += this.OnTargetDetached;
    }

    private void OnTargetDetached(object? sender, TargetDetachedEventArgs e)
    {
        if (e.SessionId == this.ActiveSessionId && e.TargetId == this.attachedTargetId)
        {
            Task.Run(async () => await this.StopSession(false)).GetAwaiter().GetResult();
        }
    }

    private async Task InitializeSocketConnection()
    {
        LogTrace("Creating WebSocket");
        this.connection = new WebSocketConnection(this.openConnectionWaitTimeSpan, this.closeConnectionWaitTimeSpan);
        connection.DataReceived += OnConnectionDataReceived;
        await connection.Start(this.EndpointAddress!).ConfigureAwait(false);
        LogTrace("WebSocket created");
    }

    private async Task TerminateSocketConnection()
    {
        LogTrace("Closing WebSocket");
        if (this.connection != null && this.connection.IsActive)
        {
            await this.connection.Stop().ConfigureAwait(false);
            await this.ShutdownMessageQueue(this.connection).ConfigureAwait(false);
        }
        LogTrace("WebSocket closed");
    }

    private async Task ShutdownMessageQueue(WebSocketConnection connection)
    {
        // THe WebSocket connection is always closed before this method
        // is called, so there will eventually be no more data written
        // into the message queue, meaning this loop should be guaranteed
        // to complete.
        while (connection.IsActive)
        {
            await Task.Delay(TimeSpan.FromMilliseconds(10));
        }

        this.messageQueue.CompleteAdding();
        await this.messageQueueMonitorTask.ConfigureAwait(false);
    }

    private void MonitorMessageQueue()
    {
        // GetConsumingEnumerable blocks until if BlockingCollection.IsCompleted
        // is false (i.e., is still able to be written to), and there are no items
        // in the collection. Once any items are added to the collection, the method
        // unblocks and we can process any items in the collection at that moment.
        // Once IsCompleted is true, the method unblocks with no items in returned
        // in the IEnumerable, meaning the foreach loop will terminate gracefully.
        foreach (string message in this.messageQueue.GetConsumingEnumerable())
        {
            // Don't break entire thread in case of unsuccessful message,
            // and give a chance for the next message in queue to be processed
            try
            {
                this.ProcessMessage(message);
            }
            catch (Exception ex)
            {
                if (logger.IsEnabled(LogEventLevel.Error))
                {
                    logger.Error($"Unexpected error occurred while processing message: {ex}");
                }

                LogError("Unexpected error occurred while processing message: {0}", ex);
            }
        }
    }

    private void ProcessMessage(string message)
    {
        if (logger.IsEnabled(LogEventLevel.Trace))
        {
            logger.Trace($"CDP RCV << {message}");
        }

        JsonElement messageObject;
        using (var doc = JsonDocument.Parse(message))
        {
            messageObject = doc.RootElement.Clone();
        }

        if (messageObject.TryGetProperty("id", out var idProperty))
        {
            long commandId = idProperty.GetInt64();

            if (this.pendingCommands.TryGetValue(commandId, out DevToolsCommandData? commandInfo))
            {
                if (messageObject.TryGetProperty("error", out var errorProperty))
                {
                    commandInfo.IsError = true;
                    commandInfo.Result = errorProperty;
                }
                else
                {
                    commandInfo.Result = messageObject.GetProperty("result");
                    LogTrace("Received Response {0}: {1}", commandId, commandInfo.Result.ToString());
                }

                commandInfo.SyncEvent.Set();
            }
            else
            {
                if (logger.IsEnabled(LogEventLevel.Error))
                {
                    logger.Error($"Received Unknown Response {commandId}: {message}");
                }

                LogError("Received Unknown Response {0}: {1}", commandId, message);
            }

            return;
        }

        if (messageObject.TryGetProperty("method", out var methodProperty))
        {
            var method = methodProperty.GetString() ?? throw new InvalidOperationException("CDP message contained \"method\" property with a value of \"null\".");
            var methodParts = method.Split(new char[] { '.' }, 2);
            var eventData = messageObject.GetProperty("params");

            LogTrace("Received Event {0}: {1}", method, eventData.ToString());

            // Dispatch the event on a new thread so that any event handlers
            // responding to the event will not block this thread from processing
            // DevTools commands that may be sent in the body of the attached
            // event handler. If thread pool starvation seems to become a problem,
            // we can switch to a channel-based queue.
            Task.Run(() =>
            {
                try
                {
                    OnDevToolsEventReceived(new DevToolsEventReceivedEventArgs(methodParts[0], methodParts[1], eventData));
                }
                catch (Exception ex)
                {
                    if (logger.IsEnabled(LogEventLevel.Warn))
                    {
                        logger.Warn($"CDP VNT ^^ Unhandled error occurred in event handler of '{method}' method. {ex}");
                    }

                    throw;
                }
            });

            return;
        }

        LogTrace("Received Other: {0}", message);
    }

    private void OnDevToolsEventReceived(DevToolsEventReceivedEventArgs e)
    {
        if (DevToolsEventReceived != null)
        {
            DevToolsEventReceived(this, e);
        }
    }

    private void OnConnectionDataReceived(object? sender, WebSocketConnectionDataReceivedEventArgs e)
    {
        this.messageQueue.Add(e.Data);
    }

    private void LogTrace(string message, params object?[] args)
    {
        if (LogMessage != null)
        {
            LogMessage(this, new DevToolsSessionLogMessageEventArgs(DevToolsSessionLogLevel.Trace, message, args));
        }
    }

    private void LogError(string message, params object?[] args)
    {
        if (LogMessage != null)
        {
            LogMessage(this, new DevToolsSessionLogMessageEventArgs(DevToolsSessionLogLevel.Error, message, args));
        }
    }
}