Files
natsdotnet/docs/plans/2026-02-23-sections3-6-gaps-plan.md
2026-02-23 00:28:31 -05:00

69 KiB

Sections 3-6 Gaps Implementation Plan

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

Goal: Implement all remaining gaps in Protocol Parsing (section 3), Subscriptions & Subject Matching (section 4), Authentication & Authorization (section 5), and Configuration (section 6) to achieve golang parity.

Architecture: Port Go reference implementations faithfully. Use Interlocked for atomic counters, ReaderWriterLockSlim for trie locking, PeriodicTimer for background sweeps. Each task is independently testable via xUnit/Shouldly.

Tech Stack: .NET 10 / C# 14, xUnit 3, Shouldly, Serilog


Task 1: NatsOptions — Add New Configuration Fields

Files:

  • Modify: src/NATS.Server/NatsOptions.cs:17 (after MaxPingsOut)

Step 1: Write the failing test

Create test file tests/NATS.Server.Tests/NatsOptionsTests.cs:

namespace NATS.Server.Tests;

public class NatsOptionsTests
{
    [Fact]
    public void Defaults_are_correct()
    {
        var opts = new NatsOptions();
        opts.MaxSubs.ShouldBe(0);
        opts.MaxSubTokens.ShouldBe(0);
        opts.Debug.ShouldBe(false);
        opts.Trace.ShouldBe(false);
        opts.LogFile.ShouldBeNull();
        opts.LogSizeLimit.ShouldBe(0L);
        opts.Tags.ShouldBeNull();
    }
}

Step 2: Run test to verify it fails

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

Step 3: Write minimal implementation

Add to NatsOptions.cs after line 17 (MaxPingsOut):

    public int MaxSubs { get; set; }             // 0 = unlimited (per-connection)
    public int MaxSubTokens { get; set; }        // 0 = unlimited
    public bool Debug { get; set; }
    public bool Trace { get; set; }
    public string? LogFile { get; set; }
    public long LogSizeLimit { get; set; }
    public Dictionary<string, string>? Tags { get; set; }

Step 4: Run test to verify it passes

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

Step 5: Commit

git add src/NATS.Server/NatsOptions.cs tests/NATS.Server.Tests/NatsOptionsTests.cs
git commit -m "feat: add MaxSubs, MaxSubTokens, Debug, Trace, LogFile, LogSizeLimit, Tags to NatsOptions"

Task 2: CLI Flags — Add -D/-V/-DV and Logging Options

Files:

  • Modify: src/NATS.Server.Host/Program.cs:13-58 (switch statement)
  • Modify: src/NATS.Server.Host/NATS.Server.Host.csproj (add Serilog.Sinks.File)
  • Modify: Directory.Packages.props (add Serilog.Sinks.File version)

Step 1: Add Serilog.Sinks.File package

Add to Directory.Packages.props under Logging:

    <PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />

Add to NATS.Server.Host.csproj:

    <PackageReference Include="Serilog.Sinks.File" />

Step 2: Add CLI flag cases to Program.cs

In the switch statement (after --tlsverify case at line 56), add:

        case "-D" or "--debug":
            options.Debug = true;
            break;
        case "-V" or "--trace":
            options.Trace = true;
            break;
        case "-DV":
            options.Debug = true;
            options.Trace = true;
            break;
        case "-l" or "--log" when i + 1 < args.Length:
            options.LogFile = args[++i];
            break;
        case "--log_size_limit" when i + 1 < args.Length:
            options.LogSizeLimit = long.Parse(args[++i]);
            break;

Step 3: Wire logging options into Serilog configuration

Replace the Serilog setup at lines 4-8 of Program.cs with:

// Parse options first (move var options above Serilog setup)
var options = new NatsOptions();

for (int i = 0; i < args.Length; i++)
{
    // ... existing switch ...
}

// Configure Serilog based on options
var logConfig = new LoggerConfiguration()
    .Enrich.FromLogContext()
    .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");

if (options.Trace)
    logConfig.MinimumLevel.Verbose();
else if (options.Debug)
    logConfig.MinimumLevel.Debug();
else
    logConfig.MinimumLevel.Information();

if (options.LogFile != null)
{
    logConfig.WriteTo.File(
        options.LogFile,
        fileSizeLimitBytes: options.LogSizeLimit > 0 ? options.LogSizeLimit : null,
        rollOnFileSizeLimit: options.LogSizeLimit > 0);
}

Log.Logger = logConfig.CreateLogger();

Step 4: Build to verify

Run: dotnet build src/NATS.Server.Host Expected: Success

Step 5: Commit

git add Directory.Packages.props src/NATS.Server.Host/NATS.Server.Host.csproj src/NATS.Server.Host/Program.cs
git commit -m "feat: add -D/-V/-DV debug/trace CLI flags and file logging support"

Task 3: SubjectMatch Utilities — TokenAt, NumTokens, SubjectsCollide, UTF-8 Validation

Files:

  • Modify: src/NATS.Server/Subscriptions/SubjectMatch.cs
  • Modify: tests/NATS.Server.Tests/SubjectMatchTests.cs

Step 1: Write failing tests

Add to SubjectMatchTests.cs:

    [Theory]
    [InlineData("foo.bar.baz", 3)]
    [InlineData("foo", 1)]
    [InlineData("a.b.c.d.e", 5)]
    [InlineData("", 0)]
    public void NumTokens(string subject, int expected)
    {
        SubjectMatch.NumTokens(subject).ShouldBe(expected);
    }

    [Theory]
    [InlineData("foo.bar.baz", 0, "foo")]
    [InlineData("foo.bar.baz", 1, "bar")]
    [InlineData("foo.bar.baz", 2, "baz")]
    [InlineData("foo", 0, "foo")]
    [InlineData("foo.bar.baz", 5, "")]
    public void TokenAt(string subject, int index, string expected)
    {
        SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected);
    }

    [Theory]
    [InlineData("foo.bar", "foo.bar", true)]
    [InlineData("foo.bar", "foo.baz", false)]
    [InlineData("foo.*", "foo.bar", true)]
    [InlineData("foo.*", "foo.>", true)]
    [InlineData("foo.>", "foo.bar.baz", true)]
    [InlineData(">", "foo.bar", true)]
    [InlineData("foo.*", "bar.*", false)]
    [InlineData("foo.*.baz", "foo.bar.*", true)]
    [InlineData("*.bar", "foo.*", true)]
    [InlineData("foo.*", "bar.>", false)]
    public void SubjectsCollide(string subj1, string subj2, bool expected)
    {
        SubjectMatch.SubjectsCollide(subj1, subj2).ShouldBe(expected);
    }

    [Theory]
    [InlineData("foo\0bar", true, false)]
    [InlineData("foo\0bar", false, true)]
    [InlineData("foo.bar", true, true)]
    public void IsValidSubject_checkRunes(string subject, bool checkRunes, bool expected)
    {
        SubjectMatch.IsValidSubject(subject, checkRunes).ShouldBe(expected);
    }

Step 2: Run tests to verify they fail

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

Step 3: Implement the methods

