Files
natsdotnet/docs/plans/2026-02-22-core-lifecycle-plan.md
Joseph Doherty 149c852510 docs: add core lifecycle implementation plan with 12 tasks
Detailed step-by-step plan covering ClosedState enum, close reason
tracking, ephemeral port, graceful shutdown, flush-before-close,
lame duck mode, PID/ports files, NKey stubs, signal handling, and
differences.md update.
2026-02-22 23:31:01 -05:00

52 KiB

Core Server Lifecycle Implementation Plan

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

Goal: Close all section 1 (Core Server Lifecycle) gaps from differences.md — ClosedState enum, accept loop backoff, ephemeral port, graceful shutdown, task tracking, flush-before-close, lame duck mode, PID/ports files, signal handling, and stubs for NKey identity, $SYS account, config file, and profiling.

Architecture: Add shutdown coordination infrastructure to NatsServer (quit CTS, shutdown TCS, active-client tracking). Add ClosedState enum and close-reason tracking to NatsClient. Add LameDuckShutdownAsync for graceful draining. Wire Unix signal handling in the host via PosixSignalRegistration.

Tech Stack: .NET 10 / C# 14, xUnit 3, Shouldly, NATS.NKeys (already referenced), System.IO.Pipelines


Task 0: Create ClosedState enum

Files:

  • Create: src/NATS.Server/ClosedState.cs

Step 1: Create ClosedState.cs with full Go enum

namespace NATS.Server;

/// <summary>
/// Reason a client connection was closed. Ported from Go client.go:188-228.
/// </summary>
public enum ClosedState
{
    ClientClosed = 1,
    AuthenticationTimeout,
    AuthenticationViolation,
    TLSHandshakeError,
    SlowConsumerPendingBytes,
    SlowConsumerWriteDeadline,
    WriteError,
    ReadError,
    ParseError,
    StaleConnection,
    ProtocolViolation,
    BadClientProtocolVersion,
    WrongPort,
    MaxAccountConnectionsExceeded,
    MaxConnectionsExceeded,
    MaxPayloadExceeded,
    MaxControlLineExceeded,
    MaxSubscriptionsExceeded,
    DuplicateRoute,
    RouteRemoved,
    ServerShutdown,
    AuthenticationExpired,
    WrongGateway,
    MissingAccount,
    Revocation,
    InternalClient,
    MsgHeaderViolation,
    NoRespondersRequiresHeaders,
    ClusterNameConflict,
    DuplicateRemoteLeafnodeConnection,
    DuplicateClientID,
    DuplicateServerName,
    MinimumVersionRequired,
    ClusterNamesIdentical,
    Kicked,
    ProxyNotTrusted,
    ProxyRequired,
}

Step 2: Verify it compiles

Run: dotnet build src/NATS.Server Expected: Build succeeded

Step 3: Commit

git add src/NATS.Server/ClosedState.cs
git commit -m "feat: add ClosedState enum ported from Go client.go"

Task 1: Add close reason tracking to NatsClient

