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

1542 lines
50 KiB
Markdown

# 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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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):
```csharp
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):
```csharp
public const long MaxPendingSize = 64 * 1024 * 1024; // 64MB default max pending
```
Also add error strings for new close reasons after line 32:
```csharp
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:
```csharp
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**
```bash
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:
```csharp
// 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:
```csharp
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:
```csharp
_flags.SetFlag(ClientFlags.ConnectReceived);
_flags.SetFlag(ClientFlags.ConnectProcessFinished);
```
**Step 3: Add CloseWithReason helper method**
Add after `SendErrAndCloseAsync` (after line 481):
```csharp
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:
```csharp
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**
```bash
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):
```csharp
private readonly SemaphoreSlim _writeLock = new(1, 1);
```
with:
```csharp
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:
```csharp
using System.Threading.Channels;
```
**Step 2: Add QueueOutbound method**
Add after the constructor:
```csharp
/// <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**
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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):
```csharp
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):
```csharp
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:
```csharp
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**
```bash
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`:
```csharp
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**
```bash
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):
```csharp
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:
```csharp
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**
```bash
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`:
```csharp
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):
```csharp
case CommandType.Connect:
await ProcessConnectAsync(cmd);
if (ClientOpts?.Verbose == true)
WriteProtocol(NatsProtocol.OkBytes);
break;
```
After `WriteProtocol(NatsProtocol.PongBytes)` for PING (line 235):
```csharp
case CommandType.Ping:
WriteProtocol(NatsProtocol.PongBytes);
if (ClientOpts?.Verbose == true)
WriteProtocol(NatsProtocol.OkBytes);
break;
```
After `ProcessSubAsync(cmd)` (line 243):
```csharp
case CommandType.Sub:
await ProcessSubAsync(cmd);
if (ClientOpts?.Verbose == true)
WriteProtocol(NatsProtocol.OkBytes);
break;
```
After `ProcessUnsub(cmd)` (line 247):
```csharp
case CommandType.Unsub:
ProcessUnsub(cmd);
if (ClientOpts?.Verbose == true)
WriteProtocol(NatsProtocol.OkBytes);
break;
```
After `ProcessPubAsync(cmd)` (line 252):
```csharp
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**
```bash
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`:
```csharp
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:
```csharp
// 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**
```bash
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:
```csharp
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:
```csharp
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:
```csharp
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**
```bash
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:
```csharp
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**
```bash
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)**
```bash
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": `Partial``Y` (now has dedicated write loop)
- "Write coalescing / batch flush": `N``Y` (channel drains all before flush)
**Client Features:**
- "Verbose mode": `N``Y`
- "No-responders validation": `N``Y` (validated + 503 sent)
- "Slow consumer detection": `N``Y` (pending bytes + write deadline)
- "Write deadline / timeout policies": `N``Y`
- "Detailed close reason tracking": `N``Y` (16-value enum)
- "Connection state flags": `Partial``Y` (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": `N``Y`
**Step 4: Commit**
```bash
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.