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());
+ }
+}