feat: add structured logging, Shouldly assertions, CPM, and project documentation

- Add Microsoft.Extensions.Logging + Serilog to NatsServer and NatsClient
- Convert all test assertions from xUnit Assert to Shouldly
- Add NSubstitute package for future mocking needs
- Introduce Central Package Management via Directory.Packages.props
- Add documentation_rules.md with style guide, generation/update rules, component map
- Generate 10 documentation files across 5 component folders (GettingStarted, Protocol, Subscriptions, Server, Configuration/Operations)
- Update CLAUDE.md with logging, testing, porting, agent model, CPM, and documentation guidance
This commit is contained in:
Joseph Doherty
2026-02-22 21:05:53 -05:00
parent b9f4dec523
commit 539b2b7588
25 changed files with 2734 additions and 110 deletions

View File

@@ -145,6 +145,56 @@ Client PUB → parser → permission check → Sublist.Match() →
└─ JetStream: if subject matches a stream, store + deliver to consumers └─ JetStream: if subject matches a stream, store + deliver to consumers
``` ```
## NuGet Package Management
This solution uses **Central Package Management (CPM)** via `Directory.Packages.props` at the repo root. All package versions are defined centrally there.
- In `.csproj` files, use `<PackageReference Include="Foo" />` **without** a `Version` attribute
- To add a new package: add a `<PackageVersion>` entry in `Directory.Packages.props`, then reference it without version in the project's csproj
- To update a version: change it only in `Directory.Packages.props` — all projects pick it up automatically
- Never specify `Version` on `<PackageReference>` in individual csproj files
## Logging
Use **Microsoft.Extensions.Logging** (`ILogger<T>`) for all logging throughout the server. Wire up **Serilog** as the logging provider in the host application.
- Inject `ILogger<T>` via constructor in all components (NatsServer, NatsClient, etc.)
- Use **Serilog.Context.LogContext** to push contextual properties (client ID, remote endpoint, subscription subject) so they appear on all log entries within that scope
- Use structured logging with message templates: `logger.LogInformation("Client {ClientId} subscribed to {Subject}", id, subject)` — never string interpolation
- Log levels: `Trace` for protocol bytes, `Debug` for per-message flow, `Information` for lifecycle events (connect/disconnect), `Warning` for protocol violations, `Error` for unexpected failures
## Testing
- **xUnit 3** for test framework
- **Shouldly** for assertions — use `value.ShouldBe(expected)`, `action.ShouldThrow<T>()`, etc. Do NOT use `Assert.*` from xUnit
- **NSubstitute** for mocking/substitution when needed
- Do **NOT** use FluentAssertions or Moq — these are explicitly excluded
- Test project uses global `using Shouldly;`
## Porting Guidelines
- Use modern .NET 10 / C# 14 best practices (primary constructors, collection expressions, `field` keyword where stable, file-scoped namespaces, raw string literals, etc.)
- Prefer `readonly record struct` for small value types over mutable structs
- Use `required` properties and `init` setters for initialization-only state
- Use pattern matching and switch expressions where they improve clarity
- Prefer `System.Text.Json` source generators for JSON serialization
- Use `ValueTask` where appropriate for hot-path async methods
## Agent Model Guidance
- **Sonnet** (`model: "sonnet"`) — use for simpler implementation tasks: straightforward file modifications, adding packages, converting assertions, boilerplate code
- **Opus** (default) — use for complex tasks, architectural decisions, design work, tricky protocol logic, and code review
- **Parallel subagents** — use where tasks are independent and don't touch the same files (e.g., converting test files in parallel, adding packages while updating docs)
## Documentation
Follow the documentation rules in [`documentation_rules.md`](documentation_rules.md) for all project documentation. Key points:
- Documentation lives in `Documentation/` with component subfolders (Protocol, Subscriptions, Server, Configuration, Operations)
- Use `PascalCase.md` file names, always specify language on code blocks, use real code snippets (not invented examples)
- Update documentation when code changes — see the trigger rules and component map in the rules file
- Technical and direct tone, explain "why" not just "what", present tense
## Conventions ## Conventions
- Reference the Go implementation file and line when porting a subsystem - Reference the Go implementation file and line when porting a subsystem

23
Directory.Packages.props Normal file
View File

