Files
natsdotnet/docs/plans/2026-02-23-config-parsing-plan.md
Joseph Doherty fadbbf463c docs: add detailed implementation plan for config parsing and hot reload
8 tasks with TDD steps, complete test code, exact file paths,
and dependency chain from lexer through to verification.
2026-02-23 04:12:11 -05:00

47 KiB
Raw Blame History

Config File Parsing & Hot Reload Implementation Plan

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

Goal: Port the Go NATS config file parser and hot reload system to .NET, resolving the two remaining high-priority gaps in differences.md.

Architecture: A state-machine lexer tokenizes the NATS config format, a parser builds Dictionary<string, object?> from the token stream, a config processor maps keys to NatsOptions fields, and a reloader diffs old/new options on SIGHUP to apply changes without restart.

Tech Stack: .NET 10 / C# 14, xUnit 3, Shouldly, System.IO.Pipelines (existing), Serilog (existing).


Task 1: Token Types and Lexer Infrastructure

Files:

  • Create: src/NATS.Server/Configuration/NatsConfToken.cs
  • Create: src/NATS.Server/Configuration/NatsConfLexer.cs
  • Test: tests/NATS.Server.Tests/NatsConfLexerTests.cs

Step 1: Write failing tests for basic token types

// tests/NATS.Server.Tests/NatsConfLexerTests.cs
using NATS.Server.Configuration;

namespace NATS.Server.Tests;

public class NatsConfLexerTests
{
    [Fact]
    public void Lex_SimpleKeyStringValue_ReturnsKeyAndString()
    {
        var tokens = NatsConfLexer.Tokenize("foo = \"bar\"").ToList();
        tokens[0].Type.ShouldBe(TokenType.Key);
        tokens[0].Value.ShouldBe("foo");
        tokens[1].Type.ShouldBe(TokenType.String);
        tokens[1].Value.ShouldBe("bar");
        tokens[2].Type.ShouldBe(TokenType.Eof);
    }

    [Fact]
    public void Lex_SingleQuotedString_ReturnsString()
    {
        var tokens = NatsConfLexer.Tokenize("foo = 'bar'").ToList();
        tokens[1].Type.ShouldBe(TokenType.String);
        tokens[1].Value.ShouldBe("bar");
    }

    [Fact]
    public void Lex_IntegerValue_ReturnsInteger()
    {
        var tokens = NatsConfLexer.Tokenize("port = 4222").ToList();
        tokens[0].Type.ShouldBe(TokenType.Key);
        tokens[0].Value.ShouldBe("port");
        tokens[1].Type.ShouldBe(TokenType.Integer);
        tokens[1].Value.ShouldBe("4222");
    }

    [Fact]
    public void Lex_IntegerWithSuffix_ReturnsInteger()
    {
        var tokens = NatsConfLexer.Tokenize("size = 64mb").ToList();
        tokens[1].Type.ShouldBe(TokenType.Integer);
        tokens[1].Value.ShouldBe("64mb");
    }

    [Fact]
    public void Lex_BooleanValues_ReturnsBool()
    {
        foreach (var val in new[] { "true", "false", "yes", "no", "on", "off" })
        {
            var tokens = NatsConfLexer.Tokenize($"flag = {val}").ToList();
            tokens[1].Type.ShouldBe(TokenType.Bool);
        }
    }

    [Fact]
    public void Lex_FloatValue_ReturnsFloat()
    {
        var tokens = NatsConfLexer.Tokenize("rate = 2.5").ToList();
        tokens[1].Type.ShouldBe(TokenType.Float);
        tokens[1].Value.ShouldBe("2.5");
    }

    [Fact]
    public void Lex_NegativeNumber_ReturnsInteger()
    {
        var tokens = NatsConfLexer.Tokenize("offset = -10").ToList();
        tokens[1].Type.ShouldBe(TokenType.Integer);
        tokens[1].Value.ShouldBe("-10");
    }

    [Fact]
    public void Lex_DatetimeValue_ReturnsDatetime()
    {
        var tokens = NatsConfLexer.Tokenize("ts = 2024-01-15T10:30:00Z").ToList();
        tokens[1].Type.ShouldBe(TokenType.DateTime);
    }

    [Fact]
    public void Lex_HashComment_IsIgnored()
    {
        var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList();
        // First meaningful token should be the key
        var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
        keys.Count.ShouldBe(1);
        keys[0].Value.ShouldBe("foo");
    }

    [Fact]
    public void Lex_SlashComment_IsIgnored()
    {
        var tokens = NatsConfLexer.Tokenize("// comment\nfoo = 1").ToList();
        var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
        keys.Count.ShouldBe(1);
    }

    [Fact]
    public void Lex_MapBlock_ReturnsMapStartEnd()
    {
        var tokens = NatsConfLexer.Tokenize("auth { user: admin }").ToList();
        tokens[0].Type.ShouldBe(TokenType.Key);
        tokens[0].Value.ShouldBe("auth");
        tokens[1].Type.ShouldBe(TokenType.MapStart);
        tokens[2].Type.ShouldBe(TokenType.Key);
        tokens[2].Value.ShouldBe("user");
        tokens[3].Type.ShouldBe(TokenType.String);
        tokens[3].Value.ShouldBe("admin");
        tokens[4].Type.ShouldBe(TokenType.MapEnd);
    }

    [Fact]
    public void Lex_Array_ReturnsArrayStartEnd()
    {
        var tokens = NatsConfLexer.Tokenize("items = [1, 2, 3]").ToList();
        tokens[1].Type.ShouldBe(TokenType.ArrayStart);
        tokens[2].Type.ShouldBe(TokenType.Integer);
        tokens[2].Value.ShouldBe("1");
        tokens[5].Type.ShouldBe(TokenType.ArrayEnd);
    }

    [Fact]
    public void Lex_Variable_ReturnsVariable()
    {
        var tokens = NatsConfLexer.Tokenize("secret = $MY_VAR").ToList();
        tokens[1].Type.ShouldBe(TokenType.Variable);
        tokens[1].Value.ShouldBe("MY_VAR");
    }

