Files
natsdotnet/docs/plans/2026-02-22-section2-client-connection-handling-plan.md
Joseph Doherty 3941c85e76 Merge branch 'feature/core-lifecycle' into main
Reconcile close reason tracking: feature branch's MarkClosed() and
ShouldSkipFlush/FlushAndCloseAsync now use main's ClientClosedReason
enum. ClosedState enum retained for forward compatibility.
2026-02-23 00:09:30 -05:00

50 KiB

Section 2: Client/Connection Handling Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Implement all 8 in-scope gaps from differences.md Section 2 (Client/Connection Handling), adding close reason tracking, connection state flags, a channel-based write loop with batch flush, slow consumer detection, write deadlines, verbose mode, no-responders, and stat batching.

Architecture: Replace the inline _writeLock + direct stream write model in NatsClient with a Channel<ReadOnlyMemory<byte>> write loop. All outbound data is serialized to bytes, enqueued via QueueOutbound(), and drained/flushed in batch by a dedicated RunWriteLoopAsync task. Slow consumer detection and write deadlines are enforced at the enqueue and flush points respectively. New enums (ClientClosedReason, ClientFlags) provide structured close tracking and state management.

Tech Stack: .NET 10 / C# 14, System.Threading.Channels, System.IO.Pipelines, xUnit 3, Shouldly


Task 1: Add ClientClosedReason enum

Files:

  • Create: src/NATS.Server/ClientClosedReason.cs
  • Test: tests/NATS.Server.Tests/ClientClosedReasonTests.cs

Step 1: Write the failing test

Create tests/NATS.Server.Tests/ClientClosedReasonTests.cs:

namespace NATS.Server.Tests;

public class ClientClosedReasonTests
{
    [Fact]
    public void All_expected_close_reasons_exist()
    {
        // Verify all 16 enum values exist and are distinct
        var values = Enum.GetValues<ClientClosedReason>();
        values.Length.ShouldBe(16);
        values.Distinct().Count().ShouldBe(16);
    }