Files:

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

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

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

    public CloseReasonTests()
    {
        _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;
    }

    [Fact]
    public async Task Client_close_reason_set_on_normal_disconnect()
    {
        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("CONNECT {}\r\nPING\r\n"u8.ToArray());
        await client.ReceiveAsync(buf, SocketFlags.None); // PONG

        // Get the NatsClient instance
        var natsClient = _server.GetClients().First();

        // Close the TCP connection (simulates client disconnect)
        client.Shutdown(SocketShutdown.Both);
        client.Dispose();

        // Wait for server to detect the disconnect
        await Task.Delay(500);

        natsClient.CloseReason.ShouldBe(ClosedState.ClientClosed);
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Client_close_reason_set_on_normal_disconnect" -v normal Expected: FAIL — CloseReason property does not exist

Step 3: Add CloseReason property and MarkClosed method to NatsClient

In src/NATS.Server/NatsClient.cs, add fields after the _lastIn field (line 67):

    // Close reason tracking
    private int _closeReason; // stores ClosedState as int for atomics
    private int _skipFlushOnClose;
    public ClosedState CloseReason => (ClosedState)Volatile.Read(ref _closeReason);

Add the MarkClosed method before RemoveAllSubscriptions (before line 525):

    /// <summary>
    /// Marks this connection as closed with the given reason.
    /// Sets skip-flush flag for error-related reasons.
    /// Thread-safe — only the first call sets the reason.
    /// </summary>
    public void MarkClosed(ClosedState reason)
    {
        // Only the first caller sets the reason
        if (Interlocked.CompareExchange(ref _closeReason, (int)reason, 0) != 0)
            return;

        switch (reason)
        {
            case ClosedState.ReadError:
            case ClosedState.WriteError:
            case ClosedState.SlowConsumerPendingBytes:
            case ClosedState.SlowConsumerWriteDeadline:
            case ClosedState.TLSHandshakeError:
                Volatile.Write(ref _skipFlushOnClose, 1);
                break;
        }

        _logger.LogDebug("Client {ClientId} connection closed: {CloseReason}", Id, reason);
    }

    public bool ShouldSkipFlush => Volatile.Read(ref _skipFlushOnClose) != 0;

Update the RunAsync method's catch/finally blocks to set close reasons:

Replace the existing catch/finally in RunAsync (lines 139-153):

        catch (OperationCanceledException)
        {
            _logger.LogDebug("Client {ClientId} operation cancelled", Id);
            // If no close reason set yet, this was a server shutdown
            MarkClosed(ClosedState.ServerShutdown);
        }
        catch (IOException)
        {
            MarkClosed(ClosedState.ReadError);
        }
        catch (SocketException)
        {
            MarkClosed(ClosedState.ReadError);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Client {ClientId} connection error", Id);
            MarkClosed(ClosedState.ReadError);
        }
        finally
        {
            // If FillPipeAsync ended with EOF (client disconnected), mark as ClientClosed
            MarkClosed(ClosedState.ClientClosed);
            try { _socket.Shutdown(SocketShutdown.Both); }
            catch (SocketException) { }
            catch (ObjectDisposedException) { }
            Router?.RemoveClient(this);
        }

Step 4: Run test to verify it passes

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

Step 5: Commit

git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add close reason tracking to NatsClient"

Task 2: Add NatsOptions for new lifecycle features

Files:

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

Step 1: Add new option fields

Add after the MonitorHttpsPort line (after line 37):

    // Lifecycle
    public TimeSpan LameDuckDuration { get; set; } = TimeSpan.FromMinutes(2);
    public TimeSpan LameDuckGracePeriod { get; set; } = TimeSpan.FromSeconds(10);
    public string? PidFile { get; set; }
    public string? PortsFileDir { get; set; }

    // Stubs
    public string? ConfigFile { get; set; }
    public int ProfPort { get; set; }

Step 2: Verify it compiles

Run: dotnet build src/NATS.Server Expected: Build succeeded

Step 3: Commit

git add src/NATS.Server/NatsOptions.cs
git commit -m "feat: add lifecycle options (lame duck, PID file, ports file, config stub)"

Task 3: Ephemeral port support

Files:

  • Modify: src/NATS.Server/NatsServer.cs
  • Test: tests/NATS.Server.Tests/ServerTests.cs

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

public class EphemeralPortTests
{
    [Fact]
    public async Task Server_resolves_ephemeral_port()
    {
        using var cts = new CancellationTokenSource();
        var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
        _ = server.StartAsync(cts.Token);
        await server.WaitForReadyAsync();

        try
        {
            server.Port.ShouldBeGreaterThan(0);

            // Verify we can actually connect to the resolved port
            using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            await client.ConnectAsync(IPAddress.Loopback, server.Port);
            var buf = new byte[4096];
            var n = await client.ReceiveAsync(buf, SocketFlags.None);
            Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO ");
        }
        finally
        {
            await cts.CancelAsync();
            server.Dispose();
        }
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Server_resolves_ephemeral_port" -v normal Expected: FAIL — Port property does not exist on NatsServer

Step 3: Add Port property and ephemeral port resolution to NatsServer

Add Port property after ClientCount (after line 40 in NatsServer.cs):

    public int Port => _options.Port;

In StartAsync, after _listener.Listen(128); and before _listeningStarted.TrySetResult(); (after line 84), add:

        // Resolve ephemeral port if port=0
        if (_options.Port == 0)
        {
            var actualPort = ((IPEndPoint)_listener.LocalEndPoint!).Port;
            _options.Port = actualPort;
            _serverInfo.Port = actualPort;
        }

Step 4: Run test to verify it passes

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

Step 5: Commit

git add src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add ephemeral port (port=0) support"

Task 4: Graceful shutdown infrastructure

Files:

  • Modify: src/NATS.Server/NatsServer.cs
  • Test: tests/NATS.Server.Tests/ServerTests.cs

This is the largest task — it adds _quitCts, _shutdownComplete, _activeClientCount, ShutdownAsync(), WaitForShutdown(), and refactors the accept loop and client lifecycle.

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

public class GracefulShutdownTests
{
    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;
    }

    [Fact]
    public async Task ShutdownAsync_disconnects_all_clients()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Connect two clients
        var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client1.ConnectAsync(IPAddress.Loopback, port);
        var buf = new byte[4096];
        await client1.ReceiveAsync(buf, SocketFlags.None); // INFO
        await client1.SendAsync("CONNECT {}\r\n"u8.ToArray());

        var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client2.ConnectAsync(IPAddress.Loopback, port);
        await client2.ReceiveAsync(buf, SocketFlags.None); // INFO
        await client2.SendAsync("CONNECT {}\r\n"u8.ToArray());

        // Allow clients to register
        await Task.Delay(200);
        server.ClientCount.ShouldBe(2);

        // Shutdown
        await server.ShutdownAsync();

        // Both clients should receive EOF (connection closed)
        var n1 = await client1.ReceiveAsync(buf, SocketFlags.None);
        var n2 = await client2.ReceiveAsync(buf, SocketFlags.None);

        // 0 bytes = connection closed, or they get -ERR data then close
        // Either way, connection should be dead
        server.ClientCount.ShouldBe(0);

        client1.Dispose();
        client2.Dispose();
        server.Dispose();
    }

    [Fact]
    public async Task WaitForShutdown_blocks_until_shutdown()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        var waitTask = Task.Run(() => server.WaitForShutdown());

        // Should not complete yet
        await Task.Delay(200);
        waitTask.IsCompleted.ShouldBeFalse();

        // Trigger shutdown
        await server.ShutdownAsync();

        // Now it should complete promptly
        await waitTask.WaitAsync(TimeSpan.FromSeconds(5));
        waitTask.IsCompletedSuccessfully.ShouldBeTrue();

        server.Dispose();
    }

    [Fact]
    public async Task ShutdownAsync_is_idempotent()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Multiple shutdowns should not throw
        await server.ShutdownAsync();
        await server.ShutdownAsync();
        await server.ShutdownAsync();

        server.Dispose();
    }

    [Fact]
    public async Task Accept_loop_waits_for_active_clients()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Connect a client
        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("CONNECT {}\r\n"u8.ToArray());
        await Task.Delay(200);

        // Shutdown should complete even with connected client
        var shutdownTask = server.ShutdownAsync();
        var completed = await Task.WhenAny(shutdownTask, Task.Delay(TimeSpan.FromSeconds(10)));
        completed.ShouldBe(shutdownTask, "Shutdown should complete within timeout");

        client.Dispose();
        server.Dispose();
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~GracefulShutdownTests" -v normal Expected: FAIL — ShutdownAsync and WaitForShutdown do not exist