    [Fact]
    public void Lex_Include_ReturnsInclude()
    {
        var tokens = NatsConfLexer.Tokenize("include \"auth.conf\"").ToList();
        tokens[0].Type.ShouldBe(TokenType.Include);
        tokens[0].Value.ShouldBe("auth.conf");
    }

    [Fact]
    public void Lex_EscapeSequences_AreProcessed()
    {
        var tokens = NatsConfLexer.Tokenize("msg = \"hello\\tworld\\n\"").ToList();
        tokens[1].Type.ShouldBe(TokenType.String);
        tokens[1].Value.ShouldBe("hello\tworld\n");
    }

    [Fact]
    public void Lex_HexEscape_IsProcessed()
    {
        var tokens = NatsConfLexer.Tokenize("val = \"\\x41\\x42\"").ToList();
        tokens[1].Value.ShouldBe("AB");
    }

    [Fact]
    public void Lex_ColonSeparator_Works()
    {
        var tokens = NatsConfLexer.Tokenize("foo: bar").ToList();
        tokens[0].Type.ShouldBe(TokenType.Key);
        tokens[1].Type.ShouldBe(TokenType.String);
    }

    [Fact]
    public void Lex_WhitespaceSeparator_Works()
    {
        var tokens = NatsConfLexer.Tokenize("foo bar").ToList();
        tokens[0].Type.ShouldBe(TokenType.Key);
        tokens[1].Type.ShouldBe(TokenType.String);
    }

    [Fact]
    public void Lex_SemicolonTerminator_IsHandled()
    {
        var tokens = NatsConfLexer.Tokenize("foo = 1; bar = 2").ToList();
        var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
        keys.Count.ShouldBe(2);
    }

    [Fact]
    public void Lex_EmptyInput_ReturnsEof()
    {
        var tokens = NatsConfLexer.Tokenize("").ToList();
        tokens.Count.ShouldBe(1);
        tokens[0].Type.ShouldBe(TokenType.Eof);
    }

    [Fact]
    public void Lex_BlockString_ReturnsString()
    {
        // Block strings delimited by ( ... ) where ) is on its own line
        var input = "desc (\nthis is\na block\n)\n";
        var tokens = NatsConfLexer.Tokenize(input).ToList();
        tokens[0].Type.ShouldBe(TokenType.Key);
        tokens[1].Type.ShouldBe(TokenType.String);
    }

    [Fact]
    public void Lex_IPAddress_ReturnsString()
    {
        var tokens = NatsConfLexer.Tokenize("host = 127.0.0.1").ToList();
        tokens[1].Type.ShouldBe(TokenType.String);
        tokens[1].Value.ShouldBe("127.0.0.1");
    }

    [Fact]
    public void Lex_TrackLineNumbers()
    {
        var tokens = NatsConfLexer.Tokenize("a = 1\nb = 2\nc = 3").ToList();
        tokens[0].Line.ShouldBe(1); // a
        tokens[2].Line.ShouldBe(2); // b
        tokens[4].Line.ShouldBe(3); // c
    }

    [Fact]
    public void Lex_UnterminatedString_ReturnsError()
    {
        var tokens = NatsConfLexer.Tokenize("foo = \"unterminated").ToList();
        tokens.ShouldContain(t => t.Type == TokenType.Error);
    }

    [Fact]
    public void Lex_StringStartingWithDigit_TreatedAsString()
    {
        // Go: `foo = 3xyz` → string "3xyz"
        var tokens = NatsConfLexer.Tokenize("foo = 3xyz").ToList();
        tokens[1].Type.ShouldBe(TokenType.String);
        tokens[1].Value.ShouldBe("3xyz");
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsConfLexerTests" -v normal Expected: FAIL — types don't exist yet

Step 3: Implement token types

// src/NATS.Server/Configuration/NatsConfToken.cs
namespace NATS.Server.Configuration;

public enum TokenType
{
    Error,
    Eof,
    Key,
    String,
    Bool,
    Integer,
    Float,
    DateTime,
    ArrayStart,
    ArrayEnd,
    MapStart,
    MapEnd,
    Variable,
    Include,
    Comment,
}

public readonly record struct Token(TokenType Type, string Value, int Line, int Position);

Step 4: Implement the lexer

Port Go conf/lex.go to C#. Create src/NATS.Server/Configuration/NatsConfLexer.cs.

Key mapping from Go → C#:

  • stateFn func(lx *lexer) stateFndelegate LexState? LexState(Lexer lx)
  • chan itemList<Token> (no need for channel, we collect eagerly)
  • lx.next() / lx.backup() / lx.peek() → same pattern on ReadOnlySpan<char> or string with position tracking
  • lx.emit() → add to List<Token>
  • lx.push() / lx.pop()Stack<LexState>
  • lx.stringPartsList<string> for escape sequence assembly
  • isWhitespace, isNL, isKeySeparator, isNumberSuffix → static helper methods

The lexer is ~400 lines. Follow the Go state machine exactly:

  • lexToplexKeyStartlexKeylexKeyEndlexValue
  • lexValue dispatches to: lexArrayValue, lexMapKeyStart, lexQuotedString, lexDubQuotedString, lexNegNumberStart, lexBlock, lexNumberOrDateOrStringOrIPStart, lexString
  • lexStringEscape handles \t, \n, \r, \", \\, \xHH
  • lexBlock looks for ) on a line by itself
  • lexConvenientNumber handles size suffixes (k, kb, mb, gb, etc.)

Public API: static IReadOnlyList<Token> Tokenize(string input)

Step 5: Run tests to verify they pass

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

Step 6: Commit

git add src/NATS.Server/Configuration/NatsConfToken.cs src/NATS.Server/Configuration/NatsConfLexer.cs tests/NATS.Server.Tests/NatsConfLexerTests.cs
git commit -m "feat: add NATS config file lexer (port of Go conf/lex.go)"

Task 2: Config Parser

Files:

