diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServiceManager.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServiceManager.cs new file mode 100644 index 0000000..2bade64 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServiceManager.cs @@ -0,0 +1,180 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 +// +// Adapted from server/service.go and server/service_windows.go in the NATS server Go source. + +using System.Runtime.InteropServices; + +namespace ZB.MOM.NatsNet.Server.Internal; + +/// +/// Service wrappers for platform-specific server startup behavior. +/// +public static class ServiceManager +{ + private static readonly Lock ServiceNameLock = new(); + private static string _serviceName = "nats-server"; + private static bool _dockerized; + + /// + /// Allows overriding the service name. + /// Mirrors Go SetServiceName. + /// + public static void SetServiceName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return; + + lock (ServiceNameLock) + { + _serviceName = name; + } + } + + /// + /// Initializes service-related environment flags. + /// Mirrors Go package init() behavior in service_windows.go. + /// + public static void Init(Func? envLookup = null) + { + var lookup = envLookup ?? Environment.GetEnvironmentVariable; + _dockerized = string.Equals(lookup("NATS_DOCKERIZED"), "1", StringComparison.Ordinal); + } + + /// + /// Runs the server startup action. + /// Mirrors Go Run behavior from service.go/service_windows.go. + /// + public static Exception? Run(Action startServer, Func? windowsServiceProbe = null) + { + ArgumentNullException.ThrowIfNull(startServer); + + if (_dockerized) + { + startServer(); + return null; + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + startServer(); + return null; + } + + if (!IsWindowsService(windowsServiceProbe)) + { + startServer(); + return null; + } + + return new PlatformNotSupportedException( + "Windows service hosting is managed by Microsoft.Extensions.Hosting.WindowsServices."); + } + + /// + /// Returns true when running as a Windows service. + /// Mirrors Go isWindowsService. + /// + public static bool IsWindowsService(Func? windowsServiceProbe = null) + { + if (_dockerized) + return false; + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return false; + + if (windowsServiceProbe is not null) + return windowsServiceProbe(); + + return !Environment.UserInteractive; + } + + /// + /// Windows service execution wrapper. + /// Mirrors Go winServiceWrapper.Execute control loop semantics. + /// + public static (bool serviceSpecificExitCode, uint exitCode) Execute( + Action startServer, + Func readyForConnections, + IEnumerable changes, + Action reloadConfig, + Action shutdown, + Action reopenLogFile, + Action enterLameDuckMode, + Func? envLookup = null, + Action? logError = null) + { + ArgumentNullException.ThrowIfNull(startServer); + ArgumentNullException.ThrowIfNull(readyForConnections); + ArgumentNullException.ThrowIfNull(changes); + ArgumentNullException.ThrowIfNull(reloadConfig); + ArgumentNullException.ThrowIfNull(shutdown); + ArgumentNullException.ThrowIfNull(reopenLogFile); + ArgumentNullException.ThrowIfNull(enterLameDuckMode); + + var startupDelay = TimeSpan.FromSeconds(10); + var lookup = envLookup ?? Environment.GetEnvironmentVariable; + var configuredDelay = lookup("NATS_STARTUP_DELAY"); + if (!string.IsNullOrEmpty(configuredDelay)) + { + if (TimeSpan.TryParse(configuredDelay, out var parsedDelay)) + { + startupDelay = parsedDelay; + } + else + { + logError?.Invoke($"Failed to parse \"{configuredDelay}\" as a duration for startup."); + } + } + + Task.Run(startServer); + + if (!readyForConnections(startupDelay)) + return (false, 1); + + foreach (var change in changes) + { + switch (change) + { + case ServiceControlCommand.Interrogate: + continue; + case ServiceControlCommand.Stop: + case ServiceControlCommand.Shutdown: + shutdown(); + return (false, 0); + case ServiceControlCommand.ReopenLog: + reopenLogFile(); + break; + case ServiceControlCommand.LameDuckMode: + Task.Run(enterLameDuckMode); + break; + case ServiceControlCommand.ParamChange: + reloadConfig(); + break; + } + } + + return (false, 0); + } + + internal static string CurrentServiceName + { + get + { + lock (ServiceNameLock) + { + return _serviceName; + } + } + } +} + +public enum ServiceControlCommand +{ + Interrogate, + Stop, + Shutdown, + ReopenLog, + LameDuckMode, + ParamChange +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SignalHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SignalHandler.cs index f0e4429..640c36c 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SignalHandler.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SignalHandler.cs @@ -220,12 +220,17 @@ public static class SignalHandler /// /// Runs the server (non-Windows). Mirrors Run in service.go. /// - public static void Run(Action startServer) => startServer(); + public static void Run(Action startServer) + { + var error = ServiceManager.Run(startServer); + if (error is not null) + throw error; + } /// /// Returns false on non-Windows. Mirrors isWindowsService. /// - public static bool IsWindowsService() => false; + public static bool IsWindowsService() => ServiceManager.IsWindowsService(); } /// Unix signal codes for NATS command mapping. diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ServiceManagerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ServiceManagerTests.cs new file mode 100644 index 0000000..9250020 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ServiceManagerTests.cs @@ -0,0 +1,97 @@ +// Copyright 2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 + +using Shouldly; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server.Tests.Internal; + +public sealed class ServiceManagerTests +{ + public ServiceManagerTests() + { + ServiceManager.Init(static _ => null); + ServiceManager.SetServiceName("nats-server"); + } + + [Fact] + public void SetServiceName_WhenProvided_StoresConfiguredName() + { + ServiceManager.SetServiceName("custom-svc"); + + ServiceManager.CurrentServiceName.ShouldBe("custom-svc"); + } + + [Fact] + public void IsWindowsService_WhenDockerized_ReturnsFalse() + { + ServiceManager.Init(static key => key == "NATS_DOCKERIZED" ? "1" : null); + + ServiceManager.IsWindowsService(() => true).ShouldBeFalse(); + } + + [Fact] + public void Run_WhenNotRunningAsWindowsService_InvokesStartAction() + { + var called = false; + + var error = ServiceManager.Run(() => called = true, () => false); + + error.ShouldBeNull(); + called.ShouldBeTrue(); + } + + [Fact] + public void Execute_WhenServerNotReady_ReturnsFailureExitCode() + { + using var started = new ManualResetEventSlim(false); + var reloadCalls = 0; + var shutdownCalls = 0; + + var result = ServiceManager.Execute( + startServer: () => started.Set(), + readyForConnections: _ => false, + changes: [], + reloadConfig: () => reloadCalls++, + shutdown: () => shutdownCalls++, + reopenLogFile: () => { }, + enterLameDuckMode: () => { }); + + result.exitCode.ShouldBe((uint)1); + result.serviceSpecificExitCode.ShouldBeFalse(); + started.Wait(TimeSpan.FromSeconds(1)).ShouldBeTrue(); + reloadCalls.ShouldBe(0); + shutdownCalls.ShouldBe(0); + } + + [Fact] + public void Execute_WhenCommandsReceived_DispatchesControlHandlers() + { + var reloadCalls = 0; + var shutdownCalls = 0; + var reopenCalls = 0; + var ldmCalled = false; + using var ldmInvoked = new ManualResetEventSlim(false); + + var result = ServiceManager.Execute( + startServer: () => { }, + readyForConnections: _ => true, + changes: [ServiceControlCommand.ReopenLog, ServiceControlCommand.ParamChange, ServiceControlCommand.LameDuckMode, ServiceControlCommand.Stop], + reloadConfig: () => reloadCalls++, + shutdown: () => shutdownCalls++, + reopenLogFile: () => reopenCalls++, + enterLameDuckMode: () => + { + ldmCalled = true; + ldmInvoked.Set(); + }); + + result.exitCode.ShouldBe((uint)0); + result.serviceSpecificExitCode.ShouldBeFalse(); + reloadCalls.ShouldBe(1); + reopenCalls.ShouldBe(1); + shutdownCalls.ShouldBe(1); + ldmInvoked.Wait(TimeSpan.FromSeconds(1)).ShouldBeTrue(); + ldmCalled.ShouldBeTrue(); + } +} diff --git a/porting.db b/porting.db index 3da9f5a..27bdc82 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index e860f29..d5fdf37 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-28 12:31:12 UTC +Generated: 2026-02-28 12:37:17 UTC ## Modules (12 total) @@ -12,10 +12,10 @@ Generated: 2026-02-28 12:31:12 UTC | Status | Count | |--------|-------| -| deferred | 2348 | +| deferred | 2341 | | n_a | 24 | | stub | 1 | -| verified | 1300 | +| verified | 1307 | ## Unit Tests (3257 total) @@ -34,4 +34,4 @@ Generated: 2026-02-28 12:31:12 UTC ## Overall Progress -**2502/6942 items complete (36.0%)** +**2509/6942 items complete (36.1%)**