// Port of Go server/reload_test.go — TestConfigReloadSIGHUP, TestReloadAsync, // TestApplyDiff, TestReloadConfigOrThrow. // Reference: golang/nats-server/server/reload_test.go, reload.go. using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Client.Core; using NATS.Server.Configuration; using NATS.Server.TestUtilities; namespace NATS.Server.Core.Tests.Configuration; /// /// Tests for SIGHUP-triggered config reload and the ConfigReloader async API. /// Covers: /// - PosixSignalRegistration for SIGHUP wired to ReloadConfig /// - ConfigReloader.ReloadAsync parses, diffs, and validates /// - ConfigReloader.ApplyDiff returns correct category flags /// - End-to-end reload via config file rewrite and ReloadConfigOrThrow /// Reference: Go server/reload.go — Reload, applyOptions. /// public class SignalReloadTests { // ─── Helpers ──────────────────────────────────────────────────────────── private static async Task RawConnectAsync(int port) { var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, port); var buf = new byte[4096]; await sock.ReceiveAsync(buf, SocketFlags.None); return sock; } private static void WriteConfigAndReload(NatsServer server, string configPath, string configText) { File.WriteAllText(configPath, configText); server.ReloadConfigOrThrow(); } // ─── Tests ────────────────────────────────────────────────────────────── /// /// Verifies that HandleSignals registers a SIGHUP handler that calls ReloadConfig. /// We cannot actually send SIGHUP in a test, but we verify the handler is registered /// by confirming ReloadConfig works when called directly, and that the server survives /// signal registration without error. /// Reference: Go server/signals_unix.go — handleSignals. /// [Fact] public async Task HandleSignals_registers_sighup_handler() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-sighup-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); File.WriteAllText(configPath, $"port: {port}\ndebug: false"); var options = new NatsOptions { ConfigFile = configPath, Port = port }; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { // Register signal handlers — should not throw server.HandleSignals(); // Verify the reload mechanism works by calling it directly // (simulating what SIGHUP would trigger) File.WriteAllText(configPath, $"port: {port}\ndebug: true"); server.ReloadConfig(); // The server should still be operational await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}", }); await client.ConnectAsync(); await client.PingAsync(); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that ConfigReloader.ReloadAsync correctly detects an unchanged config file. /// [Fact] public async Task ReloadAsync_detects_unchanged_config() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noop-{Guid.NewGuid():N}.conf"); try { File.WriteAllText(configPath, "port: 4222\ndebug: false"); var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222 }; // Compute initial digest var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath); var result = await ConfigReloader.ReloadAsync( configPath, currentOpts, initialDigest, null, [], CancellationToken.None); result.Unchanged.ShouldBeTrue(); } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that ConfigReloader.ReloadAsync correctly detects config changes. /// [Fact] public async Task ReloadAsync_detects_changes() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-change-{Guid.NewGuid():N}.conf"); try { File.WriteAllText(configPath, "port: 4222\ndebug: false"); var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = false }; // Compute initial digest var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath); // Change the config file File.WriteAllText(configPath, "port: 4222\ndebug: true"); var result = await ConfigReloader.ReloadAsync( configPath, currentOpts, initialDigest, null, [], CancellationToken.None); result.Unchanged.ShouldBeFalse(); result.NewOptions.ShouldNotBeNull(); result.NewOptions!.Debug.ShouldBeTrue(); result.Changes.ShouldNotBeNull(); result.Changes!.Count.ShouldBeGreaterThan(0); result.HasErrors.ShouldBeFalse(); } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that ConfigReloader.ReloadAsync reports errors for non-reloadable changes. /// [Fact] public async Task ReloadAsync_reports_non_reloadable_errors() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-nonreload-{Guid.NewGuid():N}.conf"); try { File.WriteAllText(configPath, "port: 4222\nserver_name: original"); var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, ServerName = "original", }; var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath); // Change a non-reloadable option File.WriteAllText(configPath, "port: 4222\nserver_name: changed"); var result = await ConfigReloader.ReloadAsync( configPath, currentOpts, initialDigest, null, [], CancellationToken.None); result.Unchanged.ShouldBeFalse(); result.HasErrors.ShouldBeTrue(); result.Errors!.ShouldContain(e => e.Contains("ServerName")); } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that ConfigReloader.ApplyDiff returns correct category flags. /// [Fact] public void ApplyDiff_returns_correct_category_flags() { var oldOpts = new NatsOptions { Debug = false, Username = "old" }; var newOpts = new NatsOptions { Debug = true, Username = "new" }; var changes = ConfigReloader.Diff(oldOpts, newOpts); var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts); result.HasLoggingChanges.ShouldBeTrue(); result.HasAuthChanges.ShouldBeTrue(); result.ChangeCount.ShouldBeGreaterThan(0); } /// /// Verifies that ApplyDiff detects TLS changes. /// [Fact] public void ApplyDiff_detects_tls_changes() { var oldOpts = new NatsOptions { TlsCert = null }; var newOpts = new NatsOptions { TlsCert = "/path/to/cert.pem" }; var changes = ConfigReloader.Diff(oldOpts, newOpts); var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts); result.HasTlsChanges.ShouldBeTrue(); } /// /// Verifies that ReloadAsync preserves CLI overrides during reload. /// [Fact] public async Task ReloadAsync_preserves_cli_overrides() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-cli-{Guid.NewGuid():N}.conf"); try { File.WriteAllText(configPath, "port: 4222\ndebug: false"); var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = true }; var cliSnapshot = new NatsOptions { Debug = true }; var cliFlags = new HashSet { "Debug" }; var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath); // Change config — debug goes to true in file, but CLI override also says true File.WriteAllText(configPath, "port: 4222\ndebug: true"); var result = await ConfigReloader.ReloadAsync( configPath, currentOpts, initialDigest, cliSnapshot, cliFlags, CancellationToken.None); // Config changed, so it should not be "unchanged" result.Unchanged.ShouldBeFalse(); result.NewOptions.ShouldNotBeNull(); result.NewOptions!.Debug.ShouldBeTrue("CLI override should preserve debug=true"); } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies end-to-end: rewrite config file and call ReloadConfigOrThrow /// to apply max_connections changes, then verify new connections are rejected. /// Reference: Go server/reload_test.go — TestConfigReloadMaxConnections. /// [Fact] public async Task Reload_via_config_file_rewrite_applies_changes() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-e2e-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); File.WriteAllText(configPath, $"port: {port}\nmax_connections: 65536"); var options = new NatsOptions { ConfigFile = configPath, Port = port }; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { // Establish one connection using var c1 = await RawConnectAsync(port); server.ClientCount.ShouldBe(1); // Reduce max_connections to 1 via reload WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 1"); // New connection should be rejected using var c2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await c2.ConnectAsync(IPAddress.Loopback, port); var response = await SocketTestHelper.ReadUntilAsync(c2, "-ERR", timeoutMs: 5000); response.ShouldContain("maximum connections exceeded"); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that ReloadConfigOrThrow throws for non-reloadable changes. /// [Fact] public async Task ReloadConfigOrThrow_throws_on_non_reloadable_change() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-throw-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); File.WriteAllText(configPath, $"port: {port}\nserver_name: original"); var options = new NatsOptions { ConfigFile = configPath, Port = port, ServerName = "original" }; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { // Try to change a non-reloadable option File.WriteAllText(configPath, $"port: {port}\nserver_name: changed"); Should.Throw(() => server.ReloadConfigOrThrow()) .Message.ShouldContain("ServerName"); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that ReloadConfig does not throw when no config file is specified /// (it logs a warning and returns). /// [Fact] public void ReloadConfig_no_config_file_does_not_throw() { var options = new NatsOptions { Port = 0 }; using var server = new NatsServer(options, NullLoggerFactory.Instance); // Should not throw; just logs a warning Should.NotThrow(() => server.ReloadConfig()); } /// /// Verifies that ReloadConfigOrThrow throws when no config file is specified. /// [Fact] public void ReloadConfigOrThrow_throws_when_no_config_file() { var options = new NatsOptions { Port = 0 }; using var server = new NatsServer(options, NullLoggerFactory.Instance); Should.Throw(() => server.ReloadConfigOrThrow()) .Message.ShouldContain("No config file"); } }