Skip to content

fix(gateway): parallel platform connects + capped Telegram cold-start budget (#85993, #83791) - #86673

Merged
teknium1 merged 6 commits into
mainfrom
fix-parallel-startup-connect
Aug 15, 2026
Merged

fix(gateway): parallel platform connects + capped Telegram cold-start budget (#85993, #83791)#86673
teknium1 merged 6 commits into
mainfrom
fix-parallel-startup-connect

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Fixes #85993. Fixes #83791.

The bug

Gateway startup connected messaging platforms sequentially — each platform's connect() (with its own timeout) was awaited before the next began (gateway/run.py startup loop). Two deliberate changes conflicted:

Result (#85993): when Telegram is unreachable, its 180s budget becomes a 180s wall-clock stall imposed on every other platform, and the gateway doesn't reach running until Telegram gives up.

The fix (two halves)

1. Parallel startup connects — salvaged from #83809 by @EvanProgramming (cherry-picked with authorship preserved, incl. the follow-up commit that makes the concurrency test genuinely distinguish serial from parallel via event ordering after @zuowen7's Windows field-testing found the original wall-clock assertion was a no-op). The serial pre-filter (cheap checks, adapter creation, handler wiring) stays sequential; the slow connect() calls run concurrently via asyncio.gather(return_exceptions=True); result aggregation is single-threaded so all shared-state mutation (self.adapters, self._failed_platforms, error lists, connected_count) is byte-identical to the old serial loop's behavior.

2. Capped Telegram cold-start budget — the residual gap called out in the #85993 thread: for a Telegram-only gateway the parallel change alone is behaviorally identical to the serial await, and even multi-platform gateways still wait the full 180s before running. The initial (pre-running) connect now uses a capped 45s budget (_TELEGRAM_INITIAL_CONNECT_TIMEOUT_SECS_DEFAULT); on timeout the platform enters the existing reconnect watcher, which retries with the full 180s budget and is_reconnect=True — preserving #67498's cold-readiness proof on the retry path and #46621's offline update queue. HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT still overrides everything. Not a Telegram skip: the platform still connects, just off the critical startup path when it's slow.

Verification

tests/gateway/test_startup_connect_parallel.py     — 6 passed (2 salvaged + 4 new cold-start-cap regressions,
                                                     incl. a wedged-adapter E2E through runner.start())
tests/gateway/test_runner_startup_failures.py      — passed
tests/gateway/test_platform_reconnect.py           — passed
tests/gateway/test_platform_reconnect_fd_leak.py   — passed
tests/gateway/test_stale_platform_lock_retryable.py — passed
tests/gateway/test_multiplex_adapter_registry.py   — 14 passed (2 mocks updated to accept the new kwarg)

Related: #82627/#80632 (adapter-level init deadlock — separate diagnosis in flight via #82626); #13602 (older competing parallel-startup PR, superseded by this).

Infographic

parallel-startup

EvanProgramming and others added 5 commits August 14, 2026 20:56
)

GatewayRunner.start() previously awaited each platform's connect() (with its
own timeout) in a serial for-loop. A single slow/failing platform (e.g.
Telegram behind a dead proxy) delayed every later platform's connect by a full
timeout window, cascading one platform's failure onto WeChat/QQ/etc.

Now the slow connect() calls run concurrently via asyncio.gather while the
serial pre-filter (checks, adapter creation, handler wiring) and the
single-threaded result aggregation (shared-state mutation, error handling)
are unchanged. A failing platform no longer blocks the others.

Adds regression tests proving connect() calls overlap and that one failing
platform leaves the others connected.
The previous concurrency assertion (slow_start < fast_end) was true under
BOTH the serial and parallel implementations, so it proved nothing -- it
even passed against the old serial code on main. The only assertion that
distinguishes the two is that the fast platform finishes before the slow
one (fast_end before slow_end), which is only possible when the connects
overlap.

Switch the test to record connect start/end events in arrival order
(clock-resolution independent) and assert fast_end precedes slow_end. This
also fixes the Windows failure @zuowen7 reported: time.monotonic() has only
~15 ms resolution there, so two parallel connects could land on the same
tick and defeat any wall-clock comparison -- event ordering cannot.

Verified the new test fails against origin/main (serial) and passes against
this branch (parallel).
…ing fast (#85993)

The initial (pre-running) connect awaited during gateway startup now uses
a capped 45s budget for Telegram instead of the full 180s (#67498) budget.
On timeout the platform is queued for the reconnect watcher, which retries
with the full budget and is_reconnect=True (preserving the offline update
queue, #46621). Combined with the parallel startup connects, an unreachable
Telegram no longer holds the whole gateway out of the running state.
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages needs-decision Awaiting maintainer decision before any implementation labels Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on bf5e2a4 — fix(gateway): abort-aware parallel startup connects (restart

⚠️ Warnings

OSV vulnerability scan · View job

5 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 11m37s vs 28m33s (-59.3%). 8 job(s) slower, 14 faster, 2 unchanged.

  • Python tests / Run tests slice 11/12: -38.0s
  • Python tests / Run tests slice 12/12: +30.0s
  • Python tests / Run tests slice 4/12: +27.0s
  • Python tests / Run tests slice 9/12: -22.0s
  • Python tests / Run tests slice 1/12: +16.0s

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core refactor is right-shaped: serial pre-filter + concurrency + single-threaded aggregation preserves serial state semantics, and the 45s Telegram cap is cleanly scoped via initial= without disturbing the reconnect budget. Two things to resolve before merge.

1. Detached wedged connect leaks and invites a double-connect race

The cap is enforced via the existing detach-on-timeout pattern: the underlying adapter.connect() task is abandoned rather than cancelled. The 45s cap makes detach 4× more likely during startup than the old 180s, and the PR's own E2E enshrines the residue: test_initial_connect_times_out_at_cap_and_queues_retry shrinks the cap to 0.2s against a _WedgedAdapter whose connect() sleeps 3600s. On timeout that 3600s task is detached and never cancelled; Telegram is queued to _failed_platforms with next_retry = now+30; when the watcher fires it calls connect() again on the same adapter while the first wedged connect is still running → two concurrent connect()s on one adapter.

The test only asserts Platform.TELEGRAM in runner._failed_platforms and not in runner.adapters — it passes while the leaked task and latent double-connect live on. Partly pre-existing in serial mode, but the cap makes it more likely and this test stabilizes the scenario without cancelling or asserting on the detached task. The test should account for the leaked task, and the reconnect path should cancel a preceding detached connect before re-connecting.

2. Confirm the abort helpers exist on the base

The new code calls _abort_startup_if_shutdown_requested(adp, p) and _startup_should_abort(), but the diff doesn't define them and they're absent from my checkout (older main). Presumably they're on the PR base — verify before merge, since if they don't exist the abort path raises.

3. The "byte-identical shared-state" claim is imprecise

_update_platform_runtime_status(...,"connecting") mutates shared runtime state from inside the parallel tasks, outside the aggregation loop. Harmless today (keyed per-platform, asyncio is single-threaded), but the claim that all shared mutation is deferred to the single-threaded loop is technically overstated — worth correcting so it doesn't mask a future real race.

4. Minor

  • 0.05s abort-poll granularity adds latency to honoring a shutdown mid-connect (bounded, not a correctness break).
  • Abort teardown matches the closure's tuple contract via _t.result()[3] == "ok" string comparisons — a fragile coupling between the closure layout and the abort path.
  • Aggregation order is deterministic (dict insertion = config order), good.

Tests

Strong on the two headline features: the concurrency proof is event-order-based (correct — wall-clock is a no-op on Windows), plus budget-cap, env-override, other-platforms-unchanged, and a wedged E2E. Gaps: the abort-mid-connect path, the detached-task leak / double-connect (finding #1), the "exception"-escape path, and non-deterministic multi-failure ordering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery needs-decision Awaiting maintainer decision before any implementation P1 High — major feature broken, no workaround platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

4 participants