File: TerminalHostAppTests.cs
Web Access
Project: src\tests\Aspire.TerminalHost.Tests\Aspire.TerminalHost.Tests.csproj (Aspire.TerminalHost.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.Sockets;
using Aspire.Shared.TerminalHost;
using Microsoft.Extensions.Logging.Abstractions;
using StreamJsonRpc;
 
namespace Aspire.TerminalHost.Tests;
 
[CollectionDefinition(nameof(TerminalHostAppTestsCollection), DisableParallelization = true)]
public sealed class TerminalHostAppTestsCollection;
 
[Collection(nameof(TerminalHostAppTestsCollection))]
public class TerminalHostAppTests(ITestOutputHelper outputHelper)
{
    /// <summary>
    /// Builds a single-replica argument set for the host. Each terminal host process
    /// serves exactly one replica, so the AppHost (and these tests) just hand it one
    /// producer/consumer/control UDS path triple. The replica index is opaque to the
    /// host — callers encode it however they like in the path layout.
    /// </summary>
    private (TerminalHostArgs args, TemporaryWorkspace workspace, string controlPath) BuildArgs()
    {
        var workspace = TemporaryWorkspace.Create(outputHelper);
        var dcpDir = Path.Combine(workspace.Path, "dcp");
        var hostDir = Path.Combine(workspace.Path, "host");
        var ctrlDir = Path.Combine(workspace.Path, "ctl");
        Directory.CreateDirectory(dcpDir);
        Directory.CreateDirectory(hostDir);
        Directory.CreateDirectory(ctrlDir);
 
        var producer = Path.Combine(dcpDir, "r.sock");
        var consumer = Path.Combine(hostDir, "r.sock");
        var control = Path.Combine(ctrlDir, "c.sock");
 
        var args = TerminalHostArgs.Parse([
            "--producer-uds", producer,
            "--consumer-uds", consumer,
            "--control-uds", control,
        ]);
 
        return (args, workspace, control);
    }
 
    [Fact]
    public async Task RunAsyncBindsControlListenerWhenStarted()
    {
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
            Assert.True(File.Exists(control));
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task ControlEndpointReturnsSessionInfo()
    {
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            using var rpc = await OpenControlRpcAsync(control);
            var info = await rpc.InvokeAsync<TerminalHostInfoResponse>(
                TerminalHostControlProtocol.GetInfoMethod);
            var session = await rpc.InvokeAsync<TerminalHostSessionInfo>(
                TerminalHostControlProtocol.GetSessionMethod);
 
            Assert.Equal(TerminalHostControlProtocol.ProtocolVersion, info.ProtocolVersion);
            Assert.Equal(args.ProducerUdsPath, session.ProducerUdsPath);
            Assert.Equal(args.ConsumerUdsPath, session.ConsumerUdsPath);
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task ShutdownRequestCausesRunAsyncToReturn()
    {
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
        using (var rpc = await OpenControlRpcAsync(control))
        {
            // Fire and forget — the host may close the socket before the RPC ack arrives.
            _ = rpc.InvokeAsync(TerminalHostControlProtocol.ShutdownMethod);
        }
 
        var exitCode = await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task ConcurrentControlConnectsAreRefusedDownToOne()
    {
        // The control protocol is documented as a single AppHost client (lifecycle,
        // shutdown, stats). Regression for the accept-loop race where two fast
        // back-to-back connects could both observe an empty slot before either
        // ServeClientAsync had registered into _activeRpcs, ending up with two
        // concurrently-served sessions instead of one. The reservation counter
        // increment must happen synchronously under _gate at the accept site.
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            // Open many concurrent sockets to maximise the chance of two reaching
            // the accept loop's reservation check at the same time. Whatever the
            // scheduling, exactly one must end up usable for an RPC; the rest
            // must fail (server-closed before the request returns or no response).
            const int concurrency = 8;
            var rawSockets = new Socket[concurrency];
            var connectTasks = new Task[concurrency];
            for (var i = 0; i < concurrency; i++)
            {
                rawSockets[i] = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
                connectTasks[i] = rawSockets[i].ConnectAsync(new UnixDomainSocketEndPoint(control));
            }
 
            try
            {
                await Task.WhenAll(connectTasks).WaitAsync(TimeSpan.FromSeconds(10));
 
                // Drive an RPC over each socket and count the survivors. Refused
                // sockets either close immediately (read returns 0) or fail the
                // header-delimited read; the StreamJsonRpc completion task surfaces
                // either as a ConnectionLostException / IOException.
                var results = await Task.WhenAll(rawSockets.Select(TryGetInfoAsync));
 
                var successes = results.Count(static r => r);
                Assert.Equal(1, successes);
            }
            finally
            {
                foreach (var s in rawSockets)
                {
                    try { s.Dispose(); } catch { }
                }
            }
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
 
        static async Task<bool> TryGetInfoAsync(Socket socket)
        {
            // Each candidate gets a short window to either ack a GetInfo or be
            // refused. The refused side observes a zero-length read on its
            // NetworkStream which StreamJsonRpc raises as a ConnectionLostException.
            try
            {
                var stream = new NetworkStream(socket, ownsSocket: false);
                using var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, new SystemTextJsonFormatter()));
                rpc.StartListening();
 
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
                _ = await rpc.InvokeAsync<TerminalHostInfoResponse>(TerminalHostControlProtocol.GetInfoMethod).WaitAsync(cts.Token);
                return true;
            }
            catch
            {
                return false;
            }
        }
    }
 
    [Fact]
    public async Task SnapshotSessionReportsConfiguredPaths()
    {
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            var snap = app.SnapshotSession();
            Assert.Equal(args.ProducerUdsPath, snap.ProducerUdsPath);
            Assert.Equal(args.ConsumerUdsPath, snap.ConsumerUdsPath);
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task RunAsyncWithBadArgsViaStaticEntryPointReturnsExUsage()
    {
        var exitCode = await TerminalHostApp.RunAsync(["--bogus"], CancellationToken.None);
        Assert.Equal(64, exitCode); // EX_USAGE
    }
 
    [Fact]
    public async Task HostStartsCleanlyWithStaleProducerAndConsumerSocketFiles()
    {
        // Regression: when a previous host crashes (or DCP kills the process before it
        // can gracefully unlink its UDS), the next host run hits "address already in use"
        // on Bind unless we explicitly pre-delete the path. Hex1b doesn't do this for us
        // because it doesn't know the path was previously bound by an Aspire host vs
        // some other process. Symmetry with TerminalHostControlListener (which has
        // always pre-deleted its control path).
        if (OperatingSystem.IsWindows())
        {
            // UDS files on Windows behave differently — File.Create at the path doesn't
            // produce a regular file that EADDRINUSEs the next bind, so the scenario
            // doesn't reproduce. The pre-delete still runs as defensive code.
            return;
        }
 
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        // Pre-place leftovers exactly as a crashed previous host would have left them.
        File.WriteAllBytes(args.ProducerUdsPath, []);
        File.WriteAllBytes(args.ConsumerUdsPath, []);
        Assert.True(File.Exists(args.ProducerUdsPath));
        Assert.True(File.Exists(args.ConsumerUdsPath));
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            // If pre-delete is missing, Bind fails inside the recycle loop and the
            // control listener may still come up (it pre-deletes its own path), but a
            // real producer connect would never succeed. Use the producer dial as the
            // end-to-end signal that both UDS paths are usable.
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            await using var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(10));
            await producer.SendHelloAsync(80, 24, default);
            await WaitForAsync(
                () => app.SnapshotSession().ProducerConnected,
                TimeSpan.FromSeconds(5),
                "Producer should connect after stale UDS files are pre-cleaned.");
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task SessionRecyclesAfterProducerDisconnect()
    {
        // End-to-end check of the recycle loop: the host should stay running
        // across a producer disconnect and accept a fresh producer on the
        // same UDS path, with ProducerConnected and RestartCount tracking
        // each cycle. This exercises the path DCP exercises in production
        // when the underlying process exits and gets relaunched.
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            // Initial state: host is up but no producer has dialed in yet.
            var initial = app.SnapshotSession();
            Assert.False(initial.ProducerConnected, "Session should report no producer before any connect.");
            Assert.False(initial.IsAlive, "Legacy IsAlive should mirror ProducerConnected.");
            Assert.Equal(0, initial.RestartCount);
 
            // Cycle 1: connect, get accepted, disconnect.
            await using (var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5)))
            {
                await producer.SendHelloAsync(80, 24, default);
                await producer.SendOutputAsync("first cycle"u8.ToArray(), default);
                await WaitForAsync(
                    () => app.SnapshotSession().ProducerConnected,
                    TimeSpan.FromSeconds(5),
                    "ProducerConnected should flip to true after producer dials in.");
            }
 
            await WaitForAsync(
                () =>
                {
                    var s = app.SnapshotSession();
                    return !s.ProducerConnected && s.RestartCount >= 1;
                },
                TimeSpan.FromSeconds(10),
                "After producer disconnect, ProducerConnected should clear and RestartCount should advance.");
 
            var afterCycle1 = app.SnapshotSession();
            Assert.Equal(1, afterCycle1.RestartCount);
 
            // Cycle 2: a fresh producer should be able to dial the same UDS path.
            // This is the critical DCP-restart scenario.
            await using (var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(10)))
            {
                await producer.SendHelloAsync(80, 24, default);
                await producer.SendOutputAsync("second cycle"u8.ToArray(), default);
                await WaitForAsync(
                    () => app.SnapshotSession().ProducerConnected,
                    TimeSpan.FromSeconds(5),
                    "ProducerConnected should flip true again after the second producer dials in.");
            }
 
            await WaitForAsync(
                () =>
                {
                    var s = app.SnapshotSession();
                    return !s.ProducerConnected && s.RestartCount >= 2;
                },
                TimeSpan.FromSeconds(10),
                "After the second producer disconnects, ProducerConnected should clear and RestartCount should reach 2.");
 
            // Session itself is still there — IsAlive/ProducerConnected being false
            // is transient, the snapshot continues to report the same UDS paths.
            var afterCycle2 = app.SnapshotSession();
            Assert.Equal(args.ProducerUdsPath, afterCycle2.ProducerUdsPath);
            Assert.Equal(args.ConsumerUdsPath, afterCycle2.ConsumerUdsPath);
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task GracefulCancellationDeletesProducerAndConsumerSockets()
    {
        // Regression for https://github.com/microsoft/aspire/issues/19302: on a graceful
        // `aspire stop`, DCP signals the terminal host (SIGTERM) which Program.cs turns into a
        // cancellation of the token passed to RunAsync. That cancellation MUST flow through
        // TearDownAsync -> TerminalReplica.DisposeAsync and unlink both listen sockets
        // ({id}.dcp.sock producer + {id}.host.sock consumer). If it doesn't (the old SIGINT-only
        // behavior), the child is SIGKILLed while those sockets are still bound and both files
        // leak on disk. This test drives exactly the token-cancel path the SIGTERM handler now
        // invokes and asserts both files are gone afterward.
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            // Both listen sockets are bound while the replica waits for its first producer — this
            // is precisely the on-disk state that leaks under SIGKILL. The producer UDS is a
            // single-accept listener (its file disappears once a producer dials in), so we assert
            // on the pre-connect bound state rather than connecting a producer. Wait for both to
            // exist first; otherwise the "deleted after shutdown" assertion below could pass
            // trivially because the sockets were never bound.
            await WaitForFileAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(10));
            await WaitForFileAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(10));
            Assert.True(File.Exists(args.ProducerUdsPath), "Producer socket should be bound while the replica waits.");
            Assert.True(File.Exists(args.ConsumerUdsPath), "Consumer socket should be bound while the replica waits.");
        }
        finally
        {
            // Cancel ONLY the external token — this is precisely what Program.cs's SIGINT/SIGTERM
            // handler does (cts.Cancel()). We deliberately do NOT call app.RequestShutdown() so the
            // test exercises the signal-driven graceful path end to end.
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
 
        Assert.False(
            File.Exists(args.ProducerUdsPath),
            $"Producer socket '{args.ProducerUdsPath}' should be deleted after graceful shutdown.");
        Assert.False(
            File.Exists(args.ConsumerUdsPath),
            $"Consumer socket '{args.ConsumerUdsPath}' should be deleted after graceful shutdown.");
    }
 
    [Fact]
    public async Task SessionSnapshotIncludesNewFields()
    {
        // Even before any producer has connected, the snapshot must populate
        // the new fields so older AppHost wire deserialisation never sees a
        // missing-required-property error.
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            var snap = app.SnapshotSession();
            Assert.False(snap.ProducerConnected);
            Assert.False(snap.IsAlive);
            Assert.Equal(0, snap.RestartCount);
            Assert.Null(snap.ExitCode);
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task DownstreamPrimaryResizeIsForwardedUpstreamAsRawResizeFrame()
    {
        // Regression: the consumer-side multi-head server fires its OnResized
        // event whenever the current primary peer changes the producer dims
        // (RequestPrimary or explicit Resize from primary). The terminal host
        // bridges that event to a raw HMP1 FrameResize (0x05) on the upstream
        // (DCP-facing) connection — bypassing Hex1b's stock Hmp1WorkloadAdapter
        // IsPrimary gate, which would silently drop every resize because DCP's
        // minimal HMP1 server never sends Hello.PrimaryPeerId or RoleChange.
        // Without this bridge, the underlying PTY stayed at its DCP-initial
        // dims forever.
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            // Stand up the upstream "DCP" first. Send Hello so the host's
            // upstream-side adapter handshakes successfully and ProducerConnected
            // flips before we attempt the consumer-side connect — otherwise the
            // resize broadcast would race the upstream stream's existence.
            await using var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5));
            await producer.SendHelloAsync(80, 24, default);
 
            await WaitForAsync(
                () => app.SnapshotSession().ProducerConnected,
                TimeSpan.FromSeconds(5),
                "ProducerConnected should flip to true after producer dials in.");
 
            // Wait for the consumer-side UDS server to bind so the dial below
            // doesn't race a not-yet-listening socket.
            await WaitForFileAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(5));
 
            // Now connect a minimal raw-frame HMP1 client to the consumer UDS:
            // ClientHello + RequestPrimary, then keep the stream open. We don't
            // need a full Hex1bTerminal because all we're verifying is that the
            // server-side OnResized event (which fires when RequestPrimary
            // promotes us and applies the requested dims) is bridged upstream.
            const int requestedWidth = 123;
            const int requestedHeight = 45;
 
            await using var consumer = await TestHmp1Consumer.ConnectAsync(
                args.ConsumerUdsPath, TimeSpan.FromSeconds(5));
            await consumer.SendClientHelloAsync("test-consumer", "primary", default);
            await consumer.SendRequestPrimaryAsync(requestedWidth, requestedHeight, default);
 
            // The frame the test producer should observe upstream:
            // [type=0x05 Resize][length=8 LE][width:4B LE][height:4B LE]
            //
            // Use the predicate variant because the host's Hex1bTerminal may
            // emit additional resize frames upstream during startup or in
            // response to incoming Hello dims (the consumer-side server's
            // Resized event also triggers Hex1bTerminal's own
            // workload.ResizeAsync). We only care that SOME FrameResize lands
            // upstream with the dims the consumer requested via RequestPrimary.
            const byte FrameResize = 0x05;
            var payload = await producer.WaitForMatchingFrameAsync(
                FrameResize,
                p =>
                {
                    if (p.Length != 8)
                    {
                        return false;
                    }
                    var w = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24);
                    var h = p[4] | (p[5] << 8) | (p[6] << 16) | (p[7] << 24);
                    return w == requestedWidth && h == requestedHeight;
                },
                TimeSpan.FromSeconds(10));
 
            // Sanity: payload encodes exactly the requested dims (defensive
            // — the predicate already filtered, but keeps the assertion intent
            // explicit on the test surface).
            Assert.Equal(8, payload.Length);
            var observedWidth = payload[0] | (payload[1] << 8) | (payload[2] << 16) | (payload[3] << 24);
            var observedHeight = payload[4] | (payload[5] << 8) | (payload[6] << 16) | (payload[7] << 24);
            Assert.Equal(requestedWidth, observedWidth);
            Assert.Equal(requestedHeight, observedHeight);
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    /// <summary>
    /// A minimal HMP1 server-role producer for tests. Connects to the UDS path
    /// the terminal host is listening on (producer side) and writes the bare
    /// minimum frames the Hex1bTerminal client expects: a Hello, optional
    /// Output frames, and EOF on dispose.
    /// </summary>
    private sealed class TestHmp1Producer : IAsyncDisposable
    {
        // HMP1 wire format: [type:1B][length:4B LE][payload:N bytes].
        private const byte FrameHello = 0x01;
        private const byte FrameOutput = 0x03;
 
        private readonly Socket _socket;
        private readonly NetworkStream _stream;
        private bool _disposed;
 
        public TestHmp1Producer(Socket socket)
        {
            _socket = socket;
            _stream = new NetworkStream(socket, ownsSocket: true);
        }
 
        public async Task SendHelloAsync(int width, int height, CancellationToken ct)
        {
            var json = $"{{\"version\":1,\"width\":{width},\"height\":{height}}}";
            await SendFrameAsync(FrameHello, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false);
        }
 
        public Task SendOutputAsync(byte[] payload, CancellationToken ct) =>
            SendFrameAsync(FrameOutput, payload, ct);
 
        /// <summary>
        /// Drains HMP1 frames until a frame of the requested type whose
        /// payload passes the <paramref name="match"/> predicate is found.
        /// Useful when upstream may receive multiple frames of the same type
        /// from different sources (e.g., terminal startup resize vs explicit
        /// user resize).
        /// </summary>
        public async Task<byte[]> WaitForMatchingFrameAsync(
            byte expectedType, Func<byte[], bool> match, TimeSpan timeout)
        {
            using var cts = new CancellationTokenSource(timeout);
            while (true)
            {
                var (type, payload) = await ReadFrameAsync(cts.Token).ConfigureAwait(false);
                if (type == expectedType && match(payload))
                {
                    return payload;
                }
            }
        }
 
        /// <summary>Reads exactly one HMP1 frame and returns its (type, payload).</summary>
        public async Task<(byte Type, byte[] Payload)> ReadFrameAsync(CancellationToken ct)
        {
            var header = new byte[5];
            await ReadExactlyAsync(header, ct).ConfigureAwait(false);
 
            var type = header[0];
            var length = (uint)(header[1] | (header[2] << 8) | (header[3] << 16) | (header[4] << 24));
            if (length > 16 * 1024 * 1024)
            {
                throw new InvalidOperationException($"Producer-side reader: frame length {length} exceeds 16MB cap.");
            }
 
            var payload = new byte[length];
            if (length > 0)
            {
                await ReadExactlyAsync(payload, ct).ConfigureAwait(false);
            }
            return (type, payload);
        }
 
        private async Task ReadExactlyAsync(byte[] buffer, CancellationToken ct)
        {
            var offset = 0;
            while (offset < buffer.Length)
            {
                var read = await _stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), ct).ConfigureAwait(false);
                if (read == 0)
                {
                    throw new EndOfStreamException(
                        $"Producer-side reader: stream EOF after {offset} of {buffer.Length} bytes.");
                }
                offset += read;
            }
        }
 
        private async Task SendFrameAsync(byte type, byte[] payload, CancellationToken ct)
        {
            var header = new byte[5];
            header[0] = type;
            header[1] = (byte)(payload.Length & 0xFF);
            header[2] = (byte)((payload.Length >> 8) & 0xFF);
            header[3] = (byte)((payload.Length >> 16) & 0xFF);
            header[4] = (byte)((payload.Length >> 24) & 0xFF);
            await _stream.WriteAsync(header, ct).ConfigureAwait(false);
            if (payload.Length > 0)
            {
                await _stream.WriteAsync(payload, ct).ConfigureAwait(false);
            }
            await _stream.FlushAsync(ct).ConfigureAwait(false);
        }
 
        public async ValueTask DisposeAsync()
        {
            if (_disposed)
            {
                return;
            }
            _disposed = true;
            try { _socket.Shutdown(SocketShutdown.Both); } catch { /* ignore */ }
            await _stream.DisposeAsync().ConfigureAwait(false);
        }
    }
 
    private static async Task<TestHmp1Producer> ConnectProducerAsync(string socketPath, TimeSpan timeout)
    {
        // Retry loop because there is a brief unbound window between recycle
        // iterations on the host side; the test producer should ride through
        // that the same way DCP does in production.
        var sw = System.Diagnostics.Stopwatch.StartNew();
        Exception? last = null;
        while (sw.Elapsed < timeout)
        {
            var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            try
            {
                await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)).ConfigureAwait(false);
                return new TestHmp1Producer(socket);
            }
            catch (Exception ex)
            {
                socket.Dispose();
                last = ex;
                await Task.Delay(50).ConfigureAwait(false);
            }
        }
        throw new TimeoutException(
            $"Timed out connecting to producer UDS '{socketPath}' after {timeout.TotalSeconds:F1}s.", last);
    }
 
    /// <summary>
    /// A minimal HMP1 client-role consumer for tests. Connects to the consumer
    /// UDS the terminal host is listening on, then writes raw HMP1 frames
    /// (ClientHello, RequestPrimary). Avoids spinning up a full
    /// <c>Hex1bTerminal</c> in a test process where no interactive console is
    /// attached.
    /// </summary>
    private sealed class TestHmp1Consumer : IAsyncDisposable
    {
        private const byte FrameRequestPrimary = 0x07;
        private const byte FrameClientHello = 0x0B;
 
        private readonly Socket _socket;
        private readonly NetworkStream _stream;
        private bool _disposed;
 
        private TestHmp1Consumer(Socket socket)
        {
            _socket = socket;
            _stream = new NetworkStream(socket, ownsSocket: true);
        }
 
        public static async Task<TestHmp1Consumer> ConnectAsync(string socketPath, TimeSpan timeout)
        {
            var sw = System.Diagnostics.Stopwatch.StartNew();
            Exception? last = null;
            while (sw.Elapsed < timeout)
            {
                var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
                try
                {
                    await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)).ConfigureAwait(false);
                    return new TestHmp1Consumer(socket);
                }
                catch (Exception ex)
                {
                    socket.Dispose();
                    last = ex;
                    await Task.Delay(50).ConfigureAwait(false);
                }
            }
            throw new TimeoutException(
                $"Timed out connecting to consumer UDS '{socketPath}' after {timeout.TotalSeconds:F1}s.", last);
        }
 
        public async Task SendClientHelloAsync(string displayName, string defaultRole, CancellationToken ct)
        {
            // JSON keys are camelCase per Hmp1JsonContext.PropertyNamingPolicy.
            // Roles on the wire are the lowercase strings "primary" / "secondary".
            var json = $"{{\"displayName\":\"{displayName}\",\"defaultRole\":\"{defaultRole}\"}}";
            await SendFrameAsync(FrameClientHello, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false);
        }
 
        public async Task SendRequestPrimaryAsync(int cols, int rows, CancellationToken ct)
        {
            var json = $"{{\"cols\":{cols},\"rows\":{rows}}}";
            await SendFrameAsync(FrameRequestPrimary, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false);
        }
 
        private async Task SendFrameAsync(byte type, byte[] payload, CancellationToken ct)
        {
            var header = new byte[5];
            header[0] = type;
            header[1] = (byte)(payload.Length & 0xFF);
            header[2] = (byte)((payload.Length >> 8) & 0xFF);
            header[3] = (byte)((payload.Length >> 16) & 0xFF);
            header[4] = (byte)((payload.Length >> 24) & 0xFF);
            await _stream.WriteAsync(header, ct).ConfigureAwait(false);
            if (payload.Length > 0)
            {
                await _stream.WriteAsync(payload, ct).ConfigureAwait(false);
            }
            await _stream.FlushAsync(ct).ConfigureAwait(false);
        }
 
        public async ValueTask DisposeAsync()
        {
            if (_disposed)
            {
                return;
            }
            _disposed = true;
            try { _socket.Shutdown(SocketShutdown.Both); } catch { /* ignore */ }
            await _stream.DisposeAsync().ConfigureAwait(false);
        }
    }
 
    private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout, string failureMessage)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        while (sw.Elapsed < timeout)
        {
            if (predicate())
            {
                return;
            }
            await Task.Delay(25).ConfigureAwait(false);
        }
        throw new TimeoutException($"{failureMessage} (waited {timeout.TotalSeconds:F1}s).");
    }
 
    private static async Task<JsonRpc> OpenControlRpcAsync(string socketPath)
    {
        var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
        try
        {
            await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath));
        }
        catch
        {
            socket.Dispose();
            throw;
        }
 
        var stream = new NetworkStream(socket, ownsSocket: true);
        var formatter = new SystemTextJsonFormatter();
        var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter);
        var rpc = new JsonRpc(handler);
        rpc.StartListening();
        return rpc;
    }
 
    [Fact]
    public async Task ControlSocketIsRestrictedToOwningUser()
    {
        // Skipped on Windows because UnixFileMode is not supported there; the
        // listener intentionally no-ops on Windows for the same reason.
        if (OperatingSystem.IsWindows())
        {
            return;
        }
 
        var (args, workspace, control) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            await WaitForFileAsync(control, TimeSpan.FromSeconds(10));
 
            // CSWSH/local-DoS defense: the control socket must be 0600 (UserRead|
            // UserWrite). Anything broader allows a local user to dial it and call
            // ShutdownAsync (no auth) or GetSessionAsync (leaks peer DisplayNames).
            var mode = File.GetUnixFileMode(control);
            Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    [Fact]
    public async Task ProducerAndConsumerSocketsAreRestrictedToOwningUser()
    {
        // Defense-in-depth (the parent ~/.aspire/trmnl/ dir is already 0700, but per-file
        // 0600 matches what the control socket does and protects in case the dir's perms
        // somehow get relaxed by a future change). The chmod is applied by
        // TerminalReplica.ApplyRestrictiveSocketPermissionsAsync after Hex1b binds.
        if (OperatingSystem.IsWindows())
        {
            return;
        }
 
        var (args, workspace, _) = BuildArgs();
        using var disp = workspace;
 
        await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance);
        using var hostCts = new CancellationTokenSource();
        var hostTask = app.RunAsync(hostCts.Token);
 
        try
        {
            // The producer socket is bound as soon as RunAsync starts; the consumer
            // socket is bound once Hex1bTerminal's HMP1 server initialises (also during
            // RunAsync). The post-bind chmod helper polls for file existence and is
            // best-effort, so we allow up to a few seconds.
            await WaitForFileAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(10));
            await WaitForFileAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(10));
 
            await WaitForUnixFileModeAsync(
                args.ProducerUdsPath,
                UnixFileMode.UserRead | UnixFileMode.UserWrite,
                TimeSpan.FromSeconds(5));
            await WaitForUnixFileModeAsync(
                args.ConsumerUdsPath,
                UnixFileMode.UserRead | UnixFileMode.UserWrite,
                TimeSpan.FromSeconds(5));
        }
        finally
        {
            app.RequestShutdown();
            hostCts.Cancel();
            await hostTask.WaitAsync(TimeSpan.FromSeconds(10));
        }
    }
 
    private static async Task WaitForUnixFileModeAsync(string path, UnixFileMode expected, TimeSpan timeout)
    {
        // Callers MUST guard this with !OperatingSystem.IsWindows(); the early-return
        // here is purely so the analyzer accepts File.GetUnixFileMode below.
        if (OperatingSystem.IsWindows())
        {
            return;
        }
 
        // The chmod is applied from a background task in TerminalReplica after the
        // socket binds (Hex1b binds lazily inside RunAsync), so the window between
        // "file exists" and "file has 0600 perms" is observable from the outside.
        // Poll briefly until we see the expected mode.
        var sw = System.Diagnostics.Stopwatch.StartNew();
        UnixFileMode last = default;
        while (sw.Elapsed < timeout)
        {
            try
            {
                last = File.GetUnixFileMode(path);
                if (last == expected)
                {
                    return;
                }
            }
            catch (FileNotFoundException)
            {
                // Socket recycled mid-poll.
            }
            await Task.Delay(50);
        }
 
        throw new TimeoutException(
            $"Expected '{path}' to have mode {expected} within {timeout.TotalSeconds:F1}s; last observed {last}.");
    }
 
    private static async Task WaitForFileAsync(string path, TimeSpan timeout)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        while (sw.Elapsed < timeout)
        {
            if (File.Exists(path))
            {
                return;
            }
            await Task.Delay(50);
        }
 
        throw new TimeoutException($"Timed out waiting for '{path}' after {timeout.TotalSeconds:F1}s.");
    }
}