Skip to content

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
devfrom
fix/discord-stop-timeout
Open

petabridge-netclaw[bot] wants to merge 2 commits into
devfrom
fix/discord-stop-timeout

Conversation

@petabridge-netclaw

Copy link
Copy Markdown
Contributor

Problem

A channel can park in Disconnecting forever, with no retry timer armed and no way out short of a process restart.

StartDisconnecting calls BeginStop, which awaits StopTransportAsync() with no deadline:

stopTask.ContinueWith(
    task => self.Tell(task.IsCompletedSuccessfully
        ? new StopSucceeded(replyTo)
        : new StopFailed(replyTo, UnwrapTaskException(task))),
    ...);

The only exits from the Disconnecting behavior are HandleStopSucceeded and HandleStopFailed, and only those two call ScheduleRetryIfEnabled(). If the transport stop never completes, neither ever runs.

Worse, Disconnecting discards every transport recovery signal:

protected override void RegisterDisconnectingChannelHandlers()
{
    Receive<DiscordConnected>(_ => { });
    Receive<DiscordReady>(_ => { });
    Receive<DiscordDisconnected>(_ => { });
}

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's StopAsync() then awaited a WebSocket close handshake that could never complete. The actor wedged at 08:25 UTC and never recovered:

08:24:33  Attempting Discord reconnect (delay was 00:01:20)
08:24:33  Gateway: Connecting
08:25:03  WARN  Gateway requested clean reconnect: did not reach ready within 30 seconds
08:25:03  <-- silence, permanently

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, no StopFailed, no DispatchFailed, no dead letter — a hang, not a crash.

This is not a 0.28.3 regression: ChannelLifecycleActor.cs and DiscordNetGatewayLifecycleActor.cs are byte-identical between 0.28.2 and 0.28.3, and Discord.Net is 3.20.2 in both. It is a latent wedge that any socket-exhaustion event can trigger.

Fix

Add an optional StopTimeout to ChannelLifecycleActor, mirroring the existing ReadySignalTimeout pattern.

  • BeginStop stamps each stop cycle with a monotonic _stopGeneration and arms a timer.
  • StopTimedOut logs a warning, schedules the retry, and transitions to Disconnected — the same terminal state the success and failure paths reach.
  • StopSucceeded / StopFailed now carry the generation and are ignored when stale, so a transport call abandoned by a timeout cannot disturb a later cycle.
  • The timer is cancelled on PostStop, on StartConnecting, and on both normal stop completions.
  • Discord overrides StopTimeout to 30 seconds, matching its ReadySignalTimeout.

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:

08:25:03  WARN  Gateway requested clean reconnect: did not reach ready within 30 seconds
08:25:33  WARN  Discord gateway stop did not complete within the stop timeout;
                abandoning the stop and scheduling a reconnect.
08:25:33  Attempting Discord reconnect (delay was 00:01:20)

Testing

  • Existing lifecycle tests pass unchanged (default StopTimeout is null).
  • New test: a transport whose StopTransportAsync never completes transitions to Disconnected and schedules a retry after StopTimeout.
  • New test: a stale StopSucceeded from an abandoned cycle does not cancel the current cycle's timer or reset its state.
  • New test: StopTimeout = null preserves 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). DiscordSocketClient is registered via AddSingleton in ChannelIntegrationRegistrationExtensions, 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.

NetSupervisor added 2 commits November 12, 2026 01:03
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants