Skip to content

test(agent-server): lifecycle lock deadlocks on thread-pool exhaustion - #4513

Closed
neubig wants to merge 12 commits into
mainfrom
fix/lifecycle-lock-thread-pool-deadlock
Closed

test(agent-server): lifecycle lock deadlocks on thread-pool exhaustion#4513
neubig wants to merge 12 commits into
mainfrom
fix/lifecycle-lock-thread-pool-deadlock

Conversation

@neubig

@neubig neubig commented Aug 17, 2026

Copy link
Copy Markdown
Member

HUMAN:

Demonstrates a production deadlock where the conversation lifecycle lock blocks all event loading when the thread pool is exhausted. Fix included in second commit.


AGENT:

Why

_get_or_load_event_service acquires lifecycle_lock and then calls asyncio.to_thread(_prepare_persisted_runtime) inside the lock. If the default thread pool is exhausted (all workers stuck on slow I/O), the to_thread call queues indefinitely while still holding the lock. Every subsequent get_event_service call — including the WebSocket event-stream path and the REST /events/search endpoint — blocks waiting for the lock, making the entire agent-server appear wedged even though simple endpoints (/ready, /api/settings) still respond.

This caused a production incident where conversations opened but events never loaded after the server ran long enough for a blocking operation to tie up all thread-pool workers.

Summary

  • Added a failing test (test_thread_pool_exhaustion_does_not_block_cached_conversation) that reproduces the deadlock by exhausting a 1-worker thread pool and asserting that a cached conversation can still be loaded.
  • Fixed the deadlock by checking the event-service cache before acquiring the lifecycle lock. This ensures already-loaded conversations can be retrieved instantly even when the lock is held by a slow to_thread call for a different conversation.

REST API contract changes

