fix(telegram): close reconnect races that leave adapter half-destroyed (salvage #56036) - #56224
fix(telegram): close reconnect races that leave adapter half-destroyed (salvage #56036)#56224kshitijk4poor wants to merge 2 commits into
Conversation
_handle_polling_network_error's chained retry never updated self._polling_error_task, so the reentrancy guard shared with the heartbeat loop and the pending-updates probe went stale mid-recovery, letting more than one recovery attempt run concurrently against the same adapter. Combined with a TOCTOU window in _handle_adapter_fatal_error (the adapter was only removed from self.adapters in a finally block after awaiting disconnect()), two concurrent fatal notifications for the same adapter could both pass the "still installed" check and call disconnect() twice, which is where the reported "'NoneType' object has no attribute 'updater'" originates once self._app is cleared by the first call. - Reassign the chained retry task to self._polling_error_task so the guard reflects an in-flight recovery. - Capture self._app in a local variable across the stop/start_polling sequence instead of re-reading self._app between awaits. - Claim (pop) the adapter from self.adapters before awaiting disconnect() in _handle_adapter_fatal_error, not after, closing the TOCTOU window for a concurrent notification on the same adapter.
…adapters A delayed fatal-error notification from an adapter instance that has already been replaced by a successful reconnect (a different adapter object now owns the platform slot) was still processed: it overwrote the platform's runtime status back to retrying/fatal and could re-queue an already-healthy platform for reconnection. Snapshot the current owner of the platform slot at the top of _handle_adapter_fatal_error and bail out before any side effect when it belongs to a different, already-installed adapter.
Rebase failed
|
Ran the full hermes-pr-review Phase 2c (concurrency/race + 4-part structured review + hermes-agent-dev checks). 0 Critical, no actionable findings — both minor warnings (the |
|
|
Both commits from this PR are already on |
|
Closing — @JoaoMarcos44's #56036 fix has already landed on main (both the gateway/run.py TOCTOU fix and the adapter app-capture + _polling_error_task guard), and teknium1's 43edbae extended the same pattern to the 409-conflict retry path. Rebasing this branch onto current main yields an empty diff. Full credit to @JoaoMarcos44 — the fix is in. |
Summary
Salvage of #56036 by @JoaoMarcos44 (rebased onto current
main). Closes the concurrency races that leave the Telegram adapter "half-destroyed" — the gateway logs✓ telegram connectedbut silently stops processing messages after a network blip + self-restart, and can crash with'NoneType' object has no attribute 'updater'(issue #55992).Original PR was 127 commits behind
main; both of @JoaoMarcos44's commits are cherry-picked here verbatim (authorship preserved).The bug
After a network outage, multiple recovery paths (the PTB error callback, the heartbeat loop, the pending-updates probe, and the gateway's fatal-error handler) can race on the same outage:
gateway/run.py::_handle_adapter_fatal_errorTOCTOU — the adapter was removed fromself.adaptersin afinallyblock afterawait disconnect(). A second concurrent fatal-error notification for the same adapter still saw itself as the installed adapter during that await and calleddisconnect()on the same object twice — the concrete origin of theNoneType ... updatercrash when teardown re-readsself._app.telegram/adapter.py::_handle_polling_network_error— readself._apprepeatedly acrossawaitpoints; a concurrentdisconnect()settingself._app = Nonemid-sequence swapped inNonesilently. The chained retry also didn't updateself._polling_error_task, so the reentrancy guard went stale and each recovery path could start its own concurrent recovery for the same outage.The fix
self.adaptersbefore awaitingdisconnect()) so a concurrent notification can't double-disconnect.app = self._applocal before the awaits and operate on it (fail fast with aRuntimeErrorif it was torn down, instead of anAttributeErroronNone.updater); and setself._polling_error_task = taskfor the chained retry so the reentrancy guard stays valid while recovery is in flight.Review (this salvage)
Ran our review workflow — a concurrency/race pass, a correctness/scope pass, and my own trace. Clean, no findings.
awaitsits between thepopandawait disconnect()(no dropped-delivery window; strictly better than the old ordering, which kept a half-disconnected adapter routable for the whole disconnect). The stale-notification early-return uses an identity check (existing is not adapter) so it only suppresses superseded instances, never a legitimate same-object error. Afterapp = self._app, there are no residualself._appreads beforestart_polling()in the method. The_polling_error_taskhandoff forms a proper chain — assigned synchronously with task creation (no yield between), cleared on teardown.raise RuntimeError(...)beforestart_polling()is caught by the method's ownexcept Exceptionhandler, which re-enters the retry ladder — it never propagates uncaught to the direct-await or background-task callers.Tests
New tests cover: two concurrent fatal notifications → exactly one
disconnect(); a stale notification from a superseded adapter is ignored (no disconnect of the new adapter, no re-queue, no shutdown); and the chained retry updating_polling_error_task. Adjacent gateway suites (delivery, runner-startup, notice-delivery) also pass — no regressions.Supersedes #56036 and #56028 (@AlexFucuson9's narrower
None-guard for the same #55992 symptom — this PR fixes the underlying races that produce theNoneTypein the first place). Full credit to @JoaoMarcos44.