Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(connectors): persistent event-loop bridge to stop Email Triage UI hang (#1579)#1589

Merged
itomek merged 5 commits into
mainfrom
fix/1579-connectors-async-bridge-hang
Jun 11, 2026
Merged

fix(connectors): persistent event-loop bridge to stop Email Triage UI hang (#1579)#1589
itomek merged 5 commits into
mainfrom
fix/1579-connectors-async-bridge-hang

Conversation

@itomek

@itomek itomek commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #1579

Why this matters

Before: in gaia chat --ui on Windows, "Triage my last 20 emails" hangs in the "thinking" phase forever — Lemonade emits one generation, then nothing, and no output ever appears. The Email Triage agent fetches Gmail before its next LLM call, and every Gmail request re-fetches an OAuth token through get_access_token_sync → a brand-new asyncio.run(...) per call (~21 per triage), from the agent's worker thread, all sharing one module-level asyncio.Lock. Spinning up and tearing down a fresh event loop on every call is fragile: on Windows it can block in ProactorEventLoop teardown (the reported hang), and on Python ≤3.11 the shared asyncio.Lock raises a cross-loop RuntimeError on the next refresh.

After: all connector async work runs on a single persistent event loop for the process lifetime. The lock binds to one stable loop, there is no per-call loop teardown churn, and a stalled connector operation now raises an actionable error within a bounded timeout instead of hanging indefinitely.

Test plan

  • python -m pytest tests/unit/connectors/ -x -q — 509 passed (incl. 7 new bridge tests: repeated-refresh cross-loop safety, bounded-timeout raise, contextvar propagation, loop-thread deadlock guard)
  • python util/lint.py --all — clean
  • Windows (Python 3.12.10, WindowsProactorEventLoopPolicy): 25 rapid run_sync httpx cycles on the persistent loop — the per-triage token-fetch pattern — complete with no hang; old asyncio.run-per-call path timed alongside for comparison
  • Confirming gate — interactive Windows session, real Gmail: "Triage my last 20 emails" in gaia chat --ui returns a triage and the UI leaves "thinking" (requires an interactive logon session — the connectors keyring is unreadable over SSH, so this can't be automated)

The synthetic Windows harness verifies the persistent-loop bridge is stable but uses an instant-closing loopback endpoint, so it does not by itself reproduce the production hang (which involves real keep-alive/TLS transports lingering at loop teardown). The fix removes per-call loop teardown entirely; the interactive real-Gmail run is the confirming gate.

itomek added 4 commits June 11, 2026 10:01
Failing tests that document the two bugs fixed in the follow-up commit:
- AC2: repeated refresh must not raise cross-loop RuntimeError
- AC3: stalled async op must surface ConnectorsError within timeout
- AC4: agent-id contextvar must propagate through the bridge
- Singleton/deadlock guard contracts for _loop.py
…ows hang (#1579)

Email triage hung forever on Windows after ~21 Gmail HTTP requests because
asyncio.run in get_access_token_sync created/destroyed a ProactorEventLoop on
each call. The asyncio.Lock in the token cache was also bound to the first loop
and raised RuntimeError when used in a subsequent loop (Python ≤ 3.11).

Introduces _loop.py: a single daemon thread running one asyncio event loop for
the process lifetime. All four sync→async worker-thread wrappers now submit
coroutines via asyncio.run_coroutine_threadsafe with a 30s bounded wait.
Contextvar propagation (agent-id) is preserved by capturing copy_context() at
submit time and running the coroutine under it in _ctx_wrap. A deadlock guard
raises RuntimeError if run_sync is called from the loop thread itself.

Wrappers migrated: get_access_token_sync (api.py), get_or_refresh_sync
(tokens.py), get_credential_sync (handler.py), emit_change else-branch (events.py).
CLI asyncio.run calls (cli.py) are left untouched — they are one-shot entry
points not subject to the teardown churn.
Preserve the original concurrent.futures.TimeoutError as the cause when
raising ConnectorsError so the full traceback is available to debuggers
and error reporters. Also removes an unused _var_token assignment in the
test spotted during review.
Future.result(timeout=) raises concurrent.futures.TimeoutError, which is only
an alias for builtins.TimeoutError on Python 3.11+. GAIA targets >=3.10, where
it is a distinct class — the bare `except TimeoutError` would not catch it and
a raw timeout would leak instead of the actionable ConnectorsError. Catch it by
its canonical name so the bounded-wait guard fires on every supported Python.
@itomek

itomek commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Real-world validation (Windows)

Drove the actual reported repro on Windows 11 / Python 3.12.10 (WindowsProactorEventLoopPolicy, gemma-4-e4b-it-gguf): gaia chat --ui → Email Triage agent → "triage my last 20 emails".

No hang. Across runs the UI returned a clean answer in seconds — the infinite-"thinking" behavior is gone. Execution went worker-thread → the new run_sync persistent-loop bridge, and in one run all the way to a real httpx round-trip to Google's token endpoint (_refresh_token) before returning cleanly. That round-trip is exactly where the old asyncio.run-per-call code churned event loops; the persistent loop handled it with zero hang.

The full 20-email fetch loop wasn't reached due to an orthogonal OAuth issue in the Agent UI (per-agent grant not conferred by the UI + a token-refresh 401) — tracked separately in #1592, not a regression of this change.

Unit: 509 passed + 7 new bridge tests; lint clean. Leaving as draft.

@itomek itomek self-assigned this Jun 11, 2026
@itomek itomek marked this pull request as ready for review June 11, 2026 15:30
@itomek itomek requested a review from kovtcharov-amd as a code owner June 11, 2026 15:30
@github-actions

Copy link
Copy Markdown
Contributor

Summary

Solid, well-scoped fix that replaces the per-call asyncio.run pattern in the sync→async connector bridge with a single process-lifetime daemon-thread event loop. This directly removes both root causes of #1579 — the Windows ProactorEventLoop teardown churn and the cross-loop asyncio.Lock RuntimeError on Python ≤3.11 — and adds a bounded timeout so a stalled connector op now fails loudly instead of hanging the UI. The new module is carefully documented, the four behavioral guarantees (cross-loop safety, bounded wait, contextvar propagation, deadlock guard) are each covered by a test, and the contextvar-propagation subtlety of run_coroutine_threadsafe is handled correctly via copy_context() + _ctx_wrap.

The single most important thing: the implementation is correct and the test plan is refreshingly honest about its one gap — the production hang involves real keep-alive/TLS transports lingering at loop teardown, which the synthetic loopback harness can't reproduce, so the interactive real-Gmail Windows run remains the genuine confirming gate. Nothing blocking; the notes below are minor.

Issues Found

🟢 Timeout cancels the future but cannot stop an already-running coroutine (src/gaia/connectors/_loop.py:179)

On timeout, future.cancel() returns False if the coroutine is already executing on the loop thread, so a truly stuck op keeps running on the shared persistent loop after run_sync raises. In practice this is bounded — _refresh_token uses httpx.AsyncClient(timeout=10.0) (tokens.py:184), well under the 30s run_sync default, so the coroutine self-terminates long before the bridge timeout in the real path. Worth a one-line comment noting the cancel is best-effort so a future caller passing a short timeout over a slow-but-not-hung op doesn't assume the work was actually stopped.

🟢 Stopped-loop re-init orphans the old loop/thread (src/gaia/connectors/_loop.py:84-110)

If _loop exists but is_running() is False, a fresh loop+thread is created and the old loop is never close()d. This is effectively unreachable today (the daemon loop only stops at process exit), so it's a latent-only concern — fine to leave, but a short comment that re-init intentionally doesn't reclaim the prior loop would save a future reader the double-take.

🟢 Loop-start readiness uses a polling busy-wait (src/gaia/connectors/_loop.py:101-107)

while not new_loop.is_running(): time.sleep(0.001) under _init_lock works and is bounded by the 5s deadline, but signalling readiness from inside _run_loop via a threading.Event (loop.call_soon(ready.set) once running) is the more idiomatic handshake and avoids the spin. Purely stylistic — current code is correct.

Strengths

  • Root-cause fix, not a workaround — collapsing N short-lived loops into one stable loop eliminates both failure modes (lock binding and Proactor teardown) at the source, and the module docstring explains exactly why with issue references.
  • Contextvar propagation done right — correctly recognizes that run_coroutine_threadsafe starts the coroutine with the loop thread's context, not the caller's, and carries the agent-id via copy_context(). The end-to-end test (test_contextvar_through_token_sync_wrapper) verifies this through the real get_access_token_sync path, not just the bridge in isolation.
  • Errors follow the fail-loudly contract — the timeout raises ConnectorsError naming what failed, what to check, and where to look, with raise ... from e preserving the cause; the deadlock guard turns a would-be hang into an actionable RuntimeError.
  • Honest test plan — explicitly flags that the synthetic harness doesn't reproduce the production transport-teardown hang and that the interactive run is the confirming gate, rather than overclaiming.
  • Logging uses logging.getLogger(__name__), matching every other module in src/gaia/connectors/.

Verdict

Approve with suggestions — no blocking issues. The three notes are minor (one best-effort-cancel comment, one latent re-init edge, one stylistic busy-wait) and none need to land before merge. Recommend merging once the interactive Windows real-Gmail gate in the description is confirmed, since that's the only check that exercises the actual reported hang.

@itomek itomek enabled auto-merge June 11, 2026 15:42
Address bot-review polish on the connectors loop bridge:
- Replace the busy-wait startup spin with a threading.Event handshake
  signalled from inside the loop thread via call_soon(ready.set); drop the
  now-unused time import. Keeps the fail-loud RuntimeError on startup timeout.
- Note that the timeout-path future.cancel() is best-effort: a coroutine
  already executing on the loop thread cannot be interrupted (bounded by the
  inner httpx 10s timeout).
- Note that the re-init branch intentionally does not reclaim a prior loop
  (latent-only — the daemon loop only stops at process exit).
@itomek

itomek commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all three notes addressed in 5fd796b4 (509 connector tests still pass, lint clean):

  1. Best-effort cancel (_loop.py) — added a comment in the timeout except noting future.cancel() can't stop a coroutine already executing on the loop thread, so a truly-stuck op keeps running after we raise (bounded in practice by _refresh_token's 10s httpx timeout, well under the 30s default).
  2. Re-init orphan — added a comment that the prior-loop-stopped branch intentionally does not close/reclaim the old loop (latent-only; the daemon loop only stops at process exit).
  3. Busy-wait — replaced the is_running() spin with a threading.Event handshake (new_loop.call_soon(ready.set)ready.wait(timeout=5.0)); removed the now-unused time import; the fail-loud 5s-startup RuntimeError is preserved.

On the confirming gate: validated on Windows 11 / Python 3.12 by driving the real repro (gaia chat --ui → Email Triage → "triage my last 20 emails") — no hang across runs, one reaching a real httpx round-trip to Google's token endpoint through the new run_sync bridge before returning cleanly. The full 20-email fetch loop is currently gated by an orthogonal Agent-UI connectors issue (per-agent grant + token 401), filed separately as #1592. Leaving this PR as draft accordingly.

@itomek itomek added this pull request to the merge queue Jun 11, 2026
Merged via the queue into main with commit 798846d Jun 11, 2026
35 checks passed
@itomek itomek deleted the fix/1579-connectors-async-bridge-hang branch June 11, 2026 16:06
itomek added a commit to RheagalFire/gaia that referenced this pull request Jun 11, 2026
amd#1591)

## Why this matters

Before: a hung agent tool — e.g. the stuck connector token call behind
amd#1579 — left the Agent UI stuck in "thinking" with no error,
indefinitely. The base agent loop called `tool(**args)` with no timeout
and `process_query` never observed any cancel signal, so the worker
(`threading.Thread(target=_run_agent)`) leaked. The only backstop was
the consumer-side 600s SSE timeout, which took a full 10 minutes *and*
only broke the consumer loop, never the producer.

After: every tool call is bounded (default 180s), so a hung tool
surfaces an actionable error well under 600s instead of an infinite hang
— and the producer thread is actually torn down rather than leaked.
amd#1589 fixed the specific connector root cause; this closes the systemic
gap so the next tool hang can't reproduce the same dead-end UX.

Two composable, fail-loud guards live in the base `Agent`, so **all**
agents benefit:

- **Per-tool execution timeout** (`_execute_tool`): the tool body runs
in a daemon worker joined with the resolved timeout; on timeout it
raises `ToolExecutionTimeout` → an actionable error dict naming the tool
and the `GAIA_AGENT_TOOL_TIMEOUT` knob. Default 180s via
`tool_execution_timeout()` (mirrors the existing `default_max_steps()` —
lazy env read, raises on an invalid value). New `@tool(timeout=...)`
override; `generate_image` opts out at 900s because SD model download
can legitimately take ~600s.
- **Cooperative cancel** (`Agent._cancel_event`): checked at each step
boundary in `_process_query_impl`. `_stream_chat_response` assigns a
fresh event per request and sets it in `_cleanup_stream`, so the
existing `producer.join(5s)` finally reaps the thread. The per-tool
timeout keeps each step bounded, guaranteeing the cancel boundary is
reached.

Known, documented limit: Python can't kill the timed-out worker thread,
so a hung tool's worker keeps running until the process exits — but it's
a daemon and no longer blocks the agent loop or leaks the producer.

Closes amd#1579.

## Test plan

- [ ] `python -m pytest tests/unit/test_agent_tool_timeout.py -q` — 14
tests: blocking tool → bounded actionable error in <5s (not 30s/600s);
per-tool override beats global; `tool_execution_timeout()` parsing incl.
invalid-raises; SD `generate_image` 900s opt-out resolves through
`_resolve_tool_timeout`; producer-style thread does not outlive the
request on mid-run cancel (asserts `send_messages` called once + thread
dead).
- [ ] `python -m pytest tests/unit/test_tool_decorator.py
tests/unit/test_tool_registry_isolation.py
tests/unit/agents/test_null_tool_name.py
tests/unit/agents/test_tool_not_found_error.py
tests/unit/chat/ui/test_chat_helpers.py -q` — regression set, green.
- [ ] `python util/lint.py --black --isort` — clean on the changed
files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Email Triage agent waits indefinitely without generating any output

2 participants