diff --git a/src/core/Akka.Tests/Actor/TerminationSignalHandlerSpec.cs b/src/core/Akka.Tests/Actor/TerminationSignalHandlerSpec.cs new file mode 100644 index 00000000000..e7616bac00a --- /dev/null +++ b/src/core/Akka.Tests/Actor/TerminationSignalHandlerSpec.cs @@ -0,0 +1,295 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.TestKit; +using Akka.TestKit.Extensions; +using Akka.Util.Internal; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; +using static Akka.Actor.CoordinatedShutdown; + +namespace Akka.Tests.Actor; + +/// +/// Tests for the CLR termination signal handling in . +/// +public class TerminationSignalHandlerSpec : AkkaSpec +{ + public TerminationSignalHandlerSpec(ITestOutputHelper output) : base(output) + { + } + + public ExtendedActorSystem ExtSys => Sys.AsInstanceOf(); + + private static readonly Phase EmptyPhase = new(ImmutableHashSet.Empty, TimeSpan.FromSeconds(10), true); + + /// + /// Test double for that allows simulating termination signals. + /// + private class TestTerminationSignalHandler : ITerminationSignalHandler + { + public Action RegisteredCallback { get; private set; } + public bool IsDisposed { get; private set; } + public int RegisterCallCount { get; private set; } + + public void Register(Action onTerminationSignal) + { + RegisterCallCount++; + RegisteredCallback = onTerminationSignal; + } + + public void SimulateTerminationSignal() + { + RegisteredCallback?.Invoke(); + } + + public void Dispose() + { + IsDisposed = true; + } + } + + [Fact(DisplayName = "CoordinatedShutdown should register handler when run-by-clr-shutdown-hook is enabled")] + public void CoordinatedShutdown_should_register_handler_when_enabled() + { + // Arrange + var phases = new Dictionary { { "a", EmptyPhase } }; + var coord = new CoordinatedShutdown(ExtSys, phases); + var testHandler = new TestTerminationSignalHandler(); + var conf = ConfigurationFactory.ParseString("run-by-clr-shutdown-hook = on"); + + // Act + CoordinatedShutdown.InitClrHook(Sys, conf, coord, testHandler); + + // Assert + testHandler.RegisterCallCount.Should().Be(1); + testHandler.RegisteredCallback.Should().NotBeNull(); + } + + [Fact(DisplayName = "CoordinatedShutdown should not register handler when run-by-clr-shutdown-hook is disabled")] + public void CoordinatedShutdown_should_not_register_handler_when_disabled() + { + // Arrange + var phases = new Dictionary { { "a", EmptyPhase } }; + var coord = new CoordinatedShutdown(ExtSys, phases); + var testHandler = new TestTerminationSignalHandler(); + var conf = ConfigurationFactory.ParseString("run-by-clr-shutdown-hook = off"); + + // Act + CoordinatedShutdown.InitClrHook(Sys, conf, coord, testHandler); + + // Assert + testHandler.RegisterCallCount.Should().Be(0); + testHandler.RegisteredCallback.Should().BeNull(); + } + + [Fact(DisplayName = "CoordinatedShutdown should run shutdown tasks when termination signal is received")] + public async Task CoordinatedShutdown_should_run_when_termination_signal_received() + { + // Arrange + var sys = ActorSystem.Create( + "TerminationSignalTest", + ConfigurationFactory.ParseString(@" + akka.coordinated-shutdown.terminate-actor-system = on + akka.coordinated-shutdown.run-by-clr-shutdown-hook = on + akka.coordinated-shutdown.run-by-actor-system-terminate = off")); + + try + { + var testHandler = new TestTerminationSignalHandler(); + var coord = CoordinatedShutdown.Get(sys); + + var taskExecuted = new TaskCompletionSource(); + coord.AddTask(PhaseBeforeServiceUnbind, "test-task", () => + { + taskExecuted.SetResult(true); + return Task.FromResult(Done.Instance); + }); + + // Re-initialize with test handler + var conf = sys.Settings.Config.GetConfig("akka.coordinated-shutdown"); + CoordinatedShutdown.InitClrHook(sys, conf, coord, testHandler); + + // Act - simulate termination signal + testHandler.SimulateTerminationSignal(); + + // Assert + var result = await taskExecuted.Task.AwaitWithTimeout(TimeSpan.FromSeconds(10)); + result.Should().BeTrue(); + coord.ShutdownReason.Should().Be(ClrExitReason.Instance); + } + finally + { + await sys.Terminate(); + } + } + + [Fact(DisplayName = "CoordinatedShutdown should set _runningClrHook flag during CLR shutdown")] + public async Task CoordinatedShutdown_should_set_running_flag_during_clr_shutdown() + { + // Arrange + var sys = ActorSystem.Create( + "RunningFlagTest", + ConfigurationFactory.ParseString(@" + akka.coordinated-shutdown.terminate-actor-system = on + akka.coordinated-shutdown.run-by-clr-shutdown-hook = on + akka.coordinated-shutdown.run-by-actor-system-terminate = off")); + + try + { + var testHandler = new TestTerminationSignalHandler(); + var coord = CoordinatedShutdown.Get(sys); + + var flagObserved = new TaskCompletionSource(); + coord.AddTask(PhaseBeforeServiceUnbind, "flag-check-task", () => + { + // The _runningClrHook flag should be set by now + // We can't directly access the private field, but we can verify + // the shutdown is running with ClrExitReason + flagObserved.SetResult(coord.ShutdownReason == ClrExitReason.Instance); + return Task.FromResult(Done.Instance); + }); + + var conf = sys.Settings.Config.GetConfig("akka.coordinated-shutdown"); + CoordinatedShutdown.InitClrHook(sys, conf, coord, testHandler); + + // Act + testHandler.SimulateTerminationSignal(); + + // Assert + var result = await flagObserved.Task.AwaitWithTimeout(TimeSpan.FromSeconds(10)); + result.Should().BeTrue(); + } + finally + { + await sys.Terminate(); + } + } + + [Fact(DisplayName = "CoordinatedShutdown should dispose handler when ActorSystem terminates normally")] + public async Task CoordinatedShutdown_should_dispose_handler_on_normal_termination() + { + // Arrange + var sys = ActorSystem.Create( + "DisposeTest", + ConfigurationFactory.ParseString(@" + akka.coordinated-shutdown.terminate-actor-system = on + akka.coordinated-shutdown.run-by-clr-shutdown-hook = on + akka.coordinated-shutdown.run-by-actor-system-terminate = on")); + + var testHandler = new TestTerminationSignalHandler(); + var coord = CoordinatedShutdown.Get(sys); + var conf = sys.Settings.Config.GetConfig("akka.coordinated-shutdown"); + CoordinatedShutdown.InitClrHook(sys, conf, coord, testHandler); + + // Act - terminate system normally (not via signal) + await sys.Terminate(); + + // Give continuation time to run + await Task.Delay(100); + + // Assert + testHandler.IsDisposed.Should().BeTrue(); + } + + [Fact(DisplayName = "CoordinatedShutdown CLR hooks should only execute once even if signal fires multiple times")] + public async Task CoordinatedShutdown_clr_hooks_should_only_execute_once() + { + // Arrange + var sys = ActorSystem.Create( + "IdempotencyTest", + ConfigurationFactory.ParseString(@" + akka.coordinated-shutdown.terminate-actor-system = on + akka.coordinated-shutdown.run-by-clr-shutdown-hook = on + akka.coordinated-shutdown.run-by-actor-system-terminate = off")); + + try + { + var testHandler = new TestTerminationSignalHandler(); + var coord = CoordinatedShutdown.Get(sys); + + var executionCount = 0; + coord.AddTask(PhaseBeforeServiceUnbind, "count-task", () => + { + executionCount++; + return Task.FromResult(Done.Instance); + }); + + var conf = sys.Settings.Config.GetConfig("akka.coordinated-shutdown"); + CoordinatedShutdown.InitClrHook(sys, conf, coord, testHandler); + + // Act - simulate multiple termination signals + testHandler.SimulateTerminationSignal(); + testHandler.SimulateTerminationSignal(); + testHandler.SimulateTerminationSignal(); + + // Wait for shutdown to complete + await sys.WhenTerminated.AwaitWithTimeout(TimeSpan.FromSeconds(10)); + + // Assert - task should only have executed once + executionCount.Should().Be(1); + } + finally + { + if (!sys.WhenTerminated.IsCompleted) + await sys.Terminate(); + } + } + + [Fact(DisplayName = "CoordinatedShutdown should handle exceptions in shutdown tasks gracefully")] + public async Task CoordinatedShutdown_should_handle_task_exceptions_gracefully() + { + // Arrange + var sys = ActorSystem.Create( + "ExceptionTest", + ConfigurationFactory.ParseString(@" + akka.coordinated-shutdown.terminate-actor-system = on + akka.coordinated-shutdown.run-by-clr-shutdown-hook = on + akka.coordinated-shutdown.run-by-actor-system-terminate = off")); + + try + { + var testHandler = new TestTerminationSignalHandler(); + var coord = CoordinatedShutdown.Get(sys); + + var secondTaskExecuted = new TaskCompletionSource(); + + coord.AddTask(PhaseBeforeServiceUnbind, "failing-task", () => + { + throw new Exception("Test exception"); + }); + + coord.AddTask(PhaseServiceUnbind, "second-task", () => + { + secondTaskExecuted.SetResult(true); + return Task.FromResult(Done.Instance); + }); + + var conf = sys.Settings.Config.GetConfig("akka.coordinated-shutdown"); + CoordinatedShutdown.InitClrHook(sys, conf, coord, testHandler); + + // Act + testHandler.SimulateTerminationSignal(); + + // Assert - second task should still execute despite first task throwing + var result = await secondTaskExecuted.Task.AwaitWithTimeout(TimeSpan.FromSeconds(10)); + result.Should().BeTrue(); + } + finally + { + if (!sys.WhenTerminated.IsCompleted) + await sys.Terminate(); + } + } +} \ No newline at end of file diff --git a/src/core/Akka.Tests/Akka.Tests.csproj b/src/core/Akka.Tests/Akka.Tests.csproj index 5d5455f948e..b04bed82bb9 100644 --- a/src/core/Akka.Tests/Akka.Tests.csproj +++ b/src/core/Akka.Tests/Akka.Tests.csproj @@ -20,13 +20,20 @@ - - + + + + + $(DefineConstants);CORECLR + + $(DefineConstants);CORECLR + + diff --git a/src/core/Akka/Actor/CoordinatedShutdown.cs b/src/core/Akka/Actor/CoordinatedShutdown.cs index f0008de8981..5476dd152d1 100644 --- a/src/core/Akka/Actor/CoordinatedShutdown.cs +++ b/src/core/Akka/Actor/CoordinatedShutdown.cs @@ -706,56 +706,65 @@ internal static void InitPhaseActorSystemTerminate(ActorSystem system, Config co } } - // TODO: do we need to check for null or empty config here? /// - /// Initializes the CLR hook + /// Initializes the CLR hook for handling process termination signals. /// /// The actor system for this extension. /// The HOCON configuration. /// The plugin instance. - internal static void InitClrHook(ActorSystem system, Config conf, CoordinatedShutdown coord) + /// Optional signal handler for testing. If null, creates platform-appropriate handler. + internal static void InitClrHook(ActorSystem system, Config conf, CoordinatedShutdown coord, ITerminationSignalHandler signalHandler = null) { var runByClrShutdownHook = conf.GetBoolean("run-by-clr-shutdown-hook", false); - if (runByClrShutdownHook) + if (!runByClrShutdownHook) + return; + + // Use injected handler or create platform-appropriate one + signalHandler ??= CreateDefaultTerminationHandler(); + + // Register the signal handler to run CLR hooks when termination signal is received + signalHandler.Register(() => { - var exitTask = TerminateOnClrExit(coord); - // run all hooks during termination sequence - AppDomain.CurrentDomain.ProcessExit += exitTask; - system.WhenTerminated.ContinueWith(_ => - { - AppDomain.CurrentDomain.ProcessExit -= exitTask; - }); + // Must block - if this returns, process exits + coord.RunClrHooks().Wait(coord.TotalTimeout); + }); - coord.AddClrShutdownHook(() => + // Add the actual shutdown hook that performs coordinated shutdown + coord.AddClrShutdownHook(() => + { + coord._runningClrHook = true; + return Task.Run(() => { - coord._runningClrHook = true; - return Task.Run(() => + if (!system.WhenTerminated.IsCompleted) { - if (!system.WhenTerminated.IsCompleted) + coord.Log.Info("Starting coordinated shutdown from CLR termination hook."); + try { - coord.Log.Info("Starting coordinated shutdown from CLR termination hook."); - try - { - coord.Run(ClrExitReason.Instance).Wait(coord.TotalTimeout); - } - catch (Exception ex) - { - coord.Log.Warning("CoordinatedShutdown from CLR shutdown failed: {0}", ex.Message); - } + coord.Run(ClrExitReason.Instance).Wait(coord.TotalTimeout); + } + catch (Exception ex) + { + coord.Log.Warning("CoordinatedShutdown from CLR shutdown failed: {0}", ex.Message); } - return Done.Instance; - }); + } + return Done.Instance; }); - } + }); + + // Cleanup handler when system terminates normally + system.WhenTerminated.ContinueWith(_ => signalHandler.Dispose()); } - private static EventHandler TerminateOnClrExit(CoordinatedShutdown coord) + /// + /// Creates the appropriate termination signal handler for the current platform. + /// + private static ITerminationSignalHandler CreateDefaultTerminationHandler() { - return (_, _) => - { - // have to block, because if this method exits the process exits. - coord.RunClrHooks().Wait(coord.TotalTimeout); - }; +#if NET6_0_OR_GREATER + return new PosixTerminationSignalHandler(); +#else + return new LegacyTerminationSignalHandler(); +#endif } } } diff --git a/src/core/Akka/Actor/TerminationSignalHandler.cs b/src/core/Akka/Actor/TerminationSignalHandler.cs new file mode 100644 index 00000000000..7083e4ccd1a --- /dev/null +++ b/src/core/Akka/Actor/TerminationSignalHandler.cs @@ -0,0 +1,117 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading; +#if NET6_0_OR_GREATER +using System.Runtime.InteropServices; +#endif + +namespace Akka.Actor +{ + /// + /// Abstraction for handling process termination signals (SIGTERM, SIGHUP, ProcessExit). + /// Required for .NET 10 compatibility where ProcessExit no longer fires on SIGTERM. + /// + internal interface ITerminationSignalHandler : IDisposable + { + /// + /// Registers a callback to be invoked when a termination signal is received. + /// The callback will only be invoked once, even if multiple signals are received. + /// + /// The callback to invoke on termination signal. + void Register(Action onTerminationSignal); + } + +#if NET6_0_OR_GREATER + /// + /// .NET 6+ implementation using PosixSignalRegistration for proper signal handling. + /// Handles SIGTERM and SIGHUP signals, plus ProcessExit as fallback. + /// This is required for .NET 10 compatibility where ProcessExit no longer fires on SIGTERM. + /// + internal sealed class PosixTerminationSignalHandler : ITerminationSignalHandler + { + private PosixSignalRegistration? _sigtermRegistration; + private PosixSignalRegistration? _sighupRegistration; + private EventHandler? _processExitHandler; + private Action? _callback; + private int _invoked; + + /// + public void Register(Action onTerminationSignal) + { + _callback = onTerminationSignal; + + // Register POSIX signals (works on Unix/macOS/Windows in .NET 6+) + _sigtermRegistration = PosixSignalRegistration.Create( + PosixSignal.SIGTERM, OnSignalReceived); + + _sighupRegistration = PosixSignalRegistration.Create( + PosixSignal.SIGINT, OnSignalReceived); + + // Keep ProcessExit as fallback for non-signal termination scenarios + _processExitHandler = (_, _) => InvokeCallback(); + AppDomain.CurrentDomain.ProcessExit += _processExitHandler; + } + + private void OnSignalReceived(PosixSignalContext context) + { + // Cancel default termination to allow graceful shutdown + context.Cancel = true; + InvokeCallback(); + } + + private void InvokeCallback() + { + // Ensure callback only runs once + if (Interlocked.CompareExchange(ref _invoked, 1, 0) == 0) + { + _callback?.Invoke(); + } + } + + /// + public void Dispose() + { + _sigtermRegistration?.Dispose(); + _sighupRegistration?.Dispose(); + if (_processExitHandler != null) + AppDomain.CurrentDomain.ProcessExit -= _processExitHandler; + } + } +#else + /// + /// Legacy implementation for .NET Standard 2.0 / .NET Framework. + /// Uses ProcessExit only (no POSIX signal support available). + /// + internal sealed class LegacyTerminationSignalHandler : ITerminationSignalHandler + { + private EventHandler? _processExitHandler; + private int _invoked; + + /// + public void Register(Action onTerminationSignal) + { + _processExitHandler = (_, _) => + { + if (Interlocked.CompareExchange(ref _invoked, 1, 0) == 0) + { + onTerminationSignal(); + } + }; + AppDomain.CurrentDomain.ProcessExit += _processExitHandler; + } + + /// + public void Dispose() + { + if (_processExitHandler != null) + AppDomain.CurrentDomain.ProcessExit -= _processExitHandler; + } + } +#endif +}