Step 3: Add shutdown infrastructure to NatsServer

Add new fields after _startTimeTicks (after line 33):

    private readonly CancellationTokenSource _quitCts = new();
    private readonly TaskCompletionSource _shutdownComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
    private readonly TaskCompletionSource _acceptLoopExited = new(TaskCreationOptions.RunContinuationsAsynchronously);
    private int _shutdown;
    private int _lameDuck;
    private int _activeClientCount;

Add public properties and methods after WaitForReadyAsync:

    public bool IsShuttingDown => Volatile.Read(ref _shutdown) != 0;
    public bool IsLameDuckMode => Volatile.Read(ref _lameDuck) != 0;

    public void WaitForShutdown() => _shutdownComplete.Task.GetAwaiter().GetResult();

    public async Task ShutdownAsync()
    {
        if (Interlocked.CompareExchange(ref _shutdown, 1, 0) != 0)
            return; // Already shutting down

        _logger.LogInformation("Initiating Shutdown...");

        // Signal all internal loops to stop
        await _quitCts.CancelAsync();

        // Close listener to stop accept loop
        _listener?.Close();

        // Wait for accept loop to exit
        await _acceptLoopExited.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

        // Close all client connections
        foreach (var client in _clients.Values)
        {
            client.MarkClosed(ClosedState.ServerShutdown);
        }

        // Wait for active client tasks to drain (with timeout)
        if (Volatile.Read(ref _activeClientCount) > 0)
        {
            using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
            try
            {
                while (Volatile.Read(ref _activeClientCount) > 0 && !drainCts.IsCancellationRequested)
                    await Task.Delay(50, drainCts.Token);
            }
            catch (OperationCanceledException) { }
        }

        // Stop monitor server
        if (_monitorServer != null)
            await _monitorServer.DisposeAsync();

        // Delete PID and ports files
        DeletePidFile();
        DeletePortsFile();

        _logger.LogInformation("Server Exiting..");
        _shutdownComplete.TrySetResult();
    }

Refactor StartAsync to use _quitCts and track the accept loop:

