diff --git a/docs/requirements/Component-Communication.md b/docs/requirements/Component-Communication.md
index ed8dd036..cee3f326 100644
--- a/docs/requirements/Component-Communication.md
+++ b/docs/requirements/Component-Communication.md
@@ -343,6 +343,7 @@ Disconnect is detected at the **transport layer**, never via an application-leve
- **In-flight command/control messages (gRPC call + deadline)**: When a connection drops while a request is in flight (e.g., a deployment sent but no response received), the gRPC call fails or hits its deadline and the caller receives a failure. There is **no automatic retry or buffering at central** — the engineer sees the failure in the UI and re-initiates the action. This is consistent with the design principle that central does not buffer messages. An in-progress deployment whose round-trip exceeds the timeout (default 120 s at `CommunicationService.DeployInstanceAsync`) surfaces as `DeploymentStatus.Failed` to the caller.
- **Debug streams (gRPC)**: Any gRPC stream interruption is detected by the HTTP/2 keepalive PING (~25 s) and triggers reconnection logic in the `DebugStreamBridgeActor`. The bridge actor attempts to reconnect to the other site node endpoint (NodeB if NodeA failed, or vice versa), with up to 3 retries and 5-second backoff. If all retries fail, the consumer is notified via `OnStreamTerminated` and the bridge actor is stopped. Events during the reconnection gap are lost (acceptable for real-time debug view). On successful reconnection, the consumer can request a fresh snapshot to re-sync state.
+- **Graceful end of stream is NOT a fault, but it IS a reconnect trigger.** When the site's `GrpcMaxStreamLifetime` (4 h) elapses — and on a graceful site shutdown — the server ends the RPC with status **OK**, so the client's read loop simply finishes with no exception. `SiteStreamGrpcClient` reports that through a dedicated `onCompleted` callback, distinct from `onError`, and both consuming actors treat it as a reconnect trigger that **does not spend (or refund) the error-retry budget** and does **not** flip the node — the peer that closed cleanly is healthy. `SiteAlarmAggregatorActor` marks the stream down and lets the periodic reconcile tick reopen it (which also re-seeds); `DebugStreamBridgeActor` reopens on the same endpoint after `ReconnectDelay`. Both stamp the stream generation on the callback so a completion racing out of an already-replaced stream is ignored, and both observe the subscription `Task` itself so a fault escaping the subscribe call can never go unobserved. Without the `onCompleted` leg an OK completion was invisible to the reconnect logic and the stream stayed silently dead until the central process restarted.
## Failover Behavior
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs
index 7496b053..e0b26082 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs
@@ -63,6 +63,14 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
private bool _stopped;
private CancellationTokenSource? _grpcCts;
+ ///
+ /// Monotonic stream generation stamped on each opened gRPC stream and echoed back on its
+ /// error/completion callbacks: a late callback raced out of a previous (cancelled) stream
+ /// carries a stale generation and is ignored, so it can neither burn retry budget nor
+ /// open a duplicate stream. Mirrors SiteAlarmAggregatorActor. Actor-thread only.
+ ///
+ private int _streamGeneration;
+
///
/// Phase flag. until the initial
/// has been delivered and the pre-snapshot buffer
@@ -198,10 +206,33 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
// gRPC stream error — attempt reconnection
Receive(msg =>
{
+ // Ignore a late error raced out of a previous (cancelled) stream: it must not
+ // burn retry budget or flip the node a second time.
+ if (msg.Generation != _streamGeneration)
+ {
+ _log.Debug("Ignoring stale gRPC error from stream generation {0} (current {1})",
+ msg.Generation, _streamGeneration);
+ return;
+ }
_log.Warning("gRPC stream error for {0}: {1}", _instanceUniqueName, msg.Exception.Message);
HandleGrpcError();
});
+ // gRPC stream ended GRACEFULLY (server status OK) — the site's 4h max stream
+ // lifetime elapsing or a graceful site shutdown. Not a fault: reopen on the SAME
+ // node without spending retry budget. Without this the session went silently deaf.
+ Receive(msg =>
+ {
+ if (_stopped) return;
+ if (msg.Generation != _streamGeneration)
+ {
+ _log.Debug("Ignoring stale gRPC completion from stream generation {0} (current {1})",
+ msg.Generation, _streamGeneration);
+ return;
+ }
+ HandleGrpcCompleted();
+ });
+
// Scheduled reconnection
Receive(_ => OpenGrpcStream());
@@ -424,20 +455,57 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
// retry budget is recovered. Cancelled by HandleGrpcError.
Timers.StartSingleTimer(StabilityTimerKey, new GrpcStreamStable(), StabilityWindow);
+ var generation = ++_streamGeneration;
var client = _grpcFactory.GetOrCreate(_siteIdentifier, endpoint);
var self = Self;
var ct = _grpcCts.Token;
- // Launch as background task — onEvent and onError marshal back to actor via Tell
+ // Launch as background task — the callbacks marshal back to the actor via Tell.
+ // The task itself is observed below: a fault escaping SubscribeAsync would otherwise
+ // leave the session waiting on a stream that does not exist, exception unobserved.
Task.Run(async () =>
{
await client.SubscribeAsync(
_correlationId,
_instanceUniqueName,
evt => self.Tell(evt),
- ex => self.Tell(new GrpcStreamError(ex)),
+ ex => self.Tell(new GrpcStreamError(ex, generation)),
+ () => self.Tell(new GrpcStreamCompleted(generation)),
ct);
- }, ct);
+ }, ct).ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ self.Tell(new GrpcStreamError(t.Exception!.GetBaseException(), generation));
+ else if (t.IsCanceled && !ct.IsCancellationRequested)
+ self.Tell(new GrpcStreamCompleted(generation));
+ // RanToCompletion: SubscribeAsync already reported its own outcome.
+ }, TaskContinuationOptions.ExecuteSynchronously);
+ }
+
+ ///
+ /// Handles a graceful end of stream (server status OK). The stream simply expired or the
+ /// site shut down cleanly, so the retry budget is left untouched and the endpoint is not
+ /// flipped; the reopen is scheduled through the existing reconnect timer, which also
+ /// rate-limits a pathological site that keeps closing streams immediately.
+ ///
+ private void HandleGrpcCompleted()
+ {
+ // The stream is gone, so its armed stability timer must not later "recover" a
+ // budget that its successor has since spent.
+ Timers.Cancel(StabilityTimerKey);
+
+ _log.Info("gRPC stream for {0} completed gracefully (server end of stream); reopening",
+ _instanceUniqueName);
+
+ // Release the site-side relay for the finished stream before reopening, so the site
+ // is not left with a zombie relay actor for this correlation id.
+ _grpcCts?.Cancel();
+ _grpcCts?.Dispose();
+ _grpcCts = null;
+ _grpcFactory.TryGet(_siteIdentifier, _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress)
+ ?.Unsubscribe(_correlationId);
+
+ Timers.StartSingleTimer(ReconnectTimerKey, new ReconnectGrpcStream(), ReconnectDelay);
}
private void HandleGrpcError()
@@ -512,9 +580,17 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
public record StopDebugStream;
///
-/// Internal message indicating a gRPC stream error occurred.
+/// Internal message indicating a gRPC stream error occurred, stamped with the stream
+/// generation it came from so a late error out of a cancelled stream can be ignored.
///
-internal record GrpcStreamError(Exception Exception);
+internal record GrpcStreamError(Exception Exception, int Generation);
+
+///
+/// Internal message indicating the gRPC stream ended gracefully (server status OK — the
+/// site's max stream lifetime elapsed, or the site shut down cleanly), stamped with its
+/// stream generation.
+///
+internal record GrpcStreamCompleted(int Generation);
///
/// Internal message to trigger gRPC stream reconnection.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs
index a320479a..94fecd5e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs
@@ -78,12 +78,23 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private bool _stopped;
///
- /// True once the live gRPC stream has been given up (retry budget exhausted). Reconcile
- /// snapshots keep serving in the meantime; the next reconcile tick self-heals the stream
- /// by resetting the retry budget and reopening it, so a sustained site outage does not
- /// permanently drop the live feed. Actor-thread only.
+ /// True while there is no live gRPC stream — either it was given up (retry budget
+ /// exhausted) or it ended gracefully (server status OK at the site's max stream
+ /// lifetime). Reconcile snapshots keep serving in the meantime; the next reconcile tick
+ /// self-heals the stream by resetting the retry budget and reopening it, so neither a
+ /// sustained site outage nor a routine 4h stream expiry permanently drops the live feed.
+ /// Actor-thread only.
///
private bool _streamDown;
+
+ ///
+ /// Why the stream is down: true = the retry budget was exhausted, so the
+ /// self-healing reopen must also reset it; false = it ended gracefully, and the
+ /// budget — which a completion neither spends nor refunds — is carried across the reopen
+ /// untouched (a stream flapping between faults and clean closes must still trip
+ /// MaxRetries). Actor-thread only.
+ ///
+ private bool _retryBudgetExhausted;
private CancellationTokenSource? _grpcCts;
private CancellationTokenSource? _lifetimeCts;
@@ -218,6 +229,22 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
HandleGrpcError();
});
+ // gRPC stream ended GRACEFULLY (server status OK) — the site's 4h max stream
+ // lifetime elapsing or a graceful site shutdown. Not a fault: the stream is marked
+ // down so the reconcile tick reopens it (which also re-seeds), but the retry budget
+ // is untouched and the node is not flipped. Same generation fence as the error path.
+ Receive(msg =>
+ {
+ if (_stopped) return;
+ if (msg.Generation != _streamGeneration)
+ {
+ _log.Debug("Ignoring stale gRPC completion from stream generation {0} (current {1})",
+ msg.Generation, _streamGeneration);
+ return;
+ }
+ HandleGrpcCompleted();
+ });
+
Receive(_ => OpenGrpcStream());
// Owning service asks us to stop (last viewer left + linger elapsed).
@@ -279,7 +306,11 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
if (_streamDown)
{
_log.Info("Site-alarm gRPC stream for {0} was down; reopening on reconcile tick", _siteIdentifier);
- _retryCount = 0;
+ if (_retryBudgetExhausted)
+ {
+ _retryBudgetExhausted = false;
+ _retryCount = 0;
+ }
// Telemetry: a reconcile-driven reopen after the stream was given up is a reconnect.
ScadaBridgeTelemetry.RecordLiveAlarmStreamReconnect();
OpenGrpcStream();
@@ -514,14 +545,26 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
var self = Self;
var ct = _grpcCts.Token;
+ // The subscription task itself is observed (below): a fault escaping
+ // SubscribeSiteAsync — or a Task.Run that never started because ct was already
+ // cancelled — would otherwise leave the actor waiting on a stream that does not
+ // exist, with the exception silently unobserved.
Task.Run(async () =>
{
await client.SubscribeSiteAsync(
_correlationId,
alarm => self.Tell(alarm),
ex => self.Tell(new GrpcAlarmStreamError(ex, generation)),
+ () => self.Tell(new GrpcAlarmStreamCompleted(generation)),
ct);
- }, ct);
+ }, ct).ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ self.Tell(new GrpcAlarmStreamError(t.Exception!.GetBaseException(), generation));
+ else if (t.IsCanceled && !ct.IsCancellationRequested)
+ self.Tell(new GrpcAlarmStreamCompleted(generation));
+ // RanToCompletion: SubscribeSiteAsync already reported its own outcome.
+ }, TaskContinuationOptions.ExecuteSynchronously);
}
private void HandleGrpcError()
@@ -543,6 +586,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
"reconcile snapshots continue and the next reconcile tick will retry the stream",
_siteIdentifier, MaxRetries);
_streamDown = true;
+ _retryBudgetExhausted = true;
CleanupGrpc();
return;
}
@@ -569,6 +613,26 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
Timers.StartSingleTimer(ReconnectTimerKey, new ReconnectAlarmStream(), _reconnectDelay);
}
+ ///
+ /// Handles a graceful end of stream. The stream is torn down and left down for the
+ /// reconcile tick to reopen — deliberately NOT reopened inline, so a site that keeps
+ /// completing streams immediately can never spin this actor into a hot reconnect loop.
+ /// The retry budget is neither spent nor reset here (completion is not a fault), and the
+ /// endpoint is not flipped: the node that just closed a stream cleanly is healthy.
+ ///
+ private void HandleGrpcCompleted()
+ {
+ // The stream is gone, so its armed stability timer must not later "recover" a
+ // budget that its successor has since spent.
+ Timers.Cancel(StabilityTimerKey);
+
+ _log.Info("Site-alarm gRPC stream for {0} completed gracefully (server end of stream); " +
+ "reopening on the next reconcile tick", _siteIdentifier);
+
+ _streamDown = true;
+ CleanupGrpc();
+ }
+
private void CleanupGrpc()
{
_grpcCts?.Cancel();
@@ -619,6 +683,11 @@ internal sealed record PublishCoalesced;
/// generation it came from so a late error from a cancelled stream can be ignored (N7.2).
internal sealed record GrpcAlarmStreamError(Exception Exception, int Generation);
+/// Internal: the site-alarm gRPC stream ended gracefully (server status OK — max
+/// stream lifetime or site shutdown), stamped with its stream generation so a late completion
+/// from a cancelled stream can be ignored.
+internal sealed record GrpcAlarmStreamCompleted(int Generation);
+
/// Internal: reconnect the site-alarm gRPC stream (flip node).
internal sealed record ReconnectAlarmStream;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs
index 52584b40..5763c6c4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs
@@ -167,13 +167,19 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
///
/// Opens a server-streaming subscription for a specific instance.
/// This is a long-running async method; the caller launches it as a background task.
- /// The callback delivers domain events, and
- /// lets the caller handle reconnection.
+ /// The callback delivers domain events,
+ /// lets the caller handle reconnection after a fault, and
+ /// reports a graceful end of stream.
///
/// Unique identifier for this subscription.
/// Unique name of the instance to subscribe to.
/// Callback invoked for each domain event received from the stream.
/// Callback invoked when the subscription encounters an error.
+ ///
+ /// Callback invoked when the server ended the RPC with OK — the site's max stream
+ /// lifetime elapsing or a graceful site shutdown. Mutually exclusive with
+ /// ; see .
+ ///
/// Cancellation token to stop the subscription.
/// A task that represents the asynchronous operation.
public virtual async Task SubscribeAsync(
@@ -181,6 +187,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
string instanceUniqueName,
Action