diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ClosedRingBuffer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ClosedRingBuffer.cs new file mode 100644 index 0000000..09109be --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ClosedRingBuffer.cs @@ -0,0 +1,118 @@ +// Copyright 2018-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/ring.go in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server.Internal; + +// ------------------------------------------------------------------------- +// Placeholder types — will be populated in session 08 (Client) and +// session 12 (Monitor/Events). +// ------------------------------------------------------------------------- + +/// +/// Subset of client connection info exposed on the monitoring endpoint (/connz). +/// Full implementation is in session 12 (monitor.go). +/// +public class ConnInfo { } + +/// +/// Subscription detail for the monitoring endpoint. +/// Full implementation is in session 12 (monitor.go). +/// +public class SubDetail { } + +// ------------------------------------------------------------------------- +// ClosedClient / ClosedRingBuffer (ring.go) +// ------------------------------------------------------------------------- + +/// +/// Wraps connection info with optional items for the /connz endpoint. +/// Mirrors closedClient in server/ring.go. +/// +public sealed class ClosedClient +{ + public ConnInfo Info { get; init; } = new(); + public IReadOnlyList Subs { get; init; } = []; + public string User { get; init; } = string.Empty; + public string Account { get; init; } = string.Empty; +} + +/// +/// Fixed-size ring buffer that retains the most recent closed connections, +/// evicting the oldest when full. +/// Mirrors closedRingBuffer in server/ring.go. +/// +public sealed class ClosedRingBuffer +{ + private ulong _total; + private readonly ClosedClient?[] _conns; + + /// Creates a ring buffer that holds at most entries. + public ClosedRingBuffer(int max) + { + _conns = new ClosedClient?[max]; + } + + /// + /// Appends a closed connection, evicting the oldest if the buffer is full. + /// Mirrors closedRingBuffer.append. + /// + public void Append(ClosedClient cc) + { + _conns[Next()] = cc; + _total++; + } + + // Index of the slot to write next — wraps around. + private int Next() => (int)(_total % (ulong)_conns.Length); + + /// + /// Returns the number of entries currently stored (≤ capacity). + /// Mirrors closedRingBuffer.len. + /// + public int Len() => + _total > (ulong)_conns.Length ? _conns.Length : (int)_total; + + /// + /// Returns the total number of connections ever appended (not capped). + /// Mirrors closedRingBuffer.totalConns. + /// + public ulong TotalConns() => _total; + + /// + /// Returns a chronologically ordered copy of the stored closed connections. + /// The caller may freely modify the returned array. + /// Mirrors closedRingBuffer.closedClients. + /// + public ClosedClient?[] ClosedClients() + { + var len = Len(); + var dup = new ClosedClient?[len]; + var head = Next(); + + if (_total <= (ulong)_conns.Length || head == 0) + { + Array.Copy(_conns, dup, len); + } + else + { + var fp = _conns.AsSpan(head); // oldest … end of array + var sp = _conns.AsSpan(0, head); // wrap-around … newest + fp.CopyTo(dup.AsSpan()); + sp.CopyTo(dup.AsSpan(fp.Length)); + } + + return dup; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/StreamDeletionMeta.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/StreamDeletionMeta.cs new file mode 100644 index 0000000..4e7af96 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/StreamDeletionMeta.cs @@ -0,0 +1,121 @@ +// Copyright 2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/sdm.go in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server.Internal.DataStructures; + +/// +/// Per-sequence data tracked by . +/// Mirrors SDMBySeq in server/sdm.go. +/// +public readonly struct SdmBySeq +{ + /// Whether this sequence was the last message for its subject. + public bool Last { get; init; } + + /// Timestamp (nanoseconds UTC) when the removal/SDM was last proposed. + public long Ts { get; init; } +} + +/// +/// Tracks pending subject delete markers (SDMs) and message removals for a stream. +/// Used by JetStream cluster consensus to avoid redundant proposals. +/// Mirrors SDMMeta in server/sdm.go. +/// +public sealed class StreamDeletionMeta +{ + // Per-subject pending-count totals. + private readonly Dictionary _totals = new(1); + + // Per-sequence data keyed by sequence number. + private readonly Dictionary _pending = new(1); + + // ------------------------------------------------------------------------- + // Header constants (forward-declared; populated in session 19 — JetStream). + // isSubjectDeleteMarker checks these header keys. + // ------------------------------------------------------------------------- + + // Mirrors JSMarkerReason header key (defined in jetstream.go). + internal const string HeaderJsMarkerReason = "Nats-Marker-Reason"; + + // Mirrors KVOperation header key (defined in jetstream.go). + internal const string HeaderKvOperation = "KV-Operation"; + + // Mirrors KVOperationValuePurge (defined in jetstream.go). + internal const string KvOperationValuePurge = "PURGE"; + + /// + /// Returns true when the given header block contains a subject delete marker + /// (either a JetStream marker or a KV purge operation). + /// Mirrors isSubjectDeleteMarker in server/sdm.go. + /// + public static bool IsSubjectDeleteMarker(ReadOnlySpan hdr) + { + // Simplified header scan: checks whether JSMarkerReason key is present + // or whether KV-Operation equals "PURGE". + // Full implementation depends on SliceHeader from session 08 (client.go). + // Until then this provides the correct contract. + var text = System.Text.Encoding.UTF8.GetString(hdr); + if (text.Contains(HeaderJsMarkerReason)) + return true; + if (text.Contains($"{HeaderKvOperation}: {KvOperationValuePurge}")) + return true; + return false; + } + + /// + /// Clears all tracked data. + /// Mirrors SDMMeta.empty. + /// + public void Empty() + { + _totals.Clear(); + _pending.Clear(); + } + + /// + /// Tracks as pending and returns whether it was + /// the last message for its subject. If the sequence is already tracked + /// the existing Last value is returned without modification. + /// Mirrors SDMMeta.trackPending. + /// + public bool TrackPending(ulong seq, string subj, bool last) + { + if (_pending.TryGetValue(seq, out var p)) + return p.Last; + + _pending[seq] = new SdmBySeq { Last = last, Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L }; + _totals[subj] = _totals.TryGetValue(subj, out var cnt) ? cnt + 1 : 1; + return last; + } + + /// + /// Removes and decrements the pending count for + /// , deleting the subject entry when it reaches zero. + /// Mirrors SDMMeta.removeSeqAndSubject. + /// + public void RemoveSeqAndSubject(ulong seq, string subj) + { + if (!_pending.Remove(seq)) + return; + + if (_totals.TryGetValue(subj, out var msgs)) + { + if (msgs <= 1) + _totals.Remove(subj); + else + _totals[subj] = msgs - 1; + } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ProtoWire.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ProtoWire.cs new file mode 100644 index 0000000..22eaff2 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ProtoWire.cs @@ -0,0 +1,284 @@ +// Copyright 2024 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/proto.go in the NATS server Go source. +// Inspired by https://github.com/protocolbuffers/protobuf-go/blob/master/encoding/protowire/wire.go + +namespace ZB.MOM.NatsNet.Server.Internal; + +/// +/// Low-level protobuf wire format helpers used internally for JetStream API encoding. +/// Mirrors server/proto.go. +/// +public static class ProtoWire +{ + private static readonly Exception ErrInsufficient = + new InvalidOperationException("insufficient data to read a value"); + + private static readonly Exception ErrOverflow = + new InvalidOperationException("too much data for a value"); + + private static readonly Exception ErrInvalidFieldNumber = + new InvalidOperationException("invalid field number"); + + // ------------------------------------------------------------------------- + // Field scanning + // ------------------------------------------------------------------------- + + /// + /// Reads a complete protobuf field (tag + value) from + /// and returns the field number, wire type, and total byte size consumed. + /// Mirrors protoScanField. + /// + public static (int num, int typ, int size, Exception? err) ScanField(ReadOnlySpan b) + { + var (num, typ, sizeTag, err) = ScanTag(b); + if (err != null) return (0, 0, 0, err); + + var (sizeValue, err2) = ScanFieldValue(typ, b[sizeTag..]); + if (err2 != null) return (0, 0, 0, err2); + + return (num, typ, sizeTag + sizeValue, null); + } + + /// + /// Reads a protobuf tag varint and returns field number, wire type, and bytes consumed. + /// Mirrors protoScanTag. + /// + public static (int num, int typ, int size, Exception? err) ScanTag(ReadOnlySpan b) + { + var (tagint, size, err) = ScanVarint(b); + if (err != null) return (0, 0, 0, err); + + if ((tagint >> 3) > int.MaxValue) + return (0, 0, 0, ErrInvalidFieldNumber); + + var num = (int)(tagint >> 3); + if (num < 1) + return (0, 0, 0, ErrInvalidFieldNumber); + + var typ = (int)(tagint & 7); + return (num, typ, size, null); + } + + /// + /// Returns the byte count consumed by a field value with the given wire type. + /// Mirrors protoScanFieldValue. + /// + public static (int size, Exception? err) ScanFieldValue(int typ, ReadOnlySpan b) + { + switch (typ) + { + case 0: // varint + { + var (_, size, err) = ScanVarint(b); + return (size, err); + } + case 5: // fixed32 + return (4, null); + case 1: // fixed64 + return (8, null); + case 2: // length-delimited + { + var (size, err) = ScanBytes(b); + return (size, err); + } + default: + return (0, new InvalidOperationException($"unsupported type: {typ}")); + } + } + + // ------------------------------------------------------------------------- + // Varint decode + // ------------------------------------------------------------------------- + + /// + /// Decodes a protobuf varint from . + /// Returns (value, bytes_consumed, error). + /// Mirrors protoScanVarint. + /// + public static (ulong v, int size, Exception? err) ScanVarint(ReadOnlySpan b) + { + if (b.Length < 1) return (0, 0, ErrInsufficient); + ulong v = b[0]; + if (v < 0x80) return (v, 1, null); + v -= 0x80; + + if (b.Length < 2) return (0, 0, ErrInsufficient); + ulong y = b[1]; + v += y << 7; + if (y < 0x80) return (v, 2, null); + v -= 0x80UL << 7; + + if (b.Length < 3) return (0, 0, ErrInsufficient); + y = b[2]; + v += y << 14; + if (y < 0x80) return (v, 3, null); + v -= 0x80UL << 14; + + if (b.Length < 4) return (0, 0, ErrInsufficient); + y = b[3]; + v += y << 21; + if (y < 0x80) return (v, 4, null); + v -= 0x80UL << 21; + + if (b.Length < 5) return (0, 0, ErrInsufficient); + y = b[4]; + v += y << 28; + if (y < 0x80) return (v, 5, null); + v -= 0x80UL << 28; + + if (b.Length < 6) return (0, 0, ErrInsufficient); + y = b[5]; + v += y << 35; + if (y < 0x80) return (v, 6, null); + v -= 0x80UL << 35; + + if (b.Length < 7) return (0, 0, ErrInsufficient); + y = b[6]; + v += y << 42; + if (y < 0x80) return (v, 7, null); + v -= 0x80UL << 42; + + if (b.Length < 8) return (0, 0, ErrInsufficient); + y = b[7]; + v += y << 49; + if (y < 0x80) return (v, 8, null); + v -= 0x80UL << 49; + + if (b.Length < 9) return (0, 0, ErrInsufficient); + y = b[8]; + v += y << 56; + if (y < 0x80) return (v, 9, null); + v -= 0x80UL << 56; + + if (b.Length < 10) return (0, 0, ErrInsufficient); + y = b[9]; + v += y << 63; + if (y < 2) return (v, 10, null); + + return (0, 0, ErrOverflow); + } + + // ------------------------------------------------------------------------- + // Length-delimited decode + // ------------------------------------------------------------------------- + + /// + /// Returns the total byte count consumed by a length-delimited field + /// (length varint + content). + /// Mirrors protoScanBytes. + /// + public static (int size, Exception? err) ScanBytes(ReadOnlySpan b) + { + var (l, lenSize, err) = ScanVarint(b); + if (err != null) return (0, err); + + if (l > (ulong)(b.Length - lenSize)) + return (0, ErrInsufficient); + + return (lenSize + (int)l, null); + } + + // ------------------------------------------------------------------------- + // Varint encode + // ------------------------------------------------------------------------- + + /// + /// Encodes a as a protobuf varint. + /// Mirrors protoEncodeVarint. + /// + public static byte[] EncodeVarint(ulong v) + { + if (v < 1UL << 7) + return [(byte)v]; + + if (v < 1UL << 14) + return [(byte)((v >> 0) & 0x7F | 0x80), (byte)(v >> 7)]; + + if (v < 1UL << 21) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)(v >> 14)]; + + if (v < 1UL << 28) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)(v >> 21)]; + + if (v < 1UL << 35) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)((v >> 21) & 0x7F | 0x80), + (byte)(v >> 28)]; + + if (v < 1UL << 42) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)((v >> 21) & 0x7F | 0x80), + (byte)((v >> 28) & 0x7F | 0x80), + (byte)(v >> 35)]; + + if (v < 1UL << 49) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)((v >> 21) & 0x7F | 0x80), + (byte)((v >> 28) & 0x7F | 0x80), + (byte)((v >> 35) & 0x7F | 0x80), + (byte)(v >> 42)]; + + if (v < 1UL << 56) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)((v >> 21) & 0x7F | 0x80), + (byte)((v >> 28) & 0x7F | 0x80), + (byte)((v >> 35) & 0x7F | 0x80), + (byte)((v >> 42) & 0x7F | 0x80), + (byte)(v >> 49)]; + + if (v < 1UL << 63) + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)((v >> 21) & 0x7F | 0x80), + (byte)((v >> 28) & 0x7F | 0x80), + (byte)((v >> 35) & 0x7F | 0x80), + (byte)((v >> 42) & 0x7F | 0x80), + (byte)((v >> 49) & 0x7F | 0x80), + (byte)(v >> 56)]; + + return [ + (byte)((v >> 0) & 0x7F | 0x80), + (byte)((v >> 7) & 0x7F | 0x80), + (byte)((v >> 14) & 0x7F | 0x80), + (byte)((v >> 21) & 0x7F | 0x80), + (byte)((v >> 28) & 0x7F | 0x80), + (byte)((v >> 35) & 0x7F | 0x80), + (byte)((v >> 42) & 0x7F | 0x80), + (byte)((v >> 49) & 0x7F | 0x80), + (byte)((v >> 56) & 0x7F | 0x80), + 1]; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/RateCounter.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/RateCounter.cs new file mode 100644 index 0000000..e1eec15 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/RateCounter.cs @@ -0,0 +1,81 @@ +// Copyright 2021-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/rate_counter.go in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server.Internal; + +/// +/// A sliding-window rate limiter that allows at most limit events +/// per window. +/// Mirrors rateCounter in server/rate_counter.go. +/// +public sealed class RateCounter +{ + private readonly long _limit; + private long _count; + private ulong _blocked; + private DateTime _end; + + // Exposed for tests (mirrors direct field access in rate_counter_test.go). + public TimeSpan Interval; + + private readonly object _lock = new(); + + public RateCounter(long limit) + { + _limit = limit; + Interval = TimeSpan.FromSeconds(1); + } + + /// + /// Returns true if the event is within the rate limit for the current window. + /// Mirrors rateCounter.allow. + /// + public bool Allow() + { + var now = DateTime.UtcNow; + + lock (_lock) + { + if (now > _end) + { + _count = 0; + _end = now + Interval; + } + else + { + _count++; + } + + var allow = _count < _limit; + if (!allow) + _blocked++; + return allow; + } + } + + /// + /// Returns and resets the count of blocked events since the last call. + /// Mirrors rateCounter.countBlocked. + /// + public ulong CountBlocked() + { + lock (_lock) + { + var blocked = _blocked; + _blocked = 0; + return blocked; + } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs new file mode 100644 index 0000000..40e8a45 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs @@ -0,0 +1,228 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/const.go in the NATS server Go source. + +using System.Reflection; + +namespace ZB.MOM.NatsNet.Server; + +/// +/// Server-wide constants and version information. +/// Mirrors server/const.go. +/// +public static class ServerConstants +{ + // Server version — mirrors VERSION in const.go. + public const string Version = "2.14.0-dev"; + + // Protocol version — mirrors PROTO in const.go. + public const int Proto = 1; + + // Default port for client connections — mirrors DEFAULT_PORT. + public const int DefaultPort = 4222; + + // Sentinel port value that triggers a random port selection — mirrors RANDOM_PORT. + public const int RandomPort = -1; + + // Default bind address — mirrors DEFAULT_HOST. + public const string DefaultHost = "0.0.0.0"; + + // Maximum allowed control line size (4 KB) — mirrors MAX_CONTROL_LINE_SIZE. + public const int MaxControlLineSize = 4096; + + // Maximum allowed payload size (1 MB) — mirrors MAX_PAYLOAD_SIZE. + public const int MaxPayloadSize = 1024 * 1024; + + // Payload size above which the server warns — mirrors MAX_PAYLOAD_MAX_SIZE. + public const int MaxPayloadMaxSize = 8 * 1024 * 1024; + + // Maximum outbound pending bytes per client (64 MB) — mirrors MAX_PENDING_SIZE. + public const int MaxPendingSize = 64 * 1024 * 1024; + + // Default maximum connections allowed (64 K) — mirrors DEFAULT_MAX_CONNECTIONS. + public const int DefaultMaxConnections = 64 * 1024; + + // TLS handshake timeout — mirrors TLS_TIMEOUT. + public static readonly TimeSpan TlsTimeout = TimeSpan.FromSeconds(2); + + // Fallback delay before sending INFO when using TLSHandshakeFirst + // — mirrors DEFAULT_TLS_HANDSHAKE_FIRST_FALLBACK_DELAY. + public static readonly TimeSpan DefaultTlsHandshakeFirstFallbackDelay = TimeSpan.FromMilliseconds(50); + + // Auth timeout — mirrors AUTH_TIMEOUT. + public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2); + + // How often pings are sent — mirrors DEFAULT_PING_INTERVAL. + public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2); + + // Maximum pings outstanding before disconnect — mirrors DEFAULT_PING_MAX_OUT. + public const int DefaultPingMaxOut = 2; + + // CR LF end-of-line — mirrors CR_LF. + public const string CrLf = "\r\n"; + + // Length of CR_LF — mirrors LEN_CR_LF. + public const int LenCrLf = 2; + + // Write/flush deadline — mirrors DEFAULT_FLUSH_DEADLINE. + public static readonly TimeSpan DefaultFlushDeadline = TimeSpan.FromSeconds(10); + + // Default monitoring port — mirrors DEFAULT_HTTP_PORT. + public const int DefaultHttpPort = 8222; + + // Default monitoring base path — mirrors DEFAULT_HTTP_BASE_PATH. + public const string DefaultHttpBasePath = "/"; + + // Minimum sleep on temporary accept errors — mirrors ACCEPT_MIN_SLEEP. + public static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10); + + // Maximum sleep on temporary accept errors — mirrors ACCEPT_MAX_SLEEP. + public static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1); + + // Route solicitation interval — mirrors DEFAULT_ROUTE_CONNECT. + public static readonly TimeSpan DefaultRouteConnect = TimeSpan.FromSeconds(1); + + // Maximum route solicitation interval — mirrors DEFAULT_ROUTE_CONNECT_MAX. + public static readonly TimeSpan DefaultRouteConnectMax = TimeSpan.FromSeconds(30); + + // Route reconnect delay — mirrors DEFAULT_ROUTE_RECONNECT. + public static readonly TimeSpan DefaultRouteReconnect = TimeSpan.FromSeconds(1); + + // Route dial timeout — mirrors DEFAULT_ROUTE_DIAL. + public static readonly TimeSpan DefaultRouteDial = TimeSpan.FromSeconds(1); + + // Default route pool size — mirrors DEFAULT_ROUTE_POOL_SIZE. + public const int DefaultRoutePoolSize = 3; + + // LeafNode reconnect interval — mirrors DEFAULT_LEAF_NODE_RECONNECT. + public static readonly TimeSpan DefaultLeafNodeReconnect = TimeSpan.FromSeconds(1); + + // TLS timeout for leaf nodes — mirrors DEFAULT_LEAF_TLS_TIMEOUT. + public static readonly TimeSpan DefaultLeafTlsTimeout = TimeSpan.FromSeconds(2); + + // Proto snippet size for parse error display — mirrors PROTO_SNIPPET_SIZE. + public const int ProtoSnippetSize = 32; + + // Max control line snippet size for error display — mirrors MAX_CONTROL_LINE_SNIPPET_SIZE. + public const int MaxControlLineSnippetSize = 128; + + // Maximum MSG proto argument count — mirrors MAX_MSG_ARGS. + public const int MaxMsgArgs = 4; + + // Maximum RMSG proto argument count — mirrors MAX_RMSG_ARGS. + public const int MaxRMsgArgs = 6; + + // Maximum HMSG proto argument count — mirrors MAX_HMSG_ARGS. + public const int MaxHMsgArgs = 7; + + // Maximum PUB proto argument count — mirrors MAX_PUB_ARGS. + public const int MaxPubArgs = 3; + + // Maximum HPUB proto argument count — mirrors MAX_HPUB_ARGS. + public const int MaxHPubArgs = 4; + + // Maximum RS+/LS+ proto argument count — mirrors MAX_RSUB_ARGS. + public const int MaxRSubArgs = 6; + + // Maximum closed connections retained — mirrors DEFAULT_MAX_CLOSED_CLIENTS. + public const int DefaultMaxClosedClients = 10000; + + // Lame duck spread duration — mirrors DEFAULT_LAME_DUCK_DURATION. + public static readonly TimeSpan DefaultLameDuckDuration = TimeSpan.FromMinutes(2); + + // Lame duck grace period — mirrors DEFAULT_LAME_DUCK_GRACE_PERIOD. + public static readonly TimeSpan DefaultLameDuckGracePeriod = TimeSpan.FromSeconds(10); + + // Leaf node INFO wait — mirrors DEFAULT_LEAFNODE_INFO_WAIT. + public static readonly TimeSpan DefaultLeafNodeInfoWait = TimeSpan.FromSeconds(1); + + // Default leaf node port — mirrors DEFAULT_LEAFNODE_PORT. + public const int DefaultLeafNodePort = 7422; + + // Connect error report threshold — mirrors DEFAULT_CONNECT_ERROR_REPORTS. + public const int DefaultConnectErrorReports = 3600; + + // Reconnect error report threshold — mirrors DEFAULT_RECONNECT_ERROR_REPORTS. + public const int DefaultReconnectErrorReports = 1; + + // RTT measurement interval — mirrors DEFAULT_RTT_MEASUREMENT_INTERVAL. + public static readonly TimeSpan DefaultRttMeasurementInterval = TimeSpan.FromHours(1); + + // Default allowed response max messages — mirrors DEFAULT_ALLOW_RESPONSE_MAX_MSGS. + public const int DefaultAllowResponseMaxMsgs = 1; + + // Default allowed response expiration — mirrors DEFAULT_ALLOW_RESPONSE_EXPIRATION. + public static readonly TimeSpan DefaultAllowResponseExpiration = TimeSpan.FromMinutes(2); + + // Default service export response threshold — mirrors DEFAULT_SERVICE_EXPORT_RESPONSE_THRESHOLD. + public static readonly TimeSpan DefaultServiceExportResponseThreshold = TimeSpan.FromMinutes(2); + + // Default service latency sampling rate — mirrors DEFAULT_SERVICE_LATENCY_SAMPLING. + public const int DefaultServiceLatencySampling = 100; + + // Default system account name — mirrors DEFAULT_SYSTEM_ACCOUNT. + public const string DefaultSystemAccount = "$SYS"; + + // Default global account name — mirrors DEFAULT_GLOBAL_ACCOUNT. + public const string DefaultGlobalAccount = "$G"; + + // Default account fetch timeout — mirrors DEFAULT_ACCOUNT_FETCH_TIMEOUT. + public static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromMilliseconds(1900); + + // VCS commit hash embedded at build time, shortened to 7 chars — mirrors gitCommit. + // Populated from AssemblyInformationalVersion metadata if available. + public static readonly string GitCommit; + + static ServerConstants() + { + // Mirror const.go init(): extract VCS revision from build info. + // In .NET we read the AssemblyInformationalVersion attribute which + // is typically set to the semantic version + commit hash by dotnet publish. + var infoVersion = typeof(ServerConstants).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (infoVersion != null) + { + // Convention: "1.2.3+abcdefg" or "1.2.3-dev+abcdefg" + var plusIdx = infoVersion.IndexOf('+'); + if (plusIdx >= 0) + { + var rev = infoVersion[(plusIdx + 1)..]; + GitCommit = FormatRevision(rev); + return; + } + } + + GitCommit = string.Empty; + } + + /// + /// Truncates a VCS revision string to 7 characters for display. + /// Mirrors formatRevision in const.go. + /// + public static string FormatRevision(string revision) => + revision.Length >= 7 ? revision[..7] : revision; +} + +/// +/// Server control commands — mirrors the Command type in const.go. +/// +public enum ServerCommand +{ + Stop, + Quit, + Reopen, + Reload, +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs new file mode 100644 index 0000000..2558140 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs @@ -0,0 +1,446 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/errors.go in the NATS server Go source. +// Note: errorsUnwrap() and ErrorIs() are Go 1.12 compat shims — .NET has +// Exception.InnerException and errors.Is() equivalents built in; +// both are represented here for completeness but map to built-in .NET patterns. + +namespace ZB.MOM.NatsNet.Server; + +// ------------------------------------------------------------------------- +// Sentinel error values (mirrors package-level var block in errors.go) +// ------------------------------------------------------------------------- + +/// +/// Standard NATS server error sentinels. +/// Mirrors the package-level var error block in server/errors.go. +/// +public static class ServerErrors +{ + public static readonly Exception ErrConnectionClosed = + new InvalidOperationException("connection closed"); + + public static readonly Exception ErrAuthentication = + new InvalidOperationException("authentication error"); + + public static readonly Exception ErrAuthTimeout = + new InvalidOperationException("authentication timeout"); + + public static readonly Exception ErrAuthExpired = + new InvalidOperationException("authentication expired"); + + public static readonly Exception ErrAuthProxyNotTrusted = + new InvalidOperationException("proxy is not trusted"); + + public static readonly Exception ErrAuthProxyRequired = + new InvalidOperationException("proxy connection required"); + + public static readonly Exception ErrMaxPayload = + new InvalidOperationException("maximum payload exceeded"); + + public static readonly Exception ErrMaxControlLine = + new InvalidOperationException("maximum control line exceeded"); + + public static readonly Exception ErrReservedPublishSubject = + new InvalidOperationException("reserved internal subject"); + + public static readonly Exception ErrBadPublishSubject = + new InvalidOperationException("invalid publish subject"); + + public static readonly Exception ErrBadSubject = + new InvalidOperationException("invalid subject"); + + public static readonly Exception ErrBadQualifier = + new InvalidOperationException("bad qualifier"); + + public static readonly Exception ErrBadClientProtocol = + new InvalidOperationException("invalid client protocol"); + + public static readonly Exception ErrTooManyConnections = + new InvalidOperationException("maximum connections exceeded"); + + public static readonly Exception ErrTooManyAccountConnections = + new InvalidOperationException("maximum account active connections exceeded"); + + public static readonly Exception ErrLeafNodeLoop = + new InvalidOperationException("leafnode loop detected"); + + public static readonly Exception ErrTooManySubs = + new InvalidOperationException("maximum subscriptions exceeded"); + + public static readonly Exception ErrTooManySubTokens = + new InvalidOperationException("subject has exceeded number of tokens limit"); + + public static readonly Exception ErrClientConnectedToRoutePort = + new InvalidOperationException("attempted to connect to route port"); + + public static readonly Exception ErrClientConnectedToLeafNodePort = + new InvalidOperationException("attempted to connect to leaf node port"); + + public static readonly Exception ErrLeafNodeHasSameClusterName = + new InvalidOperationException("remote leafnode has same cluster name"); + + public static readonly Exception ErrLeafNodeDisabled = + new InvalidOperationException("leafnodes disabled"); + + public static readonly Exception ErrConnectedToWrongPort = + new InvalidOperationException("attempted to connect to wrong port"); + + public static readonly Exception ErrAccountExists = + new InvalidOperationException("account exists"); + + public static readonly Exception ErrBadAccount = + new InvalidOperationException("bad account"); + + public static readonly Exception ErrReservedAccount = + new InvalidOperationException("reserved account"); + + public static readonly Exception ErrMissingAccount = + new InvalidOperationException("account missing"); + + public static readonly Exception ErrMissingService = + new InvalidOperationException("service missing"); + + public static readonly Exception ErrBadServiceType = + new InvalidOperationException("bad service response type"); + + public static readonly Exception ErrBadSampling = + new InvalidOperationException("bad sampling percentage, should be 1-100"); + + public static readonly Exception ErrAccountValidation = + new InvalidOperationException("account validation failed"); + + public static readonly Exception ErrAccountExpired = + new InvalidOperationException("account expired"); + + public static readonly Exception ErrNoAccountResolver = + new InvalidOperationException("account resolver missing"); + + public static readonly Exception ErrAccountResolverUpdateTooSoon = + new InvalidOperationException("account resolver update too soon"); + + public static readonly Exception ErrAccountResolverSameClaims = + new InvalidOperationException("account resolver no new claims"); + + public static readonly Exception ErrStreamImportAuthorization = + new InvalidOperationException("stream import not authorized"); + + public static readonly Exception ErrStreamImportBadPrefix = + new InvalidOperationException("stream import prefix can not contain wildcard tokens"); + + public static readonly Exception ErrStreamImportDuplicate = + new InvalidOperationException("stream import already exists"); + + public static readonly Exception ErrServiceImportAuthorization = + new InvalidOperationException("service import not authorized"); + + public static readonly Exception ErrImportFormsCycle = + new InvalidOperationException("import forms a cycle"); + + public static readonly Exception ErrCycleSearchDepth = + new InvalidOperationException("search cycle depth exhausted"); + + public static readonly Exception ErrClientOrRouteConnectedToGatewayPort = + new InvalidOperationException("attempted to connect to gateway port"); + + public static readonly Exception ErrWrongGateway = + new InvalidOperationException("wrong gateway"); + + public static readonly Exception ErrGatewayNameHasSpaces = + new InvalidOperationException("gateway name cannot contain spaces"); + + public static readonly Exception ErrNoSysAccount = + new InvalidOperationException("system account not setup"); + + public static readonly Exception ErrRevocation = + new InvalidOperationException("credentials have been revoked"); + + public static readonly Exception ErrServerNotRunning = + new InvalidOperationException("server is not running"); + + public static readonly Exception ErrServerNameHasSpaces = + new InvalidOperationException("server name cannot contain spaces"); + + public static readonly Exception ErrBadMsgHeader = + new InvalidOperationException("bad message header detected"); + + public static readonly Exception ErrMsgHeadersNotSupported = + new InvalidOperationException("message headers not supported"); + + public static readonly Exception ErrNoRespondersRequiresHeaders = + new InvalidOperationException("no responders requires headers support"); + + public static readonly Exception ErrClusterNameConfigConflict = + new InvalidOperationException("cluster name conflicts between cluster and gateway definitions"); + + public static readonly Exception ErrClusterNameRemoteConflict = + new InvalidOperationException("cluster name from remote server conflicts"); + + public static readonly Exception ErrClusterNameHasSpaces = + new InvalidOperationException("cluster name cannot contain spaces"); + + public static readonly Exception ErrMalformedSubject = + new InvalidOperationException("malformed subject"); + + public static readonly Exception ErrSubscribePermissionViolation = + new InvalidOperationException("subscribe permission violation"); + + public static readonly Exception ErrNoTransforms = + new InvalidOperationException("no matching transforms available"); + + public static readonly Exception ErrCertNotPinned = + new InvalidOperationException("certificate not pinned"); + + public static readonly Exception ErrDuplicateServerName = + new InvalidOperationException("duplicate server name"); + + public static readonly Exception ErrMinimumVersionRequired = + new InvalidOperationException("minimum version required"); + + // Mapping destination errors — the Go source wraps ErrInvalidMappingDestination. + // In .NET we use a common base message and chain inner exceptions where needed. + public static readonly Exception ErrInvalidMappingDestination = + new InvalidOperationException("invalid mapping destination"); + + public static readonly Exception ErrInvalidMappingDestinationSubject = + new InvalidOperationException("invalid mapping destination: invalid transform"); + + public static readonly Exception ErrMappingDestinationNotUsingAllWildcards = + new InvalidOperationException("invalid mapping destination: not using all of the token wildcard(s)"); + + public static readonly Exception ErrUnknownMappingDestinationFunction = + new InvalidOperationException("invalid mapping destination: unknown function"); + + public static readonly Exception ErrMappingDestinationIndexOutOfRange = + new InvalidOperationException("invalid mapping destination: wildcard index out of range"); + + public static readonly Exception ErrMappingDestinationNotEnoughArgs = + new InvalidOperationException("invalid mapping destination: not enough arguments passed to the function"); + + public static readonly Exception ErrMappingDestinationInvalidArg = + new InvalidOperationException("invalid mapping destination: function argument is invalid or in the wrong format"); + + public static readonly Exception ErrMappingDestinationTooManyArgs = + new InvalidOperationException("invalid mapping destination: too many arguments passed to the function"); + + public static readonly Exception ErrMappingDestinationNotSupportedForImport = + new InvalidOperationException("invalid mapping destination: the only mapping function allowed for import transforms is {{Wildcard()}}"); +} + +// ------------------------------------------------------------------------- +// mappingDestinationErr (mirrors server/errors.go) +// ------------------------------------------------------------------------- + +/// +/// An error type for subject mapping destination validation failures. +/// Mirrors mappingDestinationErr in server/errors.go. +/// +public sealed class MappingDestinationException : Exception +{ + private readonly string _token; + + public MappingDestinationException(string token, Exception inner) + : base($"{inner.Message} in {token}", inner) + { + _token = token; + } + + // Is() in Go is implemented via IsInvalidMappingDestination below. + + /// + /// Returns true when compared against . + /// Mirrors mappingDestinationErr.Is. + /// + public bool Is(Exception target) => + ReferenceEquals(target, ServerErrors.ErrInvalidMappingDestination); +} + +// ------------------------------------------------------------------------- +// configErr / unknownConfigFieldErr / configWarningErr (mirrors server/errors.go) +// ------------------------------------------------------------------------- +// Note: these types depend on a config-file token interface defined in the +// configuration parser (ported in session 03). Forward-declared here with the +// minimal interface needed for error formatting. + +/// +/// Represents a source location within a configuration file. +/// Mirrors the token interface used by configErr in server/errors.go. +/// Full implementation is provided by the configuration parser (session 03). +/// +public interface IConfigToken +{ + string SourceFile(); + int Line(); + int Position(); +} + +/// +/// A configuration parsing error with source location. +/// Mirrors configErr in server/errors.go. +/// +public class ConfigException : Exception +{ + private readonly IConfigToken? _token; + + public ConfigException(IConfigToken? token, string reason) + : base(reason) + { + _token = token; + } + + /// Returns "file:line:col" source location, or empty string if no token. + public new string Source() => + _token != null + ? $"{_token.SourceFile()}:{_token.Line()}:{_token.Position()}" + : string.Empty; + + public override string Message => + _token != null ? $"{Source()}: {base.Message}" : base.Message; +} + +/// +/// A configuration error for an unknown field (pedantic mode). +/// Mirrors unknownConfigFieldErr in server/errors.go. +/// +public sealed class UnknownConfigFieldException : ConfigException +{ + private readonly string _field; + + public UnknownConfigFieldException(IConfigToken token, string field) + : base(token, $"unknown field \"{field}\"") + { + _field = field; + } +} + +/// +/// A configuration warning for invalid field usage (pedantic mode). +/// Mirrors configWarningErr in server/errors.go. +/// +public sealed class ConfigWarningException : ConfigException +{ + private readonly string _field; + + public ConfigWarningException(IConfigToken token, string field, string reason) + : base(token, $"invalid use of field \"{field}\": {reason}") + { + _field = field; + } +} + +/// +/// Aggregates configuration warnings and hard errors from parsing. +/// Mirrors processConfigErr in server/errors.go. +/// +public sealed class ProcessConfigException : Exception +{ + public IReadOnlyList Warnings { get; } + public IReadOnlyList Errors { get; } + + public ProcessConfigException(IReadOnlyList errors, IReadOnlyList warnings) + : base(BuildMessage(errors, warnings)) + { + Errors = errors; + Warnings = warnings; + } + + private static string BuildMessage(IReadOnlyList errors, IReadOnlyList warnings) + { + var sb = new System.Text.StringBuilder(); + foreach (var w in warnings) sb.AppendLine(w.Message); + foreach (var e in errors) sb.AppendLine(e.Message); + return sb.ToString(); + } +} + +// ------------------------------------------------------------------------- +// errCtx — error with attached tracing context (mirrors server/errors.go) +// ------------------------------------------------------------------------- + +/// +/// Wraps an exception and attaches additional tracing context. +/// Context is not included in but is +/// accessible via and . +/// Mirrors errCtx in server/errors.go. +/// +public sealed class ErrorCtx : Exception +{ + public string Ctx { get; } + + public ErrorCtx(Exception inner, string ctx) + : base(inner.Message, inner) + { + Ctx = ctx; + } + + /// + /// Returns the context string attached to this error. + /// Mirrors errCtx.Context(). + /// + public string Context() => Ctx; +} + +/// +/// Factory and utilities for . +/// Mirrors NewErrorCtx, UnpackIfErrorCtx, and ErrorIs in server/errors.go. +/// +public static class ErrorContextHelper +{ + /// + /// Creates an wrapping with formatted context. + /// Mirrors NewErrorCtx. + /// + public static Exception NewErrorCtx(Exception err, string format, params object[] args) => + new ErrorCtx(err, string.Format(format, args)); + + /// + /// If is an , returns + /// "original.Message: ctx" (recursively). Otherwise returns err.Message. + /// Mirrors UnpackIfErrorCtx. + /// + public static string UnpackIfErrorCtx(Exception err) + { + if (err is ErrorCtx ectx) + { + if (ectx.InnerException is ErrorCtx) + return $"{UnpackIfErrorCtx(ectx.InnerException!)}: {ectx.Ctx}"; + return $"{ectx.InnerException!.Message}: {ectx.Ctx}"; + } + return err.Message; + } + + /// + /// Walks the inner exception chain checking reference equality and + /// overrides. + /// In .NET, prefer errors.Is-equivalent patterns; this mirrors + /// the Go ErrorIs shim exactly. + /// Mirrors ErrorIs. + /// + public static bool ErrorIs(Exception? err, Exception? target) + { + if (err == null || target == null) + return ReferenceEquals(err, target); + + var current = err; + while (current != null) + { + if (ReferenceEquals(current, target)) + return true; + if (current is MappingDestinationException mde && mde.Is(target)) + return true; + current = current.InnerException; + } + return false; + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Foundation/ServerErrorsTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Foundation/ServerErrorsTests.cs new file mode 100644 index 0000000..19153ab --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Foundation/ServerErrorsTests.cs @@ -0,0 +1,87 @@ +// Copyright 2020-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.Foundation; + +/// +/// Tests for and . +/// Mirrors server/errors_test.go: TestErrCtx (ID 297) and TestErrCtxWrapped (ID 298). +/// +public sealed class ServerErrorsTests +{ + [Fact] + public void ErrCtx_ShouldPreserveOriginalMessageAndAddContext() + { + // Mirror: TestErrCtx + var ctx = "Extra context information"; + var e = ErrorContextHelper.NewErrorCtx(ServerErrors.ErrWrongGateway, "{0}", ctx); + + // Message should match the underlying error. + e.Message.ShouldBe(ServerErrors.ErrWrongGateway.Message); + + // Must not be reference-equal to the sentinel. + e.ShouldNotBeSameAs(ServerErrors.ErrWrongGateway); + + // ErrorIs should find the sentinel in the chain. + ErrorContextHelper.ErrorIs(e, ServerErrors.ErrWrongGateway).ShouldBeTrue(); + + // UnpackIfErrorCtx on a non-ctx error returns Message unchanged. + ErrorContextHelper.UnpackIfErrorCtx(ServerErrors.ErrWrongGateway) + .ShouldBe(ServerErrors.ErrWrongGateway.Message); + + // UnpackIfErrorCtx should start with the original error message. + var trace = ErrorContextHelper.UnpackIfErrorCtx(e); + trace.ShouldStartWith(ServerErrors.ErrWrongGateway.Message); + + // And end with the context string. + trace.ShouldEndWith(ctx); + } + + [Fact] + public void ErrCtxWrapped_ShouldContainAllContextLayers() + { + // Mirror: TestErrCtxWrapped + var ctxO = "Original Ctx"; + var eO = ErrorContextHelper.NewErrorCtx(ServerErrors.ErrWrongGateway, "{0}", ctxO); + + var ctx = "Extra context information"; + var e = ErrorContextHelper.NewErrorCtx(eO, "{0}", ctx); + + // Message should still match the underlying error. + e.Message.ShouldBe(ServerErrors.ErrWrongGateway.Message); + + // Must not be reference-equal to the sentinel. + e.ShouldNotBeSameAs(ServerErrors.ErrWrongGateway); + + // ErrorIs should walk the chain. + ErrorContextHelper.ErrorIs(e, ServerErrors.ErrWrongGateway).ShouldBeTrue(); + + // UnpackIfErrorCtx on a non-ctx error returns Message unchanged. + ErrorContextHelper.UnpackIfErrorCtx(ServerErrors.ErrWrongGateway) + .ShouldBe(ServerErrors.ErrWrongGateway.Message); + + var trace = ErrorContextHelper.UnpackIfErrorCtx(e); + + // Must start with the original error. + trace.ShouldStartWith(ServerErrors.ErrWrongGateway.Message); + + // Must end with the outermost context. + trace.ShouldEndWith(ctx); + + // Must also contain the inner context. + trace.ShouldContain(ctxO); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ClosedRingBufferTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ClosedRingBufferTests.cs new file mode 100644 index 0000000..0e30c32 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ClosedRingBufferTests.cs @@ -0,0 +1,79 @@ +// Copyright 2018-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Shouldly; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server.Tests.Internal; + +/// +/// Tests for . +/// Mirrors server/ring_test.go: TestRBAppendAndLenAndTotal (ID 2794) +/// and TestRBclosedClients (ID 2795). +/// +public sealed class ClosedRingBufferTests +{ + [Fact] + public void AppendAndLenAndTotal_ShouldTrackCorrectly() + { + // Mirror: TestRBAppendAndLenAndTotal + var rb = new ClosedRingBuffer(10); + + for (var i = 0; i < 5; i++) + rb.Append(new ClosedClient()); + + rb.Len().ShouldBe(5); + rb.TotalConns().ShouldBe(5UL); + + for (var i = 0; i < 25; i++) + rb.Append(new ClosedClient()); + + rb.Len().ShouldBe(10); + rb.TotalConns().ShouldBe(30UL); + } + + [Fact] + public void ClosedClients_ShouldReturnChronologicalOrder() + { + // Mirror: TestRBclosedClients + var rb = new ClosedRingBuffer(10); + + // Build master list with identifiable user strings. + const int max = 100; + var master = new ClosedClient[max]; + for (var i = 1; i <= max; i++) + master[i - 1] = new ClosedClient { User = i.ToString() }; + + var ui = 0; + + void AddConn() + { + ui++; + rb.Append(new ClosedClient { User = ui.ToString() }); + } + + for (var i = 0; i < max; i++) + { + AddConn(); + + var ccs = rb.ClosedClients(); + var start = (int)rb.TotalConns() - ccs.Length; + var ms = master[start..(start + ccs.Length)]; + + // Verify order matches master using User strings. + ccs.Length.ShouldBe(ms.Length); + for (var j = 0; j < ccs.Length; j++) + ccs[j]!.User.ShouldBe(ms[j].User, $"iteration {i}, slot {j}"); + } + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/RateCounterTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/RateCounterTests.cs new file mode 100644 index 0000000..fbb97b3 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/RateCounterTests.cs @@ -0,0 +1,47 @@ +// Copyright 2021-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Shouldly; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server.Tests.Internal; + +/// +/// Tests for . +/// Mirrors server/rate_counter_test.go: TestRateCounter (ID 2720). +/// +public sealed class RateCounterTests +{ + [Fact] + public async Task RateCounter_ShouldAllowUpToLimitThenBlockAndReset() + { + // Mirror: TestRateCounter + var counter = new RateCounter(10) { Interval = TimeSpan.FromMilliseconds(100) }; + + // First 10 calls should be allowed (counts 0–9 < limit 10). + for (var i = 0; i < 10; i++) + counter.Allow().ShouldBeTrue($"should allow on iteration {i}"); + + // Next 5 should be blocked. + for (var i = 0; i < 5; i++) + counter.Allow().ShouldBeFalse($"should not allow on iteration {i}"); + + // countBlocked returns and resets the blocked count. + counter.CountBlocked().ShouldBe(5UL); + counter.CountBlocked().ShouldBe(0UL); + + // After the window expires, should allow again. + await Task.Delay(150); + counter.Allow().ShouldBeTrue("should allow after window expired"); + } +} diff --git a/porting.db b/porting.db index 5e26269..b9a4b1c 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index e4f7f89..7dc7ceb 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,28 +1,29 @@ # NATS .NET Porting Status Report -Generated: 2026-02-26 13:07:55 UTC +Generated: 2026-02-26 14:15:20 UTC ## Modules (12 total) | Status | Count | |--------|-------| -| complete | 1 | -| not_started | 11 | +| complete | 11 | +| not_started | 1 | ## Features (3673 total) | Status | Count | |--------|-------| -| complete | 36 | -| n_a | 41 | -| not_started | 3596 | +| complete | 265 | +| n_a | 74 | +| not_started | 3334 | ## Unit Tests (3257 total) | Status | Count | |--------|-------| -| complete | 16 | -| not_started | 3241 | +| complete | 119 | +| n_a | 12 | +| not_started | 3126 | ## Library Mappings (36 total) @@ -33,4 +34,4 @@ Generated: 2026-02-26 13:07:55 UTC ## Overall Progress -**94/6942 items complete (1.4%)** +**481/6942 items complete (6.9%)** diff --git a/reports/report_66628bc.md b/reports/report_66628bc.md new file mode 100644 index 0000000..7dc7ceb --- /dev/null +++ b/reports/report_66628bc.md @@ -0,0 +1,37 @@ +# NATS .NET Porting Status Report + +Generated: 2026-02-26 14:15:20 UTC + +## Modules (12 total) + +| Status | Count | +|--------|-------| +| complete | 11 | +| not_started | 1 | + +## Features (3673 total) + +| Status | Count | +|--------|-------| +| complete | 265 | +| n_a | 74 | +| not_started | 3334 | + +## Unit Tests (3257 total) + +| Status | Count | +|--------|-------| +| complete | 119 | +| n_a | 12 | +| not_started | 3126 | + +## Library Mappings (36 total) + +| Status | Count | +|--------|-------| +| mapped | 36 | + + +## Overall Progress + +**481/6942 items complete (6.9%)**