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
+}