feat(config): add SIGHUP signal handler and config reload validation

Implements SignalHandler (PosixSignalRegistration for SIGHUP) and
ReloadFromOptionsAsync/ReloadFromOptionsResult on ConfigReloader for
in-memory options comparison without reading a config file.
Ports Go server/signal_unix.go handleSignals and server/reload.go Reload.
This commit is contained in:
Joseph Doherty
2026-02-25 02:54:13 -05:00
parent b7bac8e68e
commit e09835ca70
3 changed files with 131 additions and 0 deletions

View File

@@ -398,6 +398,30 @@ public static class ConfigReloader
}, ct);
}
/// <summary>
/// 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.
/// </summary>
public static Task<ReloadFromOptionsResult> ReloadFromOptionsAsync(NatsOptions original, NatsOptions updated)
{
var changes = Diff(original, updated);
var errors = Validate(changes);
var rejectedChanges = new List<string>();
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<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
@@ -524,3 +548,8 @@ public sealed class ConfigReloadResult
public bool HasErrors => Errors is { Count: > 0 };
}
/// <summary>
/// Result of an in-memory options comparison for reload validation.
/// </summary>
public sealed record ReloadFromOptionsResult(bool Success, List<string> RejectedChanges);

View File

@@ -0,0 +1,41 @@
using System.Runtime.InteropServices;
namespace NATS.Server.Configuration;
/// <summary>
/// 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.
/// </summary>
public static class SignalHandler
{
private static PosixSignalRegistration? _registration;
/// <summary>
/// Registers a SIGHUP handler that will call the provided reload callback.
/// Go reference: server/signal_unix.go — handleSignals goroutine.
/// </summary>
/// <param name="onReload">Callback invoked when SIGHUP is received.</param>
public static void Register(Action onReload)
{
ArgumentNullException.ThrowIfNull(onReload);
_registration = PosixSignalRegistration.Create(PosixSignal.SIGHUP, _ =>
{
onReload();
});
}
/// <summary>
/// Unregisters the SIGHUP handler.
/// </summary>
public static void Unregister()
{
_registration?.Dispose();
_registration = null;
}
/// <summary>
/// Whether a SIGHUP handler is currently registered.
/// </summary>
public static bool IsRegistered => _registration is not null;
}

View File

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