Replace the entire StartAsync body with:

    public async Task StartAsync(CancellationToken ct)
    {
        // Link the external CT to our internal quit signal
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _quitCts.Token);

        _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        _listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
        _listener.Bind(new IPEndPoint(
            _options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
            _options.Port));

        // Resolve ephemeral port if port=0
        if (_options.Port == 0)
        {
            var actualPort = ((IPEndPoint)_listener.LocalEndPoint!).Port;
            _options.Port = actualPort;
            _serverInfo.Port = actualPort;
        }

        Interlocked.Exchange(ref _startTimeTicks, DateTime.UtcNow.Ticks);
        _listener.Listen(128);
        _listeningStarted.TrySetResult();

        _logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port);

        // Warn about stub features
        if (_options.ConfigFile != null)
            _logger.LogWarning("Config file parsing not yet supported (file: {ConfigFile})", _options.ConfigFile);
        if (_options.ProfPort > 0)
            _logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort);

        // Write PID and ports files
        WritePidFile();
        WritePortsFile();

        if (_options.MonitorPort > 0)
        {
            _monitorServer = new MonitorServer(this, _options, _stats, _loggerFactory);
            await _monitorServer.StartAsync(linked.Token);
        }

        var tmpDelay = AcceptMinSleep;

        try
        {
            while (!linked.Token.IsCancellationRequested)
            {
                Socket socket;
                try
                {
                    socket = await _listener.AcceptAsync(linked.Token);
                    tmpDelay = AcceptMinSleep; // Reset on success
                }
                catch (OperationCanceledException)
                {
                    break;
                }
                catch (ObjectDisposedException)
                {
                    // Listener was closed during shutdown or lame duck
                    break;
                }
                catch (SocketException ex)
                {
                    if (IsShuttingDown || IsLameDuckMode)
                        break;

                    _logger.LogError(ex, "Temporary accept error, sleeping {Delay}ms", tmpDelay.TotalMilliseconds);
                    try
                    {
                        await Task.Delay(tmpDelay, linked.Token);
                    }
                    catch (OperationCanceledException) { break; }

                    tmpDelay = TimeSpan.FromTicks(Math.Min(tmpDelay.Ticks * 2, AcceptMaxSleep.Ticks));
                    continue;
                }

                // Check MaxConnections before creating the client
                if (_options.MaxConnections > 0 && _clients.Count >= _options.MaxConnections)
                {
                    _logger.LogWarning("Client connection rejected: maximum connections ({MaxConnections}) exceeded",
                        _options.MaxConnections);
                    try
                    {
                        var stream = new NetworkStream(socket, ownsSocket: false);
                        var errBytes = Encoding.ASCII.GetBytes(
                            $"-ERR '{NatsProtocol.ErrMaxConnectionsExceeded}'\r\n");
                        await stream.WriteAsync(errBytes, linked.Token);
                        await stream.FlushAsync(linked.Token);
                        stream.Dispose();
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Failed to send -ERR to rejected client");
                    }
                    finally
                    {
                        socket.Dispose();
                    }
                    continue;
                }

                var clientId = Interlocked.Increment(ref _nextClientId);
                Interlocked.Increment(ref _stats.TotalConnections);
                Interlocked.Increment(ref _activeClientCount);

                _logger.LogDebug("Client {ClientId} connected from {RemoteEndpoint}", clientId, socket.RemoteEndPoint);

                _ = AcceptClientAsync(socket, clientId, linked.Token);
            }
        }
        catch (OperationCanceledException)
        {
            _logger.LogDebug("Accept loop cancelled, server shutting down");
        }
        finally
        {
            _acceptLoopExited.TrySetResult();
        }
    }

Update RunClientAsync to decrement active client count (replace existing):

    private async Task RunClientAsync(NatsClient client, CancellationToken ct)
    {
        try
        {
            await client.RunAsync(ct);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Client {ClientId} disconnected with error", client.Id);
        }
        finally
        {
            _logger.LogDebug("Client {ClientId} disconnected (reason: {CloseReason})", client.Id, client.CloseReason);
            RemoveClient(client);
            Interlocked.Decrement(ref _activeClientCount);
        }
    }

Update Dispose to call ShutdownAsync:

    public void Dispose()
    {
        if (!IsShuttingDown)
            ShutdownAsync().GetAwaiter().GetResult();
        _quitCts.Dispose();
        _tlsRateLimiter?.Dispose();
        _listener?.Dispose();
        foreach (var client in _clients.Values)
            client.Dispose();
        foreach (var account in _accounts.Values)
            account.Dispose();
    }

Add accept loop constants as static fields at the top of the class:

    private static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10);
    private static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1);

Step 4: Run tests to verify they pass

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

Step 5: Run all existing tests to verify no regressions

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All tests pass

Step 6: Commit

git add src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add graceful shutdown, accept loop backoff, and task tracking"

Task 5: Flush pending data before close

Files:

  • Modify: src/NATS.Server/NatsClient.cs
  • Modify: src/NATS.Server/NatsServer.cs
  • Test: tests/NATS.Server.Tests/ServerTests.cs

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

public class FlushBeforeCloseTests
{
    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;
    }

    [Fact]
    public async Task Shutdown_flushes_pending_data_to_clients()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Connect subscriber
        var sub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await sub.ConnectAsync(IPAddress.Loopback, port);
        var buf = new byte[4096];
        await sub.ReceiveAsync(buf, SocketFlags.None); // INFO
        await sub.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"u8.ToArray());
        // Read PONG
        var pong = new StringBuilder();
        while (!pong.ToString().Contains("PONG"))
        {
            var n2 = await sub.ReceiveAsync(buf, SocketFlags.None);
            pong.Append(Encoding.ASCII.GetString(buf, 0, n2));
        }

        // Connect publisher and publish a message
        var pub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await pub.ConnectAsync(IPAddress.Loopback, port);
        await pub.ReceiveAsync(buf, SocketFlags.None); // INFO
        await pub.SendAsync("CONNECT {}\r\nPUB foo 5\r\nHello\r\n"u8.ToArray());
        await Task.Delay(100);

        // The subscriber should receive the MSG
        // Read all data from subscriber until connection closes
        var allData = new StringBuilder();
        try
        {
            using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

            // First, read the MSG that should already be queued
            while (true)
            {
                var n = await sub.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
                if (n == 0) break;
                allData.Append(Encoding.ASCII.GetString(buf, 0, n));
                if (allData.ToString().Contains("Hello"))
                    break;
            }
        }
        catch (OperationCanceledException) { }

        allData.ToString().ShouldContain("MSG foo 1 5\r\nHello\r\n");

        pub.Dispose();
        sub.Dispose();
        server.Dispose();
    }
}