  • Create: src/NATS.Server/Configuration/NatsConfParser.cs
  • Test: tests/NATS.Server.Tests/NatsConfParserTests.cs

Step 1: Write failing tests

// tests/NATS.Server.Tests/NatsConfParserTests.cs
using NATS.Server.Configuration;

namespace NATS.Server.Tests;

public class NatsConfParserTests
{
    [Fact]
    public void Parse_SimpleTopLevel_ReturnsCorrectTypes()
    {
        var result = NatsConfParser.Parse("foo = '1'; bar = 2.2; baz = true; boo = 22");
        result["foo"].ShouldBe("1");
        result["bar"].ShouldBe(2.2);
        result["baz"].ShouldBe(true);
        result["boo"].ShouldBe(22L);
    }

    [Fact]
    public void Parse_Booleans_AllVariants()
    {
        foreach (var (input, expected) in new[] {
            ("true", true), ("TRUE", true), ("yes", true), ("on", true),
            ("false", false), ("FALSE", false), ("no", false), ("off", false)
        })
        {
            var result = NatsConfParser.Parse($"flag = {input}");
            result["flag"].ShouldBe(expected);
        }
    }

    [Fact]
    public void Parse_IntegerWithSuffix_AppliesMultiplier()
    {
        var result = NatsConfParser.Parse("a = 1k; b = 2mb; c = 3gb; d = 4kb");
        result["a"].ShouldBe(1000L);
        result["b"].ShouldBe(2L * 1024 * 1024);
        result["c"].ShouldBe(3L * 1000 * 1000 * 1000);
        result["d"].ShouldBe(4L * 1024);
    }

    [Fact]
    public void Parse_NestedMap_ReturnsDictionary()
    {
        var result = NatsConfParser.Parse("auth { user: admin, pass: secret }");
        var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
        auth["user"].ShouldBe("admin");
        auth["pass"].ShouldBe("secret");
    }

    [Fact]
    public void Parse_Array_ReturnsList()
    {
        var result = NatsConfParser.Parse("items = [1, 2, 3]");
        var items = result["items"].ShouldBeOfType<List<object?>>();
        items.Count.ShouldBe(3);
        items[0].ShouldBe(1L);
    }

    [Fact]
    public void Parse_Variable_ResolvesFromContext()
    {
        var result = NatsConfParser.Parse("index = 22\nfoo = $index");
        result["foo"].ShouldBe(22L);
    }

    [Fact]
    public void Parse_NestedVariable_UsesBlockScope()
    {
        var input = "index = 22\nnest {\n  index = 11\n  foo = $index\n}\nbar = $index";
        var result = NatsConfParser.Parse(input);
        var nest = result["nest"].ShouldBeOfType<Dictionary<string, object?>>();
        nest["foo"].ShouldBe(11L); // inner scope
        result["bar"].ShouldBe(22L); // outer scope
    }

    [Fact]
    public void Parse_EnvironmentVariable_ResolvesFromEnv()
    {
        Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", "hello");
        try
        {
            var result = NatsConfParser.Parse("val = $NATS_TEST_VAR_12345");
            result["val"].ShouldBe("hello");
        }
        finally
        {
            Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", null);
        }
    }

    [Fact]
    public void Parse_UndefinedVariable_Throws()
    {
        Should.Throw<FormatException>(() =>
            NatsConfParser.Parse("val = $UNDEFINED_VAR_XYZZY_99999"));
    }

    [Fact]
    public void Parse_IncludeDirective_MergesFile()
    {
        var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
        Directory.CreateDirectory(dir);
        try
        {
            File.WriteAllText(Path.Combine(dir, "main.conf"), "port = 4222\ninclude \"sub.conf\"");
            File.WriteAllText(Path.Combine(dir, "sub.conf"), "host = \"localhost\"");
            var result = NatsConfParser.ParseFile(Path.Combine(dir, "main.conf"));
            result["port"].ShouldBe(4222L);
            result["host"].ShouldBe("localhost");
        }
        finally
        {
            Directory.Delete(dir, true);
        }
    }

    [Fact]
    public void Parse_MultipleKeySeparators_AllWork()
    {
        var r1 = NatsConfParser.Parse("a = 1");
        var r2 = NatsConfParser.Parse("a : 1");
        var r3 = NatsConfParser.Parse("a 1");
        r1["a"].ShouldBe(1L);
        r2["a"].ShouldBe(1L);
        r3["a"].ShouldBe(1L);
    }

    [Fact]
    public void Parse_ErrorOnInvalidInput_Throws()
    {
        Should.Throw<FormatException>(() => NatsConfParser.Parse("= invalid"));
    }

    [Fact]
    public void Parse_CommentsInsideBlocks_AreIgnored()
    {
        var input = "auth {\n  # comment\n  user: admin\n  // another comment\n  pass: secret\n}";
        var result = NatsConfParser.Parse(input);
        var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
        auth["user"].ShouldBe("admin");
        auth["pass"].ShouldBe("secret");
    }

    [Fact]
    public void Parse_ArrayOfMaps_Works()
    {
        var input = "users = [\n  { user: alice, pass: pw1 }\n  { user: bob, pass: pw2 }\n]";
        var result = NatsConfParser.Parse(input);
        var users = result["users"].ShouldBeOfType<List<object?>>();
        users.Count.ShouldBe(2);
        var first = users[0].ShouldBeOfType<Dictionary<string, object?>>();
        first["user"].ShouldBe("alice");
    }

    [Fact]
    public void Parse_BcryptPassword_HandledAsString()
    {
        // Bcrypt strings starting with $2a$ should be preserved
        var input = "pass = $2a$04$P/.bd.7unw9Ew7yWJqXsl.f4oNRLQGvadEL2YnqQXbbb.IVQajRdK";
        var result = NatsConfParser.Parse(input);
        ((string)result["pass"]!).ShouldStartWith("$2a$");
    }

