diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f47b7c26d..b8f18c9aed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ integration tests. The 13.x line is the .NET 10 generation of Aspire; 9.x pulls a vulnerable KubernetesClient 17.0.4 (GHSA-w7r3-mgwf-4mqq) that trips NU1902 under our TreatWarningsAsErrors policy. --> - 13.6.4 + 13.6.5 13.6.1 2.2.0 diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs index ae0267fc6e..9ea94fa5c8 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs @@ -144,6 +144,13 @@ public static Props CreateProps( /// Discord's start does not imply readiness; wait up to 30s for Discord.Net's READY event. protected override TimeSpan? ReadySignalTimeout => TimeSpan.FromSeconds(30); + /// + /// Discord.Net's StopAsync awaits a WebSocket close handshake. After + /// a socket-exhaustion event the handshake can never complete, and the + /// actor would otherwise sit in Disconnecting forever with no retry armed. + /// + protected override TimeSpan? StopTimeout => TimeSpan.FromSeconds(30); + protected override bool IsTransportConnected => _client.ConnectionState == ConnectionState.Connected; protected override bool HasBotIdentity => _botUserId is not null && _botMentionTag is not null; diff --git a/src/Netclaw.Channels/ChannelLifecycleActor.cs b/src/Netclaw.Channels/ChannelLifecycleActor.cs index 5991ba4e5b..d9d89b172b 100644 --- a/src/Netclaw.Channels/ChannelLifecycleActor.cs +++ b/src/Netclaw.Channels/ChannelLifecycleActor.cs @@ -61,6 +61,14 @@ public abstract class ChannelLifecycleActor : Receiv private TimeSpan _retryDelay; private ICancelable? _retryTimer; + /// + /// Monotonic stamp identifying the current stop cycle. A stop that times out + /// leaves its transport call running; when that call finally completes it + /// must not be mistaken for the result of a later stop cycle. + /// + private long _stopGeneration; + private ICancelable? _stopTimer; + /// /// The constructor calls , which invokes the /// subclass's Register* hooks before the subclass constructor body has run. @@ -125,6 +133,22 @@ protected string? HealthDetail /// protected virtual TimeSpan? ReadySignalTimeout => null; + /// + /// How long the actor waits in Disconnecting for the transport stop to + /// complete before treating the stop as failed and scheduling the next + /// auto-reconnect attempt. Null (the default) waits indefinitely. + /// + /// + /// A transport stop that hangs — a half-open socket, a wedged WebSocket + /// close handshake, or a client whose internal reconnect loop never + /// settles — otherwise parks the actor in Disconnecting forever. That + /// state has no retry timer and discards transport recovery signals, so + /// the channel never reconnects until the process restarts. Discord's + /// StopAsync is the known offender: after a socket-exhaustion + /// event it can await a close handshake that never completes. + /// + protected virtual TimeSpan? StopTimeout => null; + protected override void PreStart() { SelfRef = Self; @@ -137,6 +161,7 @@ protected override void PostStop() UnsubscribeTransportEvents(); CancelRetryTimer(); CancelReadySignalTimer(); + CancelStopTimer(); base.PostStop(); } @@ -205,6 +230,7 @@ private void Disconnecting() _channelDisplayName + " gateway disconnect is already in progress.")))); Receive(HandleStopSucceeded); Receive(HandleStopFailed); + Receive(HandleStopTimedOut); RegisterDisconnectingChannelHandlers(); ReceiveNotReadyIngress(); ReceiveUnexpected(nameof(Disconnecting)); @@ -245,6 +271,7 @@ private void StartConnecting(TConnectCommand command, IActorRef replyTo) ResetIdentityState(); _cleanReconnectEmitted = false; _readyEnteredAt = null; + CancelStopTimer(); // Normalize Nobody to null: "no pending caller" must have exactly one // representation. The auto-retry path connects with ActorRefs.Nobody, @@ -375,6 +402,7 @@ private void StartDisconnecting(IActorRef replyTo, bool preserveAutoReconnect = private void BeginStop(IActorRef replyTo) { var self = Self; + var generation = ++_stopGeneration; Task stopTask; try { @@ -382,21 +410,74 @@ private void BeginStop(IActorRef replyTo) } catch (Exception ex) { - self.Tell(new StopFailed(replyTo, ex)); + self.Tell(new StopFailed(replyTo, ex, generation)); return; } + ArmStopTimer(generation); + stopTask.ContinueWith( task => self.Tell(task.IsCompletedSuccessfully - ? new StopSucceeded(replyTo) - : new StopFailed(replyTo, UnwrapTaskException(task))), + ? new StopSucceeded(replyTo, generation) + : new StopFailed(replyTo, UnwrapTaskException(task), generation)), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } + /// + /// Arms the stop-completion deadline. When it fires, the actor treats the + /// stop as failed and moves on rather than waiting forever on a transport + /// call that may never return. + /// + private void ArmStopTimer(long generation) + { + CancelStopTimer(); + if (StopTimeout is not { } timeout) + return; + + _stopTimer = Context.System.Scheduler.ScheduleTellOnceCancelable( + timeout, + Self, + new StopTimedOut(generation, new ChannelConnectException( + ChannelConnectFailureKind.Transient, + _channelDisplayName + " gateway did not stop within " + + $"{timeout.TotalSeconds:F0} seconds.")), + ActorRefs.NoSender); + } + + private void CancelStopTimer() + { + _stopTimer?.Cancel(); + _stopTimer = null; + } + + private void HandleStopTimedOut(StopTimedOut timedOut) + { + if (timedOut.Generation != _stopGeneration) + return; + + // The transport call is abandoned, not cancelled: it may still be + // awaiting a close handshake. Its eventual completion carries the old + // generation and is ignored, so it cannot disturb the next cycle. + Logger.LogWarning( + "{Channel} gateway stop did not complete within the stop timeout; " + + "abandoning the stop and scheduling a reconnect. {Reason}", + _channelDisplayName, timedOut.Exception.Message); + + _healthDetail = timedOut.Exception.Message; + CancelStopTimer(); + ResetIdentityState(); + ScheduleRetryIfEnabled(); + Become(Disconnected); + } + private void HandleStopSucceeded(StopSucceeded stopped) { + if (stopped.Generation != _stopGeneration) + return; + + CancelStopTimer(); _healthDetail = _disconnectedDetail; ResetIdentityState(); ScheduleRetryIfEnabled(); @@ -407,6 +488,10 @@ private void HandleStopSucceeded(StopSucceeded stopped) private void HandleStopFailed(StopFailed failed) { + if (failed.Generation != _stopGeneration) + return; + + CancelStopTimer(); _healthDetail = failed.Exception.Message; ScheduleRetryIfEnabled(); Become(Disconnected); @@ -765,9 +850,15 @@ private sealed record StartFailed(long Attempt, Exception Exception) : IGatewayC /// private sealed record ReadySignalTimedOut(long Attempt, Exception Exception) : IGatewayConnectFailure; - private sealed record StopSucceeded(IActorRef ReplyTo) : IGatewayStopWork; + private sealed record StopSucceeded(IActorRef ReplyTo, long Generation) : IGatewayStopWork; + + private sealed record StopFailed(IActorRef ReplyTo, Exception Exception, long Generation) : IGatewayStopFailure; - private sealed record StopFailed(IActorRef ReplyTo, Exception Exception) : IGatewayStopFailure; + /// + /// Fires when a transport stop exceeds . Carries the + /// stop generation so a late timer from an abandoned cycle is ignored. + /// + private sealed record StopTimedOut(long Generation, Exception Exception) : IGatewayInternalMessage; private sealed record RetryConnect : IGatewayInternalMessage {