Add to SubjectMatch.cs:

    /// <summary>Count dot-delimited tokens. Empty string returns 0.</summary>
    public static int NumTokens(string subject)
    {
        if (string.IsNullOrEmpty(subject))
            return 0;
        int count = 1;
        for (int i = 0; i < subject.Length; i++)
        {
            if (subject[i] == Sep)
                count++;
        }
        return count;
    }

    /// <summary>Return the 0-based nth token as a span. Returns empty if out of range.</summary>
    public static ReadOnlySpan<char> TokenAt(string subject, int index)
    {
        if (string.IsNullOrEmpty(subject))
            return default;

        var span = subject.AsSpan();
        int current = 0;
        int start = 0;
        for (int i = 0; i < span.Length; i++)
        {
            if (span[i] == Sep)
            {
                if (current == index)
                    return span[start..i];
                start = i + 1;
                current++;
            }
        }
        if (current == index)
            return span[start..];
        return default;
    }

    /// <summary>
    /// Determines if two subject patterns (possibly containing wildcards) can both
    /// match the same literal subject. Reference: Go sublist.go SubjectsCollide.
    /// </summary>
    public static bool SubjectsCollide(string subj1, string subj2)
    {
        if (subj1 == subj2)
            return true;

        bool lit1 = IsLiteral(subj1);
        bool lit2 = IsLiteral(subj2);

        // Both literal: must be equal (already checked)
        if (lit1 && lit2)
            return false;

        // One literal, one wildcard: check if wildcard matches the literal
        if (lit1 && !lit2)
            return MatchLiteral(subj1, subj2);
        if (lit2 && !lit1)
            return MatchLiteral(subj2, subj1);

        // Both have wildcards: token-by-token comparison
        int n1 = NumTokens(subj1);
        int n2 = NumTokens(subj2);
        bool hasFwc1 = subj1.Contains('>');
        bool hasFwc2 = subj2.Contains('>');

        if (!hasFwc1 && !hasFwc2 && n1 != n2)
            return false;
        if (n1 < n2 && !hasFwc1)
            return false;
        if (n2 < n1 && !hasFwc2)
            return false;

        int stop = Math.Min(n1, n2);
        for (int i = 0; i < stop; i++)
        {
            var t1 = TokenAt(subj1, i);
            var t2 = TokenAt(subj2, i);
            if (!TokensCanMatch(t1, t2))
                return false;
        }
        return true;
    }

    private static bool TokensCanMatch(ReadOnlySpan<char> t1, ReadOnlySpan<char> t2)
    {
        if (t1.Length == 1 && (t1[0] == Pwc || t1[0] == Fwc))
            return true;
        if (t2.Length == 1 && (t2[0] == Pwc || t2[0] == Fwc))
            return true;
        return t1.SequenceEqual(t2);
    }

    /// <summary>
    /// Validates subject. When checkRunes is true, also rejects null bytes.
    /// </summary>
    public static bool IsValidSubject(string subject, bool checkRunes)
    {
        if (!IsValidSubject(subject))
            return false;
        if (!checkRunes)
            return true;
        for (int i = 0; i < subject.Length; i++)
        {
            if (subject[i] == '\0')
                return false;
        }
        return true;
    }

Step 4: Run tests to verify they pass

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

Step 5: Commit

git add src/NATS.Server/Subscriptions/SubjectMatch.cs tests/NATS.Server.Tests/SubjectMatchTests.cs
git commit -m "feat: add NumTokens, TokenAt, SubjectsCollide, UTF-8 validation to SubjectMatch"

Task 4: SubList — Generation ID, Stats, and Utility Methods

This is the largest task. It modifies SubList.cs heavily and adds new types.

Files:

  • Modify: src/NATS.Server/Subscriptions/SubList.cs
  • Create: src/NATS.Server/Subscriptions/SubListStats.cs
  • Modify: tests/NATS.Server.Tests/SubListTests.cs

Step 1: Create SubListStats

Create src/NATS.Server/Subscriptions/SubListStats.cs:

namespace NATS.Server.Subscriptions;

public sealed class SubListStats
{
    public uint NumSubs { get; init; }
    public uint NumCache { get; init; }
    public ulong NumInserts { get; init; }
    public ulong NumRemoves { get; init; }
    public ulong NumMatches { get; init; }
    public double CacheHitRate { get; init; }
    public uint MaxFanout { get; init; }
    public double AvgFanout { get; init; }
}

Step 2: Write failing tests for all new methods

Add to SubListTests.cs:

    [Fact]
    public void Stats_returns_correct_values()
    {
        var sl = new SubList();
        sl.Insert(MakeSub("foo.bar", sid: "1"));
        sl.Insert(MakeSub("foo.baz", sid: "2"));
        sl.Match("foo.bar");
        sl.Match("foo.bar"); // cache hit

        var stats = sl.Stats();
        stats.NumSubs.ShouldBe(2u);
        stats.NumInserts.ShouldBe(2ul);
        stats.NumMatches.ShouldBe(2ul);
        stats.CacheHitRate.ShouldBeGreaterThan(0.0);
    }

    [Fact]
    public void HasInterest_returns_true_when_subscribers_exist()
    {
        var sl = new SubList();
        sl.Insert(MakeSub("foo.bar"));
        sl.HasInterest("foo.bar").ShouldBeTrue();
        sl.HasInterest("foo.baz").ShouldBeFalse();
    }

    [Fact]
    public void HasInterest_with_wildcards()
    {
        var sl = new SubList();
        sl.Insert(MakeSub("foo.*"));
        sl.HasInterest("foo.bar").ShouldBeTrue();
        sl.HasInterest("bar.baz").ShouldBeFalse();
    }

    [Fact]
    public void NumInterest_counts_subscribers()
    {
        var sl = new SubList();
        sl.Insert(MakeSub("foo.bar", sid: "1"));
        sl.Insert(MakeSub("foo.*", sid: "2"));
        sl.Insert(MakeSub("foo.bar", queue: "q1", sid: "3"));

        var (np, nq) = sl.NumInterest("foo.bar");
        np.ShouldBe(2); // foo.bar + foo.*
        nq.ShouldBe(1); // queue sub
    }

    [Fact]
    public void RemoveBatch_removes_all()
    {
        var sl = new SubList();
        var sub1 = MakeSub("foo.bar", sid: "1");
        var sub2 = MakeSub("foo.baz", sid: "2");
        var sub3 = MakeSub("bar.qux", sid: "3");
        sl.Insert(sub1);
        sl.Insert(sub2);
        sl.Insert(sub3);
        sl.Count.ShouldBe(3u);

        sl.RemoveBatch([sub1, sub2]);
        sl.Count.ShouldBe(1u);
        sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
        sl.Match("bar.qux").PlainSubs.ShouldHaveSingleItem();
    }

    [Fact]
    public void All_returns_every_subscription()
    {
        var sl = new SubList();
        var sub1 = MakeSub("foo.bar", sid: "1");
        var sub2 = MakeSub("foo.*", sid: "2");
        var sub3 = MakeSub("bar.>", queue: "q", sid: "3");
        sl.Insert(sub1);
        sl.Insert(sub2);
        sl.Insert(sub3);

        var all = sl.All();
        all.Count.ShouldBe(3);
        all.ShouldContain(sub1);
        all.ShouldContain(sub2);
        all.ShouldContain(sub3);
    }

    [Fact]
    public void ReverseMatch_finds_patterns_matching_literal()
    {
        var sl = new SubList();
        var sub1 = MakeSub("foo.bar", sid: "1");
        var sub2 = MakeSub("foo.*", sid: "2");
        var sub3 = MakeSub("foo.>", sid: "3");
        var sub4 = MakeSub("bar.baz", sid: "4");
        sl.Insert(sub1);
        sl.Insert(sub2);
        sl.Insert(sub3);
        sl.Insert(sub4);

        var result = sl.ReverseMatch("foo.bar");
        result.PlainSubs.Length.ShouldBe(3); // foo.bar, foo.*, foo.>
        result.PlainSubs.ShouldContain(sub1);
        result.PlainSubs.ShouldContain(sub2);
        result.PlainSubs.ShouldContain(sub3);
    }

    [Fact]
    public void Generation_ID_invalidates_cache()
    {
        var sl = new SubList();
        sl.Insert(MakeSub("foo.bar", sid: "1"));

        // Prime cache
        var r1 = sl.Match("foo.bar");
        r1.PlainSubs.Length.ShouldBe(1);

        // Insert another sub (bumps generation)
        sl.Insert(MakeSub("foo.bar", sid: "2"));

        // Cache should be invalidated by generation mismatch
        var r2 = sl.Match("foo.bar");
        r2.PlainSubs.Length.ShouldBe(2);
    }

Step 3: Run tests to verify they fail

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

Step 4: Implement SubList changes

Replace SubList.cs with the following major modifications:

  1. Add fields at the top of the class (after _cache):
    private long _generation;
    private ulong _matches;
    private ulong _cacheHits;
    private ulong _inserts;
    private ulong _removes;
  1. Add generation to cache result — wrap cached results:
    private readonly record struct CachedResult(SubListResult Result, long Generation);
    // Change _cache type:
    private Dictionary<string, CachedResult>? _cache = new(StringComparer.Ordinal);
  1. Modify Insert() — bump generation, track inserts, remove AddToCache:

After _count++; (line 92):

            _inserts++;
            Interlocked.Increment(ref _generation);
            // No need for per-key AddToCache — generation invalidation handles it