Step 2: Run test to verify it passes (baseline — data already reaches subscriber before shutdown)

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Shutdown_flushes_pending_data" -v normal Expected: This test should PASS with current code since the msg was delivered before shutdown. This establishes the baseline.

Step 3: Add FlushAndCloseAsync to NatsClient

In NatsClient.cs, add before RemoveAllSubscriptions:

    /// <summary>
    /// Flushes pending data (unless skip-flush is set) and closes the connection.
    /// </summary>
    public async Task FlushAndCloseAsync(bool minimalFlush = false)
    {
        if (!ShouldSkipFlush)
        {
            try
            {
                using var flushCts = new CancellationTokenSource(minimalFlush
                    ? TimeSpan.FromMilliseconds(100)
                    : TimeSpan.FromSeconds(1));
                await _stream.FlushAsync(flushCts.Token);
            }
            catch (Exception)
            {
                // Best effort flush — don't let it prevent close
            }
        }

        try { _socket.Shutdown(SocketShutdown.Both); }
        catch (SocketException) { }
        catch (ObjectDisposedException) { }
    }

Step 4: Wire FlushAndCloseAsync into ShutdownAsync

In NatsServer.ShutdownAsync, replace the client close loop:

        // Close all client connections — flush first, then mark closed
        var flushTasks = new List<Task>();
        foreach (var client in _clients.Values)
        {
            client.MarkClosed(ClosedState.ServerShutdown);
            flushTasks.Add(client.FlushAndCloseAsync(minimalFlush: true));
        }
        await Task.WhenAll(flushTasks).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

Step 5: Run all tests

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All tests pass

Step 6: Commit

git add src/NATS.Server/NatsClient.cs src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add flush-before-close for graceful client shutdown"

Task 6: Lame duck mode

Files:

  • Modify: src/NATS.Server/NatsServer.cs
  • Test: tests/NATS.Server.Tests/ServerTests.cs

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

public class LameDuckTests
{
    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;
    }

    [Fact]
    public async Task LameDuckShutdown_stops_accepting_new_connections()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions
        {
            Port = port,
            LameDuckDuration = TimeSpan.FromSeconds(3),
            LameDuckGracePeriod = TimeSpan.FromMilliseconds(500),
        }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Connect a client before lame duck
        var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        await client1.ConnectAsync(IPAddress.Loopback, port);
        var buf = new byte[4096];
        await client1.ReceiveAsync(buf, SocketFlags.None); // INFO
        await client1.SendAsync("CONNECT {}\r\n"u8.ToArray());
        await Task.Delay(200);

        // Start lame duck mode (don't await — it runs in background)
        var lameDuckTask = server.LameDuckShutdownAsync();

        // Small delay to let lame duck close the listener
        await Task.Delay(200);

        server.IsLameDuckMode.ShouldBeTrue();

        // New connection attempt should fail
        var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        var connectTask = client2.ConnectAsync(IPAddress.Loopback, port);

        // Should either throw or timeout quickly since listener is closed
        var threw = false;
        try
        {
            await connectTask.WaitAsync(TimeSpan.FromSeconds(2));
        }
        catch
        {
            threw = true;
        }
        threw.ShouldBeTrue("New connection should be rejected");

        // Wait for lame duck to finish
        await lameDuckTask.WaitAsync(TimeSpan.FromSeconds(15));

        client1.Dispose();
        client2.Dispose();
        server.Dispose();
    }

    [Fact]
    public async Task LameDuckShutdown_eventually_closes_all_clients()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions
        {
            Port = port,
            LameDuckDuration = TimeSpan.FromSeconds(2),
            LameDuckGracePeriod = TimeSpan.FromMilliseconds(200),
        }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Connect clients
        var clients = new List<Socket>();
        for (int i = 0; i < 3; i++)
        {
            var c = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            await c.ConnectAsync(IPAddress.Loopback, port);
            var buf2 = new byte[4096];
            await c.ReceiveAsync(buf2, SocketFlags.None);
            await c.SendAsync("CONNECT {}\r\n"u8.ToArray());
            clients.Add(c);
        }
        await Task.Delay(200);
        server.ClientCount.ShouldBe(3);

        // Run lame duck shutdown
        await server.LameDuckShutdownAsync();

        // All clients should be disconnected
        server.ClientCount.ShouldBe(0);

        foreach (var c in clients) c.Dispose();
        server.Dispose();
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~LameDuckTests" -v normal Expected: FAIL — LameDuckShutdownAsync does not exist

Step 3: Implement LameDuckShutdownAsync in NatsServer

