diff --git a/docs/components/Host.md b/docs/components/Host.md
index ef85e83b..f6eed4d2 100644
--- a/docs/components/Host.md
+++ b/docs/components/Host.md
@@ -111,6 +111,13 @@ Before branching on role, `AkkaHostedService.StartAsync` creates one actor uncon
Shutdown ordering for the site role is explicit: `IHostApplicationLifetime.ApplicationStopping` fires before `IHostedService.StopAsync`, so `SiteStreamGrpcServer.CancelAllStreams` is called first (clients observe a clean cancellation and reconnect), then `AkkaHostedService` runs `CoordinatedShutdown` and tears down actors.
+Since ScadaBridge#140 (the ScadaBridge half of lmxopcua#906) a graceful stop on **both** roles leaves the Akka cluster **before** Kestrel drains. `WebApplication` stops its web server before every hosted service the app registers, and Kestrel's drain waits for open requests up to `HostOptions.ShutdownTimeout` (30 s default) — on a site pair the peer's LocalDb sync stream (`/localdb_sync.v1.LocalDbSync/Sync`) never ends on its own, so the leave used to run ~30 s late and the peer took over that much later. Two pieces fix it:
+
+- `AkkaHostedService` is an `IHostedLifecycleService`. Its `StoppingAsync` runs before **any** hosted service's `StopAsync` (Kestrel's included): it marks the stop intentional, raises `ApplicationStopping` (idempotent — so `CancelAllStreams` still precedes actor teardown even on a direct `IHost.StopAsync`), then runs and awaits `CoordinatedShutdown` with `ClrExitReason`, bounded by the host's shutdown token. Cluster leave and singleton hand-over complete while Kestrel still serves `/health/active`. `StopAsync` then returns the same completed shutdown. Exit codes are unchanged: a planned stop exits 0; only a termination outside host shutdown (self-down, #132 shut-out restart) exits 75.
+- `ShutdownStreamCancellationInterceptor`, registered innermost (after the auth gates) on both the central and site `AddGrpc` pipelines, gives every client-, server- and duplex-streaming call a token linked with `ApplicationStopping` and refuses new streams `Unavailable` once stopping. This ends the LocalDb sync stream (the library only does it itself from LocalDb 0.3.8, scadaproj#57). Unary calls pass through.
+
+Measured by `IntegrationTests/GracefulStopWithOpenSyncStreamTests` (real `WebApplication` + Kestrel h2c + `MapZbLocalDbSync` + a real LocalDb sync peer + the production `AkkaHostedService`, 8 s drain budget): without the fix the stop took ~8.9 s with the member removed ~8.8 s after the drain began; with it the stop takes ~0.95 s and the member is removed before the drain begins.
+
```csharp
siteLifetime.ApplicationStopping.Register(() => siteGrpcServer.CancelAllStreams());
```
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
index a8fe7b8a..998aec43 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
@@ -26,7 +26,31 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
///
/// Transport heartbeat is explicitly configured in HOCON from CommunicationOptions.
///
-public class AkkaHostedService : IHostedService
+///
+///
+/// Leave the cluster before the web server drains (ScadaBridge#140, lmxopcua#906). With
+/// WebApplication the web server's hosted service starts after every hosted service the app
+/// registers, so on stop it runs FIRST — and Kestrel's graceful drain waits for every open request
+/// up to the host's ShutdownTimeout (30 s default). On a site pair the peer's LocalDb sync
+/// stream never ends on its own, so CoordinatedShutdown (cluster leave + singleton hand-over),
+/// which used to run only in , ran ~30 s late and the peer took over that
+/// much later. This service is therefore an :
+/// runs before ANY hosted service's StopAsync, Kestrel's
+/// included, and runs the shutdown there, while Kestrel is still up (so /health/active
+/// keeps answering and flips to 503 as the node leaves).
+///
+///
+/// Exit codes are unchanged. The shutdown still runs with
+/// — the same reason always
+/// used — and marks the stop intentional first, so the
+/// watchdog stays quiet and a planned stop exits 0. Only a termination OUTSIDE host shutdown (a
+/// self-down or a ScadaBridge#132 shut-out restart) still produces .
+/// It deliberately does not call Cluster.Leave: CoordinatedShutdown's own
+/// cluster-leave phase performs the leave, and running it through one reason keeps a single
+/// shutdown path.
+///
+///
+public class AkkaHostedService : IHostedLifecycleService
{
private readonly IServiceProvider _serviceProvider;
private readonly NodeOptions _nodeOptions;
@@ -489,7 +513,72 @@ akka {{
///
/// A cancellation token that can be used to cancel the operation.
/// A task representing the asynchronous operation.
- public async Task StopAsync(CancellationToken cancellationToken)
+ ///
+ /// Under a real host, has already run the shutdown and this returns
+ /// the same, completed CoordinatedShutdown task. Called on its own (tests, hosts that do not
+ /// honour ) it performs the whole shutdown as before.
+ ///
+ public Task StopAsync(CancellationToken cancellationToken) => ShutdownAsync();
+
+ ///
+ public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// Runs before ANY hosted service's StopAsync — Kestrel's included — and leaves the Akka
+ /// cluster there (ScadaBridge#140): raises ApplicationStopping (idempotent; in production
+ /// the service/console lifetime has already raised it, but a direct IHost.StopAsync raises
+ /// it only after the lifecycle callbacks), then runs and awaits CoordinatedShutdown, bounded by
+ /// the host's shutdown token.
+ ///
+ /// The host's shutdown token (bounded by ShutdownTimeout).
+ /// A task that completes once the node has left (or the budget ran out).
+ public async Task StoppingAsync(CancellationToken cancellationToken)
+ {
+ // Intentional shutdown first, so the WhenTerminated watchdog stays quiet; then
+ // ApplicationStopping, so the site's stream cancellation (SiteStreamGrpcServer.CancelAllStreams,
+ // ShutdownStreamCancellationInterceptor) runs before the actors are torn down — the documented
+ // order — even when the host is stopped directly rather than through its lifetime.
+ _stopRequested = true;
+ _appLifetime?.StopApplication();
+
+ if (_actorSystem is null)
+ {
+ return;
+ }
+
+ var started = TimeProvider.System.GetTimestamp();
+ _logger.LogInformation(
+ "Host stopping: leaving the Akka cluster before the web server drains its open requests (ScadaBridge#140).");
+ try
+ {
+ await ShutdownAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Akka CoordinatedShutdown completed in {ElapsedMs:0} ms; continuing host shutdown.",
+ TimeProvider.System.GetElapsedTime(started).TotalMilliseconds);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning(
+ "Akka CoordinatedShutdown did not finish within the host shutdown timeout; continuing host shutdown.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Akka CoordinatedShutdown failed during the graceful leave; continuing host shutdown.");
+ }
+ }
+
+ ///
+ public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// The shutdown proper, shared by and .
+ /// Idempotent: the tracked-disposable list is drained once and CoordinatedShutdown.Run
+ /// returns the same task on every call.
+ ///
+ private async Task ShutdownAsync()
{
// Mark this as an intentional shutdown FIRST, so the WhenTerminated
// watchdog (wired in GetOrCreateActorSystem) does not mistake the
@@ -524,10 +613,13 @@ akka {{
if (_actorSystem != null)
{
- _logger.LogInformation("Shutting down Akka.NET actor system via CoordinatedShutdown...");
var shutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem);
+ var first = shutdown.ShutdownReason is null;
+ if (first)
+ _logger.LogInformation("Shutting down Akka.NET actor system via CoordinatedShutdown...");
await shutdown.Run(Akka.Actor.CoordinatedShutdown.ClrExitReason.Instance);
- _logger.LogInformation("Akka.NET actor system shutdown complete.");
+ if (first)
+ _logger.LogInformation("Akka.NET actor system shutdown complete.");
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
index b6e172ed..8bccaa19 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
@@ -297,6 +297,11 @@ try
options.MaxReceiveMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.MaxSendMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.Interceptors.Add();
+ // Innermost, after the auth gate (ScadaBridge#140): ends any open streaming call on
+ // ApplicationStopping instead of letting Kestrel's drain wait out ShutdownTimeout, and
+ // refuses new streams Unavailable once stopping. Central's control plane is all unary
+ // today, so this is a backstop for any stream added later.
+ options.Interceptors.Add();
});
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlGrpcService>();
@@ -857,6 +862,12 @@ try
options.MaxSendMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.Interceptors.Add();
options.Interceptors.Add();
+ // Innermost, after both auth gates (ScadaBridge#140, lmxopcua#906): ends every open
+ // streaming call — the peer's LocalDb sync stream above all — on ApplicationStopping
+ // instead of letting Kestrel's drain wait out ShutdownTimeout (30 s), and refuses new
+ // streams Unavailable once stopping. Host-side until LocalDb 0.3.8 (scadaproj#57) is
+ // pinned; it stays useful after that as the backstop for every other stream.
+ options.Interceptors.Add();
});
builder.Services.AddSingleton();
// Site command plane (central→site) — the gRPC peer of SiteStreamGrpcServer. Both share
@@ -917,7 +928,11 @@ try
// THEN does AkkaHostedService run CoordinatedShutdown and tear down
// actors. Without this hand-off, in-flight streams go silent and only
// time out via gRPC keepalive (~25 s), violating the documented
- // four-step sequence.
+ // four-step sequence. Since ScadaBridge#140 AkkaHostedService runs that
+ // CoordinatedShutdown from IHostedLifecycleService.StoppingAsync (raising
+ // ApplicationStopping first), i.e. BEFORE Kestrel starts draining, and
+ // ShutdownStreamCancellationInterceptor ends the remaining streams (the
+ // peer's LocalDb sync stream) so the drain itself is short.
var siteLifetime = app.Services.GetRequiredService();
var siteGrpcServer = app.Services.GetRequiredService();
siteLifetime.ApplicationStopping.Register(() => siteGrpcServer.CancelAllStreams());
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/ShutdownStreamCancellationInterceptor.cs b/src/ZB.MOM.WW.ScadaBridge.Host/ShutdownStreamCancellationInterceptor.cs
new file mode 100644
index 00000000..722d70ec
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/ShutdownStreamCancellationInterceptor.cs
@@ -0,0 +1,181 @@
+using Grpc.Core;
+using Grpc.Core.Interceptors;
+
+namespace ZB.MOM.WW.ScadaBridge.Host;
+
+///
+/// Ends every open streaming gRPC call on this host when the host starts stopping, and refuses
+/// new ones from then on (ScadaBridge#140, the ScadaBridge half of lmxopcua#906).
+///
+///
+///
+/// Why. A server-streaming handler observes ,
+/// which ASP.NET Core cancels only when the client goes away or Kestrel aborts the request — and
+/// Kestrel aborts only at the END of its graceful drain, after the host's whole
+/// ShutdownTimeout (30 s by default). On a site pair the peer's LocalDb sync stream
+/// (/localdb_sync.v1.LocalDbSync/Sync) never ends on its own, so every graceful stop of the
+/// node it is connected to held the drain for the full 30 s. Through LocalDb 0.3.7 the library's
+/// LocalDbSyncService does not observe ApplicationStopping itself (fixed on scadaproj
+/// main 3ddb083, publish tracked as scadaproj#57), so the host supplies it here: the handler
+/// gets a context whose token is the call's own token linked with
+/// . Handlers that treat a cancelled call
+/// token as "client gone / server stopping" — the LocalDb sync service does — then return
+/// cleanly, and Kestrel's drain has nothing left to wait for.
+///
+///
+/// Scope. Every client-, server- and duplex-streaming call, whatever its service. The site's
+/// own SiteStreamService streams are also ended by SiteStreamGrpcServer.CancelAllStreams
+/// on ApplicationStopping; this is the generic backstop for every other stream, today and
+/// later. Unary calls pass through untouched — they are short, and finishing them is what a drain
+/// is for.
+///
+///
+/// A stream that arrives once stopping has begun (the peer redialling the moment its stream
+/// ended) is refused with — the retryable code — rather than
+/// admitted into a node that is about to go away.
+///
+///
+/// Exactly one public constructor — Grpc.AspNetCore silently stops invoking an
+/// interceptor that has more than one. Register it LAST (innermost), after the auth gates, so an
+/// unauthenticated stream is still refused by its gate first.
+///
+///
+public sealed class ShutdownStreamCancellationInterceptor : Interceptor
+{
+ /// The key GetHttpContext() falls back to for a context that is not ASP.NET Core's own.
+ private const string HttpContextKey = "__HttpContext";
+
+ private readonly IHostApplicationLifetime _lifetime;
+ private readonly ILogger _logger;
+
+ /// Creates the interceptor.
+ /// The host lifetime whose ApplicationStopping ends open streams.
+ /// Logger for the shutdown diagnostics.
+ public ShutdownStreamCancellationInterceptor(
+ IHostApplicationLifetime lifetime,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(lifetime);
+ ArgumentNullException.ThrowIfNull(logger);
+ _lifetime = lifetime;
+ _logger = logger;
+ }
+
+ ///
+ public override Task DuplexStreamingServerHandler(
+ IAsyncStreamReader requestStream,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ DuplexStreamingServerMethod continuation)
+ => RunAsync(context, linked => continuation(requestStream, responseStream, linked));
+
+ ///
+ public override Task ServerStreamingServerHandler(
+ TRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ ServerStreamingServerMethod continuation)
+ => RunAsync(context, linked => continuation(request, responseStream, linked));
+
+ ///
+ public override async Task ClientStreamingServerHandler(
+ IAsyncStreamReader requestStream,
+ ServerCallContext context,
+ ClientStreamingServerMethod continuation)
+ {
+ TResponse response = default!;
+ await RunAsync(context, async linked => response = await continuation(requestStream, linked).ConfigureAwait(false))
+ .ConfigureAwait(false);
+ return response;
+ }
+
+ private async Task RunAsync(ServerCallContext context, Func handler)
+ {
+ var stopping = _lifetime.ApplicationStopping;
+ if (stopping.IsCancellationRequested)
+ {
+ throw new RpcException(new Status(
+ StatusCode.Unavailable, "This node is shutting down; retry against its peer or after it restarts."));
+ }
+
+ // GetHttpContext() recognises only ASP.NET Core's own context type and otherwise falls back
+ // to a UserState entry — seed that entry so a handler given the wrapper can still reach it.
+ try
+ {
+ context.UserState.TryAdd(HttpContextKey, context.GetHttpContext());
+ }
+ catch (InvalidOperationException)
+ {
+ // Not hosted by ASP.NET Core (a unit test's fake context): nothing to carry over.
+ }
+
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, stopping);
+ using var onStopping = stopping.Register(
+ static state =>
+ {
+ var (logger, method) = ((ILogger, string))state!;
+ logger.LogInformation("Host stopping: ending the open gRPC stream {Method}.", method);
+ },
+ ((ILogger)_logger, context.Method));
+
+ await handler(new LinkedTokenServerCallContext(context, linked.Token)).ConfigureAwait(false);
+ }
+
+ ///
+ /// Delegates everything to the real call context except .
+ /// is delegated too and carries the __HttpContext
+ /// entry seeded in RunAsync, so GetHttpContext() still resolves on the wrapper.
+ ///
+ internal sealed class LinkedTokenServerCallContext(ServerCallContext inner, CancellationToken token)
+ : ServerCallContext
+ {
+ ///
+ protected override string MethodCore => inner.Method;
+
+ ///
+ protected override string HostCore => inner.Host;
+
+ ///
+ protected override string PeerCore => inner.Peer;
+
+ ///
+ protected override DateTime DeadlineCore => inner.Deadline;
+
+ ///
+ protected override Metadata RequestHeadersCore => inner.RequestHeaders;
+
+ ///
+ protected override CancellationToken CancellationTokenCore => token;
+
+ ///
+ protected override Metadata ResponseTrailersCore => inner.ResponseTrailers;
+
+ ///
+ protected override Status StatusCore
+ {
+ get => inner.Status;
+ set => inner.Status = value;
+ }
+
+ ///
+ protected override WriteOptions? WriteOptionsCore
+ {
+ get => inner.WriteOptions;
+ set => inner.WriteOptions = value;
+ }
+
+ ///
+ protected override AuthContext AuthContextCore => inner.AuthContext;
+
+ ///
+ protected override IDictionary