Remove the AddToCache(subject, sub); call.

  1. Modify Remove() — bump generation, track removes, remove RemoveFromCache:

After _count--; (line 166):

            _removes++;
            Interlocked.Increment(ref _generation);
            // No need for per-key RemoveFromCache — generation invalidation handles it

Remove the RemoveFromCache(sub.Subject); call.

  1. Modify Match() — use generation-based cache, track stats:
    public SubListResult Match(string subject)
    {
        Interlocked.Increment(ref _matches);

        var currentGen = Interlocked.Read(ref _generation);

        // Check cache under read lock first.
        _lock.EnterReadLock();
        try
        {
            if (_cache != null && _cache.TryGetValue(subject, out var cached) && cached.Generation == currentGen)
            {
                Interlocked.Increment(ref _cacheHits);
                return cached.Result;
            }
        }
        finally
        {
            _lock.ExitReadLock();
        }

        // Cache miss — tokenize and match under write lock.
        var tokens = Tokenize(subject);
        if (tokens == null)
            return SubListResult.Empty;

        _lock.EnterWriteLock();
        try
        {
            currentGen = Interlocked.Read(ref _generation);

            // Re-check cache after acquiring write lock.
            if (_cache != null && _cache.TryGetValue(subject, out var cached) && cached.Generation == currentGen)
            {
                Interlocked.Increment(ref _cacheHits);
                return cached.Result;
            }

            var plainSubs = new List<Subscription>();
            var queueSubs = new List<List<Subscription>>();
            MatchLevel(_root, tokens, 0, plainSubs, queueSubs);

            SubListResult result;
            if (plainSubs.Count == 0 && queueSubs.Count == 0)
            {
                result = SubListResult.Empty;
            }
            else
            {
                var queueSubsArr = new Subscription[queueSubs.Count][];
                for (int i = 0; i < queueSubs.Count; i++)
                    queueSubsArr[i] = queueSubs[i].ToArray();
                result = new SubListResult(plainSubs.ToArray(), queueSubsArr);
            }

            if (_cache != null)
            {
                _cache[subject] = new CachedResult(result, currentGen);
                if (_cache.Count > CacheMax)
                {
                    var keys = _cache.Keys.Take(_cache.Count - CacheSweep).ToList();
                    foreach (var key in keys)
                        _cache.Remove(key);
                }
            }

            return result;
        }
        finally
        {
            _lock.ExitWriteLock();
        }
    }
  1. Add Stats():
    public SubListStats Stats()
    {
        _lock.EnterReadLock();
        uint numSubs, numCache;
        ulong inserts, removes;
        try
        {
            numSubs = _count;
            numCache = (uint)(_cache?.Count ?? 0);
            inserts = _inserts;
            removes = _removes;
        }
        finally
        {
            _lock.ExitReadLock();
        }

        var matches = Interlocked.Read(ref _matches);
        var cacheHits = Interlocked.Read(ref _cacheHits);
        var hitRate = matches > 0 ? (double)cacheHits / matches : 0.0;

        uint maxFanout = 0;
        long totalFanout = 0;
        int cacheEntries = 0;

        _lock.EnterReadLock();
        try
        {
            if (_cache != null)
            {
                foreach (var (_, entry) in _cache)
                {
                    var r = entry.Result;
                    var f = r.PlainSubs.Length + r.QueueSubs.Length;
                    totalFanout += f;
                    if (f > maxFanout) maxFanout = (uint)f;
                    cacheEntries++;
                }
            }
        }
        finally
        {
            _lock.ExitReadLock();
        }

        return new SubListStats
        {
            NumSubs = numSubs,
            NumCache = numCache,
            NumInserts = inserts,
            NumRemoves = removes,
            NumMatches = matches,
            CacheHitRate = hitRate,
            MaxFanout = maxFanout,
            AvgFanout = cacheEntries > 0 ? (double)totalFanout / cacheEntries : 0.0,
        };
    }
  1. Add HasInterest():
    public bool HasInterest(string subject)
    {
        // Check cache first
        var currentGen = Interlocked.Read(ref _generation);
        _lock.EnterReadLock();
        try
        {
            if (_cache != null && _cache.TryGetValue(subject, out var cached) && cached.Generation == currentGen)
            {
                var r = cached.Result;
                return r.PlainSubs.Length > 0 || r.QueueSubs.Length > 0;
            }
        }
        finally
        {
            _lock.ExitReadLock();
        }

        // Walk the trie — short-circuit on first hit
        var tokens = Tokenize(subject);
        if (tokens == null) return false;

        _lock.EnterReadLock();
        try
        {
            return HasInterestLevel(_root, tokens, 0);
        }
        finally
        {
            _lock.ExitReadLock();
        }
    }

    private static bool HasInterestLevel(TrieLevel? level, string[] tokens, int tokenIndex)
    {
        TrieNode? pwc = null;
        TrieNode? node = null;

        for (int i = tokenIndex; i < tokens.Length; i++)
        {
            if (level == null) return false;
            if (level.Fwc != null && NodeHasInterest(level.Fwc)) return true;

            pwc = level.Pwc;
            if (pwc != null && HasInterestLevel(pwc.Next, tokens, i + 1)) return true;

            node = null;
            if (level.Nodes.TryGetValue(tokens[i], out var found))
            {
                node = found;
                level = node.Next;
            }
            else
            {
                level = null;
            }
        }

        if (node != null && NodeHasInterest(node)) return true;
        if (pwc != null && NodeHasInterest(pwc)) return true;
        return false;
    }

    private static bool NodeHasInterest(TrieNode node)
    {
        return node.PlainSubs.Count > 0 || node.QueueSubs.Count > 0;
    }
  1. Add NumInterest():
    public (int plainCount, int queueCount) NumInterest(string subject)
    {
        var tokens = Tokenize(subject);
        if (tokens == null) return (0, 0);

        _lock.EnterReadLock();
        try
        {
            int np = 0, nq = 0;
            CountInterestLevel(_root, tokens, 0, ref np, ref nq);
            return (np, nq);
        }
        finally
        {
            _lock.ExitReadLock();
        }
    }

    private static void CountInterestLevel(TrieLevel? level, string[] tokens, int tokenIndex,
        ref int np, ref int nq)
    {
        TrieNode? pwc = null;
        TrieNode? node = null;

        for (int i = tokenIndex; i < tokens.Length; i++)
        {
            if (level == null) return;
            if (level.Fwc != null) AddNodeCounts(level.Fwc, ref np, ref nq);

            pwc = level.Pwc;
            if (pwc != null) CountInterestLevel(pwc.Next, tokens, i + 1, ref np, ref nq);

            node = null;
            if (level.Nodes.TryGetValue(tokens[i], out var found))
            {
                node = found;
                level = node.Next;
            }
            else
            {
                level = null;
            }
        }

        if (node != null) AddNodeCounts(node, ref np, ref nq);
        if (pwc != null) AddNodeCounts(pwc, ref np, ref nq);
    }

    private static void AddNodeCounts(TrieNode node, ref int np, ref int nq)
    {
        np += node.PlainSubs.Count;
        foreach (var (_, qset) in node.QueueSubs)
            nq += qset.Count;
    }
  1. Add RemoveBatch():
    public void RemoveBatch(IEnumerable<Subscription> subs)
    {
        _lock.EnterWriteLock();
        try
        {
            var wasEnabled = _cache != null;
            _cache = null; // Disable cache for bulk operation

            foreach (var sub in subs)
                RemoveInternal(sub);

            Interlocked.Increment(ref _generation);

            if (wasEnabled)
                _cache = new Dictionary<string, CachedResult>(StringComparer.Ordinal);
        }
        finally
        {
            _lock.ExitWriteLock();
        }
    }

