// 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");
}
}