    [Fact]
    public void ParseFile_WithDigest_ReturnsStableHash()
    {
        var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
        Directory.CreateDirectory(dir);
        try
        {
            var conf = Path.Combine(dir, "test.conf");
            File.WriteAllText(conf, "port = 4222\nhost = \"localhost\"");
            var (result, digest) = NatsConfParser.ParseFileWithDigest(conf);
            result["port"].ShouldBe(4222L);
            digest.ShouldStartWith("sha256:");
            digest.Length.ShouldBeGreaterThan(10);

            // Same input → same digest
            var (_, digest2) = NatsConfParser.ParseFileWithDigest(conf);
            digest2.ShouldBe(digest);
        }
        finally
        {
            Directory.Delete(dir, true);
        }
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsConfParserTests" -v normal Expected: FAIL — NatsConfParser doesn't exist

Step 3: Implement the parser

Port Go conf/parse.go to C#. Create src/NATS.Server/Configuration/NatsConfParser.cs.

Key mapping from Go → C#:

  • parser.mappingDictionary<string, object?>
  • parser.ctx / parser.ctxsStack<object> for context (map or list)
  • parser.keysStack<string> for key tracking
  • parser.fpstring file path for relative include resolution
  • processItem switch on TokenType → same pattern
  • Integer suffix processing: "k"→×1000, "kb"/"ki"/"kib"→×1024, etc. (match Go exactly)
  • Variable lookup: walk context stack bottom-up, then Environment.GetEnvironmentVariable()
  • Bcrypt special case: $2a$... → treat as literal string
  • Include: ParseFile(Path.Combine(baseDir, includePath)), merge into current context
  • Digest: SHA256.HashData() on JSON serialization of result

Public API:

  • static Dictionary<string, object?> Parse(string data)
  • static Dictionary<string, object?> ParseFile(string filePath)
  • static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)

Step 4: Run tests to verify they pass

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

Step 5: Commit

git add src/NATS.Server/Configuration/NatsConfParser.cs tests/NATS.Server.Tests/NatsConfParserTests.cs
git commit -m "feat: add NATS config file parser (port of Go conf/parse.go)"

Task 3: New NatsOptions Fields

Files:

  • Modify: src/NATS.Server/NatsOptions.cs
  • Modify: src/NATS.Server/NatsServer.cs:23 (MaxClosedClients constant → use options)
  • Modify: tests/NATS.Server.Tests/NatsOptionsTests.cs

Step 1: Write failing tests for new defaults

// Add to tests/NATS.Server.Tests/NatsOptionsTests.cs
[Fact]
public void New_fields_have_correct_defaults()
{
    var opts = new NatsOptions();
    opts.ClientAdvertise.ShouldBeNull();
    opts.TraceVerbose.ShouldBeFalse();
    opts.MaxTracedMsgLen.ShouldBe(0);
    opts.DisableSublistCache.ShouldBeFalse();
    opts.ConnectErrorReports.ShouldBe(3600);
    opts.ReconnectErrorReports.ShouldBe(1);
    opts.NoHeaderSupport.ShouldBeFalse();
    opts.MaxClosedClients.ShouldBe(10_000);
    opts.NoSystemAccount.ShouldBeFalse();
    opts.SystemAccount.ShouldBeNull();
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsOptionsTests.New_fields" -v normal Expected: FAIL — properties don't exist

Step 3: Add new fields to NatsOptions

Add to src/NATS.Server/NatsOptions.cs (after the existing ProfPort property, before TLS section):

// Extended options for Go parity
public string? ClientAdvertise { get; set; }
public bool TraceVerbose { get; set; }
public int MaxTracedMsgLen { get; set; }
public bool DisableSublistCache { get; set; }
public int ConnectErrorReports { get; set; } = 3600;
public int ReconnectErrorReports { get; set; } = 1;
public bool NoHeaderSupport { get; set; }
public int MaxClosedClients { get; set; } = 10_000;
public bool NoSystemAccount { get; set; }
public string? SystemAccount { get; set; }

Also add a HashSet<string> for CLI flag tracking:

// Tracks which fields were set via CLI flags (for reload precedence)
public HashSet<string> InCmdLine { get; } = [];

Step 4: Update NatsServer.cs to use options.MaxClosedClients

In src/NATS.Server/NatsServer.cs:

  • Remove line 23: private const int MaxClosedClients = 10_000;
  • Change line 650 reference: while (_closedClients.Count > MaxClosedClients)while (_closedClients.Count > _options.MaxClosedClients)

Step 5: Run tests to verify they pass

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

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: PASS (all existing tests still pass)

Step 6: Commit

git add src/NATS.Server/NatsOptions.cs src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/NatsOptionsTests.cs
git commit -m "feat: add new NatsOptions fields for Go config parity"

Task 4: Config Processor

Files:

  • Create: src/NATS.Server/Configuration/ConfigProcessor.cs
  • Create: tests/NATS.Server.Tests/TestData/basic.conf
  • Create: tests/NATS.Server.Tests/TestData/auth.conf
  • Create: tests/NATS.Server.Tests/TestData/tls.conf
  • Create: tests/NATS.Server.Tests/TestData/full.conf
  • Test: tests/NATS.Server.Tests/ConfigProcessorTests.cs

Step 1: Create test config files

# tests/NATS.Server.Tests/TestData/basic.conf
port: 4222
host: "0.0.0.0"
server_name: "test-server"
max_payload: 2mb
max_connections: 1000
debug: true
trace: false
logtime: true
logtime_utc: false
ping_interval: "30s"
ping_max: 3
write_deadline: "5s"
max_subs: 100
max_sub_tokens: 16
max_control_line: 2048
max_pending: 32mb
lame_duck_duration: "60s"
lame_duck_grace_period: "5s"
http_port: 8222
# tests/NATS.Server.Tests/TestData/auth.conf
authorization {
  user: admin
  password: "s3cret"
  timeout: 5

  users = [
    { user: alice, password: "pw1", permissions: { publish: { allow: ["foo.>"] }, subscribe: { allow: [">"] } } }
    { user: bob, password: "pw2" }
  ]
}
no_auth_user: "guest"
# tests/NATS.Server.Tests/TestData/tls.conf
tls {
  cert_file: "/path/to/cert.pem"
  key_file: "/path/to/key.pem"
  ca_file: "/path/to/ca.pem"
  verify: true
  verify_and_map: true
  timeout: 3
  connection_rate_limit: 100
  pinned_certs: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"]
  handshake_first: true
}
allow_non_tls: false
# tests/NATS.Server.Tests/TestData/full.conf
# Full configuration with all supported options
port: 4222
host: "0.0.0.0"
server_name: "full-test"
client_advertise: "nats://public.example.com:4222"

max_payload: 1mb
max_control_line: 4096
max_connections: 65536
max_pending: 64mb
write_deadline: "10s"
max_subs: 0
max_sub_tokens: 0
max_traced_msg_len: 1024
disable_sublist_cache: false
max_closed_clients: 5000

ping_interval: "2m"
ping_max: 2

debug: false
trace: false
trace_verbose: false
logtime: true
logtime_utc: false
logfile: "/var/log/nats.log"
log_size_limit: 100mb
log_max_num: 5

http_port: 8222
http_base_path: "/nats"

pidfile: "/var/run/nats.pid"
ports_file_dir: "/var/run"

lame_duck_duration: "2m"
lame_duck_grace_period: "10s"

server_tags {
  region: "us-east"
  env: "production"
}

authorization {
  user: admin
  password: "secret"
  timeout: 2
}

tls {
  cert_file: "/path/to/cert.pem"
  key_file: "/path/to/key.pem"
  ca_file: "/path/to/ca.pem"
  verify: true
  timeout: 2
  handshake_first: true
}

Step 2: Write failing tests

// tests/NATS.Server.Tests/ConfigProcessorTests.cs
using NATS.Server.Configuration;

namespace NATS.Server.Tests;

public class ConfigProcessorTests
{
    private static string TestDataPath(string filename) =>
        Path.Combine(AppContext.BaseDirectory, "TestData", filename);

    [Fact]
    public void ProcessConfigFile_BasicConf_SetsNetworkOptions()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
        opts.Port.ShouldBe(4222);
        opts.Host.ShouldBe("0.0.0.0");
        opts.ServerName.ShouldBe("test-server");
        opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
        opts.MaxConnections.ShouldBe(1000);
    }

    [Fact]
    public void ProcessConfigFile_BasicConf_SetsLoggingOptions()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
        opts.Debug.ShouldBeTrue();
        opts.Trace.ShouldBeFalse();
        opts.Logtime.ShouldBeTrue();
    }