This requires extracting the core remove logic from Remove() into a private RemoveInternal() that doesn't acquire the lock or bump generation.

  1. Add All():
    public IReadOnlyList<Subscription> All()
    {
        var subs = new List<Subscription>();
        _lock.EnterReadLock();
        try
        {
            CollectAllSubs(_root, subs);
        }
        finally
        {
            _lock.ExitReadLock();
        }
        return subs;
    }

    private static void CollectAllSubs(TrieLevel level, List<Subscription> subs)
    {
        foreach (var (_, node) in level.Nodes)
        {
            foreach (var sub in node.PlainSubs)
                subs.Add(sub);
            foreach (var (_, qset) in node.QueueSubs)
                foreach (var sub in qset)
                    subs.Add(sub);
            if (node.Next != null)
                CollectAllSubs(node.Next, subs);
        }
        if (level.Pwc != null)
        {
            foreach (var sub in level.Pwc.PlainSubs)
                subs.Add(sub);
            foreach (var (_, qset) in level.Pwc.QueueSubs)
                foreach (var sub in qset)
                    subs.Add(sub);
            if (level.Pwc.Next != null)
                CollectAllSubs(level.Pwc.Next, subs);
        }
        if (level.Fwc != null)
        {
            foreach (var sub in level.Fwc.PlainSubs)
                subs.Add(sub);
            foreach (var (_, qset) in level.Fwc.QueueSubs)
                foreach (var sub in qset)
                    subs.Add(sub);
            if (level.Fwc.Next != null)
                CollectAllSubs(level.Fwc.Next, subs);
        }
    }
  1. Add ReverseMatch():
    public SubListResult ReverseMatch(string subject)
    {
        var tokens = Tokenize(subject);
        if (tokens == null)
            return SubListResult.Empty;

        _lock.EnterReadLock();
        try
        {
            var plainSubs = new List<Subscription>();
            var queueSubs = new List<List<Subscription>>();
            ReverseMatchLevel(_root, tokens, 0, plainSubs, queueSubs);

            if (plainSubs.Count == 0 && queueSubs.Count == 0)
                return SubListResult.Empty;

            var queueSubsArr = new Subscription[queueSubs.Count][];
            for (int i = 0; i < queueSubs.Count; i++)
                queueSubsArr[i] = queueSubs[i].ToArray();
            return new SubListResult(plainSubs.ToArray(), queueSubsArr);
        }
        finally
        {
            _lock.ExitReadLock();
        }
    }

    private static void ReverseMatchLevel(TrieLevel? level, string[] tokens, int tokenIndex,
        List<Subscription> plainSubs, List<List<Subscription>> queueSubs)
    {
        if (level == null || tokenIndex >= tokens.Length)
            return;

        var token = tokens[tokenIndex];
        bool isLast = tokenIndex == tokens.Length - 1;

        // Full wildcard in input: collect ALL nodes at this level and below
        if (token == ">")
        {
            CollectAllNodes(level, plainSubs, queueSubs);
            return;
        }

        // Partial wildcard in input: fan out to all literal children
        if (token == "*")
        {
            foreach (var (_, node) in level.Nodes)
            {
                if (isLast)
                    AddNodeToResults(node, plainSubs, queueSubs);
                else
                    ReverseMatchLevel(node.Next, tokens, tokenIndex + 1, plainSubs, queueSubs);
            }
        }
        else
        {
            // Literal token: match literal node
            if (level.Nodes.TryGetValue(token, out var node))
            {
                if (isLast)
                    AddNodeToResults(node, plainSubs, queueSubs);
                else
                    ReverseMatchLevel(node.Next, tokens, tokenIndex + 1, plainSubs, queueSubs);
            }
        }

        // Also check stored * and > at this level (they match any input token)
        if (level.Pwc != null)
        {
            if (isLast)
                AddNodeToResults(level.Pwc, plainSubs, queueSubs);
            else
                ReverseMatchLevel(level.Pwc.Next, tokens, tokenIndex + 1, plainSubs, queueSubs);
        }
        if (level.Fwc != null)
        {
            AddNodeToResults(level.Fwc, plainSubs, queueSubs);
        }
    }

    private static void CollectAllNodes(TrieLevel level, List<Subscription> plainSubs,
        List<List<Subscription>> queueSubs)
    {
        foreach (var (_, node) in level.Nodes)
        {
            AddNodeToResults(node, plainSubs, queueSubs);
            if (node.Next != null)
                CollectAllNodes(node.Next, plainSubs, queueSubs);
        }
        if (level.Pwc != null)
        {
            AddNodeToResults(level.Pwc, plainSubs, queueSubs);
            if (level.Pwc.Next != null)
                CollectAllNodes(level.Pwc.Next, plainSubs, queueSubs);
        }
        if (level.Fwc != null)
        {
            AddNodeToResults(level.Fwc, plainSubs, queueSubs);
            if (level.Fwc.Next != null)
                CollectAllNodes(level.Fwc.Next, plainSubs, queueSubs);
        }
    }
  1. Remove AddToCache/RemoveFromCache/AddSubToResult — these are no longer needed with generation-based invalidation.

Step 5: Run tests to verify they pass

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

Step 6: Commit

git add src/NATS.Server/Subscriptions/SubList.cs src/NATS.Server/Subscriptions/SubListStats.cs tests/NATS.Server.Tests/SubListTests.cs
git commit -m "feat: add generation-based cache, Stats, HasInterest, NumInterest, RemoveBatch, All, ReverseMatch to SubList"

Task 5: NatsHeaderParser — MIME Header Parsing

Files:

  • Create: src/NATS.Server/Protocol/NatsHeaderParser.cs
  • Create: tests/NATS.Server.Tests/NatsHeaderParserTests.cs

Step 1: Write failing tests

using NATS.Server.Protocol;

namespace NATS.Server.Tests;

public class NatsHeaderParserTests
{
    [Fact]
    public void Parse_status_line_only()
    {
        var input = "NATS/1.0 503\r\n\r\n"u8;
        var result = NatsHeaderParser.Parse(input);
        result.Status.ShouldBe(503);
        result.Description.ShouldBeEmpty();
        result.Headers.ShouldBeEmpty();
    }

    [Fact]
    public void Parse_status_with_description()
    {
        var input = "NATS/1.0 503 No Responders\r\n\r\n"u8;
        var result = NatsHeaderParser.Parse(input);
        result.Status.ShouldBe(503);
        result.Description.ShouldBe("No Responders");
    }

    [Fact]
    public void Parse_headers_with_values()
    {
        var input = "NATS/1.0\r\nFoo: bar\r\nBaz: qux\r\n\r\n"u8;
        var result = NatsHeaderParser.Parse(input);
        result.Status.ShouldBe(0);
        result.Headers["Foo"].ShouldBe(["bar"]);
        result.Headers["Baz"].ShouldBe(["qux"]);
    }

    [Fact]
    public void Parse_multi_value_header()
    {
        var input = "NATS/1.0\r\nX-Tag: a\r\nX-Tag: b\r\n\r\n"u8;
        var result = NatsHeaderParser.Parse(input);
        result.Headers["X-Tag"].ShouldBe(["a", "b"]);
    }

    [Fact]
    public void Parse_invalid_returns_defaults()
    {
        var input = "GARBAGE\r\n\r\n"u8;
        var result = NatsHeaderParser.Parse(input);
        result.Status.ShouldBe(-1);
    }
}

Step 2: Run test to verify it fails

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

Step 3: Implement NatsHeaderParser

Create src/NATS.Server/Protocol/NatsHeaderParser.cs:

using System.Text;

namespace NATS.Server.Protocol;

public readonly struct NatsHeaders
{
    public int Status { get; init; }
    public string Description { get; init; }
    public Dictionary<string, string[]> Headers { get; init; }

    public static readonly NatsHeaders Invalid = new() { Status = -1, Description = string.Empty, Headers = new() };
}

public static class NatsHeaderParser
{
    private static readonly byte[] CrLf = "\r\n"u8.ToArray();
    private static readonly byte[] Prefix = "NATS/1.0"u8.ToArray();