    [Theory]
    [InlineData(ClientClosedReason.ClientClosed, "Client Closed")]
    [InlineData(ClientClosedReason.SlowConsumerPendingBytes, "Slow Consumer (Pending Bytes)")]
    [InlineData(ClientClosedReason.SlowConsumerWriteDeadline, "Slow Consumer (Write Deadline)")]
    [InlineData(ClientClosedReason.StaleConnection, "Stale Connection")]
    [InlineData(ClientClosedReason.ServerShutdown, "Server Shutdown")]
    public void ToReasonString_returns_human_readable_description(ClientClosedReason reason, string expected)
    {
        reason.ToReasonString().ShouldBe(expected);
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientClosedReasonTests" -v quiet Expected: FAIL — ClientClosedReason type not found

Step 3: Write the implementation

Create src/NATS.Server/ClientClosedReason.cs:

namespace NATS.Server;

/// <summary>
/// Reason a client connection was closed.
/// Corresponds to Go server/client.go ClosedState (subset for single-server scope).
/// </summary>
public enum ClientClosedReason
{
    None = 0,
    ClientClosed,
    AuthenticationTimeout,
    AuthenticationViolation,
    TlsHandshakeError,
    SlowConsumerPendingBytes,
    SlowConsumerWriteDeadline,
    WriteError,
    ReadError,
    ParseError,
    StaleConnection,
    ProtocolViolation,
    MaxPayloadExceeded,
    MaxSubscriptionsExceeded,
    ServerShutdown,
    MsgHeaderViolation,
    NoRespondersRequiresHeaders,
}

public static class ClientClosedReasonExtensions
{
    public static string ToReasonString(this ClientClosedReason reason) => reason switch
    {
        ClientClosedReason.None => "",
        ClientClosedReason.ClientClosed => "Client Closed",
        ClientClosedReason.AuthenticationTimeout => "Authentication Timeout",
        ClientClosedReason.AuthenticationViolation => "Authorization Violation",
        ClientClosedReason.TlsHandshakeError => "TLS Handshake Error",
        ClientClosedReason.SlowConsumerPendingBytes => "Slow Consumer (Pending Bytes)",
        ClientClosedReason.SlowConsumerWriteDeadline => "Slow Consumer (Write Deadline)",
        ClientClosedReason.WriteError => "Write Error",
        ClientClosedReason.ReadError => "Read Error",
        ClientClosedReason.ParseError => "Parse Error",
        ClientClosedReason.StaleConnection => "Stale Connection",
        ClientClosedReason.ProtocolViolation => "Protocol Violation",
        ClientClosedReason.MaxPayloadExceeded => "Maximum Payload Exceeded",
        ClientClosedReason.MaxSubscriptionsExceeded => "Maximum Subscriptions Exceeded",
        ClientClosedReason.ServerShutdown => "Server Shutdown",
        ClientClosedReason.MsgHeaderViolation => "Message Header Violation",
        ClientClosedReason.NoRespondersRequiresHeaders => "No Responders Requires Headers",
        _ => reason.ToString(),
    };
}

Step 4: Run test to verify it passes

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientClosedReasonTests" -v quiet Expected: PASS

Step 5: Commit

git add src/NATS.Server/ClientClosedReason.cs tests/NATS.Server.Tests/ClientClosedReasonTests.cs
git commit -m "feat: add ClientClosedReason enum with 16 close reason values"

Task 2: Add ClientFlags bitfield

Files:

  • Create: src/NATS.Server/ClientFlags.cs
  • Test: tests/NATS.Server.Tests/ClientFlagsTests.cs

Step 1: Write the failing test

Create tests/NATS.Server.Tests/ClientFlagsTests.cs:

namespace NATS.Server.Tests;

public class ClientFlagsTests
{
    [Fact]
    public void SetFlag_and_HasFlag_work()
    {
        var holder = new ClientFlagHolder();
        holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeFalse();

        holder.SetFlag(ClientFlags.ConnectReceived);
        holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeTrue();
    }

    [Fact]
    public void ClearFlag_removes_flag()
    {
        var holder = new ClientFlagHolder();
        holder.SetFlag(ClientFlags.ConnectReceived);
        holder.SetFlag(ClientFlags.IsSlowConsumer);

        holder.ClearFlag(ClientFlags.ConnectReceived);

        holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeFalse();
        holder.HasFlag(ClientFlags.IsSlowConsumer).ShouldBeTrue();
    }

    [Fact]
    public void Multiple_flags_can_be_set_independently()
    {
        var holder = new ClientFlagHolder();
        holder.SetFlag(ClientFlags.ConnectReceived);
        holder.SetFlag(ClientFlags.WriteLoopStarted);
        holder.SetFlag(ClientFlags.FirstPongSent);

        holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeTrue();
        holder.HasFlag(ClientFlags.WriteLoopStarted).ShouldBeTrue();
        holder.HasFlag(ClientFlags.FirstPongSent).ShouldBeTrue();
        holder.HasFlag(ClientFlags.IsSlowConsumer).ShouldBeFalse();
    }

    [Fact]
    public void SetFlag_is_thread_safe()
    {
        var holder = new ClientFlagHolder();
        var flags = Enum.GetValues<ClientFlags>();

        Parallel.ForEach(flags, flag => holder.SetFlag(flag));

        foreach (var flag in flags)
            holder.HasFlag(flag).ShouldBeTrue();
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientFlagsTests" -v quiet Expected: FAIL — ClientFlags type not found

Step 3: Write the implementation

Create src/NATS.Server/ClientFlags.cs:

namespace NATS.Server;

/// <summary>
/// Connection state flags tracked per client.
/// Corresponds to Go server/client.go clientFlag bitfield.
/// Thread-safe via Interlocked operations on the backing int.
/// </summary>
[Flags]
public enum ClientFlags
{
    ConnectReceived = 1 << 0,
    FirstPongSent = 1 << 1,
    HandshakeComplete = 1 << 2,
    CloseConnection = 1 << 3,
    WriteLoopStarted = 1 << 4,
    IsSlowConsumer = 1 << 5,
    ConnectProcessFinished = 1 << 6,
}

/// <summary>
/// Thread-safe holder for client flags using Interlocked operations.
/// </summary>
public sealed class ClientFlagHolder
{
    private int _flags;

    public void SetFlag(ClientFlags flag)
    {
        Interlocked.Or(ref _flags, (int)flag);
    }

    public void ClearFlag(ClientFlags flag)
    {
        Interlocked.And(ref _flags, ~(int)flag);
    }

    public bool HasFlag(ClientFlags flag)
    {
        return (Volatile.Read(ref _flags) & (int)flag) != 0;
    }
}

Step 4: Run test to verify it passes

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientFlagsTests" -v quiet Expected: PASS

Step 5: Commit

git add src/NATS.Server/ClientFlags.cs tests/NATS.Server.Tests/ClientFlagsTests.cs
git commit -m "feat: add ClientFlags bitfield with thread-safe holder"

Task 3: Add MaxPending and WriteDeadline to NatsOptions

Files:

  • Modify: src/NATS.Server/NatsOptions.cs:11-15 (add new properties after MaxControlLine)
  • Modify: src/NATS.Server/Protocol/NatsProtocol.cs:8 (add MaxPendingSize constant)
  • Modify: src/NATS.Server/Monitoring/VarzHandler.cs:67-68 (wire MaxPending/WriteDeadline to varz)

Step 1: Add new options and constant

In src/NATS.Server/NatsOptions.cs, add after MaxConnections (line 13):

    public long MaxPending { get; set; } = 64 * 1024 * 1024; // 64MB, matching Go MAX_PENDING_SIZE
    public TimeSpan WriteDeadline { get; set; } = TimeSpan.FromSeconds(10);

In src/NATS.Server/Protocol/NatsProtocol.cs, add after MaxPayloadSize (line 8):

    public const long MaxPendingSize = 64 * 1024 * 1024; // 64MB default max pending

Also add error strings for new close reasons after line 32:

    public const string ErrSlowConsumer = "Slow Consumer";
    public const string ErrNoRespondersRequiresHeaders = "No Responders Requires Headers Support";

In src/NATS.Server/Monitoring/VarzHandler.cs, update line 67-68 area to wire in new values:

                MaxPending = _options.MaxPending,
                WriteDeadline = (long)_options.WriteDeadline.TotalNanoseconds,

Step 2: Run full build to verify compilation

Run: dotnet build Expected: Build succeeded

Step 3: Commit

git add src/NATS.Server/NatsOptions.cs src/NATS.Server/Protocol/NatsProtocol.cs src/NATS.Server/Monitoring/VarzHandler.cs
git commit -m "feat: add MaxPending, WriteDeadline options and error constants"

Task 4: Integrate ClientFlags into NatsClient (replace _connectReceived)

Files:

  • Modify: src/NATS.Server/NatsClient.cs

This task replaces the _connectReceived int field with ClientFlagHolder and updates all references.

Step 1: Replace _connectReceived field and property

In src/NATS.Server/NatsClient.cs:

Remove lines 49-51:

    // Thread-safe: read from auth timeout task on threadpool, written from command pipeline
    private int _connectReceived;
    public bool ConnectReceived => Volatile.Read(ref _connectReceived) != 0;

Replace with:

    private readonly ClientFlagHolder _flags = new();
    public bool ConnectReceived => _flags.HasFlag(ClientFlags.ConnectReceived);
    public ClientClosedReason CloseReason { get; private set; }

Step 2: Update all references to _connectReceived

Line 304 — change Volatile.Write(ref _connectReceived, 1); to:

        _flags.SetFlag(ClientFlags.ConnectReceived);
        _flags.SetFlag(ClientFlags.ConnectProcessFinished);

Step 3: Add CloseWithReason helper method

Add after SendErrAndCloseAsync (after line 481):

    private async Task CloseWithReasonAsync(ClientClosedReason reason, string? errMessage = null)
    {
        CloseReason = reason;
        _flags.SetFlag(ClientFlags.CloseConnection);
        if (errMessage != null)
            await SendErrAsync(errMessage);
        if (_clientCts is { } cts)
            await cts.CancelAsync();
        else
            _socket.Close();
    }

Step 4: Update existing close paths to use CloseWithReason

Update SendErrAndCloseAsync at line 474-481 to set a generic close reason:

    public async Task SendErrAndCloseAsync(string message, ClientClosedReason reason = ClientClosedReason.ProtocolViolation)
    {
        await CloseWithReasonAsync(reason, message);
    }

Update callers with specific reasons:

  • Line 119 (auth timeout): await SendErrAndCloseAsync(NatsProtocol.ErrAuthTimeout, ClientClosedReason.AuthenticationTimeout);
  • Line 275 (auth violation): await SendErrAndCloseAsync(NatsProtocol.ErrAuthorizationViolation, ClientClosedReason.AuthenticationViolation);
  • Line 364 (max payload): await SendErrAndCloseAsync(NatsProtocol.ErrMaxPayloadViolation, ClientClosedReason.MaxPayloadExceeded);
  • Line 501 (stale connection): await SendErrAndCloseAsync(NatsProtocol.ErrStaleConnection, ClientClosedReason.StaleConnection);

Step 5: Run all existing tests to verify no regressions

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: All existing tests PASS

Step 6: Commit

git add src/NATS.Server/NatsClient.cs
git commit -m "refactor: replace _connectReceived with ClientFlagHolder and add CloseReason tracking"

Task 5: Implement channel-based write loop

Files:

  • Modify: src/NATS.Server/NatsClient.cs (major rewrite of write path)

This is the core architectural change. Replace _writeLock + inline writes with Channel<ReadOnlyMemory<byte>> write loop.

Step 1: Add channel field and remove _writeLock

In NatsClient.cs, replace _writeLock field (line 37):

    private readonly SemaphoreSlim _writeLock = new(1, 1);

with:

    private readonly Channel<ReadOnlyMemory<byte>> _outbound = Channel.CreateBounded<ReadOnlyMemory<byte>>(
        new BoundedChannelOptions(8192) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
    private long _pendingBytes;

Add required using at top of file:

using System.Threading.Channels;

Step 2: Add QueueOutbound method

Add after the constructor:

    /// <summary>
    /// Enqueues serialized protocol data for the write loop to flush.
    /// Checks slow consumer threshold before enqueuing.
    /// </summary>
    public bool QueueOutbound(ReadOnlyMemory<byte> data)
    {
        if (_flags.HasFlag(ClientFlags.CloseConnection))
            return false;

        var pending = Interlocked.Add(ref _pendingBytes, data.Length);
        if (pending > _options.MaxPending)
        {
            Interlocked.Add(ref _pendingBytes, -data.Length);
            _flags.SetFlag(ClientFlags.IsSlowConsumer);
            Interlocked.Increment(ref _serverStats.SlowConsumers);
            Interlocked.Increment(ref _serverStats.SlowConsumerClients);
            _ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
            return false;
        }

        if (!_outbound.Writer.TryWrite(data))
        {
            Interlocked.Add(ref _pendingBytes, -data.Length);
            _flags.SetFlag(ClientFlags.IsSlowConsumer);
            Interlocked.Increment(ref _serverStats.SlowConsumers);
            Interlocked.Increment(ref _serverStats.SlowConsumerClients);
            _ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
            return false;
        }

        return true;
    }

    public long PendingBytes => Interlocked.Read(ref _pendingBytes);

Step 3: Add RunWriteLoopAsync method

    private async Task RunWriteLoopAsync(CancellationToken ct)
    {
        _flags.SetFlag(ClientFlags.WriteLoopStarted);
        var reader = _outbound.Reader;
        try
        {
            while (await reader.WaitToReadAsync(ct))
            {
                long batchBytes = 0;
                while (reader.TryRead(out var data))
                {
                    await _stream.WriteAsync(data, ct);
                    batchBytes += data.Length;
                }

                // Apply write deadline to the flush
                using var flushCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                flushCts.CancelAfter(_options.WriteDeadline);
                try
                {
                    await _stream.FlushAsync(flushCts.Token);
                }
                catch (OperationCanceledException) when (!ct.IsCancellationRequested)
                {
                    // Write deadline exceeded, not server shutdown
                    _flags.SetFlag(ClientFlags.IsSlowConsumer);
                    Interlocked.Increment(ref _serverStats.SlowConsumers);
                    Interlocked.Increment(ref _serverStats.SlowConsumerClients);
                    await CloseWithReasonAsync(ClientClosedReason.SlowConsumerWriteDeadline, NatsProtocol.ErrSlowConsumer);
                    return;
                }

                Interlocked.Add(ref _pendingBytes, -batchBytes);
            }
        }
        catch (OperationCanceledException)
        {
            // Normal shutdown
        }
        catch (IOException)
        {
            await CloseWithReasonAsync(ClientClosedReason.WriteError);
        }
    }

Step 4: Wire write loop into RunAsync

In RunAsync (line 96), add the write loop task alongside read/process/ping:

Change lines 130-137 from:

            var fillTask = FillPipeAsync(pipe.Writer, _clientCts.Token);
            var processTask = ProcessCommandsAsync(pipe.Reader, _clientCts.Token);
            var pingTask = RunPingTimerAsync(_clientCts.Token);

            if (authTimeoutTask != null)
                await Task.WhenAny(fillTask, processTask, pingTask, authTimeoutTask);
            else
                await Task.WhenAny(fillTask, processTask, pingTask);

to:

            var fillTask = FillPipeAsync(pipe.Writer, _clientCts.Token);
            var processTask = ProcessCommandsAsync(pipe.Reader, _clientCts.Token);
            var pingTask = RunPingTimerAsync(_clientCts.Token);
            var writeTask = RunWriteLoopAsync(_clientCts.Token);

            if (authTimeoutTask != null)
                await Task.WhenAny(fillTask, processTask, pingTask, writeTask, authTimeoutTask);
            else
                await Task.WhenAny(fillTask, processTask, pingTask, writeTask);

In the finally block (line 147-153), complete the channel before socket shutdown:

        finally
        {
            _outbound.Writer.TryComplete();
            try { _socket.Shutdown(SocketShutdown.Both); }
            catch (SocketException) { }
            catch (ObjectDisposedException) { }
            Router?.RemoveClient(this);
        }

Step 5: Refactor SendMessageAsync to use QueueOutbound

Replace SendMessageAsync (lines 403-437) with:

    public void SendMessage(string subject, string sid, string? replyTo,
        ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
    {
        Interlocked.Increment(ref OutMsgs);
        Interlocked.Add(ref OutBytes, payload.Length + headers.Length);
        Interlocked.Increment(ref _serverStats.OutMsgs);
        Interlocked.Add(ref _serverStats.OutBytes, payload.Length + headers.Length);

        byte[] line;
        if (headers.Length > 0)
        {
            int totalSize = headers.Length + payload.Length;
            line = Encoding.ASCII.GetBytes($"HMSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{headers.Length} {totalSize}\r\n");
        }
        else
        {
            line = Encoding.ASCII.GetBytes($"MSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{payload.Length}\r\n");
        }

        // Build complete message as single byte array for atomicity
        var totalLen = line.Length + headers.Length + payload.Length + NatsProtocol.CrLf.Length;
        var msg = new byte[totalLen];
        var offset = 0;
        line.CopyTo(msg.AsSpan(offset)); offset += line.Length;
        if (headers.Length > 0) { headers.Span.CopyTo(msg.AsSpan(offset)); offset += headers.Length; }
        if (payload.Length > 0) { payload.Span.CopyTo(msg.AsSpan(offset)); offset += payload.Length; }
        NatsProtocol.CrLf.CopyTo(msg.AsSpan(offset));

        QueueOutbound(msg);
    }

Note: The method signature changes from async Task SendMessageAsync(...) to void SendMessage(...). This is a breaking change that requires updating NatsServer.cs caller in Task 7.

Step 6: Refactor WriteAsync to use QueueOutbound

Replace WriteAsync (lines 439-451) with:

    private void WriteProtocol(byte[] data)
    {
        QueueOutbound(data);
    }

Step 7: Update all internal callers of WriteAsync

In DispatchCommandAsync:

  • Lines 220, 235: await WriteAsync(NatsProtocol.PongBytes, ct);WriteProtocol(NatsProtocol.PongBytes);

In SendInfoAsync (line 396-401):

    private void SendInfo()
    {
        var infoJson = JsonSerializer.Serialize(_serverInfo);
        var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
        QueueOutbound(infoLine);
    }

Update RunAsync line 105: await SendInfoAsync(_clientCts.Token);SendInfo();

In SendErrAsync (lines 453-472):

    public void SendErr(string message)
    {
        var errLine = Encoding.ASCII.GetBytes($"-ERR '{message}'\r\n");
        QueueOutbound(errLine);
    }

Update SendErrAsync callers (lines 314, 371, 380, 455) to use SendErr.

Update CloseWithReasonAsync to use SendErr instead of SendErrAsync.

In RunPingTimerAsync line 510: await WriteAsync(NatsProtocol.PingBytes, ct);WriteProtocol(NatsProtocol.PingBytes);

Step 8: Update Dispose to clean up channel instead of _writeLock

In Dispose (line 532-539), remove _writeLock.Dispose(); — channel doesn't need explicit disposal but ensure writer is completed:

    public void Dispose()
    {
        _permissions?.Dispose();
        _outbound.Writer.TryComplete();
        _clientCts?.Dispose();
        _stream.Dispose();
        _socket.Dispose();
    }

Step 9: Run all existing tests to verify no regressions

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: All existing tests PASS (tests may need minor adjustment for sync SendMessage)

Step 10: Commit

git add src/NATS.Server/NatsClient.cs
git commit -m "feat: replace inline writes with channel-based write loop and batch flush"

Task 6: Write tests for write loop and slow consumer

Files:

  • Create: tests/NATS.Server.Tests/WriteLoopTests.cs

Step 1: Write the tests

Create tests/NATS.Server.Tests/WriteLoopTests.cs:

using System.IO.Pipelines;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Protocol;

namespace NATS.Server.Tests;

public class WriteLoopTests : IAsyncDisposable
{
    private readonly Socket _serverSocket;
    private readonly Socket _clientSocket;
    private readonly NatsOptions _options;
    private NatsClient _natsClient;
    private readonly CancellationTokenSource _cts = new();

    public WriteLoopTests()
    {
        var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        listener.Listen(1);
        var port = ((IPEndPoint)listener.LocalEndPoint!).Port;

        _clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        _clientSocket.Connect(IPAddress.Loopback, port);
        _serverSocket = listener.Accept();
        listener.Dispose();

        _options = new NatsOptions();
    }

    private NatsClient CreateClient(NatsOptions? options = null)
    {
        var opts = options ?? _options;
        var serverInfo = new ServerInfo
        {
            ServerId = "test", ServerName = "test", Version = "0.1.0",
            Host = "127.0.0.1", Port = 4222,
        };
        var authService = AuthService.Build(opts);
        return new NatsClient(1, new NetworkStream(_serverSocket, ownsSocket: false),
            _serverSocket, opts, serverInfo, authService, null, NullLogger.Instance, new ServerStats());
    }

    public async ValueTask DisposeAsync()
    {
        await _cts.CancelAsync();
        _natsClient?.Dispose();
        _clientSocket.Dispose();
    }

    [Fact]
    public async Task QueueOutbound_writes_data_to_client()
    {
        _natsClient = CreateClient();
        var runTask = _natsClient.RunAsync(_cts.Token);

        // Read INFO
        var buf = new byte[4096];
        await _clientSocket.ReceiveAsync(buf, SocketFlags.None);

        // Queue some data
        _natsClient.QueueOutbound(Encoding.ASCII.GetBytes("PING\r\n"));

        // Should receive the data
        using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
        var response = Encoding.ASCII.GetString(buf, 0, n);
        response.ShouldContain("PING\r\n");

        await _cts.CancelAsync();
    }

    [Fact]
    public async Task SlowConsumer_closes_when_pending_exceeds_max()
    {
        var opts = new NatsOptions { MaxPending = 1024 }; // Very small max pending
        _natsClient = CreateClient(opts);
        var runTask = _natsClient.RunAsync(_cts.Token);

        // Read INFO
        var buf = new byte[4096];
        await _clientSocket.ReceiveAsync(buf, SocketFlags.None);

        // Stop reading from client socket to create backpressure
        // Queue data that exceeds MaxPending
        var bigData = new byte[2048];
        var queued = _natsClient.QueueOutbound(bigData);

        // Should have been rejected as slow consumer
        queued.ShouldBeFalse();
        _natsClient.CloseReason.ShouldBe(ClientClosedReason.SlowConsumerPendingBytes);

        await _cts.CancelAsync();
    }

    [Fact]
    public async Task PendingBytes_tracks_queued_data()
    {
        _natsClient = CreateClient();
        var runTask = _natsClient.RunAsync(_cts.Token);

        // Read INFO
        var buf = new byte[4096];
        await _clientSocket.ReceiveAsync(buf, SocketFlags.None);

        // Initial pending should be 0 (after INFO is flushed)
        await Task.Delay(100); // Allow write loop to flush INFO
        var initialPending = _natsClient.PendingBytes;

        // Queue some data
        var data = new byte[100];
        _natsClient.QueueOutbound(data);

        // Pending should increase (may decrease quickly as write loop flushes)
        // Just verify it was accepted
        _natsClient.CloseReason.ShouldBe(ClientClosedReason.None);

        await _cts.CancelAsync();
    }
}

Step 2: Run the tests

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~WriteLoopTests" -v quiet Expected: PASS

Step 3: Commit

git add tests/NATS.Server.Tests/WriteLoopTests.cs
git commit -m "test: add write loop and slow consumer tests"

Task 7: Update NatsServer for new SendMessage signature + no-responders

Files:

  • Modify: src/NATS.Server/NatsServer.cs

Step 1: Update DeliverMessage to use synchronous SendMessage

In NatsServer.cs, update DeliverMessage (lines 262-275):

    private static void DeliverMessage(Subscription sub, string subject, string? replyTo,
        ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
    {
        var client = sub.Client;
        if (client == null) return;

        var count = Interlocked.Increment(ref sub.MessageCount);
        if (sub.MaxMessages > 0 && count > sub.MaxMessages)
            return;

        client.SendMessage(subject, sub.Sid, replyTo, headers, payload);
    }

Step 2: Add no-responders logic to ProcessMessage

In ProcessMessage (lines 225-260), add delivery tracking and no-responders notification:

    public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
        ReadOnlyMemory<byte> payload, NatsClient sender)
    {
        var subList = sender.Account?.SubList ?? _globalAccount.SubList;
        var result = subList.Match(subject);

        bool delivered = false;

        // Deliver to plain subscribers
        foreach (var sub in result.PlainSubs)
        {
            if (sub.Client == null || sub.Client == sender && !(sender.ClientOpts?.Echo ?? true))
                continue;

            DeliverMessage(sub, subject, replyTo, headers, payload);
            delivered = true;
        }

        // Deliver to one member of each queue group (round-robin)
        foreach (var queueGroup in result.QueueSubs)
        {
            if (queueGroup.Length == 0) continue;

            var idx = Math.Abs((int)Interlocked.Increment(ref sender.OutMsgs)) % queueGroup.Length;
            Interlocked.Decrement(ref sender.OutMsgs);

            for (int attempt = 0; attempt < queueGroup.Length; attempt++)
            {
                var sub = queueGroup[(idx + attempt) % queueGroup.Length];
                if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
                {
                    DeliverMessage(sub, subject, replyTo, headers, payload);
                    delivered = true;
                    break;
                }
            }
        }

        // No-responders: send 503 HMSG back to publisher if no one received the message
        if (!delivered && replyTo != null && sender.ClientOpts?.NoResponders == true)
        {
            SendNoResponders(sender, subject, replyTo);
        }
    }

    private static void SendNoResponders(NatsClient sender, string subject, string replyTo)
    {
        // Build HMSG with NATS/1.0 503 status header
        // Format: HMSG <reply> <sid> <hdr_len> <total_len>\r\n<headers>\r\n
        var headerBlock = $"NATS/1.0 503\r\n\r\n";
        var headerBytes = Encoding.ASCII.GetBytes(headerBlock);
        var hdrLen = headerBytes.Length;

        // Find the subscription that matches the reply subject to get the sid
        // The reply subject is on the sender's subscriptions
        string? sid = null;
        foreach (var sub in sender.Subscriptions.Values)
        {
            if (SubjectMatch.IsMatch(sub.Subject, replyTo))
            {
                sid = sub.Sid;
                break;
            }
        }

        if (sid == null) return; // No matching subscription for reply

        var line = Encoding.ASCII.GetBytes($"HMSG {replyTo} {sid} {hdrLen} {hdrLen}\r\n");
        var msg = new byte[line.Length + headerBytes.Length + NatsProtocol.CrLf.Length];
        var offset = 0;
        line.CopyTo(msg.AsSpan(offset)); offset += line.Length;
        headerBytes.CopyTo(msg.AsSpan(offset)); offset += headerBytes.Length;
        NatsProtocol.CrLf.CopyTo(msg.AsSpan(offset));

        sender.QueueOutbound(msg);
    }

Step 3: Run all tests to verify

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: PASS

Step 4: Commit

git add src/NATS.Server/NatsServer.cs
git commit -m "feat: update message delivery for write loop and add no-responders 503 notification"

Task 8: Implement verbose mode

Files:

  • Modify: src/NATS.Server/NatsClient.cs (add verbose OK after commands)
  • Test: tests/NATS.Server.Tests/VerboseModeTests.cs

Step 1: Write the failing test

Create tests/NATS.Server.Tests/VerboseModeTests.cs:

using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;

namespace NATS.Server.Tests;

public class VerboseModeTests : IAsyncLifetime
{
    private readonly NatsServer _server;
    private readonly int _port;
    private readonly CancellationTokenSource _cts = new();

    public VerboseModeTests()
    {
        _port = GetFreePort();
        _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
    }

    public async Task InitializeAsync()
    {
        _ = _server.StartAsync(_cts.Token);
        await _server.WaitForReadyAsync();
    }

    public async Task DisposeAsync()
    {
        await _cts.CancelAsync();
        _server.Dispose();
    }

    private static int GetFreePort()
    {
        using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        return ((IPEndPoint)sock.LocalEndPoint!).Port;
    }

    private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
    {
        using var cts = new CancellationTokenSource(timeoutMs);
        var sb = new StringBuilder();
        var buf = new byte[4096];
        while (!sb.ToString().Contains(expected))
        {
            var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
            if (n == 0) break;
            sb.Append(Encoding.ASCII.GetString(buf, 0, n));
        }
        return sb.ToString();
    }

    [Fact]
    public async Task Verbose_mode_sends_OK_after_CONNECT()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        // Read INFO
        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None);

        // CONNECT with verbose:true
        await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":true}\r\n"));

        var response = await ReadUntilAsync(client, "+OK");
        response.ShouldContain("+OK\r\n");
    }

    [Fact]
    public async Task Verbose_mode_sends_OK_after_SUB()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None); // INFO

        await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":true}\r\n"));
        await ReadUntilAsync(client, "+OK"); // OK for CONNECT

        await client.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\n"));

        var response = await ReadUntilAsync(client, "+OK");
        response.ShouldContain("+OK\r\n");
    }

    [Fact]
    public async Task Verbose_mode_sends_OK_after_PUB()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None); // INFO

        await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":true}\r\n"));
        await ReadUntilAsync(client, "+OK"); // OK for CONNECT

        await client.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nHello\r\n"));

        var response = await ReadUntilAsync(client, "+OK");
        response.ShouldContain("+OK\r\n");
    }

    [Fact]
    public async Task Non_verbose_mode_does_not_send_OK()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None); // INFO

        // CONNECT without verbose (default)
        await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"));

        // Should get PONG but NOT +OK
        var response = await ReadUntilAsync(client, "PONG");
        response.ShouldContain("PONG");
        response.ShouldNotContain("+OK");
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~VerboseModeTests" -v quiet Expected: FAIL — no +OK sent