    [Fact]
    public void ProcessConfigFile_BasicConf_SetsPingOptions()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
        opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
        opts.MaxPingsOut.ShouldBe(3);
    }

    [Fact]
    public void ProcessConfigFile_BasicConf_SetsLimits()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
        opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5));
        opts.MaxSubs.ShouldBe(100);
        opts.MaxSubTokens.ShouldBe(16);
        opts.MaxControlLine.ShouldBe(2048);
        opts.MaxPending.ShouldBe(32L * 1024 * 1024);
    }

    [Fact]
    public void ProcessConfigFile_BasicConf_SetsLifecycle()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
        opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60));
        opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void ProcessConfigFile_BasicConf_SetsMonitoring()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
        opts.MonitorPort.ShouldBe(8222);
    }

    [Fact]
    public void ProcessConfigFile_AuthConf_SetsAuthOptions()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
        opts.Username.ShouldBe("admin");
        opts.Password.ShouldBe("s3cret");
        opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5));
        opts.NoAuthUser.ShouldBe("guest");
    }

    [Fact]
    public void ProcessConfigFile_AuthConf_ParsesUsers()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
        opts.Users.ShouldNotBeNull();
        opts.Users!.Count.ShouldBe(2);
        opts.Users[0].Username.ShouldBe("alice");
        opts.Users[0].Permissions.ShouldNotBeNull();
        opts.Users[0].Permissions!.Publish!.Allow!.ShouldContain("foo.>");
        opts.Users[1].Username.ShouldBe("bob");
    }

    [Fact]
    public void ProcessConfigFile_TlsConf_SetsTlsOptions()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
        opts.TlsCert.ShouldBe("/path/to/cert.pem");
        opts.TlsKey.ShouldBe("/path/to/key.pem");
        opts.TlsCaCert.ShouldBe("/path/to/ca.pem");
        opts.TlsVerify.ShouldBeTrue();
        opts.TlsMap.ShouldBeTrue();
        opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3));
        opts.TlsRateLimit.ShouldBe(100);
        opts.TlsPinnedCerts.ShouldNotBeNull();
        opts.TlsPinnedCerts!.Count.ShouldBe(1);
        opts.TlsHandshakeFirst.ShouldBeTrue();
        opts.AllowNonTls.ShouldBeFalse();
    }

    [Fact]
    public void ProcessConfigFile_FullConf_SetsExtendedOptions()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
        opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222");
        opts.MaxTracedMsgLen.ShouldBe(1024);
        opts.MaxClosedClients.ShouldBe(5000);
        opts.DisableSublistCache.ShouldBeFalse();
    }

    [Fact]
    public void ProcessConfigFile_FullConf_ParsesTags()
    {
        var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
        opts.Tags.ShouldNotBeNull();
        opts.Tags!["region"].ShouldBe("us-east");
        opts.Tags["env"].ShouldBe("production");
    }

    [Fact]
    public void ProcessConfigFile_ListenCombined_SetsHostAndPort()
    {
        var opts = ConfigProcessor.ProcessConfig("listen: \"127.0.0.1:5222\"");
        opts.Host.ShouldBe("127.0.0.1");
        opts.Port.ShouldBe(5222);
    }

    [Fact]
    public void ProcessConfigFile_HttpCombined_SetsMonitorHostAndPort()
    {
        var opts = ConfigProcessor.ProcessConfig("http: \"0.0.0.0:9222\"");
        opts.MonitorHost.ShouldBe("0.0.0.0");
        opts.MonitorPort.ShouldBe(9222);
    }

    [Fact]
    public void ProcessConfigFile_DurationAsNumber_TreatedAsSeconds()
    {
        // Backward compat: bare number = seconds
        var opts = ConfigProcessor.ProcessConfig("ping_interval: 30");
        opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
    }

    [Fact]
    public void ProcessConfigFile_UnknownKeys_SilentlyIgnored()
    {
        // Cluster/JetStream keys should not cause errors
        var opts = ConfigProcessor.ProcessConfig("port: 4222\ncluster { port: 6222 }\njetstream: true");
        opts.Port.ShouldBe(4222);
    }

    [Fact]
    public void ProcessConfigFile_ServerNameWithSpaces_ThrowsError()
    {
        Should.Throw<ConfigProcessorException>(() =>
            ConfigProcessor.ProcessConfig("server_name: \"has spaces\""));
    }

    [Fact]
    public void ProcessConfigFile_MaxSubTokensTooLarge_ThrowsError()
    {
        Should.Throw<ConfigProcessorException>(() =>
            ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
    }
}