    public static NatsHeaders Parse(ReadOnlySpan<byte> data)
    {
        if (data.Length < Prefix.Length)
            return NatsHeaders.Invalid;

        // Check NATS/1.0 prefix
        if (!data[..Prefix.Length].SequenceEqual(Prefix))
            return NatsHeaders.Invalid;

        int pos = Prefix.Length;
        int status = 0;
        string description = string.Empty;

        // Parse status line: NATS/1.0[ status[ description]]\r\n
        int lineEnd = data[pos..].IndexOf(CrLf);
        if (lineEnd < 0)
            return NatsHeaders.Invalid;

        var statusLine = data[pos..(pos + lineEnd)];
        pos += lineEnd + 2; // skip \r\n

        if (statusLine.Length > 0)
        {
            // Skip leading space
            int si = 0;
            while (si < statusLine.Length && statusLine[si] == (byte)' ')
                si++;

            // Parse status code
            int numStart = si;
            while (si < statusLine.Length && statusLine[si] >= (byte)'0' && statusLine[si] <= (byte)'9')
                si++;

            if (si > numStart)
            {
                status = int.Parse(Encoding.ASCII.GetString(statusLine[numStart..si]));

                // Parse description (rest after space)
                while (si < statusLine.Length && statusLine[si] == (byte)' ')
                    si++;
                if (si < statusLine.Length)
                    description = Encoding.ASCII.GetString(statusLine[si..]);
            }
        }

        // Parse key-value headers until empty line (\r\n)
        var headers = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
        while (pos < data.Length)
        {
            var remaining = data[pos..];
            // Check for empty line (end of headers)
            if (remaining.Length >= 2 && remaining[0] == (byte)'\r' && remaining[1] == (byte)'\n')
                break;

            lineEnd = remaining.IndexOf(CrLf);
            if (lineEnd < 0)
                break;

            var headerLine = remaining[..lineEnd];
            pos += lineEnd + 2;

            // Split on first ':'
            int colon = headerLine.IndexOf((byte)':');
            if (colon < 0)
                continue;

            var key = Encoding.ASCII.GetString(headerLine[..colon]).Trim();
            var value = Encoding.ASCII.GetString(headerLine[(colon + 1)..]).Trim();

            if (!headers.TryGetValue(key, out var values))
            {
                values = [];
                headers[key] = values;
            }
            values.Add(value);
        }

        var result = new Dictionary<string, string[]>(headers.Count, StringComparer.OrdinalIgnoreCase);
        foreach (var (k, v) in headers)
            result[k] = v.ToArray();

        return new NatsHeaders
        {
            Status = status,
            Description = description,
            Headers = result,
        };
    }
}

Step 4: Run tests to verify they pass

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

Step 5: Commit

git add src/NATS.Server/Protocol/NatsHeaderParser.cs tests/NATS.Server.Tests/NatsHeaderParserTests.cs
git commit -m "feat: add NatsHeaderParser for MIME header parsing"

Task 6: PermissionLruCache — 128-Entry LRU Permission Cache

Files:

  • Create: src/NATS.Server/Auth/PermissionLruCache.cs
  • Create: tests/NATS.Server.Tests/PermissionLruCacheTests.cs
  • Modify: src/NATS.Server/Auth/ClientPermissions.cs

Step 1: Write failing tests

using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class PermissionLruCacheTests
{
    [Fact]
    public void Get_returns_none_for_unknown_key()
    {
        var cache = new PermissionLruCache(128);
        cache.TryGet("foo", out _).ShouldBeFalse();
    }

    [Fact]
    public void Set_and_get_returns_value()
    {
        var cache = new PermissionLruCache(128);
        cache.Set("foo", true);
        cache.TryGet("foo", out var v).ShouldBeTrue();
        v.ShouldBeTrue();
    }

    [Fact]
    public void Evicts_oldest_when_full()
    {
        var cache = new PermissionLruCache(3);
        cache.Set("a", true);
        cache.Set("b", true);
        cache.Set("c", true);
        cache.Set("d", true); // evicts "a"

        cache.TryGet("a", out _).ShouldBeFalse();
        cache.TryGet("b", out _).ShouldBeTrue();
        cache.TryGet("d", out _).ShouldBeTrue();
    }

    [Fact]
    public void Get_promotes_to_front()
    {
        var cache = new PermissionLruCache(3);
        cache.Set("a", true);
        cache.Set("b", true);
        cache.Set("c", true);

        // Access "a" to promote it
        cache.TryGet("a", out _);

        cache.Set("d", true); // should evict "b" (oldest untouched)
        cache.TryGet("a", out _).ShouldBeTrue();
        cache.TryGet("b", out _).ShouldBeFalse();
    }
}

Step 2: Run to verify fail

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

Step 3: Implement

Create src/NATS.Server/Auth/PermissionLruCache.cs:

namespace NATS.Server.Auth;

/// <summary>
/// Fixed-capacity LRU cache for permission results.
/// Lock-protected (per-client, low contention).
/// Reference: Go client.go maxPermCacheSize=128.
/// </summary>
public sealed class PermissionLruCache
{
    private readonly int _capacity;
    private readonly Dictionary<string, LinkedListNode<(string Key, bool Value)>> _map;
    private readonly LinkedList<(string Key, bool Value)> _list = new();
    private readonly object _lock = new();

    public PermissionLruCache(int capacity = 128)
    {
        _capacity = capacity;
        _map = new Dictionary<string, LinkedListNode<(string Key, bool Value)>>(capacity, StringComparer.Ordinal);
    }

    public bool TryGet(string key, out bool value)
    {
        lock (_lock)
        {
            if (_map.TryGetValue(key, out var node))
            {
                value = node.Value.Value;
                _list.Remove(node);
                _list.AddFirst(node);
                return true;
            }
            value = default;
            return false;
        }
    }

    public void Set(string key, bool value)
    {
        lock (_lock)
        {
            if (_map.TryGetValue(key, out var existing))
            {
                _list.Remove(existing);
                existing.Value = (key, value);
                _list.AddFirst(existing);
                return;
            }

            if (_map.Count >= _capacity)
            {
                var last = _list.Last!;
                _map.Remove(last.Value.Key);
                _list.RemoveLast();
            }

            var node = new LinkedListNode<(string Key, bool Value)>((key, value));
            _list.AddFirst(node);
            _map[key] = node;
        }
    }
}

Step 4: Wire into ClientPermissions — replace ConcurrentDictionary

In ClientPermissions.cs:

  • Replace private readonly ConcurrentDictionary<string, bool> _pubCache = new(StringComparer.Ordinal); with private readonly PermissionLruCache _pubCache = new(128);
  • Change IsPublishAllowed:
    public bool IsPublishAllowed(string subject)
    {
        if (_publish == null)
            return true;

        if (_pubCache.TryGet(subject, out var cached))
            return cached;

        var allowed = _publish.IsAllowed(subject);
        _pubCache.Set(subject, allowed);
        return allowed;
    }

Step 5: Run tests

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

Step 6: Commit

git add src/NATS.Server/Auth/PermissionLruCache.cs src/NATS.Server/Auth/ClientPermissions.cs tests/NATS.Server.Tests/PermissionLruCacheTests.cs
git commit -m "feat: add PermissionLruCache (128-entry LRU) and wire into ClientPermissions"

Task 7: Account Limits and AccountConfig

Files:

  • Modify: src/NATS.Server/Auth/Account.cs
  • Create: src/NATS.Server/Auth/AccountConfig.cs
  • Modify: src/NATS.Server/NatsOptions.cs
  • Modify: src/NATS.Server/NatsServer.cs
  • Modify: src/NATS.Server/Auth/AuthService.cs
  • Create: tests/NATS.Server.Tests/AccountTests.cs

Step 1: Write failing tests

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

using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class AccountTests
{
    [Fact]
    public void Account_enforces_max_connections()
    {
        var acc = new Account("test") { MaxConnections = 2 };
        acc.AddClient(1).ShouldBeTrue();
        acc.AddClient(2).ShouldBeTrue();
        acc.AddClient(3).ShouldBeFalse(); // exceeds limit
        acc.ClientCount.ShouldBe(2);
    }

    [Fact]
    public void Account_unlimited_connections_when_zero()
    {
        var acc = new Account("test") { MaxConnections = 0 };
        acc.AddClient(1).ShouldBeTrue();
        acc.AddClient(2).ShouldBeTrue();
    }

    [Fact]
    public void Account_enforces_max_subscriptions()
    {
        var acc = new Account("test") { MaxSubscriptions = 2 };
        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.IncrementSubscriptions().ShouldBeFalse();
    }

    [Fact]
    public void Account_decrement_subscriptions()
    {
        var acc = new Account("test") { MaxSubscriptions = 1 };
        acc.IncrementSubscriptions().ShouldBeTrue();
        acc.DecrementSubscriptions();
        acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed
    }
}

Step 2: Implement Account changes

Modify Account.cs:

using System.Collections.Concurrent;
using NATS.Server.Subscriptions;

namespace NATS.Server.Auth;