@@ -0,0 +1,23 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Logging -->
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<!-- NATS Client (integration tests) -->
<PackageVersion Include="NATS.Client.Core" Version="2.7.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,185 @@
# Configuration Overview
`NatsOptions` is the single configuration object passed to `NatsServer` at construction time. The host application (`Program.cs`) also reads CLI arguments to populate it before server startup.
Go reference: `golang/nats-server/server/opts.go`
---
## NatsOptions Class
`NatsOptions` is a plain mutable class with no validation logic — values are consumed directly by `NatsServer` and `NatsClient`.
```csharp
namespace NATS.Server;
public sealed class NatsOptions
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } = 4222;
public string? ServerName { get; set; }
public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
public int MaxControlLine { get; set; } = 4096;
public int MaxConnections { get; set; } = 65536;
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
public int MaxPingsOut { get; set; } = 2;
}
```
### Option reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Host` | `string` | `"0.0.0.0"` | Bind address for the TCP listener. Use `"127.0.0.1"` to restrict to loopback. |
| `Port` | `int` | `4222` | Client listen port. Standard NATS client port. |
| `ServerName` | `string?` | `null` (auto: `nats-dotnet-{MachineName}`) | Server name sent in the `INFO` message. When `null`, the server constructs a name from `Environment.MachineName`. |
| `MaxPayload` | `int` | `1048576` (1 MB) | Maximum allowed message payload in bytes. Clients that publish larger payloads are rejected. |
| `MaxControlLine` | `int` | `4096` | Maximum protocol control line size in bytes. Matches the Go server default. |
| `MaxConnections` | `int` | `65536` | Maximum concurrent client connections the server will accept. |
| `PingInterval` | `TimeSpan` | `2 minutes` | Interval between server-initiated `PING` messages to connected clients. |
| `MaxPingsOut` | `int` | `2` | Number of outstanding `PING`s without a `PONG` response before the server disconnects a client. |
### How ServerName is resolved
`NatsServer` constructs the `ServerInfo` sent to each client at connection time. If `ServerName` is `null`, it uses `nats-dotnet-{Environment.MachineName}`:
```csharp
_serverInfo = new ServerInfo
{
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
ServerName = options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
Version = NatsProtocol.Version,
Host = options.Host,
Port = options.Port,
MaxPayload = options.MaxPayload,
};
```
---
## CLI Arguments
`Program.cs` parses command-line arguments before creating `NatsServer`. The three supported flags map directly to `NatsOptions` fields:
| Flag | Alias | Field | Example |
|------|-------|-------|---------|
| `-p` | `--port` | `Port` | `-p 14222` |
| `-a` | `--addr` | `Host` | `-a 127.0.0.1` |
| `-n` | `--name` | `ServerName` | `-n my-server` |
```csharp
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "-p" or "--port" when i + 1 < args.Length:
options.Port = int.Parse(args[++i]);
break;
case "-a" or "--addr" when i + 1 < args.Length:
options.Host = args[++i];
break;
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
break;
}
}
```
Unrecognized flags are silently ignored. There is no `--help` output.
---
## Protocol Constants
`NatsProtocol` defines wire-level constants that mirror the Go server's defaults. These values are used by the parser and the `INFO` response:
| Constant | Value | Description |
|----------|-------|-------------|
| `MaxControlLineSize` | `4096` | Maximum bytes in a protocol control line |
| `MaxPayloadSize` | `1048576` | Maximum message payload in bytes (1 MB) |
| `DefaultPort` | `4222` | Standard NATS client port |
| `Version` | `"0.1.0"` | Server version string sent in `INFO` |
| `ProtoVersion` | `1` | NATS protocol version number sent in `INFO` |
```csharp
public static class NatsProtocol
{
public const int MaxControlLineSize = 4096;
public const int MaxPayloadSize = 1024 * 1024; // 1MB
public const int DefaultPort = 4222;
public const string Version = "0.1.0";
public const int ProtoVersion = 1;
}
```
`MaxControlLine` in `NatsOptions` and `MaxControlLineSize` in `NatsProtocol` carry the same value. `NatsOptions.MaxPayload` is used as the per-server runtime limit passed into `NatsParser`; `NatsProtocol.MaxPayloadSize` is the compile-time default.
---
## Logging Configuration
### Serilog setup
Logging uses [Serilog](https://serilog.net/) with the console sink, configured in `Program.cs` before any other code runs:
```csharp
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
```
The output template produces lines like:
```
[14:32:01 INF] Listening on 0.0.0.0:4222
[14:32:01 DBG] Client 1 connected from 127.0.0.1:54321
```
### ILoggerFactory injection
`Program.cs` wraps the Serilog logger in a `SerilogLoggerFactory` and passes it to `NatsServer`:
```csharp
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
```
`NatsServer` stores the factory and uses it to create two kinds of loggers:
- **`ILogger<NatsServer>`** — obtained via `loggerFactory.CreateLogger<NatsServer>()`, used for server-level events (listening, client connect/disconnect).
- **`ILogger` with named category** — obtained via `loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]")`, created per connection. This gives each client a distinct category in log output so messages can be correlated by client ID without structured log properties.
```csharp
// In NatsServer.StartAsync — one logger per accepted connection
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
```
`NatsClient` takes `ILogger` (not `ILogger<NatsClient>`) so it can accept the pre-named logger instance rather than deriving its category from its own type.
### Log flushing
`Log.CloseAndFlush()` runs in the `finally` block of `Program.cs` after the server stops, ensuring buffered log entries are written before the process exits:
```csharp
try
{
await server.StartAsync(cts.Token);
}
catch (OperationCanceledException) { }
finally
{
Log.CloseAndFlush();
}
```
---
## Related Documentation
- [Operations Overview](../Operations/Overview.md)
- [Server Overview](../Server/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,197 @@
# Architecture
This document describes the overall architecture of the NATS .NET server — its layers, component responsibilities, message flow, and the mapping from the Go reference implementation.
## Project Overview
This project is a port of the [NATS server](https://github.com/nats-io/nats-server) (`golang/nats-server/`) to .NET 10 / C#. The Go source in `golang/nats-server/server/` is the authoritative reference.
Current scope: base publish-subscribe server with wildcard subject matching and queue groups. Authentication, clustering (routes, gateways, leaf nodes), JetStream, and HTTP monitoring are not yet implemented.
---
## Solution Layout
```
NatsDotNet.slnx
src/
NATS.Server/ # Core server library — no executable entry point
NATS.Server.Host/ # Console application — wires logging, parses CLI args, starts server
tests/
NATS.Server.Tests/ # xUnit test project — unit and integration tests
```
`NATS.Server` depends only on `Microsoft.Extensions.Logging.Abstractions`. All Serilog wiring is in `NATS.Server.Host`. This keeps the core library testable without a console host.
---
## Layers
The implementation is organized bottom-up: each layer depends only on the layers below it.
```
NatsServer (orchestrator: accept loop, client registry, message routing)
└── NatsClient (per-connection: I/O pipeline, command dispatch, subscription tracking)
└── NatsParser (protocol state machine: bytes → ParsedCommand)
└── SubList (trie: subject → matching subscriptions)
└── SubjectMatch (validation and wildcard matching primitives)
```
### SubjectMatch and SubList
`SubjectMatch` (`Subscriptions/SubjectMatch.cs`) provides the primitive operations used throughout the subscription system:
- `IsValidSubject(string)` — rejects empty tokens, whitespace, tokens after `>`
- `IsLiteral(string)` — returns `false` if the subject contains a bare `*` or `>` wildcard token
- `IsValidPublishSubject(string)` — combines both; publish subjects must be literal
- `MatchLiteral(string literal, string pattern)` — token-by-token matching for cache maintenance
`SubList` (`Subscriptions/SubList.cs`) is a trie-based subscription store. Each trie level (`TrieLevel`) holds a `Dictionary<string, TrieNode>` for literal tokens plus dedicated `Pwc` and `Fwc` pointers for `*` and `>` wildcards. Each `TrieNode` holds `PlainSubs` (a `HashSet<Subscription>`) and `QueueSubs` (a `Dictionary<string, HashSet<Subscription>>` keyed by queue group name).
`SubList.Match(string subject)` checks an in-memory cache first, then falls back to a recursive trie walk (`MatchLevel`) if there is a cache miss. The result is stored as a `SubListResult` — an immutable snapshot containing `PlainSubs` (array) and `QueueSubs` (jagged array, one sub-array per group).
The cache holds up to 1,024 entries. When that limit is exceeded, 256 entries are swept. Wildcard subscriptions invalidate all matching cache keys on insert and removal; literal subscriptions invalidate only their own key.
### NatsParser
`NatsParser` (`Protocol/NatsParser.cs`) is a stateless-per-invocation parser that operates on `ReadOnlySequence<byte>` from `System.IO.Pipelines`. The public entry point is:
```csharp
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
```
It reads one control line at a time (up to `NatsProtocol.MaxControlLineSize` = 4,096 bytes), identifies the command by the first two lowercased bytes, then dispatches to a per-command parse method. Commands with payloads (`PUB`, `HPUB`) set `_awaitingPayload = true` and return on the next call via `TryReadPayload` once enough bytes arrive. This handles TCP fragmentation without buffering the entire payload before parsing begins.
`ParsedCommand` is a `readonly struct` — zero heap allocation for control-only commands (`PING`, `PONG`, `SUB`, `UNSUB`, `CONNECT`). Payload commands allocate a `byte[]` for the payload body.
Command dispatch in `NatsClient.DispatchCommandAsync` covers: `Connect`, `Ping`/`Pong`, `Sub`, `Unsub`, `Pub`, `HPub`.
### NatsClient
`NatsClient` (`NatsClient.cs`) handles a single TCP connection. On `RunAsync`, it sends the initial `INFO` frame and then starts two concurrent tasks:
```csharp
var fillTask = FillPipeAsync(pipe.Writer, ct); // socket → PipeWriter
var processTask = ProcessCommandsAsync(pipe.Reader, ct); // PipeReader → parser → dispatch
```
`FillPipeAsync` reads from the `NetworkStream` into a `PipeWriter` in 4,096-byte chunks. `ProcessCommandsAsync` reads from the `PipeReader`, calls `NatsParser.TryParse` in a loop, and dispatches each `ParsedCommand`. The tasks share a `Pipe` instance from `System.IO.Pipelines`. Either task completing (EOF, cancellation, or error) causes `RunAsync` to return, which triggers cleanup via `Router.RemoveClient(this)`.
Write serialization uses a `SemaphoreSlim(1,1)` (`_writeLock`). All outbound writes (`SendMessageAsync`, `WriteAsync`) acquire this lock before touching the `NetworkStream`, preventing interleaved writes from concurrent message deliveries.
Subscription state is a `Dictionary<string, Subscription>` keyed by SID. This dictionary is accessed only from the single processing task, so no locking is needed. `SUB` inserts into this dictionary and into `SubList`; `UNSUB` either sets `MaxMessages` for auto-unsubscribe or immediately removes from both.
`NatsClient` exposes two interfaces to `NatsServer`:
```csharp
public interface IMessageRouter
{
void ProcessMessage(string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, NatsClient sender);
void RemoveClient(NatsClient client);
}
public interface ISubListAccess
{
SubList SubList { get; }
}
```
`NatsServer` implements both. `NatsClient.Router` is set to the server instance immediately after construction.
### NatsServer
`NatsServer` (`NatsServer.cs`) owns the TCP listener, the shared `SubList`, and the client registry. Its `StartAsync` method runs the accept loop:
```csharp
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.Bind(new IPEndPoint(
_options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
_options.Port));
_listener.Listen(128);
// ...
while (!ct.IsCancellationRequested)
{
var socket = await _listener.AcceptAsync(ct);
// create NatsClient, fire-and-forget RunClientAsync
}
}
```
Each accepted connection gets a unique `clientId` (incremented via `Interlocked.Increment`), a scoped logger, and a `NatsClient` instance registered in `_clients` (`ConcurrentDictionary<ulong, NatsClient>`). `RunClientAsync` is fired as a detached task — the accept loop does not await it.
Message delivery happens in `ProcessMessage`:
1. Call `_subList.Match(subject)` to get a `SubListResult`.
2. Iterate `result.PlainSubs` — deliver to each subscriber, skipping the sender if `echo` is false.
3. Iterate `result.QueueSubs` — for each group, use round-robin (modulo `Interlocked.Increment`) to pick one member to receive the message.
Delivery via `SendMessageAsync` is fire-and-forget (not awaited) to avoid blocking the publishing client's processing task while waiting for slow subscribers.
---
## Message Flow
```
Client sends: PUB orders.new 12\r\nhello world\r\n
1. FillPipeAsync reads bytes from socket → PipeWriter
2. ProcessCommandsAsync reads from PipeReader
3. NatsParser.TryParse parses control line "PUB orders.new 12\r\n"
sets _awaitingPayload = true
on next call: reads payload + \r\n trailer
returns ParsedCommand { Type=Pub, Subject="orders.new", Payload=[...] }
4. DispatchCommandAsync calls ProcessPub(cmd)
5. ProcessPub calls Router.ProcessMessage("orders.new", null, default, payload, this)
6. NatsServer.ProcessMessage
→ _subList.Match("orders.new")
returns SubListResult { PlainSubs=[sub1, sub2], QueueSubs=[[sub3, sub4]] }
→ DeliverMessage(sub1, ...) → sub1.Client.SendMessageAsync(...)
→ DeliverMessage(sub2, ...) → sub2.Client.SendMessageAsync(...)
→ round-robin pick from [sub3, sub4], e.g. sub3
→ DeliverMessage(sub3, ...) → sub3.Client.SendMessageAsync(...)
7. SendMessageAsync acquires _writeLock, writes MSG frame to socket
```
---
## Go Reference Mapping
| Go source | .NET source |
|-----------|-------------|
| `server/sublist.go` | `src/NATS.Server/Subscriptions/SubList.cs` |
| `server/parser.go` | `src/NATS.Server/Protocol/NatsParser.cs` |
| `server/client.go` | `src/NATS.Server/NatsClient.cs` |
| `server/server.go` | `src/NATS.Server/NatsServer.cs` |
| `server/opts.go` | `src/NATS.Server/NatsOptions.cs` |
The Go `sublist.go` uses atomic generation counters to invalidate a result cache. The .NET `SubList` uses a different strategy: it maintains the cache under `ReaderWriterLockSlim` and does targeted invalidation at insert/remove time, avoiding the need for generation counters.
The Go `client.go` uses goroutines for `readLoop` and `writeLoop`. The .NET equivalent uses `async Task` with `System.IO.Pipelines`: `FillPipeAsync` (writer side) and `ProcessCommandsAsync` (reader side).
---
## Key .NET Design Choices
| Concern | .NET choice | Reason |
|---------|-------------|--------|
| I/O buffering | `System.IO.Pipelines` (`Pipe`, `PipeReader`, `PipeWriter`) | Zero-copy buffer management; backpressure built in |
| SubList thread safety | `ReaderWriterLockSlim` | Multiple concurrent readers (match), exclusive writers (insert/remove) |
| Client registry | `ConcurrentDictionary<ulong, NatsClient>` | Lock-free concurrent access from accept loop and cleanup tasks |
| Write serialization | `SemaphoreSlim(1,1)` per client | Prevents interleaved MSG frames from concurrent deliveries |
| Concurrency | `async/await` + `Task` | Maps Go goroutines to .NET task-based async; no dedicated threads per connection |
| Protocol constants | `NatsProtocol` static class | Pre-encoded byte arrays (`PongBytes`, `CrLf`, etc.) avoid per-call allocations |
---
## Related Documentation
- [Setup](./Setup.md)
- [Protocol Overview](../Protocol/Overview.md)
- [Subscriptions Overview](../Subscriptions/Overview.md)
- [Server Overview](../Server/Overview.md)
- [Configuration Overview](../Configuration/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,183 @@
# Setup
This guide covers prerequisites, building, running, and testing the NATS .NET server.
## Prerequisites
### .NET 10 SDK
The project targets `net10.0` with `LangVersion preview` (configured in `Directory.Build.props`). Install the .NET 10 preview SDK from https://dotnet.microsoft.com/download.
Verify your installation:
```bash
dotnet --version
# Expected: 10.0.x
```
### Go Toolchain (optional)
The Go toolchain is only needed if you want to run the reference server from `golang/nats-server/` or execute Go test suites for cross-validation. It is not required for building or testing the .NET port.
```bash
go version
# Expected: go1.22 or later
```
---
## Building
The solution file uses the `.slnx` format (not `.sln`). Pass it explicitly if your tooling requires it.
```bash
# Build all projects
dotnet build
# Clean and rebuild
dotnet clean && dotnet build
```
The solution contains three projects:
| Project | Path |
|---------|------|
| `NATS.Server` | `src/NATS.Server/` |
| `NATS.Server.Host` | `src/NATS.Server.Host/` |
| `NATS.Server.Tests` | `tests/NATS.Server.Tests/` |
All projects share settings from `Directory.Build.props`: `net10.0` target framework, nullable reference types enabled, warnings treated as errors.
---
## Running
The server executable is `NATS.Server.Host`. With no arguments it binds to `0.0.0.0:4222`.
```bash
dotnet run --project src/NATS.Server.Host
```
### CLI Arguments
| Flag | Alias | Type | Default | Description |
|------|-------|------|---------|-------------|
| `-p` | `--port` | `int` | `4222` | TCP port to listen on |
| `-a` | `--addr` | `string` | `0.0.0.0` | Bind address |
| `-n` | `--name` | `string` | `nats-dotnet-<hostname>` | Server name reported in INFO |
```bash
# Custom port
dotnet run --project src/NATS.Server.Host -- -p 14222
# Custom address and name
dotnet run --project src/NATS.Server.Host -- -a 127.0.0.1 -p 4222 -n dev-server
```
The `--` separator is required to pass arguments through `dotnet run` to the application.
Startup log output (Serilog console sink):
```
[12:00:00 INF] Listening on 0.0.0.0:4222
```
---
## Testing
```bash
# Run all tests
dotnet test
# Run tests with verbose output
dotnet test -v normal
# Run a single test project
dotnet test tests/NATS.Server.Tests
# Run a specific test by name
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListTests"
```
### Test Stack
| Package | Version | Purpose |
|---------|---------|---------|
| `xunit` | 2.9.3 | Test framework |
| `xunit.runner.visualstudio` | 3.1.4 | VS/Rider test runner integration |
| `Shouldly` | 4.3.0 | Assertion library |
| `NSubstitute` | 5.3.0 | Mocking |
| `NATS.Client.Core` | 2.7.2 | Official NATS .NET client for integration tests |
| `coverlet.collector` | 6.0.4 | Code coverage |
Do not use FluentAssertions or Moq — the project uses Shouldly and NSubstitute exclusively.
### Test Files
| File | Covers |
|------|--------|
| `ParserTests.cs` | `NatsParser.TryParse` for each command type |
| `SubjectMatchTests.cs` | `SubjectMatch` validation and wildcard matching |
| `SubListTests.cs` | `SubList` trie insert, remove, match, and cache behaviour |
| `ClientTests.cs` | `NatsClient` command dispatch and subscription tracking |
| `ServerTests.cs` | `NatsServer` pub/sub, wildcards, queue groups |
| `IntegrationTests.cs` | End-to-end tests using `NATS.Client.Core` against a live server |
---
## NuGet: Central Package Management
Package versions are defined centrally in `Directory.Packages.props`. Individual `.csproj` files reference packages without a `Version` attribute:
```xml
<!-- Directory.Packages.props -->
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
```
```xml
<!-- NATS.Server.Tests.csproj -->
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
```
To add a new dependency: add a `<PackageVersion>` entry in `Directory.Packages.props`, then add a `<PackageReference>` (without `Version`) in the relevant `.csproj`.
---
## Logging
The host configures Serilog via `Microsoft.Extensions.Logging` in `Program.cs`:
```csharp
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
```
`NatsServer` and `NatsClient` receive `ILoggerFactory` and `ILogger` respectively via constructor injection. The core library (`NATS.Server`) depends only on `Microsoft.Extensions.Logging.Abstractions` — it has no direct Serilog dependency. The Serilog packages are wired in `NATS.Server.Host`.
Per-client loggers are created with a scoped category name that includes the client ID:
```csharp
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
```
To adjust log levels at runtime, modify the `LoggerConfiguration` in `Program.cs`.
---
## Related Documentation
- [Architecture](./Architecture.md)
- [Configuration Overview](../Configuration/Overview.md)
- [Protocol Overview](../Protocol/Overview.md)
- [Server Overview](../Server/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,193 @@
# Operations Overview
This document covers running the server, graceful shutdown, connecting clients, and the test suite.
---
## Running the Server
The host application is `src/NATS.Server.Host`. Run it with `dotnet run`:
```bash
# Default (port 4222, bind 0.0.0.0)
dotnet run --project src/NATS.Server.Host
# Custom port
dotnet run --project src/NATS.Server.Host -- -p 14222
# Custom bind address and name
dotnet run --project src/NATS.Server.Host -- -a 127.0.0.1 -n my-server
```
Arguments after `--` are passed to the program. The three supported flags are `-p`/`--port`, `-a`/`--addr`, and `-n`/`--name`. See [Configuration Overview](../Configuration/Overview.md) for the full CLI reference.
On startup, the server logs the address it is listening on:
```
[14:32:01 INF] Listening on 0.0.0.0:4222
```
### Full host setup
`Program.cs` initializes Serilog, parses CLI arguments, starts the server, and handles graceful shutdown:
```csharp
using NATS.Server;
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
var options = new NatsOptions();
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "-p" or "--port" when i + 1 < args.Length:
options.Port = int.Parse(args[++i]);
break;
case "-a" or "--addr" when i + 1 < args.Length:
options.Host = args[++i];
break;
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
break;
}
}
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
try
{
await server.StartAsync(cts.Token);
}
catch (OperationCanceledException) { }
finally
{
Log.CloseAndFlush();
}
```
---
## Graceful Shutdown
Pressing Ctrl+C triggers `Console.CancelKeyPress`. The handler sets `e.Cancel = true` — this prevents the process from terminating immediately — and calls `cts.Cancel()` to signal the `CancellationToken` passed to `server.StartAsync`.
`NatsServer.StartAsync` exits its accept loop on cancellation. In-flight client connections are left to drain naturally. After `StartAsync` returns (via `OperationCanceledException` which is caught), the `finally` block runs `Log.CloseAndFlush()` to ensure all buffered log output is written before the process exits.
---
## Testing
Run the full test suite with:
```bash
dotnet test
```
The test project is at `tests/NATS.Server.Tests/`. It uses xUnit with Shouldly for assertions.
### Test summary
69 tests across 6 test files:
| File | Tests | Coverage |
|------|-------|----------|
| `SubjectMatchTests.cs` | 33 | Subject validation and wildcard matching |
| `SubListTests.cs` | 12 | Trie insert, remove, match, queue groups, cache |
| `ParserTests.cs` | 14 | All command types, split packets, case insensitivity |
| `ClientTests.cs` | 2 | Socket-level INFO on connect, PING/PONG |
| `ServerTests.cs` | 3 | End-to-end accept, pub/sub, wildcard delivery |
| `IntegrationTests.cs` | 5 | NATS.Client.Core protocol compatibility |
### Test categories
**SubjectMatchTests** — 33 `[Theory]` cases verifying `SubjectMatch.IsValidSubject` (16 cases), `SubjectMatch.IsValidPublishSubject` (6 cases), and `SubjectMatch.MatchLiteral` (11 cases). Covers empty strings, leading/trailing dots, embedded spaces, `>` in non-terminal position, and all wildcard combinations.
**SubListTests** — 12 `[Fact]` tests exercising the `SubList` trie directly: literal insert and match, empty result, `*` wildcard at various token levels, `>` wildcard, root `>`, multiple overlapping subscriptions, remove, queue group grouping, `Count` tracking, and cache invalidation after a wildcard insert.
**ParserTests** — 14 `async [Fact]` tests that write protocol bytes into a `Pipe` and assert on the resulting `ParsedCommand` list. Covers `PING`, `PONG`, `CONNECT`, `SUB` (with and without queue group), `UNSUB` (with and without `max-messages`), `PUB` (with payload, with reply-to, zero payload), `HPUB` (with header), `INFO`, multiple commands in a single buffer, and case-insensitive parsing.
**ClientTests** — 2 `async [Fact]` tests using a real loopback socket pair. Verifies that `NatsClient` sends an `INFO` frame immediately on connection, and that it responds `PONG` to a `PING` after `CONNECT`.
**ServerTests** — 3 `async [Fact]` tests that start `NatsServer` on a random port. Verifies `INFO` on connect, basic pub/sub delivery (`MSG` format), and wildcard subscription matching.
**IntegrationTests** — 5 `async [Fact]` tests using the official `NATS.Client.Core` v2.7.2 NuGet package. Verifies end-to-end protocol compatibility with a real NATS client library: basic pub/sub, `*` wildcard delivery, `>` wildcard delivery, fan-out to two subscribers, and `PingAsync`.
Integration tests use `NullLoggerFactory.Instance` for the server so test output is not cluttered with server logs.
---
## Connecting with Standard NATS Clients
The server speaks the standard NATS wire protocol. Any NATS client library can connect to it.
```bash
# Using the nats CLI tool
nats sub "test.>" --server nats://127.0.0.1:4222
nats pub test.hello "world" --server nats://127.0.0.1:4222
```
From .NET, using `NATS.Client.Core`:
```csharp
var opts = new NatsOpts { Url = "nats://127.0.0.1:4222" };
await using var conn = new NatsConnection(opts);
await conn.ConnectAsync();
await conn.PublishAsync("test.hello", "world");
```
---
## Go Reference Server
The Go reference implementation can be built from `golang/nats-server/` for comparison testing:
```bash
# Build
cd golang/nats-server && go build
# Run on default port
./nats-server
# Run on custom port
./nats-server -p 14222
```
The Go server is useful for verifying that the .NET port produces identical protocol output and behaves the same way under edge-case client interactions.
---
## Current Limitations
The following features present in the Go reference server are not yet ported:
- Authentication — no username/password, token, NKey, or JWT support
- Clustering — no routes, gateways, or leaf nodes
- JetStream — no persistent streaming, streams, consumers, or RAFT
- Monitoring — no HTTP endpoints (`/varz`, `/connz`, `/healthz`, etc.)
- TLS — all connections are plaintext
- WebSocket — no WebSocket transport
---
## Related Documentation
- [Configuration Overview](../Configuration/Overview.md)
- [Server Overview](../Server/Overview.md)
- [Protocol Overview](../Protocol/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,151 @@
# Protocol Overview
NATS uses a line-oriented, text-based protocol over TCP. All commands are terminated by `\r\n`. This simplicity makes it easy to debug with raw TCP tools and keeps parsing overhead low.
## Command Reference
All commands flow either from server to client (S→C) or client to server (C→S). PING and PONG travel in both directions as part of the keepalive mechanism.
| Command | Direction | Format |
|---------|-----------|--------|
| INFO | S→C | `INFO {json}\r\n` |
| CONNECT | C→S | `CONNECT {json}\r\n` |
| PUB | C→S | `PUB subject [reply] size\r\n[payload]\r\n` |
| HPUB | C→S | `HPUB subject [reply] hdr_size total_size\r\n[headers+payload]\r\n` |
| SUB | C→S | `SUB subject [queue] sid\r\n` |
| UNSUB | C→S | `UNSUB sid [max_msgs]\r\n` |
| MSG | S→C | `MSG subject sid [reply] size\r\n[payload]\r\n` |
| HMSG | S→C | `HMSG subject sid [reply] hdr_size total_size\r\n[headers+payload]\r\n` |
| PING/PONG | Both | `PING\r\n` / `PONG\r\n` |
| +OK/-ERR | S→C | `+OK\r\n` / `-ERR 'msg'\r\n` |
Arguments in brackets are optional. `sid` is the subscription ID string assigned by the client. Commands with a payload body (PUB, HPUB, MSG, HMSG) use a two-line structure: a control line with sizes, then the raw payload bytes, then a terminating `\r\n`.
## Connection Handshake
The handshake is always server-initiated:
1. Client opens TCP connection.
2. Server immediately sends `INFO {json}\r\n` describing itself.
3. Client sends `CONNECT {json}\r\n` with its options.
4. Normal operation begins (PUB, SUB, MSG, PING/PONG, etc.).
If `verbose` is enabled in `ClientOptions`, the server sends `+OK` after each valid client command. If the server rejects the CONNECT (bad credentials, unsupported protocol version, etc.) it sends `-ERR 'message'\r\n` and closes the connection.
## ServerInfo
The `ServerInfo` JSON payload is sent in the initial INFO message. The `ClientId` and `ClientIp` fields are omitted from JSON when not set.
```csharp
public sealed class ServerInfo
{
[JsonPropertyName("server_id")]
public required string ServerId { get; set; }
[JsonPropertyName("server_name")]
public required string ServerName { get; set; }
[JsonPropertyName("version")]
public required string Version { get; set; }
[JsonPropertyName("proto")]
public int Proto { get; set; } = NatsProtocol.ProtoVersion;
[JsonPropertyName("host")]
public required string Host { get; set; }
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("headers")]
public bool Headers { get; set; } = true;
[JsonPropertyName("max_payload")]
public int MaxPayload { get; set; } = NatsProtocol.MaxPayloadSize;
[JsonPropertyName("client_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ulong ClientId { get; set; }
[JsonPropertyName("client_ip")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientIp { get; set; }
}
```
`headers` signals that the server supports HPUB/HMSG. `max_payload` advertises the largest message body the server accepts (default 1 MB). `proto` is the protocol version integer; the current value is `1`.
## ClientOptions
The `ClientOptions` JSON payload is sent by the client in the CONNECT command.
```csharp
public sealed class ClientOptions
{
[JsonPropertyName("verbose")]
public bool Verbose { get; set; }
[JsonPropertyName("pedantic")]
public bool Pedantic { get; set; }
[JsonPropertyName("echo")]
public bool Echo { get; set; } = true;
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("lang")]
public string? Lang { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("protocol")]
public int Protocol { get; set; }
[JsonPropertyName("headers")]
public bool Headers { get; set; }
[JsonPropertyName("no_responders")]
public bool NoResponders { get; set; }
}
```
`echo` defaults to `true`, meaning a client receives its own published messages if it has a matching subscription. Setting `echo` to `false` suppresses that. `no_responders` causes the server to send a status message when a request has no subscribers, rather than letting the client time out.
## Protocol Constants
`NatsProtocol` centralises limits and pre-encoded byte arrays to avoid repeated allocations in the hot path.
```csharp
public static class NatsProtocol
{
public const int MaxControlLineSize = 4096;
public const int MaxPayloadSize = 1024 * 1024; // 1MB
public const int DefaultPort = 4222;
// Pre-encoded protocol fragments
public static readonly byte[] CrLf = "\r\n"u8.ToArray();
public static readonly byte[] PingBytes = "PING\r\n"u8.ToArray();
public static readonly byte[] PongBytes = "PONG\r\n"u8.ToArray();
public static readonly byte[] OkBytes = "+OK\r\n"u8.ToArray();
public static readonly byte[] InfoPrefix = "INFO "u8.ToArray();
public static readonly byte[] MsgPrefix = "MSG "u8.ToArray();
public static readonly byte[] HmsgPrefix = "HMSG "u8.ToArray();
public static readonly byte[] ErrPrefix = "-ERR "u8.ToArray();
}
```
`MaxControlLineSize` (4096 bytes) is the maximum length of a command line before the payload. Any control line that exceeds this limit causes the parser to throw `ProtocolViolationException`. `MaxPayloadSize` (1 MB) is the default limit enforced by the parser; it is configurable per server instance.
## Go Reference
The Go implementation of protocol parsing is in `golang/nats-server/server/parser.go`. The .NET implementation follows the same command identification strategy and enforces the same control line and payload size limits.
## Related Documentation
- [Parser](Parser.md)
- [Server Overview](../Server/Overview.md)
- [Configuration Overview](../Configuration/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,271 @@
# Parser
`NatsParser` is a stateful byte-level parser that processes NATS protocol commands from a `ReadOnlySequence<byte>` provided by `System.IO.Pipelines`. It is called repeatedly in a read loop until no more complete commands are available in the buffer.
## Key Types
### CommandType
The `CommandType` enum identifies every command the parser can produce:
```csharp
public enum CommandType
{
Ping,
Pong,
Connect,
Info,
Pub,
HPub,
Sub,
Unsub,
Ok,
Err,
}
```
### ParsedCommand
`ParsedCommand` is a `readonly struct` that carries the result of a successful parse. Using a struct avoids a heap allocation per command on the fast path.
```csharp
public readonly struct ParsedCommand
{
public CommandType Type { get; init; }
public string? Subject { get; init; }
public string? ReplyTo { get; init; }
public string? Queue { get; init; }
public string? Sid { get; init; }
public int MaxMessages { get; init; }
public int HeaderSize { get; init; }
public ReadOnlyMemory<byte> Payload { get; init; }
public static ParsedCommand Simple(CommandType type) => new() { Type = type, MaxMessages = -1 };
}
```
Fields that do not apply to a given command type are left at their default values (`null` for strings, `0` for integers). `MaxMessages` uses `-1` as a sentinel meaning "unset" (relevant for UNSUB with no max). `HeaderSize` is set for HPUB/HMSG; `-1` indicates no headers. `Payload` carries the raw body bytes for PUB/HPUB, and the raw JSON bytes for CONNECT/INFO.
## TryParse
`TryParse` is the main entry point. It is called by the read loop after each `PipeReader.ReadAsync` completes.
```csharp
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
```
The method returns `true` and advances `buffer` past the consumed bytes when a complete command is available. It returns `false` — leaving `buffer` unchanged — when more data is needed. The caller must call `TryParse` in a loop until it returns `false`, then call `PipeReader.AdvanceTo` to signal how far the buffer was consumed.
If the parser detects a malformed command it throws `ProtocolViolationException`, which the read loop catches to close the connection.
## Command Identification
After locating the `\r\n` control line terminator, the parser lowercase-normalises the first two bytes using a bitwise OR with `0x20` and dispatches on them. This single branch handles both upper- and lowercase input without a string comparison or allocation.
```csharp
byte b0 = (byte)(lineSpan[0] | 0x20); // lowercase
byte b1 = (byte)(lineSpan[1] | 0x20);
switch (b0)
{
case (byte)'p':
if (b1 == (byte)'i') // PING
{
command = ParsedCommand.Simple(CommandType.Ping);
buffer = buffer.Slice(reader.Position);
return true;
}
if (b1 == (byte)'o') // PONG
{
command = ParsedCommand.Simple(CommandType.Pong);
buffer = buffer.Slice(reader.Position);
return true;
}
if (b1 == (byte)'u') // PUB
{
return ParsePub(lineSpan, ref buffer, reader.Position, out command);
}
break;
case (byte)'h':
if (b1 == (byte)'p') // HPUB
{
return ParseHPub(lineSpan, ref buffer, reader.Position, out command);
}
break;
case (byte)'s':
if (b1 == (byte)'u') // SUB
{
command = ParseSub(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'u':
if (b1 == (byte)'n') // UNSUB
{
command = ParseUnsub(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'c':
if (b1 == (byte)'o') // CONNECT
{
command = ParseConnect(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'i':
if (b1 == (byte)'n') // INFO
{
command = ParseInfo(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'+': // +OK
command = ParsedCommand.Simple(CommandType.Ok);
buffer = buffer.Slice(reader.Position);
return true;
case (byte)'-': // -ERR
command = ParsedCommand.Simple(CommandType.Err);
buffer = buffer.Slice(reader.Position);
return true;
}
throw new ProtocolViolationException("Unknown protocol operation");
```
The two-character pairs are: `p+i` = PING, `p+o` = PONG, `p+u` = PUB, `h+p` = HPUB, `s+u` = SUB, `u+n` = UNSUB, `c+o` = CONNECT, `i+n` = INFO. `+` and `-` are matched on `b0` alone since their second characters are unambiguous.
## Two-Phase Parsing for PUB and HPUB
PUB and HPUB require a payload body that follows the control line. The parser handles split reads — where the TCP segment boundary falls inside the payload — through an `_awaitingPayload` state flag.
**Phase 1 — control line:** The parser reads the control line up to `\r\n`, extracts the subject, optional reply-to, and payload size(s), then stores these in private fields (`_pendingSubject`, `_pendingReplyTo`, `_expectedPayloadSize`, `_pendingHeaderSize`, `_pendingType`) and sets `_awaitingPayload = true`. It then immediately calls `TryReadPayload` to attempt phase 2.
**Phase 2 — payload read:** `TryReadPayload` checks whether `buffer.Length >= _expectedPayloadSize + 2` (the `+ 2` accounts for the trailing `\r\n`). If enough data is present, the payload bytes are copied to a new `byte[]`, the trailing `\r\n` is verified, the `ParsedCommand` is constructed, and `_awaitingPayload` is reset to `false`. If not enough data is present, `TryReadPayload` returns `false` and `_awaitingPayload` remains `true`.
On the next call to `TryParse`, the check at the top of the method routes straight to `TryReadPayload` without re-parsing the control line:
```csharp
if (_awaitingPayload)
return TryReadPayload(ref buffer, out command);
```
This means the parser correctly handles payloads that arrive across multiple `PipeReader.ReadAsync` completions without buffering the control line a second time.
## Zero-Allocation Argument Splitting
`SplitArgs` splits the argument portion of a control line into token ranges without allocating. The caller `stackalloc`s a `Span<Range>` sized to the maximum expected argument count for the command, then passes it to `SplitArgs`:
```csharp
internal static int SplitArgs(Span<byte> data, Span<Range> ranges)
{
int count = 0;
int start = -1;
for (int i = 0; i < data.Length; i++)
{
byte b = data[i];
if (b is (byte)' ' or (byte)'\t')
{
if (start >= 0)
{
if (count >= ranges.Length)
throw new ProtocolViolationException("Too many arguments");
ranges[count++] = start..i;
start = -1;
}
}
else
{
if (start < 0)
start = i;
}
}
if (start >= 0)
{
if (count >= ranges.Length)
throw new ProtocolViolationException("Too many arguments");
ranges[count++] = start..data.Length;
}
return count;
}
```
The returned `int` is the number of populated entries in `ranges`. Callers index into the original span using those ranges (e.g. `argsSpan[ranges[0]]`) to extract each token as a sub-span, then decode to `string` with `Encoding.ASCII.GetString`. Consecutive whitespace is collapsed: a new token only begins on a non-whitespace byte after one or more whitespace bytes.
## Decimal Integer Parsing
`ParseSize` converts an ASCII decimal integer in a byte span to an `int`. It is used for payload sizes and UNSUB max-message counts.
```csharp
internal static int ParseSize(Span<byte> data)
{
if (data.Length == 0 || data.Length > 9)
return -1;
int n = 0;
foreach (byte b in data)
{
if (b < (byte)'0' || b > (byte)'9')
return -1;
n = n * 10 + (b - '0');
}
return n;
}
```
The length cap of 9 digits prevents overflow without a checked-arithmetic check. A return value of `-1` signals a parse failure; callers treat this as a `ProtocolViolationException`.
## Error Handling
`ProtocolViolationException` is thrown for all malformed input:
- Control line exceeds `MaxControlLineSize` (4096 bytes).
- Unknown command bytes.
- Wrong number of arguments for a command.
- Payload size is negative, exceeds `MaxPayloadSize`, or the trailing `\r\n` after the payload is absent.
- `SplitArgs` receives more tokens than the caller's `ranges` span can hold.
The read loop is responsible for catching `ProtocolViolationException`, sending `-ERR` to the client, and closing the connection.
## Limits
| Limit | Value | Source |
|-------|-------|--------|
| Max control line | 4096 bytes | `NatsProtocol.MaxControlLineSize` |
| Max payload (default) | 1 048 576 bytes | `NatsProtocol.MaxPayloadSize` |
| Max size field digits | 9 | `ParseSize` length check |
The max payload is configurable: `NatsParser` accepts a `maxPayload` constructor argument, which `NatsClient` sets from `NatsOptions`.
## Go Reference
The .NET parser is a direct port of the state machine in `golang/nats-server/server/parser.go`. The Go implementation uses the same two-byte command identification technique and the same two-phase control-line/payload split for PUB and HPUB.
## Related Documentation
- [Protocol Overview](Overview.md)
- [Server Overview](../Server/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,375 @@
# Client Connection Handler
`NatsClient` manages the full lifecycle of one TCP connection: sending the initial INFO handshake, reading and parsing the incoming byte stream, dispatching protocol commands, and writing outbound messages. One `NatsClient` instance exists per accepted socket.
## Key Concepts
### Fields and properties
```csharp
public sealed class NatsClient : IDisposable
{
private readonly Socket _socket;
private readonly NetworkStream _stream;
private readonly NatsOptions _options;
private readonly ServerInfo _serverInfo;
private readonly NatsParser _parser;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly Dictionary<string, Subscription> _subs = new();
private readonly ILogger _logger;
public ulong Id { get; }
public ClientOptions? ClientOpts { get; private set; }
public IMessageRouter? Router { get; set; }
public bool ConnectReceived { get; private set; }
public long InMsgs;
public long OutMsgs;
public long InBytes;
public long OutBytes;
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
}
```
`_writeLock` is a `SemaphoreSlim(1, 1)` — a binary semaphore that serializes all writes to `_stream`. Without it, concurrent `SendMessageAsync` calls from different publisher threads would interleave bytes on the wire. See [Write serialization](#write-serialization) below.
`_subs` maps subscription IDs (SIDs) to `Subscription` objects. SIDs are client-assigned strings; `Dictionary<string, Subscription>` gives O(1) lookup for UNSUB processing.
The four stat fields (`InMsgs`, `OutMsgs`, `InBytes`, `OutBytes`) are `long` fields accessed via `Interlocked` operations throughout the hot path. They are exposed as public fields rather than properties to allow `Interlocked.Increment` and `Interlocked.Add` directly by reference.
`Router` is set by `NatsServer` after construction, before `RunAsync` is called. It is typed as `IMessageRouter?` rather than `NatsServer` so that tests can substitute a stub.
### Constructor
```csharp
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo, ILogger logger)
{
Id = id;
_socket = socket;
_stream = new NetworkStream(socket, ownsSocket: false);
_options = options;
_serverInfo = serverInfo;
_logger = logger;
_parser = new NatsParser(options.MaxPayload);
}
```
`NetworkStream` is created with `ownsSocket: false`. This keeps socket lifetime management in `NatsServer`, which disposes the socket explicitly in `Dispose`. If `ownsSocket` were `true`, disposing the `NetworkStream` would close the socket, potentially racing with the disposal path in `NatsServer`.
`NatsParser` is constructed with `MaxPayload` from options. The parser enforces this limit: a payload larger than `MaxPayload` causes a `ProtocolViolationException` and terminates the connection.
## Connection Lifecycle
### RunAsync
`RunAsync` is the single entry point for a connection. `NatsServer` calls it as a fire-and-forget task.
```csharp
public async Task RunAsync(CancellationToken ct)
{
var pipe = new Pipe();
try
{
await SendInfoAsync(ct);
var fillTask = FillPipeAsync(pipe.Writer, ct);
var processTask = ProcessCommandsAsync(pipe.Reader, ct);
await Task.WhenAny(fillTask, processTask);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
}
finally
{
Router?.RemoveClient(this);
}
}
```
The method:
1. Sends `INFO {json}\r\n` immediately on connect — required by the NATS protocol before the client sends CONNECT.
2. Creates a `System.IO.Pipelines.Pipe` and starts two concurrent tasks: `FillPipeAsync` reads bytes from the socket into the pipe's write end; `ProcessCommandsAsync` reads from the pipe's read end and dispatches commands.
3. Awaits `Task.WhenAny`. Either task completing signals the connection is done — either the socket closed (fill task returns) or a protocol error caused the process task to throw.
4. In `finally`, calls `Router?.RemoveClient(this)` to clean up subscriptions and remove the client from the server's client dictionary.
`Router?.RemoveClient(this)` uses a null-conditional because `Router` could be null if the client is used in a test context without a server.
### FillPipeAsync
```csharp
private async Task FillPipeAsync(PipeWriter writer, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var memory = writer.GetMemory(4096);
int bytesRead = await _stream.ReadAsync(memory, ct);
if (bytesRead == 0)
break;
writer.Advance(bytesRead);
var result = await writer.FlushAsync(ct);
if (result.IsCompleted)
break;
}
}
finally
{
await writer.CompleteAsync();
}
}
```
`writer.GetMemory(4096)` requests at least 4096 bytes of buffer space from the pipe. The pipe may provide more. `_stream.ReadAsync` fills as many bytes as the OS delivers in one call. `writer.Advance(bytesRead)` commits those bytes. `writer.FlushAsync` makes them available to the reader.
When `bytesRead` is 0 the socket has closed. `writer.CompleteAsync()` in the `finally` block signals end-of-stream to the reader, which causes `ProcessCommandsAsync` to exit its loop on the next iteration.
### ProcessCommandsAsync
```csharp
private async Task ProcessCommandsAsync(PipeReader reader, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var result = await reader.ReadAsync(ct);
var buffer = result.Buffer;
while (_parser.TryParse(ref buffer, out var cmd))
{
await DispatchCommandAsync(cmd, ct);
}
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted)
break;
}
}
finally
{
await reader.CompleteAsync();
}
}
```
`reader.ReadAsync` returns a `ReadResult` containing a `ReadOnlySequence<byte>`. The inner `while` loop calls `_parser.TryParse` repeatedly, which slices `buffer` forward past each complete command. When `TryParse` returns `false`, not enough data is available for a complete command.
`reader.AdvanceTo(buffer.Start, buffer.End)` uses the two-argument form: `buffer.Start` (the consumed position — data before this is discarded) and `buffer.End` (the examined position — the pipe knows to wake this task when more data arrives beyond this point). This is the standard `System.IO.Pipelines` backpressure pattern.
## Command Dispatch
`DispatchCommandAsync` switches on the `CommandType` returned by the parser:
```csharp
private async ValueTask DispatchCommandAsync(ParsedCommand cmd, CancellationToken ct)
{
switch (cmd.Type)
{
case CommandType.Connect:
ProcessConnect(cmd);
break;
case CommandType.Ping:
await WriteAsync(NatsProtocol.PongBytes, ct);
break;
case CommandType.Pong:
// Update RTT tracking (placeholder)
break;
case CommandType.Sub:
ProcessSub(cmd);
break;
case CommandType.Unsub:
ProcessUnsub(cmd);
break;
case CommandType.Pub:
case CommandType.HPub:
ProcessPub(cmd);
break;
}
}
```
### CONNECT
`ProcessConnect` deserializes the JSON payload into a `ClientOptions` record and sets `ConnectReceived = true`. `ClientOptions` carries the `echo` flag (default `true`), the client name, language, and version strings.
### PING / PONG
PING is responded to immediately with the pre-allocated `NatsProtocol.PongBytes` (`"PONG\r\n"`). The response goes through `WriteAsync`, which acquires the write lock. PONG handling is currently a placeholder for future RTT tracking.
### SUB
```csharp
private void ProcessSub(ParsedCommand cmd)
{
var sub = new Subscription
{
Subject = cmd.Subject!,
Queue = cmd.Queue,
Sid = cmd.Sid!,
};
_subs[cmd.Sid!] = sub;
sub.Client = this;
if (Router is ISubListAccess sl)
sl.SubList.Insert(sub);
}
```
A `Subscription` is stored in `_subs` (keyed by SID) and inserted into the shared `SubList` trie. The `Client` back-reference on `Subscription` is set to `this` so that `NatsServer.ProcessMessage` can reach the client from the subscription without a separate lookup.
`Router is ISubListAccess sl` checks the interface at runtime. In production, `Router` is `NatsServer`, which implements both interfaces. In tests using a stub `IMessageRouter` that does not implement `ISubListAccess`, the insert is silently skipped.
### UNSUB
```csharp
private void ProcessUnsub(ParsedCommand cmd)
{
if (!_subs.TryGetValue(cmd.Sid!, out var sub))
return;
if (cmd.MaxMessages > 0)
{
sub.MaxMessages = cmd.MaxMessages;
return;
}
_subs.Remove(cmd.Sid!);
if (Router is ISubListAccess sl)
sl.SubList.Remove(sub);
}
```
UNSUB has two modes:
- With `max_msgs > 0`: sets `sub.MaxMessages` to limit future deliveries. The subscription stays in the trie and the client's `_subs` dict. `DeliverMessage` in `NatsServer` checks `MessageCount` against `MaxMessages` on each delivery and silently drops messages beyond the limit.
- Without `max_msgs` (or `max_msgs == 0`): removes the subscription immediately from both `_subs` and the `SubList`.
### PUB and HPUB
```csharp
private void ProcessPub(ParsedCommand cmd)
{
Interlocked.Increment(ref InMsgs);
Interlocked.Add(ref InBytes, cmd.Payload.Length);
ReadOnlyMemory<byte> headers = default;
ReadOnlyMemory<byte> payload = cmd.Payload;
if (cmd.Type == CommandType.HPub && cmd.HeaderSize > 0)
{
headers = cmd.Payload[..cmd.HeaderSize];
payload = cmd.Payload[cmd.HeaderSize..];
}
Router?.ProcessMessage(cmd.Subject!, cmd.ReplyTo, headers, payload, this);
}
```
Stats are updated before routing. For HPUB, the combined payload from the parser is split into a header slice and a body slice using `cmd.HeaderSize`. Both slices are `ReadOnlyMemory<byte>` views over the same backing array — no copy. `Router.ProcessMessage` then delivers to all matching subscribers.
## Write Serialization
Multiple concurrent `SendMessageAsync` calls can arrive from different publisher connections at the same time. Without coordination, their writes would interleave on the socket and corrupt the message stream for the receiving client. `_writeLock` prevents this:
```csharp
public async Task SendMessageAsync(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
{
Interlocked.Increment(ref OutMsgs);
Interlocked.Add(ref OutBytes, payload.Length + headers.Length);
byte[] line;
if (headers.Length > 0)
{
int totalSize = headers.Length + payload.Length;
line = Encoding.ASCII.GetBytes(
$"HMSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{headers.Length} {totalSize}\r\n");
}
else
{
line = Encoding.ASCII.GetBytes(
$"MSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{payload.Length}\r\n");
}
await _writeLock.WaitAsync(ct);
try
{
await _stream.WriteAsync(line, ct);
if (headers.Length > 0)
await _stream.WriteAsync(headers, ct);
if (payload.Length > 0)
await _stream.WriteAsync(payload, ct);
await _stream.WriteAsync(NatsProtocol.CrLf, ct);
await _stream.FlushAsync(ct);
}
finally
{
_writeLock.Release();
}
}
```
The control line is constructed before acquiring the lock so the string formatting work happens outside the critical section. Once the lock is held, all writes for one message — control line, optional headers, payload, and trailing `\r\n` — happen atomically from the perspective of other writers.
Stats (`OutMsgs`, `OutBytes`) are updated before the lock because they are independent of the write ordering constraint.
## Subscription Cleanup
`RemoveAllSubscriptions` is called by `NatsServer.RemoveClient` when a connection ends:
```csharp
public void RemoveAllSubscriptions(SubList subList)
{
foreach (var sub in _subs.Values)
subList.Remove(sub);
_subs.Clear();
}
```
This removes every subscription this client holds from the shared `SubList` trie, then clears the local dictionary. After this call, no future `ProcessMessage` call will deliver to this client's subscriptions.
## Dispose
```csharp
public void Dispose()
{
_stream.Dispose();
_socket.Dispose();
_writeLock.Dispose();
}
```
Disposing `_stream` closes the network stream. Disposing `_socket` closes the OS socket. Any in-flight `ReadAsync` or `WriteAsync` will fault with an `ObjectDisposedException` or `IOException`, which causes the read/write tasks to terminate. `_writeLock` is disposed last to release the `SemaphoreSlim`'s internal handle.
## Go Reference
The Go counterpart is `golang/nats-server/server/client.go`. Key differences in the .NET port:
- Go uses separate goroutines for `readLoop` and `writeLoop`; the .NET port uses `FillPipeAsync` and `ProcessCommandsAsync` as concurrent `Task`s sharing a `Pipe`.
- Go uses dynamic buffer sizing (512 to 65536 bytes) in `readLoop`; the .NET port requests 4096-byte chunks from the `PipeWriter`.
- Go uses a mutex for write serialization (`c.mu`); the .NET port uses `SemaphoreSlim(1,1)` to allow `await`-based waiting without blocking a thread.
- The `System.IO.Pipelines` `Pipe` replaces Go's direct `net.Conn` reads. This separates the I/O pump from command parsing and avoids partial-read handling in the parser itself.
## Related Documentation
- [Server Overview](Overview.md)
- [Protocol Parser](../Protocol/Parser.md)
- [SubList Trie](../Subscriptions/SubList.md)
- [Subscriptions Overview](../Subscriptions/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,223 @@
# Server Overview
`NatsServer` is the top-level orchestrator: it binds the TCP listener, accepts incoming connections, and routes published messages to matching subscribers. Each connected client is managed by a `NatsClient` instance; `NatsServer` coordinates them through two interfaces that decouple message routing from connection management.
## Key Concepts
### Interfaces
`NatsServer` exposes two interfaces that `NatsClient` depends on:
```csharp
public interface IMessageRouter
{
void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender);
void RemoveClient(NatsClient client);
}
public interface ISubListAccess
{
SubList SubList { get; }
}
```
`IMessageRouter` is the surface `NatsClient` calls when a PUB command arrives. `ISubListAccess` gives `NatsClient` access to the shared `SubList` so it can insert and remove subscriptions directly — without needing a concrete reference to `NatsServer`. Both interfaces are implemented by `NatsServer`, and both are injected into `NatsClient` through the `Router` property after construction.
Defining them separately makes unit testing straightforward: a test can supply a stub `IMessageRouter` without standing up a real server.
### Fields and State
```csharp
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
private readonly NatsOptions _options;
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
private readonly SubList _subList = new();
private readonly ServerInfo _serverInfo;
private readonly ILogger<NatsServer> _logger;
private readonly ILoggerFactory _loggerFactory;
private Socket? _listener;
private ulong _nextClientId;
public SubList SubList => _subList;
}
```
`_clients` tracks every live connection. `_nextClientId` is incremented with `Interlocked.Increment` for each accepted socket, producing monotonically increasing client IDs without a lock. `_loggerFactory` is retained so per-client loggers can be created at accept time, each tagged with the client ID.
### Constructor
The constructor takes `NatsOptions` and `ILoggerFactory`. It builds a `ServerInfo` struct that is sent to every connecting client in the initial INFO message:
```csharp
public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
{
_options = options;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<NatsServer>();
_serverInfo = new ServerInfo
{
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
ServerName = options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
Version = NatsProtocol.Version,
Host = options.Host,
Port = options.Port,
MaxPayload = options.MaxPayload,
};
}
```
The `ServerId` is derived from a GUID — taking the first 20 characters of its `"N"` format (32 hex digits, no hyphens) and uppercasing them. This matches the fixed-length alphanumeric server ID format used by the Go server.
## Accept Loop
`StartAsync` binds the socket, enables `SO_REUSEADDR` so the port can be reused immediately after a restart, and enters an async accept loop:
```csharp
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_listener.Bind(new IPEndPoint(
_options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
_options.Port));
_listener.Listen(128);
while (!ct.IsCancellationRequested)
{
var socket = await _listener.AcceptAsync(ct);
var clientId = Interlocked.Increment(ref _nextClientId);
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
client.Router = this;
_clients[clientId] = client;
_ = RunClientAsync(client, ct);
}
}
```
`RunClientAsync` is fire-and-forget (`_ = ...`). The accept loop does not await it, so accepting new connections is not blocked by any single client's I/O. Each client runs concurrently on the thread pool.
The backlog of 128 passed to `Listen` controls the OS-level queue of unaccepted connections — matching the Go server default.
## Message Routing
`ProcessMessage` is called by `NatsClient` for every PUB or HPUB command. It is the hot path: called once per published message.
```csharp
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
{
var result = _subList.Match(subject);
// Deliver to plain subscribers
foreach (var sub in result.PlainSubs)
{
if (sub.Client == null || sub.Client == sender && !(sender.ClientOpts?.Echo ?? true))
continue;
DeliverMessage(sub, subject, replyTo, headers, payload);
}
// Deliver to one member of each queue group (round-robin)
foreach (var queueGroup in result.QueueSubs)
{
if (queueGroup.Length == 0) continue;
var idx = Math.Abs((int)Interlocked.Increment(ref sender.OutMsgs)) % queueGroup.Length;
Interlocked.Decrement(ref sender.OutMsgs);
for (int attempt = 0; attempt < queueGroup.Length; attempt++)
{
var sub = queueGroup[(idx + attempt) % queueGroup.Length];
if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
{
DeliverMessage(sub, subject, replyTo, headers, payload);
break;
}
}
}
}
```
### Plain subscriber delivery
Each subscription in `result.PlainSubs` receives the message unless:
- `sub.Client` is null (the subscription was removed concurrently), or
- the subscriber is the sender and the sender has `echo: false` in its CONNECT options.
The `echo` flag defaults to `true` in `ClientOptions`, so publishers receive their own messages unless they explicitly opt out.
### Queue group delivery
Queue groups provide load-balanced fan-out: exactly one member of each group receives each message. The selection uses a round-robin counter derived from `sender.OutMsgs`. An `Interlocked.Increment` picks the starting index; the `Interlocked.Decrement` immediately after undoes the side effect on the stat, since `OutMsgs` will be incremented correctly inside `SendMessageAsync` when the message is actually dispatched.
The loop walks from the selected index, wrapping around, until it finds an eligible member (non-null client, echo check). This handles stale subscriptions where the client has disconnected but the subscription object has not yet been cleaned up.
### DeliverMessage and auto-unsub
```csharp
private static void DeliverMessage(Subscription sub, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
var client = sub.Client;
if (client == null) return;
var count = Interlocked.Increment(ref sub.MessageCount);
if (sub.MaxMessages > 0 && count > sub.MaxMessages)
return;
_ = client.SendMessageAsync(subject, sub.Sid, replyTo, headers, payload, CancellationToken.None);
}
```
`MessageCount` is incremented atomically before the send. If it exceeds `MaxMessages` (set by an UNSUB with a message count argument), the message is silently dropped. The subscription itself is not removed here — removal happens when the client processes the count limit through `ProcessUnsub`, or when the client disconnects and `RemoveAllSubscriptions` is called.
`SendMessageAsync` is again fire-and-forget. Multiple deliveries to different clients happen concurrently.
## Client Removal
```csharp
public void RemoveClient(NatsClient client)
{
_clients.TryRemove(client.Id, out _);
client.RemoveAllSubscriptions(_subList);
}
```
`RemoveClient` is called either from `RunClientAsync`'s `finally` block (after a client disconnects or errors) or from `NatsClient.RunAsync`'s own `finally` block. Both paths may call it; `TryRemove` is idempotent, so double-calls are safe. After removal from `_clients`, all subscriptions belonging to that client are purged from the `SubList` trie and its internal cache.
## Shutdown and Dispose
```csharp
public void Dispose()
{
_listener?.Dispose();
foreach (var client in _clients.Values)
client.Dispose();
_subList.Dispose();
}
```
Disposing the listener socket causes `AcceptAsync` to throw, which unwinds `StartAsync`. Client sockets are disposed, which closes their `NetworkStream` and causes their read loops to terminate. `SubList.Dispose` releases its `ReaderWriterLockSlim`.
## Go Reference
The Go counterpart is `golang/nats-server/server/server.go`. Key differences in the .NET port:
- Go uses goroutines for the accept loop and per-client read/write loops; the .NET port uses `async`/`await` with `Task`.
- Go uses `sync/atomic` for client ID generation; the .NET port uses `Interlocked.Increment`.
- Go passes the server to clients via the `srv` field on the client struct; the .NET port uses the `IMessageRouter` interface through the `Router` property.
## Related Documentation
- [Client Connection Handler](Client.md)
- [SubList Trie](../Subscriptions/SubList.md)
- [Protocol Overview](../Protocol/Overview.md)
- [Configuration](../Configuration/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,169 @@
# Subscriptions Overview
The subscription system maps published subjects to interested subscribers. It consists of four classes: `Subscription` (the subscription model), `SubListResult` (a match result container), `SubjectMatch` (subject validation and wildcard matching), and `SubList` (the trie that routes messages to subscribers).
Go reference: `golang/nats-server/server/sublist.go`
---
## Subjects
A NATS subject is a dot-separated string of tokens: `foo.bar.baz`. Each token is a non-empty sequence of non-whitespace characters. The token separator is `.`.
### Wildcards
Two wildcard tokens are supported:
| Token | Name | Matches |
|-------|------|---------|
| `*` | Partial wildcard (Pwc) | Exactly one token at that position |
| `>` | Full wildcard (Fwc) | One or more remaining tokens |
Rules:
- `foo.*` matches `foo.bar` but not `foo.bar.baz` (only one token after `foo`).
- `foo.>` matches `foo.bar` and `foo.bar.baz` (one or more tokens after `foo`).
- `>` must be the last token in a subject. `foo.>.bar` is invalid.
- Publishers cannot use wildcards — only subscribers can.
### Queue Groups
Subscribers with the same queue name form a queue group. When a message matches a queue group, exactly one member of that group receives the message. The server selects a member per published message, which distributes load across the group.
Queue subscriptions are independent of plain subscriptions. If a subject has both plain subscribers and queue group subscribers, all plain subscribers receive the message and one member from each queue group receives it.
---
## The `Subscription` Class
A `Subscription` represents one subscriber's interest in a subject.
```csharp
public sealed class Subscription
{
public required string Subject { get; init; }
public string? Queue { get; init; }
public required string Sid { get; init; }
public long MessageCount; // Interlocked
public long MaxMessages; // 0 = unlimited
public NatsClient? Client { get; set; }
}
```
- `Subject` — the subject pattern, which may contain wildcards.
- `Queue` — the queue group name. `null` means this is a plain subscription.
- `Sid` — the subscription ID string assigned by the client. Used to identify the subscription in `UNSUB` commands.
- `MessageCount` — updated with `Interlocked.Increment` on each delivered message. Compared against `MaxMessages` to implement `UNSUB maxMessages`.
- `MaxMessages` — the auto-unsubscribe limit. `0` means unlimited.
- `Client` — back-reference to the `NatsClient` that owns this subscription. Used during message delivery to write `MSG` frames to the connection.
---
## The `SubListResult` Class
`SubListResult` is the return type of `SubList.Match()`. It separates plain subscribers from queue group subscribers.
```csharp
public sealed class SubListResult
{
public static readonly SubListResult Empty = new([], []);
public Subscription[] PlainSubs { get; }
public Subscription[][] QueueSubs { get; } // outer = groups, inner = members
public SubListResult(Subscription[] plainSubs, Subscription[][] queueSubs)
{
PlainSubs = plainSubs;
QueueSubs = queueSubs;
}
}
```
- `PlainSubs` — all plain subscribers whose subject pattern matches the published subject.
- `QueueSubs` — one entry per queue group. Each inner array contains all members of that group. The message router picks one member per group.
- `Empty` — a static singleton returned when no subscriptions match. Avoids allocation on the common case of no interest.
---
## The `SubjectMatch` Class
`SubjectMatch` is a static utility class for subject validation and wildcard pattern matching. It defines the three fundamental constants used throughout the subscription system:
```csharp
public static class SubjectMatch
{
public const char Pwc = '*'; // partial wildcard
public const char Fwc = '>'; // full wildcard
public const char Sep = '.'; // token separator
...
}
```
### Validation methods
**`IsValidSubject(string subject)`** — returns `true` if the subject is a well-formed NATS subject. Rejects:
- Empty strings.
- Empty tokens (consecutive separators, or leading/trailing separators).
- Tokens containing whitespace characters.
- Any token after `>` (full wildcard must be last).
**`IsLiteral(string subject)`** — returns `true` if the subject contains no wildcards. A character is treated as a wildcard only when it appears as a complete token (preceded and followed by `.` or the string boundary).
**`IsValidPublishSubject(string subject)`** — returns `true` if the subject is both valid and literal. Publishers must use literal subjects; this is the combined check applied before inserting a message into the routing path.
### Pattern matching
**`MatchLiteral(string literal, string pattern)`** — tests whether a literal subject matches a pattern that may contain wildcards. Used by the cache invalidation logic to determine which cached results are affected when a wildcard subscription is added or removed.
```csharp
public static bool MatchLiteral(string literal, string pattern)
{
int li = 0, pi = 0;
while (pi < pattern.Length)
{
// Get next pattern token
int pTokenStart = pi;
while (pi < pattern.Length && pattern[pi] != Sep)
pi++;
int pTokenLen = pi - pTokenStart;
if (pi < pattern.Length)
pi++; // skip separator
// Full wildcard -- matches everything remaining
if (pTokenLen == 1 && pattern[pTokenStart] == Fwc)
return li < literal.Length; // must have at least one token left
// Get next literal token
if (li >= literal.Length)
return false;
int lTokenStart = li;
while (li < literal.Length && literal[li] != Sep)
li++;
int lTokenLen = li - lTokenStart;
if (li < literal.Length)
li++; // skip separator
// Partial wildcard -- matches any single token
if (pTokenLen == 1 && pattern[pTokenStart] == Pwc)
continue;
// Literal comparison
if (pTokenLen != lTokenLen)
return false;
if (string.Compare(literal, lTokenStart, pattern, pTokenStart, pTokenLen, StringComparison.Ordinal) != 0)
return false;
}
return li >= literal.Length; // both exhausted
}
```
The algorithm walks both strings token by token without allocating. `>` short-circuits immediately, returning `true` as long as at least one literal token remains. `*` skips the corresponding literal token without comparison. Literal tokens are compared with `StringComparison.Ordinal`.
---
## Related Documentation
- [SubList Trie](../Subscriptions/SubList.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,242 @@
# SubList
`SubList` is the subscription routing trie. Every published message triggers a `Match()` call to find all interested subscribers. `SubList` stores subscriptions indexed by their subject tokens and returns a `SubListResult` containing both plain subscribers and queue groups.
Go reference: `golang/nats-server/server/sublist.go`
---
## Thread Safety
`SubList` uses a `ReaderWriterLockSlim` (`_lock`) with the following locking discipline:
| Operation | Lock |
|-----------|------|
| `Count` read | Read lock |
| `Match()` — cache hit | Read lock only |
| `Match()` — cache miss | Write lock (to update cache) |
| `Insert()` | Write lock |
| `Remove()` | Write lock |
Cache misses in `Match()` require a write lock because the cache must be updated after the trie traversal. To avoid a race between the read-lock check and the write-lock update, `Match()` uses double-checked locking: after acquiring the write lock, it checks the cache again before doing trie work.
---
## Trie Structure
The trie is built from two private classes, `TrieLevel` and `TrieNode`, nested inside `SubList`.
### `TrieLevel` and `TrieNode`
```csharp
private sealed class TrieLevel
{
public readonly Dictionary<string, TrieNode> Nodes = new(StringComparer.Ordinal);
public TrieNode? Pwc; // partial wildcard (*)
public TrieNode? Fwc; // full wildcard (>)
}
private sealed class TrieNode
{
public TrieLevel? Next;
public readonly HashSet<Subscription> PlainSubs = [];
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
public bool IsEmpty => PlainSubs.Count == 0 && QueueSubs.Count == 0 &&
(Next == null || (Next.Nodes.Count == 0 && Next.Pwc == null && Next.Fwc == null));
}
```
Each level in the trie represents one token position in a subject. A `TrieLevel` holds:
- `Nodes` — a dictionary keyed by literal token string, mapping to the `TrieNode` for that token. Uses `StringComparer.Ordinal` for performance.
- `Pwc` — the node for the `*` wildcard at this level, or `null` if no `*` subscriptions exist at this depth.
- `Fwc` — the node for the `>` wildcard at this level, or `null` if no `>` subscriptions exist at this depth.
A `TrieNode` sits at the boundary between two levels. It holds the subscriptions registered for subjects whose last token leads to this node:
- `PlainSubs` — a `HashSet<Subscription>` of plain (non-queue) subscribers.
- `QueueSubs` — a dictionary from queue name to the set of members in that queue group. Uses `StringComparer.Ordinal`.
- `Next` — the next `TrieLevel` for deeper token positions. `null` for leaf nodes.
- `IsEmpty``true` when the node and all its descendants have no subscriptions. Used during `Remove()` to prune dead branches.
The trie root is a `TrieLevel` (`_root`) with no parent node.
### `TokenEnumerator`
`TokenEnumerator` is a `ref struct` that splits a subject string by `.` without allocating. It operates on a `ReadOnlySpan<char>` derived from the original string.
```csharp
private ref struct TokenEnumerator
{
private ReadOnlySpan<char> _remaining;
public TokenEnumerator(string subject)
{
_remaining = subject.AsSpan();
Current = default;
}
public ReadOnlySpan<char> Current { get; private set; }
public TokenEnumerator GetEnumerator() => this;
public bool MoveNext()
{
if (_remaining.IsEmpty)
return false;
int sep = _remaining.IndexOf(SubjectMatch.Sep);
if (sep < 0)
{
Current = _remaining;
_remaining = default;
}
else
{
Current = _remaining[..sep];
_remaining = _remaining[(sep + 1)..];
}
return true;
}
}
```
`TokenEnumerator` implements the `foreach` pattern directly (via `GetEnumerator()` returning `this`), so it can be used in `foreach` loops without boxing. `Insert()` uses it during trie traversal to avoid string allocations per token.
---
## Insert
`Insert(Subscription sub)` adds a subscription to the trie under a write lock.
The method walks the trie one token at a time using `TokenEnumerator`. For each token:
- If the token is `*`, it creates or follows `level.Pwc`.
- If the token is `>`, it creates or follows `level.Fwc` and sets `sawFwc = true` to reject further tokens.
- Otherwise it creates or follows `level.Nodes[token]`.
At each step, `node.Next` is created if absent, and `level` advances to `node.Next`.
After all tokens are consumed, the subscription is added to the terminal node:
- Plain subscription: `node.PlainSubs.Add(sub)`.
- Queue subscription: `node.QueueSubs[sub.Queue].Add(sub)`, creating the inner `HashSet<Subscription>` if this is the first member of that group.
`_count` is incremented and `AddToCache` is called to update any cached results that would now include this subscription.
---
## Remove
`Remove(Subscription sub)` removes a subscription from the trie under a write lock.
The method walks the trie along the subscription's subject, recording the path as a `List<(TrieLevel, TrieNode, string token, bool isPwc, bool isFwc)>`. If any node along the path is missing, the method returns without error (the subscription was never inserted).
After locating the terminal node, the subscription is removed from `PlainSubs` or from the appropriate `QueueSubs` group. If the queue group becomes empty, its entry is removed from the dictionary.
If removal succeeds:
- `_count` is decremented.
- `RemoveFromCache` is called to invalidate affected cache entries.
- The path list is walked backwards. At each step, if `node.IsEmpty` is `true`, the node is removed from its parent level (`Pwc = null`, `Fwc = null`, or `Nodes.Remove(token)`). This prunes dead branches so the trie does not accumulate empty nodes over time.
---
## Match
`Match(string subject)` is called for every published message. It returns a `SubListResult` containing all matching plain and queue subscriptions.
### Cache check and fallback
```csharp
public SubListResult Match(string subject)
{
// Check cache under read lock first.
_lock.EnterReadLock();
try
{
if (_cache != null && _cache.TryGetValue(subject, out var cached))
return cached;
}
finally
{
_lock.ExitReadLock();
}
// Cache miss -- tokenize and match under write lock (needed for cache update).
var tokens = Tokenize(subject);
if (tokens == null)
return SubListResult.Empty;
_lock.EnterWriteLock();
try
{
// Re-check cache after acquiring write lock.
if (_cache != null && _cache.TryGetValue(subject, out var cached))
return cached;
var plainSubs = new List<Subscription>();
var queueSubs = new List<List<Subscription>>();
MatchLevel(_root, tokens, 0, plainSubs, queueSubs);
...
if (_cache != null)
{
_cache[subject] = result;
if (_cache.Count > CacheMax) { /* sweep */ }
}
return result;
}
finally { _lock.ExitWriteLock(); }
}
```
On a read-lock cache hit, `Match()` returns immediately with no trie traversal. On a miss, `Tokenize()` splits the subject before acquiring the write lock (subjects with empty tokens return `SubListResult.Empty` immediately). The write lock is then taken and the cache is checked again before invoking `MatchLevel`.
### `MatchLevel` traversal
`MatchLevel` is a recursive method that descends the trie matching tokens against the subject array. At each level, for each remaining token position:
1. If `level.Fwc` is set, all subscriptions from that node are added to the result. The `>` wildcard matches all remaining tokens, so no further recursion is needed for this branch.
2. If `level.Pwc` is set, `MatchLevel` recurses with the next token index and `pwc.Next` as the new level. This handles `*` matching the current token.
3. A literal dictionary lookup on `level.Nodes[tokens[i]]` advances the level pointer for the next iteration.
After all tokens are consumed, subscriptions from the final literal node and the final `*` position (if present at the last level) are added to the result. The `*` case at the last token requires explicit handling because the loop exits before the recursive call for `*` can execute.
`AddNodeToResults` flattens a node's `PlainSubs` into the accumulator list and merges its `QueueSubs` groups into the queue accumulator, combining groups by name across multiple matching nodes.
---
## Cache Strategy
The cache is a `Dictionary<string, SubListResult>` keyed by the literal published subject. All operations use `StringComparer.Ordinal`.
**Size limits:** The cache holds at most `CacheMax` (1024) entries. When `_cache.Count` exceeds this, a sweep removes entries until the count reaches `CacheSweep` (256). The sweep takes the first `count - 256` keys from the dictionary — no LRU ordering is maintained.
**`AddToCache`** is called from `Insert()` to keep cached results consistent after adding a subscription:
- For a literal subscription subject, `AddToCache` does a direct lookup. If the exact key is in the cache, it creates a new `SubListResult` with the subscription appended and replaces the cached entry.
- For a wildcard subscription subject, `AddToCache` scans all cached keys and updates any entry whose key is matched by `SubjectMatch.MatchLiteral(key, subject)`.
**`RemoveFromCache`** is called from `Remove()` to invalidate cached results after removing a subscription:
- For a literal subscription subject, `RemoveFromCache` removes the exact cache key.
- For a wildcard subscription subject, `RemoveFromCache` removes all cached keys matched by the pattern. Because it is difficult to reconstruct the correct result without a full trie traversal, invalidation is preferred over update.
The asymmetry between `AddToCache` (updates in place) and `RemoveFromCache` (invalidates) avoids a second trie traversal on removal at the cost of a cache miss on the next `Match()` for those keys.
---
## Disposal
`SubList` implements `IDisposable`. `Dispose()` releases the `ReaderWriterLockSlim`:
```csharp
public void Dispose() => _lock.Dispose();
```
`SubList` instances are owned by `NatsServer` and disposed during server shutdown.
---
## Related Documentation
- [Subscriptions Overview](../Subscriptions/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

318
documentation_rules.md Normal file
View File

@@ -0,0 +1,318 @@
# Documentation Rules
This document defines the documentation system for the NATS .NET server project. It provides guidelines for generating, updating, and maintaining project documentation.
The documentation is intended for internal team reference — explaining what the system is, how it works, how to extend it, and how to debug it.
## Folder Structure
```
Documentation/
├── Instructions/ # Guidelines for LLMs (meta-documentation)
│ └── (this file serves as the single instructions reference)
├── GettingStarted/ # Onboarding, prerequisites, first run
├── Protocol/ # Wire protocol, parser, command types
├── Subscriptions/ # SubList trie, subject matching, wildcards
├── Server/ # NatsServer orchestrator, NatsClient handler
├── Configuration/ # NatsOptions, appsettings, CLI arguments
├── Operations/ # Deployment, monitoring, health checks, troubleshooting
└── Plans/ # Design documents and implementation plans
```
Future module folders (add as modules are ported):
```
├── Authentication/ # Auth mechanisms, NKeys, JWT, accounts
├── Clustering/ # Routes, gateways, leaf nodes
├── JetStream/ # Streams, consumers, storage, RAFT
├── Monitoring/ # HTTP endpoints (/varz, /connz, etc.)
├── WebSocket/ # WebSocket transport
└── TLS/ # TLS configuration and setup
```
---
## Style Guide
### Tone and Voice
- **Technical and direct** — no marketing language. Avoid "powerful", "robust", "seamless", "blazing fast".
- **Assume the reader is a .NET developer** — don't explain dependency injection, async/await, or LINQ basics.
- **Explain "why" not just "what"** — document reasoning behind patterns and decisions.
- **Use present tense** — "The parser reads..." not "The parser will read..."
### Formatting Rules
| Aspect | Convention |
|--------|------------|
| File names | `PascalCase.md` |
| H1 (`#`) | Document title only, Title Case |
| H2 (`##`) | Major sections, Title Case |
| H3+ (`###`) | Subsections, Sentence case |
| Code blocks | Always specify language (`csharp`, `json`, `bash`, `xml`) |
| Code snippets | 5-25 lines typical; include class/method context |
| Cross-references | Relative paths: `[See SubList](../Subscriptions/SubList.md)` |
| Inline code | Backticks for code refs: `NatsServer`, `SubList.Match()`, `NatsOptions` |
| Lists | Bullets for unordered, numbers for sequential steps |
| Tables | For structured reference (config options, command formats) |
### Naming Conventions
- Match code terminology exactly: `SubList` not "Subject List", `NatsClient` not "NATS Client Handler"
- Use backticks for all code references: `NatsParser`, `appsettings.json`, `dotnet test`
- Spell out acronyms on first use: "NATS Adaptive Edge Messaging (NATS)" — common acronyms that don't need expansion: API, JSON, TCP, HTTP, TLS, JWT
### Code Snippet Guidelines
**Do:**
- Copy snippets from actual source files
- Include enough context (class name, method signature)
- Specify the language in code blocks
- Show 5-25 line examples
```csharp
// Good — shows class context
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.Bind(new IPEndPoint(IPAddress.Parse(_options.Host), _options.Port));
_listener.Listen(128);
}
}
```
**Don't:**
- Invent example code that doesn't exist in the codebase
- Include 100+ line dumps without explanation
- Use pseudocode when real code is available
- Omit the language specifier on code blocks
### Structure Conventions
Every documentation file must include:
1. **Title and purpose** — H1 heading with 1-2 sentence description
2. **Key concepts** — if the topic requires background understanding
3. **Code examples** — embedded snippets from actual codebase
4. **Configuration** — if the component has configurable options
5. **Related documentation** — links to related topics
Organize content from general to specific:
1. Overview/introduction
2. Key concepts
3. Basic usage
4. Advanced usage / internals
5. Configuration
6. Troubleshooting
7. Related documentation
End each document with:
```markdown
## Related Documentation
- [Related Topic](../Component/Topic.md)
```
### What to Avoid
- Don't document the obvious (e.g., "The constructor creates a new instance")
- Don't duplicate source code comments — reference the file instead
- Don't include temporary information (dates, version numbers, "coming soon")
- Don't over-explain .NET basics
---
## Generating Documentation
### Document Types
Each component folder should contain these standard files:
| File | Purpose |
|------|---------|
| `Overview.md` | What the component does, key concepts, architecture |
| `Development.md` | How to add/modify features, patterns to follow |
| `Configuration.md` | All configurable options with defaults and examples |
| `Troubleshooting.md` | Common issues, error messages, debugging steps |
Create additional topic-specific files as needed (e.g., `Protocol/Parser.md`, `Subscriptions/SubList.md`).
### Generation Process
1. **Identify scope** — which component folder does this belong to? (See Component Map below)
2. **Read source code** — understand the current implementation, identify key classes/methods/patterns, note configuration options
3. **Check existing documentation** — avoid duplication, cross-reference rather than repeat
4. **Write documentation** — follow the style guide, use real code snippets
5. **Verify accuracy** — confirm snippets match source, verify file paths and class names, test commands
### Creating New Component Folders
1. Create the folder under `Documentation/`
2. Add at minimum `Overview.md`
3. Add other standard files as content warrants
4. Update the Component Map section below
5. Add cross-references from related documentation
---
## Updating Documentation
### Update Triggers
| Code Change | Update These Docs |
|-------------|-------------------|
| New protocol command | `Protocol/` relevant file |
| Parser modified | `Protocol/Parser.md` |
| Subject matching changed | `Subscriptions/SubjectMatch.md` |
| SubList trie modified | `Subscriptions/SubList.md` |
| New subscription type | `Subscriptions/Overview.md` |
| NatsServer changed | `Server/Overview.md` |
| NatsClient changed | `Server/Client.md` |
| Config option added/removed | Component's `Configuration.md` |
| NatsOptions changed | `Configuration/Overview.md` |
| Host startup changed | `Operations/Deployment.md` + `Configuration/` |
| New test patterns | Corresponding component docs |
| Auth mechanism added | `Authentication/` (create if needed) |
| Clustering added | `Clustering/` (create if needed) |
### Update Process
1. **Identify affected documentation** — use the Component Map to determine which docs need updating
2. **Read current documentation** — understand existing structure before making changes
3. **Make targeted updates** — only modify sections affected by the code change; don't rewrite unaffected sections
4. **Update code snippets** — if the code change affects documented examples, update them to match
5. **Update cross-references** — add links to newly related docs, remove links to deleted content
6. **Add verification comment** — at the bottom: `<!-- Last verified against codebase: YYYY-MM-DD -->`
### Deletion Handling
- When code is removed, remove corresponding doc sections
- When code is renamed, update all references (docs, snippets, cross-reference links)
- If an entire feature is removed, delete the doc file and update any index/overview docs
- Search all docs for links to removed content
### What Not to Update
- Don't reformat documentation that wasn't affected by the code change
- Don't update examples that still work correctly
- Don't add new content unrelated to the code change
- Don't change writing style in unaffected sections
---
## Component Map
### Source to Documentation Mapping
| Source Path | Documentation Folder |
|-------------|---------------------|
| `src/NATS.Server/Protocol/NatsParser.cs` | `Protocol/` |
| `src/NATS.Server/Protocol/NatsProtocol.cs` | `Protocol/` |
| `src/NATS.Server/Subscriptions/SubList.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/SubjectMatch.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/Subscription.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/SubListResult.cs` | `Subscriptions/` |
| `src/NATS.Server/NatsServer.cs` | `Server/` |
| `src/NATS.Server/NatsClient.cs` | `Server/` |
| `src/NATS.Server/NatsOptions.cs` | `Configuration/` |
| `src/NATS.Server.Host/Program.cs` | `Operations/` and `Configuration/` |
| `tests/NATS.Server.Tests/` | Document in corresponding component |
| `golang/nats-server/server/` | Reference material (not documented separately) |
### Component Details
#### Protocol/
Documents the wire protocol and parser.
**Source paths:**
- `src/NATS.Server/Protocol/NatsParser.cs` — state machine parser
- `src/NATS.Server/Protocol/NatsProtocol.cs` — constants, ServerInfo, ClientOptions
**Typical files:**
- `Overview.md` — NATS protocol format, command types, wire format
- `Parser.md` — Parser implementation, `TryParse` flow, state machine
- `Commands.md` — Individual command formats (PUB, SUB, UNSUB, MSG, etc.)
#### Subscriptions/
Documents subject matching and the subscription trie.
**Source paths:**
- `src/NATS.Server/Subscriptions/SubList.cs` — trie + cache
- `src/NATS.Server/Subscriptions/SubjectMatch.cs` — validation and wildcard matching
- `src/NATS.Server/Subscriptions/Subscription.cs` — subscription model
- `src/NATS.Server/Subscriptions/SubListResult.cs` — match result container
**Typical files:**
- `Overview.md` — Subject namespace, wildcard rules, queue groups
- `SubList.md` — Trie internals, cache invalidation, thread safety
- `SubjectMatch.md` — Validation rules, wildcard matching algorithm
#### Server/
Documents the server orchestrator and client connection handler.
**Source paths:**
- `src/NATS.Server/NatsServer.cs` — accept loop, message routing
- `src/NATS.Server/NatsClient.cs` — per-connection read/write, subscription tracking
**Typical files:**
- `Overview.md` — Server architecture, connection lifecycle, message flow
- `Client.md` — Client connection handling, command dispatch, write serialization
- `MessageRouting.md` — How messages flow from PUB to subscribers
#### Configuration/
Documents server configuration options.
**Source paths:**
- `src/NATS.Server/NatsOptions.cs` — configuration model
- `src/NATS.Server.Host/Program.cs` — CLI argument parsing, Serilog setup
**Typical files:**
- `Overview.md` — All options with defaults and descriptions
- `Logging.md` — Serilog configuration, log levels, LogContext usage
#### Operations/
Documents deployment and operational concerns.
**Source paths:**
- `src/NATS.Server.Host/` — host application
**Typical files:**
- `Overview.md` — Running the server, CLI arguments
- `Deployment.md` — Deployment procedures
- `Troubleshooting.md` — Common issues and debugging
#### GettingStarted/
Documents onboarding and project overview.
**Typical files:**
- `Setup.md` — Prerequisites, building, running
- `Architecture.md` — System overview, Go reference mapping
- `Development.md` — Development workflow, testing, contributing
### Ambiguous Cases
| Code Type | Document In |
|-----------|-------------|
| Logging setup | `Configuration/Logging.md` |
| Integration tests | `Operations/Testing.md` or corresponding component |
| Shared interfaces (`IMessageRouter`, `ISubListAccess`) | `Server/Overview.md` |
| Go reference code | Don't document separately; reference in `.NET` component docs |
### Adding New Components
When a new module is ported (Authentication, Clustering, JetStream, etc.):
1. Create a new folder under `Documentation/`
2. Add at minimum `Overview.md`
3. Add this mapping table entry
4. Update CLAUDE.md documentation index if it has one
5. Cross-reference from related component docs

View File

@@ -8,4 +8,9 @@
<ProjectReference Include="..\NATS.Server\NATS.Server.csproj" /> <ProjectReference Include="..\NATS.Server\NATS.Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,5 +1,11 @@
// src/NATS.Server.Host/Program.cs
using NATS.Server; using NATS.Server;
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
var options = new NatsOptions(); var options = new NatsOptions();
@@ -20,7 +26,8 @@ for (int i = 0; i < args.Length; i++)
} }
} }
var server = new NatsServer(options); using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
@@ -29,12 +36,12 @@ Console.CancelKeyPress += (_, e) =>
cts.Cancel(); cts.Cancel();
}; };
Console.WriteLine($"[NATS] Listening on {options.Host}:{options.Port}");
try try
{ {
await server.StartAsync(cts.Token); await server.StartAsync(cts.Token);
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
finally
Console.WriteLine("[NATS] Server stopped."); {
Log.CloseAndFlush();
}

View File

@@ -1,3 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project> </Project>

View File

@@ -3,6 +3,7 @@ using System.IO.Pipelines;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging;
using NATS.Server.Protocol; using NATS.Server.Protocol;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
@@ -29,6 +30,7 @@ public sealed class NatsClient : IDisposable
private readonly NatsParser _parser; private readonly NatsParser _parser;
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly Dictionary<string, Subscription> _subs = new(); private readonly Dictionary<string, Subscription> _subs = new();
private readonly ILogger _logger;
public ulong Id { get; } public ulong Id { get; }
public ClientOptions? ClientOpts { get; private set; } public ClientOptions? ClientOpts { get; private set; }
@@ -43,13 +45,14 @@ public sealed class NatsClient : IDisposable
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs; public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo) public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo, ILogger logger)
{ {
Id = id; Id = id;
_socket = socket; _socket = socket;
_stream = new NetworkStream(socket, ownsSocket: false); _stream = new NetworkStream(socket, ownsSocket: false);
_options = options; _options = options;
_serverInfo = serverInfo; _serverInfo = serverInfo;
_logger = logger;
_parser = new NatsParser(options.MaxPayload); _parser = new NatsParser(options.MaxPayload);
} }
@@ -68,7 +71,10 @@ public sealed class NatsClient : IDisposable
await Task.WhenAny(fillTask, processTask); await Task.WhenAny(fillTask, processTask);
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch (Exception) { /* connection error -- clean up */ } catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
}
finally finally
{ {
Router?.RemoveClient(this); Router?.RemoveClient(this);
@@ -160,6 +166,7 @@ public sealed class NatsClient : IDisposable
ClientOpts = JsonSerializer.Deserialize<ClientOptions>(cmd.Payload.Span) ClientOpts = JsonSerializer.Deserialize<ClientOptions>(cmd.Payload.Span)
?? new ClientOptions(); ?? new ClientOptions();
ConnectReceived = true; ConnectReceived = true;
_logger.LogDebug("CONNECT received from client {ClientId}, name={ClientName}", Id, ClientOpts?.Name);
} }
private void ProcessSub(ParsedCommand cmd) private void ProcessSub(ParsedCommand cmd)
@@ -174,12 +181,16 @@ public sealed class NatsClient : IDisposable
_subs[cmd.Sid!] = sub; _subs[cmd.Sid!] = sub;
sub.Client = this; sub.Client = this;
_logger.LogDebug("SUB {Subject} {Sid} from client {ClientId}", cmd.Subject, cmd.Sid, Id);
if (Router is ISubListAccess sl) if (Router is ISubListAccess sl)
sl.SubList.Insert(sub); sl.SubList.Insert(sub);
} }
private void ProcessUnsub(ParsedCommand cmd) private void ProcessUnsub(ParsedCommand cmd)
{ {
_logger.LogDebug("UNSUB {Sid} from client {ClientId}", cmd.Sid, Id);
if (!_subs.TryGetValue(cmd.Sid!, out var sub)) if (!_subs.TryGetValue(cmd.Sid!, out var sub))
return; return;

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using NATS.Server.Protocol; using NATS.Server.Protocol;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
@@ -12,14 +13,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new(); private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
private readonly SubList _subList = new(); private readonly SubList _subList = new();
private readonly ServerInfo _serverInfo; private readonly ServerInfo _serverInfo;
private readonly ILogger<NatsServer> _logger;
private readonly ILoggerFactory _loggerFactory;
private Socket? _listener; private Socket? _listener;
private ulong _nextClientId; private ulong _nextClientId;
public SubList SubList => _subList; public SubList SubList => _subList;
public NatsServer(NatsOptions options) public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
{ {
_options = options; _options = options;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<NatsServer>();
_serverInfo = new ServerInfo _serverInfo = new ServerInfo
{ {
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(), ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
@@ -40,6 +45,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_options.Port)); _options.Port));
_listener.Listen(128); _listener.Listen(128);
_logger.LogInformation("Listening on {Host}:{Port}", _options.Host, _options.Port);
try try
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
@@ -47,7 +54,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
var socket = await _listener.AcceptAsync(ct); var socket = await _listener.AcceptAsync(ct);
var clientId = Interlocked.Increment(ref _nextClientId); var clientId = Interlocked.Increment(ref _nextClientId);
var client = new NatsClient(clientId, socket, _options, _serverInfo); _logger.LogDebug("Client {ClientId} connected from {RemoteEndpoint}", clientId, socket.RemoteEndPoint);
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
client.Router = this; client.Router = this;
_clients[clientId] = client; _clients[clientId] = client;
@@ -69,6 +79,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
} }
finally finally
{ {
_logger.LogDebug("Client {ClientId} disconnected", client.Id);
RemoveClient(client); RemoveClient(client);
} }
} }
@@ -127,6 +138,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public void RemoveClient(NatsClient client) public void RemoveClient(NatsClient client)
{ {
_clients.TryRemove(client.Id, out _); _clients.TryRemove(client.Id, out _);
_logger.LogDebug("Removed client {ClientId}", client.Id);
client.RemoveAllSubscriptions(_subList); client.RemoveAllSubscriptions(_subList);
} }

View File

@@ -3,6 +3,8 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server; using NATS.Server;
using NATS.Server.Protocol; using NATS.Server.Protocol;
@@ -37,7 +39,7 @@ public class ClientTests : IAsyncDisposable
Port = 4222, Port = 4222,
}; };
_natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo); _natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo, NullLogger.Instance);
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -57,9 +59,9 @@ public class ClientTests : IAsyncDisposable
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None); var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n); var response = Encoding.ASCII.GetString(buf, 0, n);
Assert.StartsWith("INFO ", response); response.ShouldStartWith("INFO ");
Assert.Contains("server_id", response); response.ShouldContain("server_id");
Assert.Contains("\r\n", response); response.ShouldContain("\r\n");
await _cts.CancelAsync(); await _cts.CancelAsync();
} }
@@ -80,7 +82,7 @@ public class ClientTests : IAsyncDisposable
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None); var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n); var response = Encoding.ASCII.GetString(buf, 0, n);
Assert.Contains("PONG\r\n", response); response.ShouldContain("PONG\r\n");
await _cts.CancelAsync(); await _cts.CancelAsync();
} }

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Server; using NATS.Server;
@@ -16,7 +17,7 @@ public class IntegrationTests : IAsyncDisposable
public IntegrationTests() public IntegrationTests()
{ {
_port = GetFreePort(); _port = GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port }); _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
_serverTask = _server.StartAsync(_cts.Token); _serverTask = _server.StartAsync(_cts.Token);
Thread.Sleep(200); // Let server start Thread.Sleep(200); // Let server start
} }
@@ -65,7 +66,7 @@ public class IntegrationTests : IAsyncDisposable
await pub.PublishAsync("test.subject", "Hello NATS!"); await pub.PublishAsync("test.subject", "Hello NATS!");
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("Hello NATS!", result); result.ShouldBe("Hello NATS!");
} }
[Fact] [Fact]
@@ -92,7 +93,7 @@ public class IntegrationTests : IAsyncDisposable
await pub.PublishAsync("test.hello", "data"); await pub.PublishAsync("test.hello", "data");
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("test.hello", result); result.ShouldBe("test.hello");
} }
[Fact] [Fact]
@@ -119,7 +120,7 @@ public class IntegrationTests : IAsyncDisposable
await pub.PublishAsync("test.foo.bar.baz", "data"); await pub.PublishAsync("test.foo.bar.baz", "data");
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("test.foo.bar.baz", result); result.ShouldBe("test.foo.bar.baz");
} }
[Fact] [Fact]
@@ -163,8 +164,8 @@ public class IntegrationTests : IAsyncDisposable
await pub.PublishAsync("fanout", "hello"); await pub.PublishAsync("fanout", "hello");
await done.Task.WaitAsync(TimeSpan.FromSeconds(5)); await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, count1); count1.ShouldBe(1);
Assert.Equal(1, count2); count2.ShouldBe(1);
} }
[Fact] [Fact]