Add after ShutdownAsync:

    public async Task LameDuckShutdownAsync()
    {
        if (IsShuttingDown || Interlocked.CompareExchange(ref _lameDuck, 1, 0) != 0)
            return;

        _logger.LogInformation("Entering lame duck mode, stop accepting new clients");

        // Close listener to stop accepting new connections
        _listener?.Close();

        // Wait for accept loop to exit
        await _acceptLoopExited.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

        var gracePeriod = _options.LameDuckGracePeriod;
        if (gracePeriod < TimeSpan.Zero) gracePeriod = -gracePeriod;

        // If no clients, go straight to shutdown
        if (_clients.IsEmpty)
        {
            await ShutdownAsync();
            return;
        }

        // Wait grace period for clients to drain naturally
        _logger.LogInformation("Waiting {GracePeriod}ms grace period", gracePeriod.TotalMilliseconds);
        try
        {
            await Task.Delay(gracePeriod, _quitCts.Token);
        }
        catch (OperationCanceledException) { return; }

        if (_clients.IsEmpty)
        {
            await ShutdownAsync();
            return;
        }

        // Stagger-close remaining clients
        var dur = _options.LameDuckDuration - gracePeriod;
        if (dur <= TimeSpan.Zero) dur = TimeSpan.FromSeconds(1);

        var clients = _clients.Values.ToList();
        var numClients = clients.Count;

        if (numClients > 0)
        {
            _logger.LogInformation("Closing {Count} existing clients over {Duration}ms",
                numClients, dur.TotalMilliseconds);

            var sleepInterval = dur.Ticks / numClients;
            if (sleepInterval < TimeSpan.TicksPerMillisecond)
                sleepInterval = TimeSpan.TicksPerMillisecond;
            if (sleepInterval > TimeSpan.TicksPerSecond)
                sleepInterval = TimeSpan.TicksPerSecond;

            for (int i = 0; i < clients.Count; i++)
            {
                clients[i].MarkClosed(ClosedState.ServerShutdown);
                await clients[i].FlushAndCloseAsync(minimalFlush: true);

                if (i < clients.Count - 1)
                {
                    // Randomize slightly to avoid reconnect storms
                    var jitter = Random.Shared.NextInt64(sleepInterval / 2, sleepInterval);
                    try
                    {
                        await Task.Delay(TimeSpan.FromTicks(jitter), _quitCts.Token);
                    }
                    catch (OperationCanceledException) { break; }
                }
            }
        }

        await ShutdownAsync();
    }

Step 4: Run tests to verify they pass

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

Step 5: Run all tests

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All tests pass

Step 6: Commit

git add src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add lame duck mode with staggered client shutdown"

Task 7: PID file and ports file

Files:

  • Modify: src/NATS.Server/NatsServer.cs
  • Test: tests/NATS.Server.Tests/ServerTests.cs

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

public class PidFileTests : IDisposable
{
    private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"nats-test-{Guid.NewGuid():N}");

    public PidFileTests() => Directory.CreateDirectory(_tempDir);

    public void Dispose()
    {
        if (Directory.Exists(_tempDir))
            Directory.Delete(_tempDir, recursive: true);
    }

    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;
    }

    [Fact]
    public async Task Server_writes_pid_file_on_startup()
    {
        var pidFile = Path.Combine(_tempDir, "nats.pid");
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port, PidFile = pidFile }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        File.Exists(pidFile).ShouldBeTrue();
        var content = await File.ReadAllTextAsync(pidFile);
        int.Parse(content).ShouldBe(Environment.ProcessId);

        await server.ShutdownAsync();

        // PID file should be deleted on shutdown
        File.Exists(pidFile).ShouldBeFalse();

        server.Dispose();
    }

    [Fact]
    public async Task Server_writes_ports_file_on_startup()
    {
        var port = GetFreePort();
        var server = new NatsServer(new NatsOptions { Port = port, PortsFileDir = _tempDir }, NullLoggerFactory.Instance);
        _ = server.StartAsync(CancellationToken.None);
        await server.WaitForReadyAsync();

        // Find the ports file
        var portsFiles = Directory.GetFiles(_tempDir, "*.ports");
        portsFiles.Length.ShouldBe(1);

        var content = await File.ReadAllTextAsync(portsFiles[0]);
        content.ShouldContain($"\"client\":{port}");

        await server.ShutdownAsync();

        // Ports file should be deleted on shutdown
        Directory.GetFiles(_tempDir, "*.ports").Length.ShouldBe(0);

        server.Dispose();
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PidFileTests" -v normal Expected: FAIL — WritePidFile/DeletePidFile methods don't exist yet (called in ShutdownAsync but not implemented)

Step 3: Add PID file and ports file methods to NatsServer

Add using System.Diagnostics; and using System.Text.Json; to the top of NatsServer.cs.

Add private methods before Dispose:

    private void WritePidFile()
    {
        if (string.IsNullOrEmpty(_options.PidFile)) return;
        try
        {
            File.WriteAllText(_options.PidFile, Environment.ProcessId.ToString());
            _logger.LogDebug("Wrote PID file {PidFile}", _options.PidFile);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error writing PID file {PidFile}", _options.PidFile);
        }
    }

    private void DeletePidFile()
    {
        if (string.IsNullOrEmpty(_options.PidFile)) return;
        try
        {
            if (File.Exists(_options.PidFile))
                File.Delete(_options.PidFile);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting PID file {PidFile}", _options.PidFile);
        }
    }

    private void WritePortsFile()
    {
        if (string.IsNullOrEmpty(_options.PortsFileDir)) return;
        try
        {
            var exeName = Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? "nats-server");
            var fileName = $"{exeName}_{Environment.ProcessId}.ports";
            _portsFilePath = Path.Combine(_options.PortsFileDir, fileName);

            var ports = new
            {
                client = _options.Port,
                monitor = _options.MonitorPort > 0 ? _options.MonitorPort : (int?)null,
            };
            var json = JsonSerializer.Serialize(ports);
            File.WriteAllText(_portsFilePath, json);
            _logger.LogDebug("Wrote ports file {PortsFile}", _portsFilePath);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error writing ports file to {PortsFileDir}", _options.PortsFileDir);
        }
    }

    private void DeletePortsFile()
    {
        if (_portsFilePath == null) return;
        try
        {
            if (File.Exists(_portsFilePath))
                File.Delete(_portsFilePath);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting ports file {PortsFile}", _portsFilePath);
        }
    }

