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:
@@ -398,6 +398,30 @@ public static class ConfigReloader
|
|||||||
}, ct);
|
}, 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 ─────────────────────────────────────────
|
// ─── Comparison helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
|
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 };
|
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);
|
||||||
|
|||||||
41
src/NATS.Server/Configuration/SignalHandler.cs
Normal file
41
src/NATS.Server/Configuration/SignalHandler.cs
Normal 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;
|
||||||
|
}
|
||||||
61
tests/NATS.Server.Tests/SignalHandlerTests.cs
Normal file
61
tests/NATS.Server.Tests/SignalHandlerTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user