public sealed class Account : IDisposable
{
    public const string GlobalAccountName = "$G";

    public string Name { get; }
    public SubList SubList { get; } = new();
    public Permissions? DefaultPermissions { get; set; }
    public int MaxConnections { get; set; } // 0 = unlimited
    public int MaxSubscriptions { get; set; } // 0 = unlimited

    private readonly ConcurrentDictionary<ulong, byte> _clients = new();
    private int _subscriptionCount;

    public Account(string name)
    {
        Name = name;
    }

    public int ClientCount => _clients.Count;
    public int SubscriptionCount => Volatile.Read(ref _subscriptionCount);

    /// <summary>Returns false if max connections exceeded.</summary>
    public bool AddClient(ulong clientId)
    {
        if (MaxConnections > 0 && _clients.Count >= MaxConnections)
            return false;
        _clients[clientId] = 0;
        return true;
    }

    public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);

    public bool IncrementSubscriptions()
    {
        if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
            return false;
        Interlocked.Increment(ref _subscriptionCount);
        return true;
    }

    public void DecrementSubscriptions()
    {
        Interlocked.Decrement(ref _subscriptionCount);
    }

    public void Dispose() => SubList.Dispose();
}

Step 3: Create AccountConfig

Create src/NATS.Server/Auth/AccountConfig.cs:

namespace NATS.Server.Auth;

public sealed class AccountConfig
{
    public int MaxConnections { get; init; }      // 0 = unlimited
    public int MaxSubscriptions { get; init; }    // 0 = unlimited
    public Permissions? DefaultPermissions { get; init; }
}

Step 4: Add Accounts to NatsOptions

Add to NatsOptions.cs after NoAuthUser:

    // Account configuration
    public Dictionary<string, AccountConfig>? Accounts { get; set; }

Step 5: Wire account limits in NatsServer.GetOrCreateAccount

Modify GetOrCreateAccount in NatsServer.cs:

    public Account GetOrCreateAccount(string name)
    {
        return _accounts.GetOrAdd(name, n =>
        {
            var acc = new Account(n);
            if (_options.Accounts != null && _options.Accounts.TryGetValue(n, out var config))
            {
                acc.MaxConnections = config.MaxConnections;
                acc.MaxSubscriptions = config.MaxSubscriptions;
                acc.DefaultPermissions = config.DefaultPermissions;
            }
            return acc;
        });
    }

Step 6: Update Account.AddClient callers to check return value

In NatsClient.ProcessConnectAsync(), after Account.AddClient(Id):

                if (!Account.AddClient(Id))
                {
                    Account = null;
                    await SendErrAndCloseAsync("maximum connections for account exceeded",
                        ClientClosedReason.AuthenticationViolation);
                    return;
                }

Do the same for the no-auth path below.

Step 7: Run tests

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

Step 8: Commit

git add src/NATS.Server/Auth/Account.cs src/NATS.Server/Auth/AccountConfig.cs src/NATS.Server/NatsOptions.cs src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/AccountTests.cs
git commit -m "feat: add per-account connection/subscription limits with AccountConfig"

Task 8: MaxSubs Enforcement, Subscribe Deny Queue, Delivery-Time Deny

Files:

  • Modify: src/NATS.Server/NatsClient.cs (ProcessSub for MaxSubs + account sub limits)
  • Modify: src/NATS.Server/NatsServer.cs (DeliverMessage for deny filtering + auto-unsub cleanup)
  • Modify: src/NATS.Server/Auth/ClientPermissions.cs (IsDeliveryAllowed, queue deny)
  • Modify: src/NATS.Server/Protocol/NatsProtocol.cs (add error string)

Step 1: Add error constant

In NatsProtocol.cs, add:

    public const string ErrMaxSubscriptionsExceeded = "Maximum Subscriptions Exceeded";

Step 2: Enforce MaxSubs in ProcessSub

In NatsClient.ProcessSub(), after the permission check and before creating the subscription:

        // Per-connection subscription limit
        if (_options.MaxSubs > 0 && _subs.Count >= _options.MaxSubs)
        {
            _logger.LogDebug("Client {ClientId} max subscriptions exceeded", Id);
            _ = SendErrAndCloseAsync(NatsProtocol.ErrMaxSubscriptionsExceeded,
                ClientClosedReason.MaxSubscriptionsExceeded);
            return;
        }

        // Per-account subscription limit
        if (Account != null && !Account.IncrementSubscriptions())
        {
            _logger.LogDebug("Client {ClientId} account subscription limit exceeded", Id);
            _ = SendErrAndCloseAsync(NatsProtocol.ErrMaxSubscriptionsExceeded,
                ClientClosedReason.MaxSubscriptionsExceeded);
            return;
        }

Step 3: Add IsDeliveryAllowed to ClientPermissions

In ClientPermissions.cs, add:

    /// <summary>
    /// Checks whether a message on this subject should be delivered to this client.
    /// Evaluates the subscribe deny list (msg delivery filter).
    /// </summary>
    public bool IsDeliveryAllowed(string subject)
    {
        if (_subscribe == null)
            return true;
        return _subscribe.IsDeliveryAllowed(subject);
    }

Add to PermissionSet:

    /// <summary>Checks deny list only — for delivery-time filtering.</summary>
    public bool IsDeliveryAllowed(string subject)
    {
        if (_deny == null)
            return true;
        var result = _deny.Match(subject);
        return result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
    }

Step 4: Fix IsSubscribeAllowed to check queue against deny list

    public bool IsSubscribeAllowed(string subject, string? queue = null)
    {
        if (_subscribe == null)
            return true;

        if (!_subscribe.IsAllowed(subject))
            return false;

        // Check queue group against subscribe deny list
        if (queue != null && _subscribe.IsDenied(queue))
            return false;

        return true;
    }

Add IsDenied to PermissionSet:

    public bool IsDenied(string subject)
    {
        if (_deny == null) return false;
        var result = _deny.Match(subject);
        return result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
    }

Step 5: Update DeliverMessage for deny filtering + auto-unsub cleanup

In NatsServer.DeliverMessage(), replace the method:

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

        // Check auto-unsub
        var count = Interlocked.Increment(ref sub.MessageCount);
        if (sub.MaxMessages > 0 && count > sub.MaxMessages)
        {
            // Clean up exhausted subscription from trie and client tracking
            var subList = client.Account?.SubList ?? _globalAccount.SubList;
            subList.Remove(sub);
            client.RemoveSubscription(sub.Sid);
            return;
        }

        // Deny-list delivery filter
        if (client.Permissions?.IsDeliveryAllowed(subject) == false)
            return;

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

Step 6: Add RemoveSubscription to NatsClient

Add public method to NatsClient:

    public void RemoveSubscription(string sid)
    {
        if (_subs.Remove(sid))
            Account?.DecrementSubscriptions();
    }

Step 7: Also decrement account subs in ProcessUnsub

In ProcessUnsub, after _subs.Remove(cmd.Sid!);:

        Account?.DecrementSubscriptions();

Step 8: Also add Permissions property to NatsClient (public accessor)

    public ClientPermissions? Permissions => _permissions;

Step 9: Run all tests

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

Step 10: Commit

git add src/NATS.Server/NatsClient.cs src/NATS.Server/NatsServer.cs src/NATS.Server/Auth/ClientPermissions.cs src/NATS.Server/Protocol/NatsProtocol.cs
git commit -m "feat: add MaxSubs enforcement, delivery-time deny filtering, auto-unsub cleanup"

Task 9: Response Permissions (Reply Tracking)

Files:

  • Create: src/NATS.Server/Auth/ResponseTracker.cs
  • Modify: src/NATS.Server/Auth/ClientPermissions.cs
  • Modify: src/NATS.Server/NatsClient.cs
  • Create: tests/NATS.Server.Tests/ResponseTrackerTests.cs

Step 1: Write failing tests

