Bound Chat.leave_room() with a timeout, matching join_room() - #365
Open
janovak wants to merge 1 commit into
Open
Bound Chat.leave_room() with a timeout, matching join_room()#365janovak wants to merge 1 commit into
janovak wants to merge 1 commit into
Conversation
janovak
force-pushed
the
fix/leave-room-timeout
branch
from
August 12, 2026 14:37
4108364 to
e235cc1
Compare
leave_room() waits on _room_leave_locks with no deadline, unlike join_room() which already bounds its equivalent wait with join_timeout. If the underlying connection dies (or is silently replaced by a reconnect) while a leave_room() call is mid-wait, the PART confirmation that would clear its lock never arrives, and the call -- and anything awaiting it -- hangs forever. Add a leave_timeout attribute (default 10s, same default as join_timeout) and bound the wait loop with it, returning the list of channels that couldn't be left in time, mirroring join_room()'s existing contract.
janovak
force-pushed
the
fix/leave-room-timeout
branch
from
August 12, 2026 14:38
e235cc1 to
41cd78e
Compare
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
Chat.leave_room()waits on_room_leave_lockswith no deadline:join_room()already protects the equivalent wait withjoin_timeout:If the connection dies (or gets silently replaced by a reconnect) while a
leave_room()call is mid-wait, the PART confirmation that would clear its lock never arrives — and the call hangs forever, along with anything awaiting it.Why a reconnect is enough to trigger it
The lock is only ever cleared in two places: on the PART confirmation in
_handle_part, or by a wholesale reset of the list. That reset lives in_stop()— the full-shutdown path:__connect()— the reconnect path, reached via_handle_base_reconnect()— does not reset those lists. So a reconnect discards the in-flight PART confirmation while leaving the lock behind, and thewhileloop above can never exit.This makes
no_message_reset_timea direct amplifier. It becomes the websocketreceive_timeout:so any quiet period longer than that forces a reconnect. Lowering it to catch dead sockets faster (we run 30s) raises the chance of a reconnect landing inside a
leave_room()call. The failure is also self-reinforcing: fewer messages → more reconnects → higher chance of the hang → our watchlist freezes → fewer messages still.How we hit this
We run
Chatin a service that periodically joins/leaves rooms based on a rotating watchlist. A transient asyncio SSL-transport error (a known, sporadic issue — see cpython#81407 / cpython#85762) killed the underlying connection while aleave_room()call was in flight. Becauseleave_room()has no timeout, that single call blocked forever, which wedged the scheduled task that owned it — with no exception raised anywhere for calling code to catch. We only recovered by restarting the process.It happened twice in one day, hanging for 1h15m and 3h10m before we caught it. Because our other rooms stayed joined and kept delivering messages, there was no crash and no error log — throughput just decayed quietly over several hours as streams ended and the frozen watchlist could not refresh.
join_room()would have survived the identical failure by simply giving up afterjoin_timeout(10s) and returning the channels it couldn't join.Fix
Give
leave_room()the same protectionjoin_room()already has:leave_timeout(default10, matchingjoin_timeout's default)join_room()'s cleanup of_room_join_locks)join_room()'sfailed_to_joinreturn contractAdditive/non-breaking on the success path — with the default
leave_timeout=10, behavior is identical to today as long as the PART confirmation arrives within 10s, which is the case in the overwhelming majority of calls.Worth noting this cannot be fixed correctly from the caller's side: wrapping
leave_room()inasyncio.wait_forcancels it from outside, which leaves the channel's entry in_room_leave_lockspermanently — the same corruption, just relocated. The cleanup has to happen inside the method.Testing
Running this patch in production against the stock 4.5.0 release, in a service that continuously joins/leaves 15-30 rooms. It has been deployed for a short time so far (rolled out today), so I would not yet call the soak long — but normal join/leave behavior is unaffected, and the hang has not recurred since. Before the patch, on the identical workload and stock 4.5.0, we reproduced the hang twice in a single day.