Step 3: Add verbose OK sending to NatsClient

In NatsClient.cs, in DispatchCommandAsync, add verbose OK after each command:

After ProcessConnectAsync(cmd) in the switch (line 231):

            case CommandType.Connect:
                await ProcessConnectAsync(cmd);
                if (ClientOpts?.Verbose == true)
                    WriteProtocol(NatsProtocol.OkBytes);
                break;

After WriteProtocol(NatsProtocol.PongBytes) for PING (line 235):

            case CommandType.Ping:
                WriteProtocol(NatsProtocol.PongBytes);
                if (ClientOpts?.Verbose == true)
                    WriteProtocol(NatsProtocol.OkBytes);
                break;

After ProcessSubAsync(cmd) (line 243):

            case CommandType.Sub:
                await ProcessSubAsync(cmd);
                if (ClientOpts?.Verbose == true)
                    WriteProtocol(NatsProtocol.OkBytes);
                break;

After ProcessUnsub(cmd) (line 247):

            case CommandType.Unsub:
                ProcessUnsub(cmd);
                if (ClientOpts?.Verbose == true)
                    WriteProtocol(NatsProtocol.OkBytes);
                break;

After ProcessPubAsync(cmd) (line 252):

            case CommandType.Pub:
            case CommandType.HPub:
                await ProcessPubAsync(cmd);
                if (ClientOpts?.Verbose == true)
                    WriteProtocol(NatsProtocol.OkBytes);
                break;