using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class ResponseTrackerTests
{
    [Fact]
    public void Allows_reply_subject_after_registration()
    {
        var tracker = new ResponseTracker(maxMsgs: 1, expires: TimeSpan.FromMinutes(5));
        tracker.RegisterReply("_INBOX.abc123");
        tracker.IsReplyAllowed("_INBOX.abc123").ShouldBeTrue();
    }

    [Fact]
    public void Denies_unknown_reply_subject()
    {
        var tracker = new ResponseTracker(maxMsgs: 1, expires: TimeSpan.FromMinutes(5));
        tracker.IsReplyAllowed("_INBOX.unknown").ShouldBeFalse();
    }

    [Fact]
    public void Enforces_max_messages()
    {
        var tracker = new ResponseTracker(maxMsgs: 2, expires: TimeSpan.FromMinutes(5));
        tracker.RegisterReply("_INBOX.abc");
        tracker.IsReplyAllowed("_INBOX.abc").ShouldBeTrue();
        tracker.IsReplyAllowed("_INBOX.abc").ShouldBeTrue();
        tracker.IsReplyAllowed("_INBOX.abc").ShouldBeFalse(); // exceeded
    }

    [Fact]
    public void Enforces_expiry()
    {
        var tracker = new ResponseTracker(maxMsgs: 0, expires: TimeSpan.FromMilliseconds(1));
        tracker.RegisterReply("_INBOX.abc");
        Thread.Sleep(50);
        tracker.IsReplyAllowed("_INBOX.abc").ShouldBeFalse();
    }

    [Fact]
    public void Prune_removes_expired()
    {
        var tracker = new ResponseTracker(maxMsgs: 0, expires: TimeSpan.FromMilliseconds(1));
        tracker.RegisterReply("_INBOX.a");
        tracker.RegisterReply("_INBOX.b");
        Thread.Sleep(50);
        tracker.Prune();
        tracker.Count.ShouldBe(0);
    }
}

Step 2: Implement ResponseTracker

Create src/NATS.Server/Auth/ResponseTracker.cs:

namespace NATS.Server.Auth;

/// <summary>
/// Tracks reply subjects that a client is temporarily allowed to publish to.
/// Reference: Go client.go resp struct, setResponsePermissionIfNeeded.
/// </summary>
public sealed class ResponseTracker
{
    private readonly int _maxMsgs;      // 0 = unlimited
    private readonly TimeSpan _expires; // TimeSpan.Zero = no TTL
    private readonly Dictionary<string, (DateTime RegisteredAt, int Count)> _replies = new(StringComparer.Ordinal);
    private readonly object _lock = new();

    public ResponseTracker(int maxMsgs, TimeSpan expires)
    {
        _maxMsgs = maxMsgs;
        _expires = expires;
    }

    public int Count
    {
        get { lock (_lock) return _replies.Count; }
    }

    public void RegisterReply(string replySubject)
    {
        lock (_lock)
        {
            _replies[replySubject] = (DateTime.UtcNow, 0);
        }
    }

    /// <summary>
    /// Returns true if the reply subject is allowed and increments the count.
    /// Returns false if unknown, expired, or count exhausted.
    /// </summary>
    public bool IsReplyAllowed(string subject)
    {
        lock (_lock)
        {
            if (!_replies.TryGetValue(subject, out var entry))
                return false;

            // Check expiry
            if (_expires > TimeSpan.Zero && DateTime.UtcNow - entry.RegisteredAt > _expires)
            {
                _replies.Remove(subject);
                return false;
            }

            // Check max messages
            var newCount = entry.Count + 1;
            if (_maxMsgs > 0 && newCount > _maxMsgs)
            {
                _replies.Remove(subject);
                return false;
            }

            _replies[subject] = (entry.RegisteredAt, newCount);
            return true;
        }
    }

    public void Prune()
    {
        lock (_lock)
        {
            if (_expires <= TimeSpan.Zero && _maxMsgs <= 0)
                return;

            var now = DateTime.UtcNow;
            var toRemove = new List<string>();
            foreach (var (key, entry) in _replies)
            {
                if (_expires > TimeSpan.Zero && now - entry.RegisteredAt > _expires)
                    toRemove.Add(key);
                else if (_maxMsgs > 0 && entry.Count >= _maxMsgs)
                    toRemove.Add(key);
            }
            foreach (var key in toRemove)
                _replies.Remove(key);
        }
    }
}

Step 3: Wire into ClientPermissions.Build

In ClientPermissions.cs, add field and modify constructor/build:

    private readonly ResponseTracker? _responseTracker;

    private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe, ResponseTracker? responseTracker)
    {
        _publish = publish;
        _subscribe = subscribe;
        _responseTracker = responseTracker;
    }

    public static ClientPermissions? Build(Permissions? permissions)
    {
        if (permissions == null)
            return null;

        var pub = PermissionSet.Build(permissions.Publish);
        var sub = PermissionSet.Build(permissions.Subscribe);
        ResponseTracker? responseTracker = null;
        if (permissions.Response != null)
            responseTracker = new ResponseTracker(permissions.Response.MaxMsgs, permissions.Response.Expires);

        if (pub == null && sub == null && responseTracker == null)
            return null;

        return new ClientPermissions(pub, sub, responseTracker);
    }

    public ResponseTracker? ResponseTracker => _responseTracker;

Step 4: Modify IsPublishAllowed to check response tracker

    public bool IsPublishAllowed(string subject)
    {
        if (_publish == null)
            return true;

        if (_pubCache.TryGet(subject, out var cached))
            return cached;

        var allowed = _publish.IsAllowed(subject);

        // If denied but response tracking is enabled, check reply table
        if (!allowed && _responseTracker != null)
        {
            if (_responseTracker.IsReplyAllowed(subject))
                return true; // Don't cache dynamic reply permissions
        }

        _pubCache.Set(subject, allowed);
        return allowed;
    }

Step 5: Register reply subjects in NatsServer.DeliverMessage

After client.SendMessage(...) in DeliverMessage():

        // Track reply subject for response permissions
        if (replyTo != null && client.Permissions?.ResponseTracker != null)
        {
            if (client.Permissions.IsPublishAllowed(replyTo) == false)
                client.Permissions.ResponseTracker.RegisterReply(replyTo);
        }

Step 6: Run tests

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

Step 7: Commit

git add src/NATS.Server/Auth/ResponseTracker.cs src/NATS.Server/Auth/ClientPermissions.cs src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ResponseTrackerTests.cs
git commit -m "feat: add response permission tracking for dynamic reply subject authorization"

Task 10: Auth Expiry Enforcement

Files:

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

Step 1: Add AuthenticationExpired close reason

In ClientClosedReason.cs, add after NoRespondersRequiresHeaders:

    AuthenticationExpired,

Add to the extension method:

        ClientClosedReason.AuthenticationExpired => "Authentication Expired",

Step 2: Implement expiry timer in ProcessConnectAsync

In NatsClient.ProcessConnectAsync(), after _flags.SetFlag(ClientFlags.ConnectProcessFinished);, add:

        // Start auth expiry timer if needed
        if (_authService.IsAuthRequired && result?.Expiry is { } expiry)
        {
            var remaining = expiry - DateTimeOffset.UtcNow;
            if (remaining > TimeSpan.Zero)
            {
                _ = Task.Run(async () =>
                {
                    try
                    {
                        await Task.Delay(remaining, _clientCts!.Token);
                        _logger.LogDebug("Client {ClientId} authentication expired", Id);
                        await SendErrAndCloseAsync("Authentication Expired",
                            ClientClosedReason.AuthenticationExpired);
                    }
                    catch (OperationCanceledException) { }
                }, _clientCts!.Token);
            }
            else
            {
                await SendErrAndCloseAsync("Authentication Expired",
                    ClientClosedReason.AuthenticationExpired);
                return;
            }
        }

Note: we need to capture result from the auth block so it's accessible. Move the AuthResult? result = null declaration to before the auth-required check and store it.

Step 3: Run all tests

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

Step 4: Commit

git add src/NATS.Server/NatsClient.cs src/NATS.Server/ClientClosedReason.cs
git commit -m "feat: add auth expiry enforcement timer"

Task 11: INFO Serialization Caching

Files:

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

Step 1: Add cached INFO bytes to NatsServer

In NatsServer, add field:

    private byte[] _cachedInfoLine = [];

In StartAsync(), after _serverInfo.Port = actualPort; (line 294), add:

        BuildCachedInfo();

Also call BuildCachedInfo() at the end of the constructor (after _serverInfo is fully populated, line 263):

        BuildCachedInfo();

Add the method:

    private void BuildCachedInfo()
    {
        var infoJson = System.Text.Json.JsonSerializer.Serialize(_serverInfo);
        _cachedInfoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
    }

    public byte[] CachedInfoLine => _cachedInfoLine;

