diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs index 5406f86..20052f9 100644 --- a/src/NATS.Server/Configuration/ConfigReloader.cs +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -398,6 +398,30 @@ public static class ConfigReloader }, ct); } + /// + /// Compares two options directly (without reading from a config file) and returns + /// a reload result indicating whether the change is valid. + /// Go reference: server/reload.go — Reload with in-memory options comparison. + /// + public static Task ReloadFromOptionsAsync(NatsOptions original, NatsOptions updated) + { + var changes = Diff(original, updated); + var errors = Validate(changes); + var rejectedChanges = new List(); + + foreach (var change in changes) + { + if (change.IsNonReloadable) + { + rejectedChanges.Add($"{change.Name} cannot be changed at runtime"); + } + } + + return Task.FromResult(new ReloadFromOptionsResult( + Success: rejectedChanges.Count == 0 && errors.Count == 0, + RejectedChanges: rejectedChanges)); + } + // ─── Comparison helpers ───────────────────────────────────────── private static void CompareAndAdd(List changes, string name, T oldVal, T newVal) @@ -524,3 +548,8 @@ public sealed class ConfigReloadResult public bool HasErrors => Errors is { Count: > 0 }; } + +/// +/// Result of an in-memory options comparison for reload validation. +/// +public sealed record ReloadFromOptionsResult(bool Success, List RejectedChanges); diff --git a/src/NATS.Server/Configuration/SignalHandler.cs b/src/NATS.Server/Configuration/SignalHandler.cs new file mode 100644 index 0000000..45c0bd2 --- /dev/null +++ b/src/NATS.Server/Configuration/SignalHandler.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; + +namespace NATS.Server.Configuration; + +/// +/// Registers POSIX signal handlers for config reload. +/// Go reference: server/signal_unix.go, server/opts.go reload logic. +/// On SIGHUP, triggers config reload via ConfigReloader. +/// +public static class SignalHandler +{ + private static PosixSignalRegistration? _registration; + + /// + /// Registers a SIGHUP handler that will call the provided reload callback. + /// Go reference: server/signal_unix.go — handleSignals goroutine. + /// + /// Callback invoked when SIGHUP is received. + public static void Register(Action onReload) + { + ArgumentNullException.ThrowIfNull(onReload); + _registration = PosixSignalRegistration.Create(PosixSignal.SIGHUP, _ => + { + onReload(); + }); + } + + /// + /// Unregisters the SIGHUP handler. + /// + public static void Unregister() + { + _registration?.Dispose(); + _registration = null; + } + + /// + /// Whether a SIGHUP handler is currently registered. + /// + public static bool IsRegistered => _registration is not null; +} diff --git a/tests/NATS.Server.Tests/SignalHandlerTests.cs b/tests/NATS.Server.Tests/SignalHandlerTests.cs new file mode 100644 index 0000000..87aede0 --- /dev/null +++ b/tests/NATS.Server.Tests/SignalHandlerTests.cs @@ -0,0 +1,61 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +// Go reference: server/signal_unix.go (handleSignals), server/reload.go (Reload) + +public class SignalHandlerTests +{ + [Fact] + public void SignalHandler_registers_without_throwing() + { + // Go reference: server/signal_unix.go — registration should succeed + Should.NotThrow(() => SignalHandler.Register(() => { })); + SignalHandler.IsRegistered.ShouldBeTrue(); + SignalHandler.Unregister(); + SignalHandler.IsRegistered.ShouldBeFalse(); + } + + [Fact] + public async Task ConfigReloader_ReloadFromOptionsAsync_applies_reloadable_changes() + { + // Go reference: server/reload.go — reloadable options (e.g., MaxPayload) pass validation + var original = new NatsOptions { Port = 4222, MaxPayload = 1024 }; + var updated = new NatsOptions { Port = 4222, MaxPayload = 2048 }; // MaxPayload is reloadable + + var result = await ConfigReloader.ReloadFromOptionsAsync(original, updated); + result.Success.ShouldBeTrue(); + result.RejectedChanges.ShouldBeEmpty(); + } + + [Fact] + public async Task ConfigReloader_rejects_non_reloadable_changes() + { + // Go reference: server/reload.go — Port change is NOT reloadable + var original = new NatsOptions { Port = 4222 }; + var updated = new NatsOptions { Port = 5555 }; // port change is NOT reloadable + + var result = await ConfigReloader.ReloadFromOptionsAsync(original, updated); + result.RejectedChanges.ShouldContain(c => c.Contains("Port")); + } + + [Fact] + public async Task ConfigReloader_identical_options_succeeds() + { + // Go reference: server/reload.go — no changes = success + var original = new NatsOptions { Port = 4222 }; + var updated = new NatsOptions { Port = 4222 }; + + var result = await ConfigReloader.ReloadFromOptionsAsync(original, updated); + result.Success.ShouldBeTrue(); + result.RejectedChanges.ShouldBeEmpty(); + } + + [Fact] + public void SignalHandler_unregister_is_idempotent() + { + // Calling Unregister when not registered should not throw + Should.NotThrow(() => SignalHandler.Unregister()); + Should.NotThrow(() => SignalHandler.Unregister()); + } +}