File: TestServices\NativeSocketTestHost.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.Tests)
// 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;
using System.Net.Sockets;
using System.IO.Pipes;
using System.Text;
using System.Text.Json.Nodes;
using Aspire.Hosting.Native.Cli;
using Aspire.Hosting.Native.Rpc;
using Aspire.Hosting.Native.Server;
using Aspire.Hosting.Native.Transport;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.AspNetCore.Connections;
 
namespace Aspire.Hosting.Native.Core.Tests.TestServices;
 
/// <summary>Runs real backchannel sockets without depending on a DCP process or a running AppHost.</summary>
internal sealed class NativeSocketTestHost : IAsyncDisposable
{
    private readonly Socket _listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    private readonly CancellationTokenSource _lifetime = new();
    private readonly Task _run;
 
    public NativeSocketTestHost(Func<NativeCliRequest, CancellationToken, Task<NativeBackchannelReply>> dispatch)
        : this((stream, token) => new NativeBackchannelRpc(new NativeServerOptions(), NullLogger.Instance)
            .ServeAsync(stream, dispatch, token), 4)
    {
    }
 
    public NativeSocketTestHost(Func<Stream, CancellationToken, Task> serve, int maximumConnections)
    {
        _listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        _listener.Listen(maximumConnections);
        _run = SocketConnectionListener.RunAsync(_listener, maximumConnections, serve, _lifetime.Token);
    }
 
    public Task Completion => _run;
    public EndPoint EndPoint => _listener.LocalEndPoint!;
 
    public async ValueTask DisposeAsync()
    {
        await _lifetime.CancelAsync();
        try
        {
            await _run.WaitAsync(TimeSpan.FromSeconds(10));
        }
        finally
        {
            _listener.Dispose();
            _lifetime.Dispose();
        }
    }
}
 
/// <summary>Exchanges framed JSON-RPC requests on an actual socket.</summary>
internal sealed class NativeSocketTestClient : IDisposable
{
    private readonly Stream _stream;
    private readonly NativeRpcFraming _framing;
    private int _id;
    private readonly Socket? _socket;
    public Socket Socket => _socket ?? throw new InvalidOperationException("This client uses a named pipe.");
 
    public NativeSocketTestClient(EndPoint endPoint)
    {
        if (endPoint is NamedPipeEndPoint pipe)
        {
            var stream = new NamedPipeClientStream(pipe.ServerName, pipe.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
            try
            {
                stream.Connect(10000);
                _stream = stream;
            }
            catch
            {
                stream.Dispose();
                throw;
            }
        }
        else
        {
            _socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Unspecified);
            _socket.Connect(endPoint);
            _stream = new NetworkStream(_socket, ownsSocket: true);
        }
        // Bootstrap responses contain the complete offline-generated bundle and
        // can exceed the server's separate 256 KiB inbound request limit.
        _framing = new NativeRpcFraming(_stream, 4 * 1024 * 1024);
    }
 
    public Task SendRawAsync(string frame, CancellationToken cancellationToken) =>
        _stream.WriteAsync(Encoding.UTF8.GetBytes(frame), cancellationToken).AsTask();
 
    public Task<byte[]?> ReadAsync(CancellationToken cancellationToken) => _framing.ReadAsync(cancellationToken);
 
    public async Task<JsonObject> CallAsync(string method, JsonNode? parameters, CancellationToken cancellationToken)
    {
        await SendAsync(method, parameters, cancellationToken);
        var response = await ReadAsync(cancellationToken) ?? throw new EndOfStreamException("Expected a response.");
        return JsonNode.Parse(response)!.AsObject();
    }
 
    public Task SendAsync(string method, JsonNode? parameters, CancellationToken cancellationToken)
    {
        var request = new JsonObject
        {
            ["jsonrpc"] = "2.0", ["id"] = ++_id, ["method"] = method, ["params"] = parameters
        };
        return NativeRpcFraming.WriteAsync(_stream, Encoding.UTF8.GetBytes(request.ToJsonString()), cancellationToken);
    }
 
    public void Dispose() => _stream.Dispose();
}
 
/// <summary>Owns a dedicated socket directory beneath repository artifacts, including on Unix.</summary>
internal sealed class NativeSocketTestDirectory : IDisposable
{
    private readonly DirectoryInfo _directory;
    public string SocketPath => Path.Combine(_directory.FullName, "rpc.sock");
 
    public NativeSocketTestDirectory()
    {
        // MTP runs in artifacts/bin/<project>/<configuration>/<tfm>. Anchor
        // sockets at the repository to stay below Unix's 104-byte path limit.
        var root = new DirectoryInfo(AppContext.BaseDirectory);
        while (!File.Exists(Path.Combine(root.FullName, "Aspire.slnx")))
        {
            root = root.Parent ?? throw new InvalidOperationException("The repository root was not found.");
        }
        var path = Path.Combine(root.FullName, "artifacts", $"nst-{Guid.NewGuid():N}");
        _directory = OperatingSystem.IsWindows()
            ? Directory.CreateDirectory(path)
            : Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    public void Dispose() => _directory.Delete(recursive: true);
}