Compared with base OpenAPI b56221283f74 for public /api/** paths.

--- base public OpenAPI
+++ head public OpenAPI
@@ -4,0 +5 @@
+operation DELETE /api/llm/provider-connections/{connection_id} operationId=delete_provider_connection_api_llm_provider_connections__connection_id__delete
@@ -40,0 +42 @@
+operation GET /api/llm/provider-connections operationId=list_provider_connections_api_llm_provider_connections_get
@@ -62,0 +65 @@
+operation PATCH /api/llm/provider-connections/{connection_id} operationId=update_provider_connection_api_llm_provider_connections__connection_id__patch
@@ -97,0 +101 @@
+operation POST /api/llm/provider-connections operationId=create_provider_connection_api_llm_provider_connections_post
@@ -121,0 +126 @@
+parameter DELETE /api/llm/provider-connections/{connection_id} path:connection_id required=true schema=type="string"
@@ -201,0 +207 @@
+parameter PATCH /api/llm/provider-connections/{connection_id} path:connection_id required=true schema=type="string"
@@ -243,0 +250 @@
+requestBody PATCH /api/llm/provider-connections/{connection_id} application/json required=true schema=ProviderConnectionUpdateRequest
@@ -268,0 +276 @@
+requestBody POST /api/llm/provider-connections application/json required=true schema=ProviderConnectionCreateRequest
@@ -291,0 +300,2 @@
+response DELETE /api/llm/provider-connections/{connection_id} 200 application/json schema=ProviderConnectionResponse
+response DELETE /api/llm/provider-connections/{connection_id} 422 application/json schema=HTTPValidationError
@@ -370,0 +381 @@
+response GET /api/llm/provider-connections 200 application/json schema=type="array" items=ProviderConnectionResponse
@@ -402,0 +414,2 @@
+response PATCH /api/llm/provider-connections/{connection_id} 200 application/json schema=ProviderConnectionResponse
+response PATCH /api/llm/provider-connections/{connection_id} 422 application/json schema=HTTPValidationError
@@ -500,0 +514,2 @@
+response POST /api/llm/provider-connections 201 application/json schema=ProviderConnectionResponse
+response POST /api/llm/provider-connections 422 application/json schema=HTTPValidationError
@@ -1568,0 +1584 @@
+schema LLM-Input property provider_connection_id optional schema=anyOf=[type="string",type="null"]
@@ -1624,0 +1641 @@
+schema LLM-Output property provider_connection_id optional schema=anyOf=[type="string",type="null"]
@@ -2066,0 +2084,2 @@
+schema ProfileInfo property provider_connection_broken optional schema=type="boolean" default=false
+schema ProfileInfo property provider_connection_id optional schema=anyOf=[type="string",type="null"]
@@ -2073,0 +2093,18 @@
+schema ProviderConnectionCreateRequest property api_key required schema=type="string" format="password" minLength=1
+schema ProviderConnectionCreateRequest property base_url optional schema=anyOf=[type="string" maxLength=2048,type="null"]
+schema ProviderConnectionCreateRequest property display_name required schema=type="string" minLength=1 maxLength=128
+schema ProviderConnectionCreateRequest property provider optional schema=type="string" default="custom" minLength=1 maxLength=128
+schema ProviderConnectionCreateRequest type="object" additionalProperties=false
+schema ProviderConnectionResponse property api_key_set optional schema=type="boolean" default=false
+schema ProviderConnectionResponse property base_url optional schema=anyOf=[type="string",type="null"]
+schema ProviderConnectionResponse property created_at required schema=type="integer"
+schema ProviderConnectionResponse property display_name required schema=type="string"
+schema ProviderConnectionResponse property id required schema=type="string"
+schema ProviderConnectionResponse property provider required schema=type="string"
+schema ProviderConnectionResponse property updated_at required schema=type="integer"
+schema ProviderConnectionResponse type="object"
+schema ProviderConnectionUpdateRequest property api_key optional schema=anyOf=[type="string" format="password",type="null"]
+schema ProviderConnectionUpdateRequest property base_url optional schema=anyOf=[type="string" maxLength=2048,type="null"]
+schema ProviderConnectionUpdateRequest property display_name optional schema=anyOf=[type="string" minLength=1 maxLength=128,type="null"]
+schema ProviderConnectionUpdateRequest property provider optional schema=anyOf=[type="string" minLength=1 maxLength=128,type="null"]
+schema ProviderConnectionUpdateRequest type="object" additionalProperties=false
@@ -2437,0 +2475 @@
+schema TelemetrySpec property deployment_kind optional schema=type="string" enum=["local","remote"] default="local"

Issue Number

Fixes #4514.

How to Test

uv run pytest tests/agent_server/test_event_service_thread_pool_exhaustion.py -xvs --timeout=60

Before the fix (first commit only): fails with TimeoutError — the cached get_event_service call hangs because the lifecycle lock is held by the stuck to_thread call.

After the fix (both commits): passes — the cached conversation loads instantly because the cache is checked before acquiring the lock.

All existing tests pass:

uv run pytest tests/agent_server/test_conversation_service.py tests/agent_server/test_event_service.py -x --timeout=120
# 217 passed

Video/Screenshots

Test output before fix (first commit):

FAILED tests/agent_server/test_event_service_thread_pool_exhaustion.py::test_thread_pool_exhaustion_does_not_block_cached_conversation - TimeoutError

Test output after fix (both commits):

PASSED tests/agent_server/test_event_service_thread_pool_exhaustion.py::test_thread_pool_exhaustion_does_not_block_cached_conversation

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:d1b25f5-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-d1b25f5-python \
  ghcr.io/openhands/agent-server:d1b25f5-python

All tags pushed for this build

ghcr.io/openhands/agent-server:d1b25f5-golang-amd64
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-golang-amd64
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-golang-amd64
ghcr.io/openhands/agent-server:d1b25f5-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:d1b25f5-golang-arm64
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-golang-arm64
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-golang-arm64
ghcr.io/openhands/agent-server:d1b25f5-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:d1b25f5-java-amd64
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-java-amd64
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-java-amd64
ghcr.io/openhands/agent-server:d1b25f5-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:d1b25f5-java-arm64
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-java-arm64
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-java-arm64
ghcr.io/openhands/agent-server:d1b25f5-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:d1b25f5-python-amd64
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-python-amd64
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-python-amd64
ghcr.io/openhands/agent-server:d1b25f5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:d1b25f5-python-arm64
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-python-arm64
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-python-arm64
ghcr.io/openhands/agent-server:d1b25f5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:d1b25f5-golang
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-golang
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-golang
ghcr.io/openhands/agent-server:d1b25f5-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:d1b25f5-java
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-java
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-java
ghcr.io/openhands/agent-server:d1b25f5-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:d1b25f5-python
ghcr.io/openhands/agent-server:d1b25f5837b0157ca34fe8ed32546cc75821900a-python
ghcr.io/openhands/agent-server:fix-lifecycle-lock-thread-pool-deadlock-python
ghcr.io/openhands/agent-server:d1b25f5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., d1b25f5-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., d1b25f5-python-amd64) are also available if needed

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_service.py115212689%181–182, 191, 218–219, 223–224, 229, 332–333, 336–337, 349–350, 364, 537–538, 599, 663, 685, 692–693, 776, 852, 903–904, 911, 943–944, 960, 991, 995, 1012, 1024–1027, 1033–1034, 1043, 1045, 1114, 1120–1121, 1125–1126, 1134, 1161, 1167, 1261, 1267, 1272, 1278, 1286–1287, 1296–1299, 1308, 1320, 1328, 1357, 1363–1364, 1367–1369, 1396, 1448, 1541–1542, 1613, 1668–1670, 1672–1673, 1676–1677, 1697, 1783–1784, 1815–1817, 1820–1821, 1825–1827, 1830–1831, 1835–1837, 1840–1841, 1870, 1879, 1922, 1932–1934, 1994, 1997, 2024, 2034, 2039–2042, 2056, 2067, 2079–2080, 2112, 2206, 2263, 2321, 2336–2337, 2469, 2715, 2768, 2771
TOTAL412171730458% 

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

Co-authored-by: openhands <openhands@all-hands.dev>
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: e3890ce1e2052d68def743239c2096dbbe213a90
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/9be9f369-0229-4619-8704-c63efb040362

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

The fix adds a fast-path cache check in _get_or_load_event_service before acquiring _lifecycle_lock, so already-cached conversations can be retrieved even when the lock is held by a slow asyncio.to_thread(_prepare_persisted_runtime) call for a different conversation.

Analysis

The fix is sound. The fast path:

event_services = self._event_services
if event_services is not None:
    cached = event_services.get(conversation_id)
    if cached is not None and cached.is_open():
        cached.touch()
        return cached

is safe because it contains no await points, making it atomic with respect to the asyncio event loop. No other coroutine (including the lock holder) can interleave between the dict read, the is_open() check, and touch(). The inline comment accurately documents this reasoning.

I verified all mutation sites of _event_services:

  • = {} in __aenter__ — before the service is active, so no concurrent readers.
  • = None / = {...} in __aexit__ — under _lifecycle_lock.
  • pop() in delete_conversation and _evict_idle_conversations — under _lifecycle_lock.
  • [stored.id] = event_service in _start_event_service — all callers (_get_or_load_event_service_locked, _start_conversation, fork) acquire _lifecycle_lock first.

The claim that _event_services is only mutated under the lock (with the __aenter__ exception) holds.

Non-blocking observation

The fix addresses the most impactful scenario (cached conversations blocked by a slow uncached load), but the root cause — asyncio.to_thread(_prepare_persisted_runtime) inside the lock in _get_or_load_event_service_locked — still means that loading an uncached conversation while the thread pool is exhausted will block all other uncached loads behind the lock. This is a known scope limitation acknowledged in the PR description and is acceptable for this fix. A future improvement could move the to_thread call outside the lock or use per-conversation locking, but that's a larger change.

Test

The test is well-structured: it reproduces the deadlock by exhausting a 1-worker thread pool, evicting one cached conversation to force a to_thread load, and asserting that a different cached conversation still loads within a timeout. The cleanup in the finally block is thorough.

Risk Assessment

Low risk. The change adds a read-only fast path that is a strict optimization — the locked path is unchanged and remains the fallback. No new mutations, no new await points, no behavioral change for uncached conversations.

Verdict

No material issues found. The fix is correct, minimal, and well-documented.

@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@/tmp/pr-evidence-revised-4513.md

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor
  ✅ **PR Artifacts Cleaned Up**

  The `.pr/` directory is no longer present.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: b25bdc2c932b3d54aff804a0338f34c80ed840b3
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/bd54ea8a-9f37-4c4d-9721-c291c33aa7f5

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR fixes a real production deadlock: _get_or_load_event_service holds _lifecycle_lock across asyncio.to_thread(_prepare_persisted_runtime), so thread-pool exhaustion wedges all event-service lookups — including cached conversations that need no thread work.

Fix Assessment: Sound

The unlocked fast-path cache check before _lifecycle_lock acquisition is safe in this codebase:

  1. Single-threaded event loop: CPython dict reads are atomic with respect to the event loop. No concurrent coroutine can mutate _event_services during a synchronous .get()asyncio.to_thread offloads _prepare_persisted_runtime but does not touch _event_services directly.
  2. Established pattern: Unlocked reads of _event_services already exist elsewhere (_renew_all_leases_loop, get_conversation, _start_conversation), so this is consistent.
  3. touch() / is_open() are safe outside the lock: both are simple synchronous attribute reads/writes with no I/O or lock dependencies.
  4. Stale-snapshot fallback is correct: if the entry is evicted between the unlocked check and lock acquisition, the locked path re-checks and handles the miss.

Findings

.pr/repro-async-executor-close-hang.py — accidental commit from unrelated work

This script should be removed before merge. It has several problems:

  • References a different issue/PR: The docstring cites PR #4548 / issue #4546, not #4513 / #4514 that this PR claims to fix. This looks like it was carried over from a separate debugging session.
  • Hardcoded developer paths: sys.path.insert(0, "/home/gneubig/work/software-agent-sdk/openhands-sdk") (line 47) — crashes or does nothing on any other machine.
  • Reads a local credential file at import time: open("/home/gneubig/.openhands/agent-canvas/api-key.txt").read().strip() (line 268) is evaluated as a default argument value at module load. This will raise FileNotFoundError on any machine that lacks this exact path, making the script unrunnable. It also references a local credential file.

While .pr/ is auto-removed on PR approval, this artifact adds noise and appears unrelated to the actual fix. Recommend removing it.

Test: acceptable but brittle

The test directly manipulates private internals (service._event_services.pop(conv_a, None)) and calls dunder lifecycle methods (service.__aenter__() / service.__aexit__()) instead of using async with. This is understandable for controlling concurrency state in a deadlock repro, but makes the test fragile to internal refactors.

Risk Assessment: LOW

The core fix is correct, minimal, and consistent with existing patterns. The only actionable issue is removing the accidental repro script.

Comment thread .pr/repro-async-executor-close-hang.py Outdated
Comment thread .pr/repro-async-executor-close-hang.py Outdated
Comment thread .pr/repro-async-executor-close-hang.py Outdated
The repro script (.pr/repro-async-executor-close-hang.py) was accidentally
committed from a separate debugging session (references PR #4548/issue #4546,
not this PR's issue #4514). It fails pre-commit (import ordering, ARG001
unused arg) and has hardcoded developer paths and credential references.
Deleting it resolves both the CI lint failures and the 3 review threads.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Re-requesting review after fix: removed unrelated repro script (commit 0608752). Pre-commit failures and all 3 review threads addressed.

…reads

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig requested a review from all-hands-bot August 21, 2026 13:23
@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Re-requesting bot review. All CI checks are green on SHA 61069b2. The repro script has been removed (commit 0608752). All 3 review threads have been resolved. Please review and approve if satisfactory.

all-hands-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟢 Good taste — Minimal, correct fix for a real production deadlock. The double-checked locking pattern (fast-path cache read before lock acquisition) is the right tool here, and the test exercises the real code path without mocks.

Two small observations inline; neither is blocking.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW
    The fast-path read is atomic in asyncio (no await points between the dict snapshot and return cached), so no coroutine can interleave. All mutations to _event_services happen under the lock, confirmed at call sites. The worst-case fast-path miss is a benign fallthrough to the locked path. The test exercises real code; timing is inherently best-effort for asyncio concurrency tests but adequate here.

VERDICT:
Worth merging: The fix is minimal, mechanically sound, and properly regression-tested against the real deadlock scenario.

KEY INSIGHT:
The fast path is fully atomic (no await between snapshot and return), so the lock invariant it relies on only needs to hold for the locked slow path — which it does.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

Comment thread openhands-agent-server/openhands/agent_server/conversation_service.py Outdated
Comment thread tests/agent_server/test_event_service_thread_pool_exhaustion.py
…anup in finally)

- Reword fast-path comment per bot suggestion: replace stale-snapshot
  language with accurate description of the fast-path miss scenario.
- Declare stuck_task before try block, init to None, cancel in finally
  to prevent pending task warning if TimeoutError jumps to finally.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Re-requesting review after addressing both bot suggestions (commit 37099a1): (1) reworded fast-path comment, (2) added stuck_task cleanup in finally block. Both threads resolved.

@neubig
neubig requested a review from all-hands-bot August 21, 2026 13:39

all-hands-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

Use Task[EventService | None] instead of Task[None] to match the
return type of get_event_service. Add assert for type narrowing in
the happy-path cleanup block.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Updates pushed at d1b25f5 — fixed pyright type errors in test file (stuck_task type annotation and type narrowing). Please re-review.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Taste Rating: 🟢 Good taste

The fix is minimal and correct. The fast-path cache check before acquiring lifecycle_lock is the right approach: it eliminates the deadlock without changing the locked slow path's semantics. The inline comment correctly explains why the unlocked dict lookup is safe — insert/delete happen only under the lock, and a fast-path miss falls through to the locked path which re-checks.

The test exercises the real code path end-to-end (no mocks): it creates a real ConversationService, saturates the default executor pool with a 1-worker ThreadPoolExecutor, evicts one conversation from the cache, kicks off a background load that hangs inside asyncio.to_thread(_prepare_persisted_runtime), and then verifies that loading a different cached conversation completes immediately. That is exactly the production scenario the bug report describes.

Both follow-up commits address the previous review feedback cleanly: the stray repro script is gone, stuck_task is cleaned up in finally, the comment wording is tightened, and the stuck_task type annotation is now Task[EventService | None] to match get_event_service's return type. Nice iteration loop.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

The unlocked fast-path reads _event_services (a dict) without holding lifecycle_lock, but the invariant — that _event_services is only mutated under the lock — is preserved by the existing code (all insert/delete sites go through the locked path). is_open() and touch() on the cached service are simple atomic operations. A fast-path miss correctly falls through to the locked path which re-checks the cache.

VERDICT

Worth merging: Core logic is sound, the test is pragmatic and exercises real code paths, and all previous review feedback has been addressed.

KEY INSIGHT

Adding a lock-free read path before acquiring a hot lock is the correct pattern for eliminating convoy effects — the key invariant is that the unlocked state must be a self-consistent snapshot that the locked path will re-validate.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Requesting changes: this is a mitigation, not a fundamental fix. The cache fast-path only helps the read path for cached conversations — create_conversation (line ~1332) and delete_conversation/close() (line ~1697) still acquire the global _lifecycle_lock and can still hang, blocking all conversations.

The root cause is a single global lock for all conversation lifecycle operations. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).

This PR can likely be closed as superseded once #4569 is implemented — the underlying scenario (a stuck operation on conversation A blocking conversation B) can no longer occur.

@neubig

neubig commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Closing since it's not a fundamental fix.

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.

Lifecycle lock deadlock: thread-pool exhaustion blocks all event loading

3 participants