File: Rpc\NativeRpcPeer.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.Collections.Concurrent;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.Native.Server;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Native.Rpc;
 
internal sealed class NativeRpcPeerFactory(NativeServerOptions options, ILogger logger) : INativeRpcPeerFactory
{
    public INativeRpcPeer Create(Stream stream) => new NativeRpcPeer(stream, options, logger);
}
 
/// <summary>Multiplexes bounded requests and responses over one caller-owned duplex stream.</summary>
internal sealed class NativeRpcPeer(Stream stream, NativeServerOptions options, ILogger logger) : INativeRpcPeer
{
    private readonly CancellationTokenSource _closed = new();
    private readonly SemaphoreSlim _writes = new(1);
    private readonly SemaphoreSlim _outbound = new(options.MaximumConcurrentRequests);
    private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonNode?>> _pending = new(StringComparer.Ordinal);
    private long _nextId;
    private int _running;
    public CancellationToken Closed => _closed.Token;
 
    public async Task<JsonNode?> RequestAsync(string method, JsonArray arguments, CancellationToken cancellationToken)
    {
        var completion = new TaskCompletionSource<JsonNode?>(TaskCreationOptions.RunContinuationsAsynchronously);
        var id = $"native:{Interlocked.Increment(ref _nextId)}";
        if (!_outbound.Wait(0, cancellationToken))
        {
            throw RpcFault.RpcBusy("The peer request limit was reached.");
        }
        _pending.TryAdd(id, completion);
        try
        {
            using var deadline = new CancellationTokenSource(options.ProviderInvocationTimeout, options.TimeProvider);
            using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closed.Token, deadline.Token);
            var payload = JsonSerializer.SerializeToUtf8Bytes(new JsonObject
            {
                ["jsonrpc"] = "2.0",
                ["id"] = id,
                ["method"] = method,
                ["params"] = arguments.DeepClone()
            }, NativeRpcMessageJsonContext.Default.JsonNode);
            if (payload.Length > options.MaximumRequestBytes)
            {
                throw RpcFault.RequestTooLarge("The request exceeds the size limit.");
            }
            try
            {
                // The deadline covers both a blocked write and the provider reply.
                await WriteAsync(payload, lifetime.Token).ConfigureAwait(false);
                return await completion.Task.WaitAsync(lifetime.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (deadline.IsCancellationRequested && !cancellationToken.IsCancellationRequested && !_closed.IsCancellationRequested)
            {
                // Stop accepting replies, not replaying a call whose side effects may
                // already have occurred. The provider registry revokes this peer.
                throw RpcFault.ProviderTimeout("The provider invocation deadline was reached.");
            }
        }
        finally
        {
            _pending.TryRemove(id, out _);
            _outbound.Release();
        }
    }
 
    public async Task RunAsync(Func<JsonElement, Task<byte[]?>> dispatch, CancellationToken cancellationToken)
    {
        if (Interlocked.Exchange(ref _running, 1) != 0)
        {
            throw new InvalidOperationException("The RPC peer is already running.");
        }
        using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closed.Token);
        var framing = new NativeRpcFraming(stream, options.MaximumRequestBytes);
        var requests = new List<Task>();
        try
        {
            while (await framing.ReadAsync(lifetime.Token).ConfigureAwait(false) is { } payload)
            {
                JsonDocument? document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 32 });
                try
                {
                    if (document.RootElement.ValueKind != JsonValueKind.Object)
                    {
                        throw new InvalidDataException("An RPC envelope must be an object.");
                    }
                    NativeRpcProtocol.ValidateUniqueProperties(document.RootElement);
                    // JSON-RPC is bidirectional: {"id":"native:1","result":...} is a
                    // reply, not an inbound call. Peer-supplied IDs never select authority.
                    if (!document.RootElement.TryGetProperty("method", out _))
                    {
                        AcceptResponse(document.RootElement);
                        continue;
                    }
                    for (var index = requests.Count - 1; index >= 0; index--)
                    {
                        if (requests[index].IsCompleted)
                        {
                            await requests[index].ConfigureAwait(false);
                            requests.RemoveAt(index);
                        }
                    }
                    if (requests.Count >= options.MaximumConcurrentRequests)
                    {
                        throw new InvalidDataException("The connection response backlog exceeds the limit.");
                    }
                    // Dispatch borrows the parsed envelope across awaits. Transfer
                    // disposal to the response task rather than cloning or reparsing it.
                    requests.Add(RespondAsync(document));
                    document = null;
                }
                finally
                {
                    document?.Dispose();
                }
            }
        }
        catch (Exception exception) when (exception is IOException or SocketException or InvalidDataException or JsonException or RpcFault)
        {
            logger.LogWarning("Native connection closed: {ErrorType}.", exception.GetType().Name);
        }
        catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
        {
        }
        finally
        {
            Dispose();
            await Task.WhenAll(requests).ConfigureAwait(false);
        }
 
        async Task RespondAsync(JsonDocument document)
        {
            using var envelope = document;
            try
            {
                var response = await dispatch(envelope.RootElement).ConfigureAwait(false);
                if (response is null || lifetime.IsCancellationRequested)
                {
                    return;
                }
                await WriteAsync(response, lifetime.Token).ConfigureAwait(false);
            }
            catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
            {
                Dispose();
            }
        }
    }
 
    private void AcceptResponse(JsonElement response)
    {
        var envelope = NativeRpcProtocol.ParseResponse(response);
        // Late replies after local cancellation are ignored, never treated as calls.
        if (!_pending.TryRemove(envelope.Id, out var completion))
        {
            return;
        }
        if (envelope.Error is { } error)
        {
            try
            {
                NativeRpcProtocol.ValidateResponseError(error);
            }
            catch (InvalidDataException)
            {
                completion.TrySetCanceled();
                throw;
            }
            completion.TrySetException(RpcFault.ProviderFailed("The provider rejected the invocation."));
            // Observe a fault if the initiating caller concurrently stopped waiting.
            _ = completion.Task.Exception;
        }
        else
        {
            completion.TrySetResult(NativeRpcProtocol.ToNode(envelope.Result));
        }
    }
 
    public Task SendAsync(byte[] payload, CancellationToken cancellationToken) => WriteAsync(payload, cancellationToken);
 
    private async Task WriteAsync(byte[] payload, CancellationToken cancellationToken)
    {
        await _writes.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await NativeRpcFraming.WriteAsync(stream, payload, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _writes.Release();
        }
    }
 
    public void Dispose()
    {
        if (_closed.IsCancellationRequested)
        {
            return;
        }
        _closed.Cancel();
        foreach (var (id, completion) in _pending)
        {
            if (_pending.TryRemove(id, out _))
            {
                completion.TrySetCanceled(_closed.Token);
            }
        }
    }
}