Step 2b: Ensure TestData files are copied to output

Add to tests/NATS.Server.Tests/NATS.Server.Tests.csproj:

<ItemGroup>
  <None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Step 3: Run tests to verify they fail

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConfigProcessorTests" -v normal Expected: FAIL — ConfigProcessor doesn't exist

Step 4: Implement the config processor

Create src/NATS.Server/Configuration/ConfigProcessor.cs.

Port the option processing from Go opts.go:1050-1400 (the processConfigFileLine switch). Key methods:

public static class ConfigProcessor
{
    public static NatsOptions ProcessConfigFile(string filePath)
    {
        var config = NatsConfParser.ParseFile(filePath);
        var opts = new NatsOptions { ConfigFile = filePath };
        ApplyConfig(config, opts);
        return opts;
    }

    public static NatsOptions ProcessConfig(string configText)
    {
        var config = NatsConfParser.Parse(configText);
        var opts = new NatsOptions();
        ApplyConfig(config, opts);
        return opts;
    }

    public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts) { ... }
}

ApplyConfig iterates the dictionary, switching on key.ToLowerInvariant():

  • "listen"ParseListen(value) to split "host:port" into opts.Host/opts.Port
  • "port"opts.Port = (int)(long)value
  • "host", "net"opts.Host = (string)value
  • "server_name" → validate no spaces, set opts.ServerName
  • "debug"opts.Debug = (bool)value
  • "trace"opts.Trace = (bool)value
  • "trace_verbose"opts.TraceVerbose = (bool)value; opts.Trace = (bool)value
  • "authorization"ParseAuthorization((Dictionary<string,object?>)value, opts)
  • "tls"ParseTls((Dictionary<string,object?>)value, opts)
  • "server_tags"ParseTags((Dictionary<string,object?>)value, opts)
  • Duration fields (ping_interval, write_deadline, lame_duck_duration, lame_duck_grace_period) → ParseDuration() helper
  • Unknown keys → silently ignored (for cluster/JetStream forward compat)

Helper methods:

  • ParseListen(object value) → splits "host:port" string
  • ParseDuration(string field, object value) → handles string durations ("30s", "2m") and bare numbers (seconds)
  • ParseAuthorization(dict, opts) → processes user, password, token, timeout, users array
  • ParseUsers(List<object?> users) → builds List<User> with optional permissions
  • ParsePermissions(dict) → builds Permissions with SubjectPermission (allow/deny lists)
  • ParseTls(dict, opts) → maps TLS block keys to TLS options
  • ParseTags(dict, opts) → builds Dictionary<string, string> tags

Also create ConfigProcessorException for error reporting:

public sealed class ConfigProcessorException(string message, List<string> errors)
    : Exception(message)
{
    public IReadOnlyList<string> Errors => errors;
}

Errors are collected into a List<string> during processing. If any errors exist after processing all keys, throw ConfigProcessorException with all errors.

Step 5: Run tests to verify they pass

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

Step 6: Commit

git add src/NATS.Server/Configuration/ConfigProcessor.cs tests/NATS.Server.Tests/ConfigProcessorTests.cs tests/NATS.Server.Tests/TestData/ tests/NATS.Server.Tests/NATS.Server.Tests.csproj
git commit -m "feat: add config processor mapping parsed config to NatsOptions"

Task 5: Hot Reload System

Files:

  • Create: src/NATS.Server/Configuration/IConfigChange.cs
  • Create: src/NATS.Server/Configuration/ConfigReloader.cs
  • Modify: src/NATS.Server/NatsServer.cs (SIGHUP handler + ReloadConfig())
  • Test: tests/NATS.Server.Tests/ConfigReloadTests.cs

Step 1: Write failing tests

// tests/NATS.Server.Tests/ConfigReloadTests.cs
using NATS.Server.Configuration;

namespace NATS.Server.Tests;

public class ConfigReloadTests
{
    [Fact]
    public void Diff_NoChanges_ReturnsEmpty()
    {
        var old = new NatsOptions { Port = 4222, Debug = true };
        var @new = new NatsOptions { Port = 4222, Debug = true };
        var changes = ConfigReloader.Diff(old, @new);
        changes.ShouldBeEmpty();
    }

    [Fact]
    public void Diff_ReloadableChange_ReturnsChange()
    {
        var old = new NatsOptions { Debug = false };
        var @new = new NatsOptions { Debug = true };
        var changes = ConfigReloader.Diff(old, @new);
        changes.Count.ShouldBe(1);
        changes[0].Name.ShouldBe("Debug");
        changes[0].IsLoggingChange.ShouldBeTrue();
    }

    [Fact]
    public void Diff_NonReloadableChange_ReturnsNonReloadableChange()
    {
        var old = new NatsOptions { Port = 4222 };
        var @new = new NatsOptions { Port = 5222 };
        var changes = ConfigReloader.Diff(old, @new);
        changes.Count.ShouldBe(1);
        changes[0].IsNonReloadable.ShouldBeTrue();
    }

    [Fact]
    public void Diff_MultipleChanges_ReturnsAll()
    {
        var old = new NatsOptions { Debug = false, MaxPayload = 1024 };
        var @new = new NatsOptions { Debug = true, MaxPayload = 2048 };
        var changes = ConfigReloader.Diff(old, @new);
        changes.Count.ShouldBe(2);
    }

