Repository navigation
fix(channels): bound transport stop so a hung StopAsync cannot wedge the lifecycle actor - #2795
Open
petabridge-netclaw[bot] wants to merge 2 commits into
Open
petabridge-netclaw[bot] wants to merge 2 commits into
petabridge-netclaw[bot] wants to merge 2 commits into
Conversation
added 2 commits
November 12, 2026 01:03
…the lifecycle actor
13.6.4 carries a high-severity advisory (GHSA-f7vh-h5g3-7jrh) that TreatWarningsAsErrors promotes to NU1903, failing restore on every platform. 13.6.5 is the first patched release.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A channel can park in
Disconnectingforever, with no retry timer armed and no way out short of a process restart.StartDisconnectingcallsBeginStop, which awaitsStopTransportAsync()with no deadline:The only exits from the
Disconnectingbehavior areHandleStopSucceededandHandleStopFailed, and only those two callScheduleRetryIfEnabled(). If the transport stop never completes, neither ever runs.Worse,
Disconnectingdiscards every transport recovery signal:So even if the transport reconnects on its own and fires
READY, the actor throws it away. The channel is dead until restart.Observed in production
On a Discord QA pod running 0.28.3, a node-level network blip caused socket exhaustion (
SocketException (11)/EAGAIN). Discord.Net'sStopAsync()then awaited a WebSocket close handshake that could never complete. The actor wedged at 08:25 UTC and never recovered:The daemon stayed alive and outbound REST sends kept working, so scheduled reminders still delivered while the bot showed offline in Discord and received nothing. No
StopSucceeded, noStopFailed, noDispatchFailed, no dead letter — a hang, not a crash.This is not a 0.28.3 regression:
ChannelLifecycleActor.csandDiscordNetGatewayLifecycleActor.csare byte-identical between 0.28.2 and 0.28.3, and Discord.Net is3.20.2in both. It is a latent wedge that any socket-exhaustion event can trigger.Fix
Add an optional
StopTimeouttoChannelLifecycleActor, mirroring the existingReadySignalTimeoutpattern.BeginStopstamps each stop cycle with a monotonic_stopGenerationand arms a timer.StopTimedOutlogs a warning, schedules the retry, and transitions toDisconnected— the same terminal state the success and failure paths reach.StopSucceeded/StopFailednow carry the generation and are ignored when stale, so a transport call abandoned by a timeout cannot disturb a later cycle.PostStop, onStartConnecting, and on both normal stop completions.StopTimeoutto 30 seconds, matching itsReadySignalTimeout.The abandoned transport task is deliberately not cancelled — it may still be awaiting a close handshake, and cancelling it would not unblock the socket. It is simply orphaned; its eventual completion is discarded by the generation check.
Default remains
null(wait indefinitely), so Mattermost and any other channel keep their current behavior.Behavior after the fix
The same sequence now recovers instead of wedging:
Testing
StopTimeoutisnull).StopTransportAsyncnever completes transitions toDisconnectedand schedules a retry afterStopTimeout.StopSucceededfrom an abandoned cycle does not cancel the current cycle's timer or reset its state.StopTimeout = nullpreserves the wait-indefinitely behavior.Notes for reviewers
The two-actor question is not addressed here. Seq showed each ready-timeout warning logged twice ~100µs apart with distinct event IDs, and two distinct gateway actor channel IDs (
1498109472239915138,1510034945710755900).DiscordSocketClientis registered viaAddSingletoninChannelIntegrationRegistrationExtensions, so a single client is shared — but whether two lifecycle actors can be constructed against it is worth a separate look. That would be an independent bug from this wedge.