Skip to content

fix(sdk/python): resolve ResultCache cross-loop deadlock (#623)#799

Open
7vignesh wants to merge 9 commits into
Agent-Field:mainfrom
7vignesh:fix/result-cache-deadlock-623
Open

fix(sdk/python): resolve ResultCache cross-loop deadlock (#623)#799
7vignesh wants to merge 9 commits into
Agent-Field:mainfrom
7vignesh:fix/result-cache-deadlock-623

Conversation

@7vignesh

@7vignesh 7vignesh commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

ResultCache mixed a threading.RLock with loop-bound asyncio primitives (an asyncio.Event for shutdown and an asyncio.Task for the cleanup loop). When start() and stop() ran on different event loops which happens when the client's sync and async execution paths are mixed (#620) stop() raised got Future attached to a different loop and could wedge the process. This makes the cache lifecycle loop-aware so it never awaits a task from the wrong loop.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

Test plan

  • cd sdk/python && python -m pytest tests/test_result_cache_deadlock.py -v
  • cd sdk/python && python -m pytest tests/test_result_cache.py tests/test_result_cache_bigfiles_coverage.py
  • Reproduced the deadlock before the fix (RuntimeError: got Future attached to a different loop) and confirmed cross-loop stop() now completes cleanly with no lingering pending-task warnings.

Test coverage

  • I ran tests for the surface(s) I changed locally.
  • New code paths are covered by tests in this PR (no bare additions).
  • If I removed code, I updated coverage-baseline.json in this PR only if the removal caused a legitimate regression and I called it out in the summary above.
  • The coverage gate check is green in CI before requesting review.

result_cache.py coverage went from 88% to 95%; the sdk-python aggregate stays at 94% (baseline 93.73%). New tests/test_result_cache_deadlock.py covers cross-loop stop, idempotent/rebinding start, concurrent sync access during cleanup, stop-without-start, and the disabled-cache no-op path.

Checklist

Related issues / PRs

Fixes #623
Related to #620 (mixing threads and async code)

…#623)

ResultCache mixed a threading.RLock (for its data) with loop-bound
asyncio primitives (asyncio.Event for shutdown, asyncio.Task for the
cleanup loop). When start() and stop() ran on different event loops —
which happens when the AgentFieldClient's sync and async execution
paths are mixed (Agent-Field#620) — stop() would raise 'got Future attached to a
different loop' and could wedge the process waiting on a task it can
never await.

Fix: make the cache lifecycle loop-aware.
- Record the event loop the cleanup task/shutdown event are bound to.
- start() is now idempotent on the same loop and rebinds cleanly when
  called on a new loop, discarding the stale task via
  call_soon_threadsafe(task.cancel) on its owning loop — never a
  cross-loop await.
- stop() only awaits the cleanup task when on its owning loop; from a
  different loop it cancels without awaiting. The cache is always
  cleared regardless (that path only needs the thread lock).
- Shrink the cleanup loop's critical section so stats logging no longer
  runs while holding the lock, reducing contention with sync callers.

Adds tests/test_result_cache_deadlock.py covering cross-loop stop,
idempotent/rebinding start, concurrent sync access during cleanup, and
the disabled-cache no-op path. result_cache.py coverage: 88% -> 95%.
@7vignesh
7vignesh requested a review from AbirAbbas as a code owner July 20, 2026 11:50
Copilot AI review requested due to automatic review settings July 20, 2026 11:50
@7vignesh
7vignesh requested a review from a team as a code owner July 20, 2026 11:50

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.5 KB +6% 0.36 µs +3%

✓ No regressions detected

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 86.90% 87.40% ↓ -0.50 pp 🟡
sdk-go 92.50% 92.00% ↑ +0.50 pp 🟢
sdk-python 94.17% 93.73% ↑ +0.44 pp 🟢
sdk-typescript 91.05% 90.42% ↑ +0.63 pp 🟢
web-ui 84.75% 84.79% ↓ -0.04 pp 🟡
aggregate 85.54% 85.75% ↓ -0.21 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 33 100.00%
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas AbirAbbas 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.

I took this for a spin rather than just reading the diff. On the merge-base I reproduced the failure with start() on a background thread's loop and stop() via asyncio.run() on the main thread: RuntimeError: got Future attached to a different loop, and since stop() aborts mid-way the cache is left populated. On this branch the same scenario (and the owning-loop-closed variant) completes cleanly, and I checked that the stale cleanup task really does get cancelled on its owning loop rather than leaked. I also ran the new test file against the merge-base — 3 of the 8 genuinely fail pre-fix, so these are real regression tests rather than mirrors of the implementation. Full suite on the branch is green for me locally (1767 passed, 4 skipped) and ruff is clean. Nice touch that stop() now clears the cache even on the cross-loop path — before, the failed stop left the contents behind.

The one thing I'd like sorted before this closes #623: the same foreign-loop task.cancel(); await task pattern still lives in AsyncExecutionManager.stop() (async_execution_manager.py:337-340) and ConnectionManager.close() (connection_manager.py:99-102). I verified on this branch that starting the manager on one loop and stopping it from another still raises the identical RuntimeError at async_execution_manager.py:339 — before result_cache.stop() is ever reached. Since client.aclose() goes through manager.stop(), the end-to-end sync/async mixing case from #620/#623 still fails one level up. Happy with either extending the same loop-aware treatment to those two sites in this PR or a follow-up, but either way I'd change "Fixes #623" to "Partially addresses #623" so the issue doesn't auto-close on merge.

# Cross-loop stop: cancel on the owning loop, never await here.
self._discard_stale_cleanup_task()

self._cleanup_task = None

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.

One residual race here: stop() snapshots these fields at the top, awaits the task in between, and then unconditionally nulls all three — with no lock on either side. A start() racing in from another thread (or even on the same loop during the await task) can install a fresh task that gets clobbered here and leaks, still running on its loop. The serialized cross-loop case — the #623 scenario — is handled, and the old code was just as unguarded, so not blocking. But since the instance already has self._lock, it'd be cheap to take it around the snapshot and around this mutation block (never across the await).

7vignesh and others added 2 commits July 20, 2026 20:31
…ool (Agent-Field#623)

Addresses review feedback on Agent-Field#799: the same foreign-loop
'task.cancel(); await task' hazard that affected ResultCache also lived
in AsyncExecutionManager.stop() and http_connection_manager
ConnectionManager.close(). Since client.aclose() flows through
manager.stop() -> connection_manager.close(), the end-to-end sync/async
mixing case (Agent-Field#620/Agent-Field#623) still raised 'got Future attached to a different
loop' one level up, before result_cache.stop() was reached.

Changes:
- New agentfield/async_lifecycle.py with shared loop-aware teardown
  helpers: current_running_loop(), cancel_task_cross_loop(),
  cancel_and_await_if_same_loop(). Single source of truth for the safe
  pattern.
- ResultCache refactored to use the shared helpers (behaviour unchanged).
- AsyncExecutionManager records its owning loop at start(); stop() only
  awaits background tasks / sets the shutdown Event on that loop, and
  cancels cross-loop without awaiting otherwise.
- http_connection_manager ConnectionManager records its owning loop at
  start(); close() takes the async lock + awaits the session only on the
  owning loop, and on a foreign loop schedules session/connector close on
  the owning loop via call_soon_threadsafe without awaiting.

Tests: new tests/test_async_lifecycle_deadlock.py covers cross-loop
teardown of both components (3 of its 4 checks fail pre-fix on the
merge-base). Full result_cache + manager + connection suite green
(110 passed); result_cache.py coverage 98%.
Copilot AI review requested due to automatic review settings July 20, 2026 18:08

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@7vignesh

7vignesh commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Good catch you're right that the same foreign-loop task.cancel(); await task pattern lived one level up and client.aclose() would hit it before result_cache.stop() was ever reached. I extended the fix rather than deferring it.

What changed in the latest push:

  • New agentfield/async_lifecycle.py holds the loop-aware teardown pattern as shared helpers (current_running_loop, cancel_task_cross_loop, cancel_and_await_if_same_loop) so all three sites share one implementation instead of three copies.
  • ResultCache now uses those helpers (behaviour unchanged).
  • AsyncExecutionManager.stop() (async_execution_manager.py:337-340) records its owning loop at start() and only awaits the four background tasks / sets the shutdown Event when on that loop; cross-loop it cancels without awaiting.
  • http_connection_manager.ConnectionManager.close() (the site you flagged) records its owning loop at start(). On the owning loop it takes the async lock and awaits the session close as before; on a foreign loop it skips the loop-bound lock and schedules the aiohttp session/connector close on the owning loop via call_soon_threadsafe without awaiting.

I verified the end-to-end path: starting the manager on a background thread's loop and stopping it from another no longer raises at async_execution_manager.py:339, and client.aclose() from a foreign loop now completes cleanly through both managers down to the cache.

New tests/test_async_lifecycle_deadlock.py covers cross-loop teardown of both components 3 of its 4 checks genuinely fail on the merge-base, so they're real regression tests. Full result_cache + manager + connection suite is green locally (110 passed) and ruff is clean.

Since the whole client.aclose() chain is now loop-safe, I've kept this as "Fixes #623". Happy to revisit if you'd still prefer to scope it as partial.

Copilot AI review requested due to automatic review settings July 20, 2026 19:00

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 21, 2026 13:43

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 21, 2026 17:57

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 22, 2026 20:38

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 22, 2026 22:31

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@7vignesh

Copy link
Copy Markdown
Contributor Author

@santoshkumarradha pls review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Python SDK] ResultCache can deadlock the process

3 participants