Step 4: Run tests

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~VerboseModeTests" -v quiet Expected: PASS

Step 5: Run all tests to verify no regressions

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: PASS

Step 6: Commit

git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/VerboseModeTests.cs
git commit -m "feat: implement verbose mode (+OK responses after commands)"

Task 9: Implement no-responders CONNECT validation

Files:

  • Modify: src/NATS.Server/NatsClient.cs (ProcessConnectAsync)
  • Test: tests/NATS.Server.Tests/NoRespondersTests.cs

Step 1: Write the failing test

Create tests/NATS.Server.Tests/NoRespondersTests.cs:

using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;

namespace NATS.Server.Tests;

public class NoRespondersTests : IAsyncLifetime
{
    private readonly NatsServer _server;
    private readonly int _port;
    private readonly CancellationTokenSource _cts = new();

    public NoRespondersTests()
    {
        _port = GetFreePort();
        _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
    }

    public async Task InitializeAsync()
    {
        _ = _server.StartAsync(_cts.Token);
        await _server.WaitForReadyAsync();
    }

    public async Task DisposeAsync()
    {
        await _cts.CancelAsync();
        _server.Dispose();
    }

    private static int GetFreePort()
    {
        using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        return ((IPEndPoint)sock.LocalEndPoint!).Port;
    }