Step 2: Modify NatsClient.SendInfo to use cached bytes

In NatsClient.cs, replace SendInfo() method:

    private void SendInfo()
    {
        if (Router is NatsServer server)
        {
            QueueOutbound(server.CachedInfoLine);
        }
        else
        {
            // Fallback for tests or non-server contexts
            var infoJson = System.Text.Json.JsonSerializer.Serialize(_serverInfo);
            var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
            QueueOutbound(infoLine);
        }
    }

For NKey connections, the per-connection INFO (with nonce) is already built in AcceptClientAsync and passed via _serverInfo constructor param — that path serializes fresh per connection which is correct.

Step 3: Run all tests

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

Step 4: Commit

git add src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs
git commit -m "feat: cache INFO serialization — build once at startup instead of per-connection"

Task 12: Protocol Tracing

Files:

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

Step 1: Add trace logging to NatsParser

Add ILogger? parameter to NatsParser constructor:

    private readonly ILogger? _logger;

    public NatsParser(int maxPayload = NatsProtocol.MaxPayloadSize, ILogger? logger = null)
    {
        _maxPayload = maxPayload;
        _logger = logger;
    }

Add trace method:

    private void TraceInOp(string op, ReadOnlySpan<byte> arg = default)
    {
        if (_logger == null || !_logger.IsEnabled(LogLevel.Trace))
            return;
        if (arg.IsEmpty)
            _logger.LogTrace("<<- {Op}", op);
        else
            _logger.LogTrace("<<- {Op} {Arg}", op, Encoding.ASCII.GetString(arg));
    }

Add using Microsoft.Extensions.Logging; at top.

Call TraceInOp after each command dispatch in TryParse:

  • After PING: TraceInOp("PING");
  • After PONG: TraceInOp("PONG");
  • After PUB return: TraceInOp("PUB", argsSpan); (inside ParsePub before return)
  • After HPUB: TraceInOp("HPUB", argsSpan);
  • After SUB: TraceInOp("SUB", argsSpan);
  • After UNSUB: TraceInOp("UNSUB", argsSpan);
  • After CONNECT: TraceInOp("CONNECT");
  • After INFO: TraceInOp("INFO");
  • After +OK: TraceInOp("+OK");
  • After -ERR: TraceInOp("-ERR");

Step 2: Pass logger to NatsParser in NatsClient constructor

In NatsClient constructor, change:

        _parser = new NatsParser(options.MaxPayload, options.Trace ? logger : null);

Step 3: Run all tests

Run: dotnet test tests/NATS.Server.Tests -v normal Expected: All PASS (parser tests create NatsParser without logger, which is fine — logger is optional)

Step 4: Commit

git add src/NATS.Server/Protocol/NatsParser.cs src/NATS.Server/NatsClient.cs
git commit -m "feat: add protocol tracing (<<- op arg) at LogLevel.Trace"

Task 13: MSG/HMSG Construction Optimization

Files:

  • Modify: src/NATS.Server/NatsClient.cs (SendMessage method)

Step 1: Optimize SendMessage to use Span-based construction

Replace the SendMessage method body with buffer-based construction:

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

        // Estimate control line size: "MSG subject sid [reply] size\r\n" or HMSG variant
        // Max: "HMSG " + subject + " " + sid + " " + reply + " " + hdrSize + " " + totalSize + "\r\n"
        var estimatedLineSize = 5 + subject.Length + 1 + sid.Length + 1
            + (replyTo != null ? replyTo.Length + 1 : 0) + 20 + 2; // 20 for numbers + \r\n

        var totalPayloadLen = headers.Length + payload.Length;
        var totalLen = estimatedLineSize + totalPayloadLen + 2; // trailing \r\n
        var buffer = new byte[totalLen];
        var span = buffer.AsSpan();
        int pos = 0;

        // Write prefix
        if (headers.Length > 0)
        {
            "HMSG "u8.CopyTo(span);
            pos = 5;
        }
        else
        {
            "MSG "u8.CopyTo(span);
            pos = 4;
        }

        // Subject
        pos += Encoding.ASCII.GetBytes(subject, span[pos..]);
        span[pos++] = (byte)' ';

        // SID
        pos += Encoding.ASCII.GetBytes(sid, span[pos..]);
        span[pos++] = (byte)' ';

        // Reply-to
        if (replyTo != null)
        {
            pos += Encoding.ASCII.GetBytes(replyTo, span[pos..]);
            span[pos++] = (byte)' ';
        }

        // Sizes
        if (headers.Length > 0)
        {
            int totalSize = headers.Length + payload.Length;
            headers.Length.TryFormat(span[pos..], out int written);
            pos += written;
            span[pos++] = (byte)' ';
            totalSize.TryFormat(span[pos..], out written);
            pos += written;
        }
        else
        {
            payload.Length.TryFormat(span[pos..], out int written);
            pos += written;
        }

        // CRLF
        span[pos++] = (byte)'\r';
        span[pos++] = (byte)'\n';

        // Headers + payload + trailing CRLF
        if (headers.Length > 0)
        {
            headers.Span.CopyTo(span[pos..]);
            pos += headers.Length;
        }
        if (payload.Length > 0)
        {
            payload.Span.CopyTo(span[pos..]);
            pos += payload.Length;
        }
        span[pos++] = (byte)'\r';
        span[pos++] = (byte)'\n';

        QueueOutbound(buffer.AsMemory(0, pos));
    }

Step 2: Run all tests

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

Step 3: Commit

git add src/NATS.Server/NatsClient.cs
git commit -m "feat: optimize MSG/HMSG construction using Span-based buffer writes"

Task 14: Verify, Run Full Test Suite, Update differences.md

Files:

  • Run: full test suite
  • Modify: differences.md (sections 3-6)

Step 1: Build entire solution

Run: dotnet build Expected: Success with no errors

Step 2: Run all tests

Run: dotnet test -v normal Expected: All PASS

Step 3: Update differences.md sections 3-6

Update each table row where a feature has been implemented, changing N to Y and updating the Notes column. Key changes:

  • Section 3: INFO serialization → "Y (cached at startup)", protocol tracing → "Y", MIME header parsing → "Y", MSG/HMSG construction → "Y (Span-based)"
  • Section 4: Stats → "Y", HasInterest → "Y", NumInterest → "Y", ReverseMatch → "Y", RemoveBatch → "Y", All → "Y", atomic generation ID → "Y", SubjectsCollide → "Y", tokenAt/numTokens → "Y", UTF-8 validation → "Y", per-account subscription limits → "Y", permission caching → "Y (128-entry LRU)"
  • Section 5: Deny enforcement at delivery → "Y", permission caching → "Y", queue deny → "Y", response permissions → "Y", per-account connection limits → "Y", per-account subscription limits → "Y", auth expiry → "Y", auto-unsub cleanup → "Y", multi-account user resolution → "Y"
  • Section 6: -D/-V/-DV → "Y", MaxSubs → "Y", MaxSubTokens → "Y", Debug/Trace → "Y", LogFile → "Y", Tags → "Y"
  • Summary section: remove items that are now implemented

Step 4: Commit

git add differences.md
git commit -m "docs: update differences.md sections 3-6 to reflect implemented features"

Parallelization Strategy

The following tasks can be safely executed in parallel (they touch different files):

Batch 1 (all independent, different files):

  • Task 1: NatsOptions fields
  • Task 3: SubjectMatch utilities
  • Task 5: NatsHeaderParser
  • Task 6: PermissionLruCache

Batch 2 (depend on Batch 1 outputs):

  • Task 2: CLI flags (depends on Task 1 for NatsOptions fields)
  • Task 4: SubList overhaul (independent)
  • Task 7: Account limits (depends on Task 1 for NatsOptions.Accounts)

Batch 3 (depend on Batch 2):

  • Task 8: MaxSubs/deny/delivery (depends on Tasks 6, 7)
  • Task 9: Response permissions (depends on Task 6)

Batch 4 (depend on earlier tasks):

  • Task 10: Auth expiry (light, independent)
  • Task 11: INFO caching (independent)
  • Task 12: Protocol tracing (depends on Task 1 for Trace option)
  • Task 13: MSG optimization (independent)

Final:

  • Task 14: Verify and update docs (depends on all)