File: Rpc\NativeBackchannelRpc.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net.Sockets;
using System.Text.Json;
using Aspire.Hosting.Native.Diagnostics;
using Aspire.Hosting.Native.Cli;
using Aspire.Hosting.Native.Server;
using Aspire.Hosting.Native.Runtime;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Native.Rpc;
 
/// <summary>Shares header framing, cancellation and StreamJsonRpc enumeration between backchannels.</summary>
internal sealed class NativeBackchannelRpc(NativeServerOptions options, ILogger logger, INativeRpcPeerFactory peers) : INativeBackchannelRpc
{
    public NativeBackchannelRpc(NativeServerOptions options, ILogger logger)
        : this(options, logger, new NativeRpcPeerFactory(options, logger))
    {
    }
 
    public async Task ServeAsync(Stream stream,
        Func<NativeCliRequest, CancellationToken, Task<NativeBackchannelReply>> dispatch, CancellationToken cancellationToken)
    {
        using var firstRequest = new TimeProviderCancellation(options.BackchannelFirstRequestTimeout, options.TimeProvider, cancellationToken);
        using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(firstRequest.Token);
        using var peer = peers.Create(stream);
        using var disconnected = peer.Closed.Register(lifetime.Cancel);
        var requests = new Dictionary<string, CancellationTokenSource>(StringComparer.Ordinal);
        var cursors = new Dictionary<long, Cursor>();
        var gate = new Lock();
        long nextToken = 0;
        try
        {
            await peer.RunAsync(async envelope =>
            {
                var request = NativeBackchannelProtocol.ParseRequest(envelope);
                // Bound silent and partial-frame clients without imposing an idle
                // deadline on legitimate long-lived log/resource watches.
                firstRequest.StopTimeout();
                if (request.Method == "$/cancelRequest")
                {
                    var id = NativeBackchannelProtocol.ReadCancellationId(request);
                    lock (gate)
                    {
                        if (requests.TryGetValue(id, out var pending))
                        {
                            pending.Cancel();
                        }
                    }
                    return null;
                }
                var key = request.Id?.GetRawText();
                var cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
                lock (gate)
                {
                    if (requests.Count >= options.MaximumConcurrentRequests || key is not null && requests.ContainsKey(key))
                    {
                        cancellation.Dispose();
                        throw new InvalidDataException("The request limit was reached or an ID was reused.");
                    }
                    if (key is not null)
                    {
                        requests.Add(key, cancellation);
                    }
                }
                await RespondAsync(request, key, cancellation).ConfigureAwait(false);
                return null;
            }, lifetime.Token).ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is IOException or SocketException or InvalidDataException or JsonException or RpcFault)
        {
            logger.LogWarning("Backchannel disconnected: {ErrorType}.", exception.GetType().Name);
        }
        catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
        {
        }
        finally
        {
            await lifetime.CancelAsync().ConfigureAwait(false);
            foreach (var cursor in cursors.Values)
            {
                await cursor.DisposeAsync().ConfigureAwait(false);
            }
        }
 
        async Task RespondAsync(NativeCliRequest request, string? key, CancellationTokenSource cancellation)
        {
            NativeBackchannelReply? reply = null;
            NativeCliError? error = null;
            var result = NativeBackchannelProtocol.NullResult;
            try
            {
                try
                {
                    if (request.Method is "$/enumerator/next" or "$/enumerator/abort")
                    {
                        var token = NativeBackchannelProtocol.ReadEnumeratorToken(request);
                        Cursor cursor;
                        lock (gate)
                        {
                            if (!cursors.TryGetValue(token, out cursor!))
                            {
                                throw new InvalidOperationException("The stream has ended.");
                            }
                            if (request.Method == "$/enumerator/abort")
                            {
                                cursors.Remove(token);
                            }
                        }
                        if (request.Method == "$/enumerator/abort")
                        {
                            await cursor.DisposeAsync().ConfigureAwait(false);
                        }
                        else
                        {
                            var next = await cursor.NextAsync(cancellation.Token).ConfigureAwait(false);
                            if (next.Finished)
                            {
                                lock (gate)
                                {
                                    cursors.Remove(token);
                                }
                                await cursor.DisposeAsync().ConfigureAwait(false);
                            }
                            result = NativeBackchannelProtocol.EnumeratorResult(next);
                        }
                    }
                    else
                    {
                        reply = await dispatch(request, cancellation.Token).ConfigureAwait(false);
                        result = reply.Result;
                        if (reply.Stream is not null)
                        {
                            lock (gate)
                            {
                                if (cursors.Count >= options.MaximumCliStreams)
                                {
                                    throw new InvalidOperationException("The stream limit was reached.");
                                }
                                var token = checked(++nextToken);
                                cursors.Add(token, new Cursor(reply.Stream, lifetime.Token));
                                result = NativeBackchannelProtocol.StreamDescriptor(token);
                            }
                        }
                    }
                }
                catch (MissingMethodException)
                {
                    error = new(-32601, "The server does not implement this backchannel method.");
                }
                catch (Exception exception) when (exception is ArgumentException or RpcFault or JsonException)
                {
                    error = new(-32602, "The backchannel arguments are invalid.");
                }
                catch (InvalidOperationException exception) when (exception is not ObjectDisposedException)
                {
                    error = new(-32000, exception.Message);
                }
                catch (OperationCanceledException) when (cancellation.IsCancellationRequested || lifetime.IsCancellationRequested)
                {
                    error = new(-32800, "The backchannel request was cancelled.");
                }
                catch (Exception exception)
                {
                    // Dispatch and iterator failures are request failures, not transport
                    // failures. Log only classification: controller exceptions can carry secrets.
                    using var operation = new NativeDiagnostics.Operation("backchannel.request", null, null, options.TimeProvider)
                    {
                        ErrorType = exception.GetType().FullName
                    };
                    error = new(-32603, "The backchannel request failed internally.");
                }
                if (request.Id is null)
                {
                    // Notifications have no response, but must still perform their side effects.
                    if (error is null)
                    {
                        reply?.AfterResponse?.Invoke();
                    }
                    return;
                }
                if (lifetime.IsCancellationRequested)
                {
                    return;
                }
                await peer.SendAsync(NativeBackchannelProtocol.EncodeResponse(request.Id.Value, result, error), lifetime.Token).ConfigureAwait(false);
                // A stop response must be flushed before cancellation closes either backchannel.
                if (error is null)
                {
                    reply?.AfterResponse?.Invoke();
                }
            }
            catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
            {
            }
            catch (Exception exception) when (exception is IOException or SocketException)
            {
                // Handle transport failures inside each response, including responses
                // still being drained after the reader has observed a disconnect.
                logger.LogWarning("Backchannel response failed: {ErrorType}.", exception.GetType().Name);
                await lifetime.CancelAsync().ConfigureAwait(false);
            }
            finally
            {
                lock (gate)
                {
                    if (key is not null)
                    {
                        requests.Remove(key);
                    }
                }
                cancellation.Dispose();
            }
        }
    }
 