View File

@@ -5,15 +5,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NATS.Client.Core" Version="2.7.2" /> <PackageReference Include="NATS.Client.Core" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -40,117 +40,117 @@ public class ParserTests
public async Task Parse_PING() public async Task Parse_PING()
{ {
var cmds = await ParseAsync("PING\r\n"); var cmds = await ParseAsync("PING\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Ping, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Ping);
} }
[Fact] [Fact]
public async Task Parse_PONG() public async Task Parse_PONG()
{ {
var cmds = await ParseAsync("PONG\r\n"); var cmds = await ParseAsync("PONG\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Pong, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Pong);
} }
[Fact] [Fact]
public async Task Parse_CONNECT() public async Task Parse_CONNECT()
{ {
var cmds = await ParseAsync("CONNECT {\"verbose\":false,\"echo\":true}\r\n"); var cmds = await ParseAsync("CONNECT {\"verbose\":false,\"echo\":true}\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Connect, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Connect);
Assert.Contains("verbose", Encoding.ASCII.GetString(cmds[0].Payload.ToArray())); Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldContain("verbose");
} }
[Fact] [Fact]
public async Task Parse_SUB_without_queue() public async Task Parse_SUB_without_queue()
{ {
var cmds = await ParseAsync("SUB foo 1\r\n"); var cmds = await ParseAsync("SUB foo 1\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Sub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Sub);
Assert.Equal("foo", cmds[0].Subject); cmds[0].Subject.ShouldBe("foo");
Assert.Null(cmds[0].Queue); cmds[0].Queue.ShouldBeNull();
Assert.Equal("1", cmds[0].Sid); cmds[0].Sid.ShouldBe("1");
} }
[Fact] [Fact]
public async Task Parse_SUB_with_queue() public async Task Parse_SUB_with_queue()
{ {
var cmds = await ParseAsync("SUB foo workers 1\r\n"); var cmds = await ParseAsync("SUB foo workers 1\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Sub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Sub);
Assert.Equal("foo", cmds[0].Subject); cmds[0].Subject.ShouldBe("foo");
Assert.Equal("workers", cmds[0].Queue); cmds[0].Queue.ShouldBe("workers");
Assert.Equal("1", cmds[0].Sid); cmds[0].Sid.ShouldBe("1");
} }
[Fact] [Fact]
public async Task Parse_UNSUB() public async Task Parse_UNSUB()
{ {
var cmds = await ParseAsync("UNSUB 1\r\n"); var cmds = await ParseAsync("UNSUB 1\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Unsub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Unsub);
Assert.Equal("1", cmds[0].Sid); cmds[0].Sid.ShouldBe("1");
Assert.Equal(-1, cmds[0].MaxMessages); cmds[0].MaxMessages.ShouldBe(-1);
} }
[Fact] [Fact]
public async Task Parse_UNSUB_with_max() public async Task Parse_UNSUB_with_max()
{ {
var cmds = await ParseAsync("UNSUB 1 5\r\n"); var cmds = await ParseAsync("UNSUB 1 5\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Unsub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Unsub);
Assert.Equal("1", cmds[0].Sid); cmds[0].Sid.ShouldBe("1");
Assert.Equal(5, cmds[0].MaxMessages); cmds[0].MaxMessages.ShouldBe(5);
} }
[Fact] [Fact]
public async Task Parse_PUB_with_payload() public async Task Parse_PUB_with_payload()
{ {
var cmds = await ParseAsync("PUB foo 5\r\nHello\r\n"); var cmds = await ParseAsync("PUB foo 5\r\nHello\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Pub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Pub);
Assert.Equal("foo", cmds[0].Subject); cmds[0].Subject.ShouldBe("foo");
Assert.Null(cmds[0].ReplyTo); cmds[0].ReplyTo.ShouldBeNull();
Assert.Equal("Hello", Encoding.ASCII.GetString(cmds[0].Payload.ToArray())); Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("Hello");
} }
[Fact] [Fact]
public async Task Parse_PUB_with_reply() public async Task Parse_PUB_with_reply()
{ {
var cmds = await ParseAsync("PUB foo reply 5\r\nHello\r\n"); var cmds = await ParseAsync("PUB foo reply 5\r\nHello\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Pub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Pub);
Assert.Equal("foo", cmds[0].Subject); cmds[0].Subject.ShouldBe("foo");
Assert.Equal("reply", cmds[0].ReplyTo); cmds[0].ReplyTo.ShouldBe("reply");
Assert.Equal("Hello", Encoding.ASCII.GetString(cmds[0].Payload.ToArray())); Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("Hello");
} }
[Fact] [Fact]
public async Task Parse_multiple_commands() public async Task Parse_multiple_commands()
{ {
var cmds = await ParseAsync("PING\r\nPONG\r\nSUB foo 1\r\n"); var cmds = await ParseAsync("PING\r\nPONG\r\nSUB foo 1\r\n");
Assert.Equal(3, cmds.Count); cmds.Count.ShouldBe(3);
Assert.Equal(CommandType.Ping, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Ping);
Assert.Equal(CommandType.Pong, cmds[1].Type); cmds[1].Type.ShouldBe(CommandType.Pong);
Assert.Equal(CommandType.Sub, cmds[2].Type); cmds[2].Type.ShouldBe(CommandType.Sub);
} }
[Fact] [Fact]
public async Task Parse_PUB_zero_payload() public async Task Parse_PUB_zero_payload()
{ {
var cmds = await ParseAsync("PUB foo 0\r\n\r\n"); var cmds = await ParseAsync("PUB foo 0\r\n\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Pub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Pub);
Assert.Empty(cmds[0].Payload.ToArray()); cmds[0].Payload.ToArray().ShouldBeEmpty();
} }
[Fact] [Fact]
public async Task Parse_case_insensitive() public async Task Parse_case_insensitive()
{ {
var cmds = await ParseAsync("ping\r\npub FOO 3\r\nabc\r\n"); var cmds = await ParseAsync("ping\r\npub FOO 3\r\nabc\r\n");
Assert.Equal(2, cmds.Count); cmds.Count.ShouldBe(2);
Assert.Equal(CommandType.Ping, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Ping);
Assert.Equal(CommandType.Pub, cmds[1].Type); cmds[1].Type.ShouldBe(CommandType.Pub);
} }
[Fact] [Fact]
@@ -161,17 +161,17 @@ public class ParserTests
var payload = "Hello"; var payload = "Hello";
var total = header.Length + payload.Length; var total = header.Length + payload.Length;
var cmds = await ParseAsync($"HPUB foo {header.Length} {total}\r\n{header}{payload}\r\n"); var cmds = await ParseAsync($"HPUB foo {header.Length} {total}\r\n{header}{payload}\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.HPub, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.HPub);
Assert.Equal("foo", cmds[0].Subject); cmds[0].Subject.ShouldBe("foo");
Assert.Equal(header.Length, cmds[0].HeaderSize); cmds[0].HeaderSize.ShouldBe(header.Length);
} }
[Fact] [Fact]
public async Task Parse_INFO() public async Task Parse_INFO()
{ {
var cmds = await ParseAsync("INFO {\"server_id\":\"test\"}\r\n"); var cmds = await ParseAsync("INFO {\"server_id\":\"test\"}\r\n");
Assert.Single(cmds); cmds.ShouldHaveSingleItem();
Assert.Equal(CommandType.Info, cmds[0].Type); cmds[0].Type.ShouldBe(CommandType.Info);
} }
} }

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server; using NATS.Server;
namespace NATS.Server.Tests; namespace NATS.Server.Tests;
@@ -15,7 +16,7 @@ public class ServerTests : IAsyncDisposable
{ {
// Use random port // Use random port
_port = GetFreePort(); _port = GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port }); _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -54,7 +55,7 @@ public class ServerTests : IAsyncDisposable
using var client = await ConnectClientAsync(); using var client = await ConnectClientAsync();
var response = await ReadLineAsync(client); var response = await ReadLineAsync(client);
Assert.StartsWith("INFO ", response); response.ShouldStartWith("INFO ");
await _cts.CancelAsync(); await _cts.CancelAsync();
} }
@@ -84,7 +85,7 @@ public class ServerTests : IAsyncDisposable
var n = await sub.ReceiveAsync(buf, SocketFlags.None); var n = await sub.ReceiveAsync(buf, SocketFlags.None);
var msg = Encoding.ASCII.GetString(buf, 0, n); var msg = Encoding.ASCII.GetString(buf, 0, n);
Assert.Contains("MSG foo 1 5\r\nHello\r\n", msg); msg.ShouldContain("MSG foo 1 5\r\nHello\r\n");
await _cts.CancelAsync(); await _cts.CancelAsync();
} }
@@ -110,7 +111,7 @@ public class ServerTests : IAsyncDisposable
var n = await sub.ReceiveAsync(buf, SocketFlags.None); var n = await sub.ReceiveAsync(buf, SocketFlags.None);
var msg = Encoding.ASCII.GetString(buf, 0, n); var msg = Encoding.ASCII.GetString(buf, 0, n);
Assert.Contains("MSG foo.bar 1 5\r\n", msg); msg.ShouldContain("MSG foo.bar 1 5\r\n");
await _cts.CancelAsync(); await _cts.CancelAsync();
} }
} }