    private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
    {
        using var cts = new CancellationTokenSource(timeoutMs);
        var sb = new StringBuilder();
        var buf = new byte[4096];
        while (!sb.ToString().Contains(expected))
        {
            var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
            if (n == 0) break;
            sb.Append(Encoding.ASCII.GetString(buf, 0, n));
        }
        return sb.ToString();
    }

    [Fact]
    public async Task NoResponders_without_headers_closes_connection()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None); // INFO

        // CONNECT with no_responders:true but headers:false (default)
        await client.SendAsync(Encoding.ASCII.GetBytes(
            "CONNECT {\"no_responders\":true,\"headers\":false}\r\n"));

        // Should get -ERR and connection close
        var sb = new StringBuilder();
        using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        try
        {
            while (true)
            {
                var n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
                if (n == 0) break;
                sb.Append(Encoding.ASCII.GetString(buf, 0, n));
            }
        }
        catch (OperationCanceledException) { }

        sb.ToString().ShouldContain("-ERR");
    }

    [Fact]
    public async Task NoResponders_with_headers_accepted()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None); // INFO

        // CONNECT with no_responders:true AND headers:true — should be accepted
        await client.SendAsync(Encoding.ASCII.GetBytes(
            "CONNECT {\"no_responders\":true,\"headers\":true}\r\nPING\r\n"));

        var response = await ReadUntilAsync(client, "PONG");
        response.ShouldContain("PONG");
    }

    [Fact]
    public async Task NoResponders_sends_503_when_no_subscribers()
    {
        using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client.ConnectAsync(IPAddress.Loopback, _port);

        var buf = new byte[4096];
        await client.ReceiveAsync(buf, SocketFlags.None); // INFO

        // CONNECT with no_responders + headers, then SUB to reply inbox
        await client.SendAsync(Encoding.ASCII.GetBytes(
            "CONNECT {\"no_responders\":true,\"headers\":true}\r\n" +
            "SUB _INBOX.reply 99\r\n" +
            "PING\r\n"));
        await ReadUntilAsync(client, "PONG");

        // PUB to a subject with no subscribers, with reply-to set
        await client.SendAsync(Encoding.ASCII.GetBytes(
            "PUB no.one.listening _INBOX.reply 5\r\nHello\r\n"));

        // Should receive HMSG with 503 status on the reply subject
        var response = await ReadUntilAsync(client, "503", timeoutMs: 5000);
        response.ShouldContain("HMSG _INBOX.reply 99");
        response.ShouldContain("503");
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NoRespondersTests" -v quiet Expected: FAIL

Step 3: Add CONNECT validation in NatsClient.ProcessConnectAsync

In ProcessConnectAsync, after Volatile.Write(ref _connectReceived, 1) (now _flags.SetFlag(ClientFlags.ConnectReceived)), add:

        // Validate no_responders requires headers
        if (ClientOpts.NoResponders && !ClientOpts.Headers)
        {
            _logger.LogDebug("Client {ClientId} no_responders requires headers", Id);
            await CloseWithReasonAsync(ClientClosedReason.NoRespondersRequiresHeaders,
                NatsProtocol.ErrNoRespondersRequiresHeaders);
            return;
        }

Place this BEFORE setting ConnectReceived flag so the connection is rejected properly. Actually, place it right after parsing ClientOpts and before the auth check to match Go's ordering. In Go, this check is done after auth. Let's place it after auth and before setting ConnectReceived:

Insert between the account assignment block and the _flags.SetFlag(ClientFlags.ConnectReceived) line.

Step 4: Run tests

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NoRespondersTests" -v quiet Expected: PASS

Step 5: Run all tests

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: PASS

Step 6: Commit

git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/NoRespondersTests.cs
git commit -m "feat: implement no-responders validation and 503 notification"

Task 10: Implement stat batching in read loop

Files:

  • Modify: src/NATS.Server/NatsClient.cs (ProcessPubAsync + ProcessCommandsAsync)

Step 1: Refactor ProcessCommandsAsync for stat batching

In ProcessCommandsAsync, add local accumulators and flush at end of each read cycle:

    private async Task ProcessCommandsAsync(PipeReader reader, CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                var result = await reader.ReadAsync(ct);
                var buffer = result.Buffer;

                long localInMsgs = 0;
                long localInBytes = 0;

                while (_parser.TryParse(ref buffer, out var cmd))
                {
                    Interlocked.Exchange(ref _lastIn, Environment.TickCount64);
                    await DispatchCommandAsync(cmd, ct, ref localInMsgs, ref localInBytes);
                }

                // Flush batched stats at end of read cycle
                if (localInMsgs > 0)
                {
                    Interlocked.Add(ref InMsgs, localInMsgs);
                    Interlocked.Add(ref _serverStats.InMsgs, localInMsgs);
                }
                if (localInBytes > 0)
                {
                    Interlocked.Add(ref InBytes, localInBytes);
                    Interlocked.Add(ref _serverStats.InBytes, localInBytes);
                }

                reader.AdvanceTo(buffer.Start, buffer.End);

                if (result.IsCompleted)
                    break;
            }
        }
        finally
        {
            await reader.CompleteAsync();
        }
    }