Add the field for ports file tracking near the other fields:

    private string? _portsFilePath;

Step 4: Run tests to verify they pass

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

Step 5: Commit

git add src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add PID file and ports file support"

Task 8: System account and NKey identity stubs

Files:

  • Modify: src/NATS.Server/NatsServer.cs
  • Test: tests/NATS.Server.Tests/ServerTests.cs

Step 1: Write failing test

Add to tests/NATS.Server.Tests/ServerTests.cs:

public class ServerIdentityTests
{
    [Fact]
    public void Server_creates_system_account()
    {
        var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
        server.SystemAccount.ShouldNotBeNull();
        server.SystemAccount.Name.ShouldBe("$SYS");
        server.Dispose();
    }

    [Fact]
    public void Server_generates_nkey_identity()
    {
        var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
        server.ServerNKey.ShouldNotBeNullOrEmpty();
        // Server NKey public keys start with 'N'
        server.ServerNKey[0].ShouldBe('N');
        server.Dispose();
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ServerIdentityTests" -v normal Expected: FAIL — SystemAccount and ServerNKey properties do not exist

Step 3: Add system account and NKey generation to NatsServer constructor

Add to NatsServer fields:

    private readonly Account _systemAccount;
    public Account SystemAccount => _systemAccount;
    public string ServerNKey { get; }

In the constructor, after _accounts[Account.GlobalAccountName] = _globalAccount;:

        // Create $SYS system account (stub — no internal subscriptions yet)
        _systemAccount = new Account("$SYS");
        _accounts["$SYS"] = _systemAccount;

        // Generate server identity NKey (Ed25519)
        var kp = NATS.NKeys.NKeys.FromSeed(NATS.NKeys.NKeys.CreatePair(NATS.NKeys.NKeys.PrefixByte.Server));
        ServerNKey = kp.EncodedPublicKey;

Note: Check the NATS.NKeys API — the exact method names may vary. If the API is different, adjust accordingly. The important thing is to generate a server-type key pair and store the public key.

Step 4: Run tests to verify they pass

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

Step 5: Commit

git add src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add system account ($SYS) and server NKey identity stubs"

Task 9: Signal handling and CLI stubs

Files:

  • Modify: src/NATS.Server.Host/Program.cs
  • Modify: src/NATS.Server/NatsServer.cs (add HandleSignals method)

Step 1: Add signal handling to NatsServer

Add to NatsServer after LameDuckShutdownAsync:

    /// <summary>
    /// Registers Unix signal handlers. Call from the host process after server creation.
    /// SIGTERM → shutdown, SIGUSR2 → lame duck, SIGUSR1 → log reopen (stub), SIGHUP → reload (stub).
    /// </summary>
    public void HandleSignals()
    {
        PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
        {
            ctx.Cancel = true;
            _logger.LogInformation("Trapped SIGTERM signal");
            _ = Task.Run(async () =>
            {
                await ShutdownAsync();
            });
        });

        PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx =>
        {
            ctx.Cancel = true;
            _logger.LogInformation("Trapped SIGQUIT signal");
            _ = Task.Run(async () =>
            {
                await ShutdownAsync();
            });
        });

        // SIGUSR1 and SIGUSR2 — only available on Unix
        if (!OperatingSystem.IsWindows())
        {
            // SIGUSR1 = reopen log files (stub)
            PosixSignalRegistration.Create((PosixSignal)10, ctx => // SIGUSR1 = 10
            {
                ctx.Cancel = true;
                _logger.LogWarning("Trapped SIGUSR1 signal — log reopen not yet supported");
            });

            // SIGUSR2 = lame duck mode
            PosixSignalRegistration.Create((PosixSignal)12, ctx => // SIGUSR2 = 12
            {
                ctx.Cancel = true;
                _logger.LogInformation("Trapped SIGUSR2 signal — entering lame duck mode");
                _ = Task.Run(async () =>
                {
                    await LameDuckShutdownAsync();
                });
            });
        }

        PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
        {
            ctx.Cancel = true;
            _logger.LogWarning("Trapped SIGHUP signal — config reload not yet supported");
        });
    }

Step 2: Update Program.cs with signal handling and new CLI flags

Replace the entire Program.cs with:

using NATS.Server;
using Serilog;

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .Enrich.FromLogContext()
    .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

var options = new NatsOptions();

