From 7ebbaeb6b3bf5665fe972c1a70db8fa787b06e5b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 07:37:17 -0500 Subject: [PATCH] feat(batch3): implement service feature group --- .../Internal/ServiceManager.cs | 180 ++++++++++++++++++ .../Internal/SignalHandler.cs | 9 +- .../Internal/ServiceManagerTests.cs | 97 ++++++++++ porting.db | Bin 6361088 -> 6365184 bytes reports/current.md | 8 +- 5 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServiceManager.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/ServiceManagerTests.cs 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 3da9f5a9da524055407ad836aa61d174053e83e0..27bdc821d6355a8abf2e992250dca55670d5b402 100644 GIT binary patch delta 2791 zcmchYUu;ul7{*V}x3-7&v>RiCv8}8dGH{HQuCR>)Veo&WZF1s`n-_2L z{?2*xoF{Ld^L>+ZQ)KeS6q&8iw{e`I_-*?EUE3$2HivHM+%<=C&7s@+885agx9{ub z^&J*s<0E4u@tB;F#i02%cSNNCqZXC?jOtYKF{)I_%gCydN4fE{?$9OOU8U~d(J);?}uGBq9+1FUPzc7E1v%XU82ZOV3D)|F&C59>_Xu3cIGG5@ey2wFFC znLSc*k)P+S8>#dSzhC)fk*~KH*z>j1y-L*&{8GA#O^MtsZq2;#sd2(^fi&JM?IfyqYsa&khkcUAkTHWJaL(hVq1H6v!04KDO+| zLf4%pujfk4pC&VO;exrE`iDtV?wi?Ra)loJ)AB^zNV{XCDtBgci2O(&NaT+4c!aFs z=^HWf9o0$X{^hu6IB$p(Rt2o|tTJq5C9qn<%EZddN@QhWWmOFE!asg3I4@k~sDF;H zrS`VMkX3jj+!wwVz7oC=J`%1fZ-t6@;W89iI)j;9O~?rt0G6^q9{Kagmq*?_^5juF zLJ`7Rgkpqs2qg%m2@I1l`2+Xzz!HeKS@FN5ef(S1nbRcvh97pIv=tk&4IDycM z(1*~Ea1!Cv=MXBOq?EgO$tc;Z1+(M<*~h=5`$^ZzUCd-TT$Zk#?lo~X-gc}s-Rv5W zrPO#rPKrT#)8MS7Ck3gHPCX74ZQpB8%83bx$o3IwR2q~M_T+eCKw^9bVljJIwnOFs z8j(j+RQfGsv$>q^#+^+4gob)x#ngK=)cq@_-mRhTTQT)64R!B|sUO!+_pF$Dr-pj_ ze^u9KGo!fm(wC<77IqXHO-o$iVe6ZguPr5FxA}@`NvV$a-!YY?o2SEB2d~)Dw%pW8 zqt0^bbMzF`qJKKA0FAkK4oJ z5Q{oeax&$}Tq!$}5oz>tO@C=sqnjP~JDZplNyAEjX01FlNF_bl|0c)Bu2u2TG9AkL zcr9-w{)~?bdRO18xsp9i|6KViyOL8HEAcI_lfd3$1vy6`^IXwnDVj z)mETzEBn{vact(*Hm1&e6J5jNCzAYZfSeX)R`2*(m8kyiVWqNcZt!?L9UYzqQzeaS zEN=Dr085O`XD)NKkk{FD$<2z8G6Sg)DJ75!3_Of2CwUytLIY_G$rwnZYWNfOBTip# zwh7CZCeu8dqS-ghp?)t5%2ZU_2e!}?Z|V_DLV_R<8K(>l`fe#kBe7?qXu7E^iC-}Y z0^M!lAIaLzKhwqY{58F-9Ur7$gGQMm-Mo<&!`2NtdgSKaWQpQN zHNBocW;B%>wRTQN8ZW;_zI$Az0WZ%X_pG$G>S@u7!+}>{Zsa#8q{Ohc>L{s+_pO|r zZsNE8UnA&hGmlwWA2#zbT6`O^{%lJNw*@{+uhauo0h0+pqO;(0~NXT?cwFfDvRcfdXb#>kInxQ{y+_ca}}C zWQuhLxtT#whiZe`^z@*~qVy}x76^tA*Z`pr2H_9^kq`wNAsS-93Y)+Nu@DFGkN}C0 z1j%5B6xa-@um!e48l*!8Y=iBP2|HjX?1J5p1=)}Txv&TFU@zo@1DsF*g-`@8*ayW> z0{h_r9E3w~7>>YED1|a8hhuOYD&PcE!bzxtQ*av2KsB6&8aSsa&9zG-+$`k|>*}nk OxWK|CHCL*8W&8__bv*q5 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%)**