    private sealed class Cursor : IAsyncDisposable
    {
        private readonly CancellationTokenSource _lifetime;
        private readonly IAsyncEnumerator<JsonElement> _enumerator;
        private readonly SemaphoreSlim _gate = new(1);
        private bool _finished;
        private int _disposed;
 
        public Cursor(IAsyncEnumerable<JsonElement> source, CancellationToken cancellationToken)
        {
            _lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            _enumerator = source.GetAsyncEnumerator(_lifetime.Token);
        }
 
        public async Task<NativeCliEnumeratorResult> NextAsync(CancellationToken cancellationToken)
        {
            if (!await _gate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
            {
                throw new InvalidOperationException("The stream already has an outstanding next request.");
            }
            try
            {
                using var registration = cancellationToken.Register(_lifetime.Cancel);
                if (!_finished && await _enumerator.MoveNextAsync().ConfigureAwait(false))
                {
                    // StreamJsonRpc IAsyncEnumerable: {"token":1}, next [1] -> {"values":[...],"finished":false}.
                    // https://github.com/microsoft/vs-streamjsonrpc/blob/main/src/StreamJsonRpc/Reflection/MessageFormatterEnumerableTracker.cs
                    return new([_enumerator.Current], false);
                }
                _finished = true;
 
                return new([], true);
            }
            finally
            {
                _gate.Release();
            }
        }
 
        public async ValueTask DisposeAsync()
        {
            if (Interlocked.Exchange(ref _disposed, 1) != 0)
            {
                return;
            }
            await _lifetime.CancelAsync().ConfigureAwait(false);
            await _gate.WaitAsync().ConfigureAwait(false);
            try
            {
                await _enumerator.DisposeAsync().ConfigureAwait(false);
            }
            finally
            {
                _gate.Release();
                _gate.Dispose();
                _lifetime.Dispose();
            }
        }
    }
}