Step 2: Update DispatchCommandAsync signature

Add ref long localInMsgs, ref long localInBytes parameters.

Step 3: Update ProcessPubAsync to use local accumulators

Change ProcessPubAsync to accept and use local stats instead of Interlocked:

    private async ValueTask ProcessPubAsync(ParsedCommand cmd, ref long localInMsgs, ref long localInBytes)
    {
        localInMsgs++;
        localInBytes += cmd.Payload.Length;

        // ... rest of method unchanged (remove the 4 Interlocked lines for InMsgs/InBytes)
    }

Remove from ProcessPubAsync lines 354-357:

        Interlocked.Increment(ref InMsgs);
        Interlocked.Add(ref InBytes, cmd.Payload.Length);
        Interlocked.Increment(ref _serverStats.InMsgs);
        Interlocked.Add(ref _serverStats.InBytes, cmd.Payload.Length);

Step 4: Run all tests

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: PASS (stats still correct, just batched differently)

Step 5: Commit

git add src/NATS.Server/NatsClient.cs
git commit -m "perf: batch inbound stats per read cycle instead of per message"

Task 11: Update ConnzHandler for close reason and pending bytes

Files:

  • Modify: src/NATS.Server/Monitoring/ConnzHandler.cs

Step 1: Wire CloseReason and PendingBytes into ConnInfo