    [Fact]
    public void Diff_AuthChange_MarkedCorrectly()
    {
        var old = new NatsOptions { Username = "alice" };
        var @new = new NatsOptions { Username = "bob" };
        var changes = ConfigReloader.Diff(old, @new);
        changes[0].IsAuthChange.ShouldBeTrue();
    }

    [Fact]
    public void Diff_TlsChange_MarkedCorrectly()
    {
        var old = new NatsOptions { TlsCert = "/old/cert.pem" };
        var @new = new NatsOptions { TlsCert = "/new/cert.pem" };
        var changes = ConfigReloader.Diff(old, @new);
        changes[0].IsTlsChange.ShouldBeTrue();
    }

    [Fact]
    public void Validate_NonReloadableChanges_ReturnsErrors()
    {
        var changes = new List<IConfigChange>
        {
            new ConfigChange("Port", isNonReloadable: true),
        };
        var errors = ConfigReloader.Validate(changes);
        errors.Count.ShouldBe(1);
        errors[0].ShouldContain("Port");
    }

    [Fact]
    public void MergeWithCli_CliOverridesConfig()
    {
        var fromConfig = new NatsOptions { Port = 5222, Debug = true };
        var cliFlags = new HashSet<string> { "Port" };
        var cliValues = new NatsOptions { Port = 4222 };

        ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
        fromConfig.Port.ShouldBe(4222); // CLI wins
        fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI)
    }
}

Step 2: Run tests to verify they fail

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

Step 3: Implement the change interface and reloader

// src/NATS.Server/Configuration/IConfigChange.cs
namespace NATS.Server.Configuration;

public interface IConfigChange
{
    string Name { get; }
    bool IsLoggingChange { get; }
    bool IsAuthChange { get; }
    bool IsTlsChange { get; }
    bool IsNonReloadable { get; }
}

public sealed class ConfigChange(
    string name,
    bool isLoggingChange = false,
    bool isAuthChange = false,
    bool isTlsChange = false,
    bool isNonReloadable = false) : IConfigChange
{
    public string Name => name;
    public bool IsLoggingChange => isLoggingChange;
    public bool IsAuthChange => isAuthChange;
    public bool IsTlsChange => isTlsChange;
    public bool IsNonReloadable => isNonReloadable;
}
// src/NATS.Server/Configuration/ConfigReloader.cs
namespace NATS.Server.Configuration;

public static class ConfigReloader
{
    // Non-reloadable options (match Go server)
    private static readonly HashSet<string> NonReloadable = ["Host", "Port", "ServerName"];

    // Logging-related options
    private static readonly HashSet<string> LoggingOptions =
        ["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile",
         "LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"];

    // Auth-related options
    private static readonly HashSet<string> AuthOptions =
        ["Username", "Password", "Authorization", "Users", "NKeys",
         "NoAuthUser", "AuthTimeout"];

    // TLS-related options
    private static readonly HashSet<string> TlsOptions =
        ["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap",
         "TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback",
         "AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"];

    public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts) { ... }
    public static List<string> Validate(List<IConfigChange> changes) { ... }
    public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags) { ... }
}

Diff compares each property of NatsOptions using reflection or explicit property-by-property checks. For each changed property, creates a ConfigChange with the appropriate flags.

Validate returns error messages for any IsNonReloadable changes.

MergeCliOverrides copies properties listed in cliFlags from cliValues back to fromConfig.

Step 4: Run tests to verify they pass

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

Step 5: Commit

git add src/NATS.Server/Configuration/IConfigChange.cs src/NATS.Server/Configuration/ConfigReloader.cs tests/NATS.Server.Tests/ConfigReloadTests.cs
git commit -m "feat: add config reloader with diff, validate, and CLI merge"

Task 6: Server Integration (Config Loading + SIGHUP Reload)

Files:

  • Modify: src/NATS.Server/NatsServer.cs (constructor, SIGHUP handler, new ReloadConfig())
  • Modify: src/NATS.Server.Host/Program.cs (config file loading, CLI tracking)
  • Test: tests/NATS.Server.Tests/ConfigIntegrationTests.cs (new file)

Step 1: Write failing integration tests

// tests/NATS.Server.Tests/ConfigIntegrationTests.cs
using NATS.Server.Configuration;

namespace NATS.Server.Tests;

public class ConfigIntegrationTests
{
    [Fact]
    public void Server_WithConfigFile_LoadsOptionsFromFile()
    {
        var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
        Directory.CreateDirectory(dir);
        try
        {
            var confPath = Path.Combine(dir, "test.conf");
            File.WriteAllText(confPath, "port: 14222\nmax_payload: 2mb\ndebug: true");

            var opts = ConfigProcessor.ProcessConfigFile(confPath);
            opts.Port.ShouldBe(14222);
            opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
            opts.Debug.ShouldBeTrue();
        }
        finally
        {
            Directory.Delete(dir, true);
        }
    }

    [Fact]
    public void Server_CliOverridesConfig()
    {
        var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
        Directory.CreateDirectory(dir);
        try
        {
            var confPath = Path.Combine(dir, "test.conf");
            File.WriteAllText(confPath, "port: 14222\ndebug: true");

            var opts = ConfigProcessor.ProcessConfigFile(confPath);
            // Simulate CLI override
            opts.InCmdLine.Add("Port");
            var cliOpts = new NatsOptions { Port = 5222 };
            ConfigReloader.MergeCliOverrides(opts, cliOpts, opts.InCmdLine);

            opts.Port.ShouldBe(5222); // CLI wins
            opts.Debug.ShouldBeTrue(); // config value preserved
        }
        finally
        {
            Directory.Delete(dir, true);
        }
    }

    [Fact]
    public void Reload_ChangingPort_ReturnsError()
    {
        var oldOpts = new NatsOptions { Port = 4222 };
        var newOpts = new NatsOptions { Port = 5222 };
        var changes = ConfigReloader.Diff(oldOpts, newOpts);
        var errors = ConfigReloader.Validate(changes);
        errors.Count.ShouldBeGreaterThan(0);
        errors[0].ShouldContain("Port");
    }