View File

@@ -15,9 +15,9 @@ public class SubListTests
sl.Insert(sub); sl.Insert(sub);
var r = sl.Match("foo.bar"); var r = sl.Match("foo.bar");
Assert.Single(r.PlainSubs); r.PlainSubs.ShouldHaveSingleItem();
Assert.Same(sub, r.PlainSubs[0]); r.PlainSubs[0].ShouldBeSameAs(sub);
Assert.Empty(r.QueueSubs); r.QueueSubs.ShouldBeEmpty();
} }
[Fact] [Fact]
@@ -27,7 +27,7 @@ public class SubListTests
sl.Insert(MakeSub("foo.bar")); sl.Insert(MakeSub("foo.bar"));
var r = sl.Match("foo.baz"); var r = sl.Match("foo.baz");
Assert.Empty(r.PlainSubs); r.PlainSubs.ShouldBeEmpty();
} }
[Fact] [Fact]
@@ -37,9 +37,9 @@ public class SubListTests
var sub = MakeSub("foo.*"); var sub = MakeSub("foo.*");
sl.Insert(sub); sl.Insert(sub);
Assert.Single(sl.Match("foo.bar").PlainSubs); sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
Assert.Single(sl.Match("foo.baz").PlainSubs); sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem();
Assert.Empty(sl.Match("foo.bar.baz").PlainSubs); sl.Match("foo.bar.baz").PlainSubs.ShouldBeEmpty();
} }
[Fact] [Fact]
@@ -49,9 +49,9 @@ public class SubListTests
var sub = MakeSub("foo.>"); var sub = MakeSub("foo.>");
sl.Insert(sub); sl.Insert(sub);
Assert.Single(sl.Match("foo.bar").PlainSubs); sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
Assert.Single(sl.Match("foo.bar.baz").PlainSubs); sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
Assert.Empty(sl.Match("foo").PlainSubs); sl.Match("foo").PlainSubs.ShouldBeEmpty();
} }
[Fact] [Fact]
@@ -60,9 +60,9 @@ public class SubListTests
var sl = new SubList(); var sl = new SubList();
sl.Insert(MakeSub(">")); sl.Insert(MakeSub(">"));
Assert.Single(sl.Match("foo").PlainSubs); sl.Match("foo").PlainSubs.ShouldHaveSingleItem();
Assert.Single(sl.Match("foo.bar").PlainSubs); sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
Assert.Single(sl.Match("foo.bar.baz").PlainSubs); sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
} }
[Fact] [Fact]
@@ -75,7 +75,7 @@ public class SubListTests
sl.Insert(MakeSub(">", sid: "4")); sl.Insert(MakeSub(">", sid: "4"));
var r = sl.Match("foo.bar"); var r = sl.Match("foo.bar");
Assert.Equal(4, r.PlainSubs.Length); r.PlainSubs.Length.ShouldBe(4);
} }
[Fact] [Fact]
@@ -84,10 +84,10 @@ public class SubListTests
var sl = new SubList(); var sl = new SubList();
var sub = MakeSub("foo.bar"); var sub = MakeSub("foo.bar");
sl.Insert(sub); sl.Insert(sub);
Assert.Single(sl.Match("foo.bar").PlainSubs); sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
sl.Remove(sub); sl.Remove(sub);
Assert.Empty(sl.Match("foo.bar").PlainSubs); sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
} }
[Fact] [Fact]
@@ -99,19 +99,19 @@ public class SubListTests
sl.Insert(MakeSub("foo.bar", queue: "loggers", sid: "3")); sl.Insert(MakeSub("foo.bar", queue: "loggers", sid: "3"));
var r = sl.Match("foo.bar"); var r = sl.Match("foo.bar");
Assert.Empty(r.PlainSubs); r.PlainSubs.ShouldBeEmpty();
Assert.Equal(2, r.QueueSubs.Length); // 2 queue groups r.QueueSubs.Length.ShouldBe(2); // 2 queue groups
} }
[Fact] [Fact]
public void Count_tracks_subscriptions() public void Count_tracks_subscriptions()
{ {
var sl = new SubList(); var sl = new SubList();
Assert.Equal(0u, sl.Count); sl.Count.ShouldBe(0u);
sl.Insert(MakeSub("foo", sid: "1")); sl.Insert(MakeSub("foo", sid: "1"));
sl.Insert(MakeSub("bar", sid: "2")); sl.Insert(MakeSub("bar", sid: "2"));
Assert.Equal(2u, sl.Count); sl.Count.ShouldBe(2u);
sl.Remove(MakeSub("foo", sid: "1")); sl.Remove(MakeSub("foo", sid: "1"));
// Remove by reference won't work — we need the same instance // Remove by reference won't work — we need the same instance
@@ -123,9 +123,9 @@ public class SubListTests
var sl = new SubList(); var sl = new SubList();
var sub = MakeSub("foo"); var sub = MakeSub("foo");
sl.Insert(sub); sl.Insert(sub);
Assert.Equal(1u, sl.Count); sl.Count.ShouldBe(1u);
sl.Remove(sub); sl.Remove(sub);
Assert.Equal(0u, sl.Count); sl.Count.ShouldBe(0u);
} }
[Fact] [Fact]
@@ -136,13 +136,13 @@ public class SubListTests
// Prime the cache // Prime the cache
var r1 = sl.Match("foo.bar"); var r1 = sl.Match("foo.bar");
Assert.Single(r1.PlainSubs); r1.PlainSubs.ShouldHaveSingleItem();
// Insert a wildcard that matches — cache should be invalidated // Insert a wildcard that matches — cache should be invalidated
sl.Insert(MakeSub("foo.*", sid: "2")); sl.Insert(MakeSub("foo.*", sid: "2"));
var r2 = sl.Match("foo.bar"); var r2 = sl.Match("foo.bar");
Assert.Equal(2, r2.PlainSubs.Length); r2.PlainSubs.Length.ShouldBe(2);
} }
[Fact] [Fact]
@@ -154,6 +154,6 @@ public class SubListTests
sl.Insert(MakeSub("foo.bar.*", sid: "3")); sl.Insert(MakeSub("foo.bar.*", sid: "3"));
var r = sl.Match("foo.bar.baz"); var r = sl.Match("foo.bar.baz");
Assert.Equal(3, r.PlainSubs.Length); r.PlainSubs.Length.ShouldBe(3);
} }
} }