In ConnzHandler.cs BuildConnInfo method (line 51), add:

            Pending = (int)client.PendingBytes,
            Reason = client.CloseReason.ToReasonString(),

These fields already exist in the ConnInfo model (Pending at line 75, Reason at line 63).

Step 2: Run build and tests

Run: dotnet build && dotnet test tests/NATS.Server.Tests -v quiet Expected: PASS

Step 3: Commit

git add src/NATS.Server/Monitoring/ConnzHandler.cs
git commit -m "feat: wire pending bytes and close reason into connz monitoring"

Task 12: Update existing ClientTests for new write model

Files:

  • Modify: tests/NATS.Server.Tests/ClientTests.cs

The existing tests should still pass since the write loop is transparent to the wire protocol. However, if any tests relied on synchronous write behavior, they may need minor adjustments (e.g., small delays for the write loop to flush).

Step 1: Run existing ClientTests

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientTests" -v quiet

If all pass, no changes needed. If any fail due to timing, add small delays after operations that queue data.

Step 2: Run full test suite

Run: dotnet test tests/NATS.Server.Tests -v quiet Expected: ALL PASS

Step 3: Commit (if changes were needed)

git add tests/NATS.Server.Tests/ClientTests.cs
git commit -m "test: adjust client tests for channel-based write loop timing"