    [Fact]
    public void Reload_ChangingDebug_IsValid()
    {
        var oldOpts = new NatsOptions { Debug = false };
        var newOpts = new NatsOptions { Debug = true };
        var changes = ConfigReloader.Diff(oldOpts, newOpts);
        var errors = ConfigReloader.Validate(changes);
        errors.ShouldBeEmpty();
        changes.ShouldContain(c => c.IsLoggingChange);
    }
}

Step 2: Run tests to verify they fail (or pass if processor already works)

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

Step 3: Update NatsServer.cs

Add ReloadConfig() method and update SIGHUP handler.

In the constructor (around line 248), after storing options:

// Store a snapshot of CLI-set flags for reload precedence
_cliSnapshot = new NatsOptions { /* copy current values */ };
_cliFlags = options.InCmdLine;

Add new fields:

private readonly NatsOptions? _cliSnapshot;
private readonly HashSet<string> _cliFlags;
private string? _configDigest;

Update SIGHUP handler (line 223-227):

_signalRegistrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
{
    ctx.Cancel = true;
    _logger.LogInformation("Trapped SIGHUP signal — reloading configuration");
    _ = Task.Run(() => ReloadConfig());
}));

Add ReloadConfig():

public void ReloadConfig()
{
    if (_options.ConfigFile == null)
    {
        _logger.LogWarning("No config file specified, cannot reload");
        return;
    }

    try
    {
        var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(_options.ConfigFile);
        if (digest == _configDigest)
        {
            _logger.LogInformation("Config file unchanged, no reload needed");
            return;
        }

        var newOpts = new NatsOptions { ConfigFile = _options.ConfigFile };
        ConfigProcessor.ApplyConfig(newConfig, newOpts);

        // CLI flags override config
        if (_cliSnapshot != null)
            ConfigReloader.MergeCliOverrides(newOpts, _cliSnapshot, _cliFlags);

        var changes = ConfigReloader.Diff(_options, newOpts);
        var errors = ConfigReloader.Validate(changes);
        if (errors.Count > 0)
        {
            foreach (var err in errors)
                _logger.LogError("Config reload error: {Error}", err);
            return;
        }

        // Apply changes
        ApplyConfigChanges(changes, newOpts);
        _configDigest = digest;
        _logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Failed to reload config file: {ConfigFile}", _options.ConfigFile);
    }
}

Add ApplyConfigChanges() which copies new values to _options and triggers side effects:

  • If any IsLoggingChange → invoke ReOpenLogFile callback
  • If any IsAuthChange → rebuild _authService
  • If any IsTlsChange → rebuild _sslOptions (log warning, actual TLS reload requires cert reload hook)
  • Update limits directly on _options

Step 4: Update Program.cs

Before CLI parsing, add config file loading:

// After extracting -c flag but before other CLI args
string? configFile = null;
for (int i = 0; i < args.Length; i++)
{
    if (args[i] == "-c" && i + 1 < args.Length)
    {
        configFile = args[i + 1];
        break;
    }
}

if (configFile != null)
{
    options = ConfigProcessor.ProcessConfigFile(configFile);
    options.ConfigFile = configFile;
}

// Then apply CLI args (overriding config values) and track in InCmdLine

Update the CLI switch to track overrides:

case "-p" or "--port" when i + 1 < args.Length:
    options.Port = int.Parse(args[++i]);
    options.InCmdLine.Add("Port");
    break;
// ... same pattern for all other CLI flags

Remove the startup warning about config file not being supported:

// DELETE these lines from NatsServer.cs StartAsync:
// if (_options.ConfigFile != null)
//     _logger.LogWarning("Config file parsing not yet supported...");

Step 5: Run all tests

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

Step 6: Commit

git add src/NATS.Server/NatsServer.cs src/NATS.Server.Host/Program.cs tests/NATS.Server.Tests/ConfigIntegrationTests.cs
git commit -m "feat: integrate config file loading and SIGHUP hot reload"

Task 7: Update differences.md

Files:

  • Modify: differences.md

Step 1: Update the config parsing entries

In section 6 (Configuration), update:

  • Config file parsing | Y | NConfig file parsing | Y | Y | Custom NATS conf parser ported from Go
  • Hot reload (SIGHUP) | Y | NHot reload (SIGHUP) | Y | Y | Reloads logging, auth, limits, TLS certs on SIGHUP
  • Config change detection | Y | NConfig change detection | Y | Y | SHA256 digest comparison; CLI flag precedence tracking
  • Update ~62 options count to reflect new fields

In section 1 (Core Server Lifecycle):

  • Config file validation on startup | Y | StubConfig file validation on startup | Y | Y | Full config parsing with error collection

In section 1 (Signal Handling):

  • SIGHUP (config reload) | Y | StubSIGHUP (config reload) | Y | Y | Re-parses config, diffs options, applies reloadable subset

In Summary section:

  • Move config file parsing and hot reload from "Remaining High Priority" to "Resolved Since Initial Audit"
  • Remove the "Remaining High Priority" section entirely if empty

Step 2: Verify the document is accurate

Read through the full differences.md to ensure all updated entries match actual implementation.

Step 3: Commit

git add differences.md
git commit -m "docs: update differences.md to reflect config parsing and hot reload implementation"

Task 8: Full Test Suite Verification

Step 1: Run the full test suite

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

Step 2: Build the solution

Run: dotnet build Expected: Build succeeded with 0 errors

Step 3: Smoke test with config file

Create a temp config file and verify the server starts with it:

echo 'port: 14222\ndebug: true\nmax_payload: 2mb' > /tmp/test-nats.conf
dotnet run --project src/NATS.Server.Host -- -c /tmp/test-nats.conf &
sleep 2
curl -s http://localhost:8222/varz | grep -q "14222"  # verify port from config
kill %1

Step 4: Final commit (if any fixes needed)

git add -A
git commit -m "fix: address issues found during full test verification"