// Simple CLI argument parsing
for (int i = 0; i < args.Length; i++)
{
    switch (args[i])
    {
        case "-p" or "--port" when i + 1 < args.Length:
            options.Port = int.Parse(args[++i]);
            break;
        case "-a" or "--addr" when i + 1 < args.Length:
            options.Host = args[++i];
            break;
        case "-n" or "--name" when i + 1 < args.Length:
            options.ServerName = args[++i];
            break;
        case "-m" or "--http_port" when i + 1 < args.Length:
            options.MonitorPort = int.Parse(args[++i]);
            break;
        case "--http_base_path" when i + 1 < args.Length:
            options.MonitorBasePath = args[++i];
            break;
        case "--https_port" when i + 1 < args.Length:
            options.MonitorHttpsPort = int.Parse(args[++i]);
            break;
        case "-c" when i + 1 < args.Length:
            options.ConfigFile = args[++i];
            break;
        case "--pid" when i + 1 < args.Length:
            options.PidFile = args[++i];
            break;
        case "--ports_file_dir" when i + 1 < args.Length:
            options.PortsFileDir = args[++i];
            break;
        case "--tls":
            break;
        case "--tlscert" when i + 1 < args.Length:
            options.TlsCert = args[++i];
            break;
        case "--tlskey" when i + 1 < args.Length:
            options.TlsKey = args[++i];
            break;
        case "--tlscacert" when i + 1 < args.Length:
            options.TlsCaCert = args[++i];
            break;
        case "--tlsverify":
            options.TlsVerify = true;
            break;
    }
}

using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);

// Register Unix signal handlers
server.HandleSignals();

// Ctrl+C triggers graceful shutdown
Console.CancelKeyPress += (_, e) =>
{
    e.Cancel = true;
    Log.Information("Trapped SIGINT signal");
    _ = Task.Run(async () =>
    {
        await server.ShutdownAsync();
    });
};

try
{
    _ = server.StartAsync(CancellationToken.None);
    await server.WaitForReadyAsync();
    server.WaitForShutdown();
}
catch (OperationCanceledException)
{
    Log.Information("Server shutdown requested");
}
finally
{
    Log.CloseAndFlush();
}

Step 3: Verify it compiles

Run: dotnet build Expected: Build succeeded

Step 4: Run all tests

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All tests pass

Step 5: Commit

git add src/NATS.Server/NatsServer.cs src/NATS.Server.Host/Program.cs
git commit -m "feat: add signal handling (SIGTERM, SIGUSR2, SIGHUP) and CLI stubs"

Task 10: Update differences.md

Files:

  • Modify: differences.md

Step 1: Run full test suite to verify all implementations

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All tests pass

Step 2: Update differences.md section 1 tables

Update each row in section 1 that was implemented. Change .NET column from N to Y (or Stub for stub items). Update Notes column with implementation details.

Server Initialization table:

Feature Go .NET Notes
NKey generation (server identity) Y Stub Generates Ed25519 key at startup, not used in protocol yet
System account setup Y Stub Creates $SYS account, no internal subscriptions
Config file validation on startup Y Stub -c flag parsed, logs warning that parsing not yet supported
PID file writing Y Y --pid flag, writes/deletes on startup/shutdown
Profiling HTTP endpoint (/debug/pprof) Y Stub ProfPort option, logs warning if set
Ports file output Y Y --ports_file_dir flag, JSON with client/monitor ports

Accept Loop table:

Feature Go .NET Notes
Exponential backoff on accept errors Y Y 10ms→1s doubling, resets on success
Config reload lock during client creation Y Stub No config reload yet
Goroutine/task tracking (WaitGroup) Y Y _activeClientCount with Interlocked, waits on shutdown
Callback-based error handling Y N .NET uses structured exception handling instead
Random/ephemeral port (port=0) Y Y Resolves actual port after bind

Shutdown table:

Feature Go .NET Notes
Graceful shutdown with WaitForShutdown() Y Y ShutdownAsync() + WaitForShutdown()
Close reason tracking per connection Y Y Full 37-value ClosedState enum
Lame duck mode (stop new, drain existing) Y Y LameDuckShutdownAsync() with staggered close
Wait for accept loop completion Y Y _acceptLoopExited TaskCompletionSource
Flush pending data before close Y Y FlushAndCloseAsync() with skip-flush for error reasons

Signal Handling table:

Signal Go .NET Notes
SIGINT (Ctrl+C) Y Y Triggers ShutdownAsync
SIGTERM Y Y Via PosixSignalRegistration
SIGUSR1 (reopen logs) Y Stub Logs warning, no log rotation support yet
SIGUSR2 (lame duck mode) Y Y Triggers LameDuckShutdownAsync
SIGHUP (config reload) Y Stub Logs warning, no config reload support yet
Windows Service integration Y N

Step 3: Update the summary section

Remove items from "Critical Gaps" that are now implemented. Move to a new "Recently Implemented" section or adjust status.

Step 4: Commit

git add differences.md
git commit -m "docs: update differences.md section 1 — mark implemented lifecycle gaps"

Task 11: Final verification

Step 1: Run full test suite

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All tests pass

Step 2: Run build to verify no warnings

Run: dotnet build -warnaserrors Expected: Build succeeded (or only pre-existing warnings)

Step 3: Verify git status is clean

Run: git status Expected: Clean working tree (all changes committed)