Task 13: Final verification and differences.md update

Files:

  • Modify: differences.md (update Section 2 statuses)

Step 1: Run full test suite

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: ALL PASS

Step 2: Run build with no warnings

Run: dotnet build -warnaserror Expected: Build succeeded

Step 3: Update differences.md Section 2

Update the following rows in Section 2 tables:

Concurrency Model:

  • "Separate read + write loops": PartialY (now has dedicated write loop)
  • "Write coalescing / batch flush": NY (channel drains all before flush)

Client Features:

  • "Verbose mode": NY
  • "No-responders validation": NY (validated + 503 sent)
  • "Slow consumer detection": NY (pending bytes + write deadline)
  • "Write deadline / timeout policies": NY
  • "Detailed close reason tracking": NY (16-value enum)
  • "Connection state flags": PartialY (7 flags via ClientFlagHolder)

Slow Consumer Handling: Update the ".NET has no equivalent" text to describe the new implementation.

Stats Tracking:

  • "Per-read-cycle stat batching": NY

Step 4: Commit

git add differences.md
git commit -m "docs: update differences.md section 2 to reflect implemented features"

Dependency Graph

Task 1 (ClientClosedReason) ──┐
Task 2 (ClientFlags)     ─────┤
Task 3 (NatsOptions)     ─────┼──→ Task 4 (Integrate flags into NatsClient)
                               │         │
                               │         ▼
                               └──→ Task 5 (Write loop) ──→ Task 6 (Write loop tests)
                                         │
                                         ▼
                                    Task 7 (NatsServer updates) ──→ Task 9 (No-responders)
                                         │
                                         ▼
                                    Task 8 (Verbose mode)
                                         │
                                         ▼
                                    Task 10 (Stat batching)
                                         │
                                         ▼
                                    Task 11 (Connz updates)
                                         │
                                         ▼
                                    Task 12 (Fix existing tests)
                                         │
                                         ▼
                                    Task 13 (Verify + update docs)

Parallelizable: Tasks 1, 2, 3 can run in parallel. Task 8 and 9 can run in parallel after Task 7.