feat(batch3): implement service feature group

This commit is contained in:
Joseph Doherty
2026-02-28 07:37:17 -05:00
parent beab0e60da
commit 7ebbaeb6b3
5 changed files with 288 additions and 6 deletions

View File

@@ -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;
/// <summary>
/// Service wrappers for platform-specific server startup behavior.
/// </summary>
public static class ServiceManager
{
private static readonly Lock ServiceNameLock = new();
private static string _serviceName = "nats-server";
private static bool _dockerized;
/// <summary>
/// Allows overriding the service name.
/// Mirrors Go <c>SetServiceName</c>.
/// </summary>
public static void SetServiceName(string name)
{
if (string.IsNullOrWhiteSpace(name))
return;
lock (ServiceNameLock)
{
_serviceName = name;
}
}
/// <summary>
/// Initializes service-related environment flags.
/// Mirrors Go package <c>init()</c> behavior in service_windows.go.
/// </summary>
public static void Init(Func<string, string?>? envLookup = null)
{
var lookup = envLookup ?? Environment.GetEnvironmentVariable;
_dockerized = string.Equals(lookup("NATS_DOCKERIZED"), "1", StringComparison.Ordinal);
}
/// <summary>
/// Runs the server startup action.
/// Mirrors Go <c>Run</c> behavior from service.go/service_windows.go.
/// </summary>
public static Exception? Run(Action startServer, Func<bool>? 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.");
}
/// <summary>
/// Returns true when running as a Windows service.
/// Mirrors Go <c>isWindowsService</c>.
/// </summary>
public static bool IsWindowsService(Func<bool>? windowsServiceProbe = null)
{
if (_dockerized)
return false;
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return false;
if (windowsServiceProbe is not null)
return windowsServiceProbe();
return !Environment.UserInteractive;
}
/// <summary>
/// Windows service execution wrapper.
/// Mirrors Go <c>winServiceWrapper.Execute</c> control loop semantics.
/// </summary>
public static (bool serviceSpecificExitCode, uint exitCode) Execute(
Action startServer,
Func<TimeSpan, bool> readyForConnections,
IEnumerable<ServiceControlCommand> changes,
Action reloadConfig,
Action shutdown,
Action reopenLogFile,
Action enterLameDuckMode,
Func<string, string?>? envLookup = null,
Action<string>? 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
}

View File

@@ -220,12 +220,17 @@ public static class SignalHandler
/// <summary>
/// Runs the server (non-Windows). Mirrors <c>Run</c> in service.go.
/// </summary>
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;
}
/// <summary>
/// Returns false on non-Windows. Mirrors <c>isWindowsService</c>.
/// </summary>
public static bool IsWindowsService() => false;
public static bool IsWindowsService() => ServiceManager.IsWindowsService();
}
/// <summary>Unix signal codes for NATS command mapping.</summary>

View File

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

Binary file not shown.

View File

@@ -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%)**