fix(telegram): close reconnect races that leave adapter half-destroyed - #56036
fix(telegram): close reconnect races that leave adapter half-destroyed#56036JoaoMarcos44 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.
Competing with #56028 for the same issue (#55992). #56028 is the narrow defense-in-depth guard ( |
|
Merged via PR #56200 — your two commits were rebased onto current main with your authorship preserved in git log. Thanks for the root-cause fix (claim-slot-before-disconnect, stable-local/fail-fast, reentrancy-guard rearm). We added one follow-up commit widening the same guard to the 409-conflict retry path. #56200 |
|
Superseded by #56224, which carries both your commits verbatim (authorship preserved) rebased onto current |
Fixes #55992
Summary
After a network blip, the Telegram adapter self-restarts, logs
✓ telegram connected, and then the gateway silently stops processing any further messages — whilegateway_state.jsonkeeps reportingconnected/runningand the process itself stays alive (or, on Windows, dies with a non-standard exit code). This PR fixes the root cause: a pair of concurrency bugs that let more than one recovery attempt run against the same Telegram adapter instance at once.Root cause
Three issues combine to produce the symptom:
1. Stale reentrancy guard (
plugins/platforms/telegram/adapter.py,_handle_polling_network_error)When
start_polling()fails during a reconnect attempt, the handler self-schedules a follow-up retry viaasyncio.ensure_future(...), but that task was only tracked inself._background_tasks— it was never assigned toself._polling_error_task. That field is the only reentrancy guard checked by three independent trigger points: the PTBerror_callback,_polling_heartbeat_loop(runs every 90s), and_probe_pending_updates. With the guard going stale while a retry was still in flight, any of those three could start a second, fully independent recovery attempt for the same underlying outage, sharing the sameself._appwith no synchronization.2. TOCTOU window in the fatal-error handler (
gateway/run.py,_handle_adapter_fatal_error)The adapter was only removed from
self.adaptersin thefinallyblock, after awaitingdisconnect(). If issue #1 above caused a second concurrent fatal-error notification for the same adapter object, that second call would still seeexisting is adapter == True(because the first call hadn't reached itsfinallyyet) and would calldisconnect()a second time on the same object.That double
disconnect()is the concrete origin of the'NoneType' object has no attribute 'updater'exception reported in the issue: the firstdisconnect()call setsself._app = Noneat the end of its teardown, and the second concurrent call — already past its ownif self._app and self._app.updater...check — re-readsself._appa few lines later and findsNone.Net effect: two live PTB
Application/Updaterobjects (or one live + one half-torn-down) can end up racing for the same bot token'sgetUpdateslong-poll. TheUpdaterthat loses that race sits in a healthy-lookingrunning=Truestate, receiving empty long-poll responses forever, with no exception ever raised — which is why the log goes completely silent andgateway_state.jsonnever updates again (it's written once, on theconnect()that "won"). This also explains why the failure is intermittent rather than deterministic: it depends on the exact interleaving of the heartbeat loop, the pending-updates probe, and the network-error callback around the same outage.3. Stale notifications outliving their adapter (
gateway/run.py,_handle_adapter_fatal_error)Even after closing #1 and #2, a delayed fatal-error notification can still arrive from an adapter instance that has already been replaced — e.g. its own background retry chain (started before the reconnect watcher successfully installed a fresh adapter) finally exhausts its retries and reports failure after the fact. The handler processed that notification unconditionally: it overwrote the platform's runtime status back to
retrying/fataleven though a different, healthy adapter was already running, and could re-queue an already-connected platform into_failed_platformsfor reconnection it didn't need.Fix
Three small, localized changes — no architectural changes, no behavior change to the retry/backoff strategy itself:
plugins/platforms/telegram/adapter.py— the chained retry task created in_handle_polling_network_error'sexceptblock is now assigned toself._polling_error_task, so the shared reentrancy guard correctly reflects that a recovery is still in flight. Also capturesself._appin a local variable at the top of the stop/start_polling sequence and reuses that reference across bothawaitpoints, instead of re-readingself._app(which a concurrentdisconnect()could have already cleared).gateway/run.py—_handle_adapter_fatal_errornow pops the adapter out ofself.adapters(and updatesdelivery_router.adapters) before awaitingdisconnect(), not after. A second concurrent notification for the same adapter now correctly sees it's already been claimed and skips the redundantdisconnect()call.gateway/run.py—_handle_adapter_fatal_errornow snapshots the current owner of the platform slot as its very first step, before any log line or status write. If that owner is neitherNonenor the adapter reporting the error, the notification is stale (a different adapter already owns the slot) and the handler returns immediately, before touching runtime status or the reconnect queue.Steps to reproduce (from the issue)
httpx.ConnectErrorfires a few times in a row.Fatal telegram adapter error → Restarting gateway.✓ telegram connected.gateway_state.jsonstill saysrunning/connected. On Docker this has been observed to last 6h9m+ of total silence; on Windows the process exits instead (non-standard exit code) with the state file stuck atstarting.This is a timing-dependent race, so it isn't reliably reproducible with a fixed manual recipe — the two regression tests below reproduce the exact interleavings that trigger it.
Testing
Added three regression tests that fail against the pre-fix code and pass against this change:
tests/gateway/test_telegram_network_reconnect.py::test_reconnect_chained_retry_updates_polling_error_task— asserts that after a failedstart_polling()retry,adapter._polling_error_taskpoints at the newly scheduled task (not stale/done), closing the reentrancy-guard gap described in root cause Terminal tool #1.tests/gateway/test_runner_fatal_adapter.py::test_concurrent_fatal_notifications_disconnect_same_adapter_once— drives two concurrent_handle_adapter_fatal_error(adapter)calls for the same adapter object (viaasyncio.gather, with a mockeddisconnect()that yields control mid-teardown) and assertsdisconnect()is called exactly once. Without the fix, this test observes 2 calls.tests/gateway/test_runner_fatal_adapter.py::test_stale_fatal_notification_from_superseded_adapter_is_ignored— installs a healthy adapter, then feeds a fatal-error notification from a different, superseded adapter instance for the same platform, and asserts the healthy adapter is left untouched (disconnect()never called on it), the platform is not re-queued into_failed_platforms, and the gateway doesn't shut down. Without the fix, the stale notification is processed as if it came from the live adapter.Ran:
All 81 tests pass, including the 3 new regression tests. Also ran the full
tests/gateway/suite; the only failures present are pre-existing and unrelated to this change (confirmed viagit stash— they reproduce identically onmain, e.g. Windows-vs-Unix path assumptions intest_status.py).Out of scope
gateway/status.py's one-shot (event-driven, not periodic)connectedstate write is a separate, larger design question (should there be a periodic "still healthy" re-validation?) and is intentionally not addressed here to keep this fix minimal and low-risk._handle_polling_conflict(the 409-conflict retry ladder) has a structurally similar pattern to the network-error ladder and may warrant the same local-self._app-capture treatment, but wasn't implicated in the reported symptom and is left for a follow-up if confirmed necessary.