View File

@@ -23,7 +23,7 @@ public class SubjectMatchTests
[InlineData("foo\tbar", false)] // no tabs [InlineData("foo\tbar", false)] // no tabs
public void IsValidSubject(string subject, bool expected) public void IsValidSubject(string subject, bool expected)
{ {
Assert.Equal(expected, SubjectMatch.IsValidSubject(subject)); SubjectMatch.IsValidSubject(subject).ShouldBe(expected);
} }
[Theory] [Theory]
@@ -35,7 +35,7 @@ public class SubjectMatchTests
[InlineData("*", false)] [InlineData("*", false)]
public void IsValidPublishSubject(string subject, bool expected) public void IsValidPublishSubject(string subject, bool expected)
{ {
Assert.Equal(expected, SubjectMatch.IsValidPublishSubject(subject)); SubjectMatch.IsValidPublishSubject(subject).ShouldBe(expected);
} }
[Theory] [Theory]
@@ -52,6 +52,6 @@ public class SubjectMatchTests
[InlineData("foo.bar", "foo.bar.>", false)] [InlineData("foo.bar", "foo.bar.>", false)]
public void MatchLiteral(string literal, string pattern, bool expected) public void MatchLiteral(string literal, string pattern, bool expected)
{ {
Assert.Equal(expected, SubjectMatch.MatchLiteral(literal, pattern)); SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected);
} }
} }