-
Notifications
You must be signed in to change notification settings - Fork 462
test(agent-server): lifecycle lock deadlocks on thread-pool exhaustion #4513
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 8 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
366bd8c
test(agent-server): lifecycle lock deadlocks on thread-pool exhaustion
openhands-agent 5ff593b
fix(agent-server): check event-service cache before acquiring lifecyc…
openhands-agent f269e7a
ci: re-trigger PR description check
openhands-agent 3391fe5
ci: re-trigger after issue label
openhands-agent a2b8112
ci: re-trigger after label fix
openhands-agent 5c0ff27
ci: re-trigger after issue readiness
openhands-agent e3890ce
ci: re-trigger flaky release-note label sync
openhands-agent b25bdc2
chore: add live repro script for PR evidence
openhands-agent 0608752
fix: remove unrelated repro script causing lint failures
openhands-agent 61069b2
ci: re-trigger unresolved-review-threads check after resolving all th…
openhands-agent 37099a1
fix: address bot review suggestions (comment wording + stuck_task cle…
openhands-agent d1b25f5
fix: correct stuck_task type annotation for pyright
openhands-agent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,289 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| repro-async-executor-close-hang.py — Live evidence for PR #4548 / issue #4546. | ||
|
|
||
| Reproduces the AsyncExecutor.close() hang that wedges the agent-server's | ||
| conversation lifecycle lock, and verifies the fix bounds it. | ||
|
|
||
| Two phases: | ||
|
|
||
| Phase 1 (deterministic, the smoking gun): | ||
| Directly exercise AsyncExecutor.close() with a task that never completes. | ||
| Without the fix: close() blocks forever (we abort after PROBE_DEADLINE). | ||
| With the fix: close() returns within DEFAULT_CLOSE_TIMEOUT (30s), and | ||
| near-instantly for a cancellable task (anyio.sleep_forever). | ||
|
|
||
| Phase 2 (HTTP, live backend): | ||
| Hammer the running agent-server with concurrent conversation create + | ||
| search + delete traffic. With the fix in place, all requests succeed | ||
| quickly and the backend stays responsive. (Phase 1 is the deterministic | ||
| proof that without the fix, close() hangs; Phase 2 confirms the live | ||
| backend does not stall once the fix is applied.) | ||
|
|
||
| USAGE | ||
| python3 repro-async-executor-close-hang.py [--http URL] [--no-unit] | ||
|
|
||
| --http URL Also run the HTTP concurrent-load phase against URL | ||
| (default: http://localhost:8000) | ||
| --no-unit Skip the deterministic unit phase | ||
|
|
||
| EXIT CODE | ||
| 0 all phases passed (fix is working) | ||
| 1 a phase failed/stalled (bug reproduced) | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import json | ||
| import os | ||
| import sys | ||
| import threading | ||
| import time | ||
| import urllib.request | ||
| import urllib.error | ||
| from concurrent.futures import ThreadPoolExecutor, as_completed | ||
|
|
||
| # Make the SDK importable from the local checkout. | ||
| _SDK = "/home/gneubig/work/software-agent-sdk/openhands-sdk" | ||
|
neubig marked this conversation as resolved.
Outdated
|
||
| if _SDK not in sys.path: | ||
| sys.path.insert(0, _SDK) | ||
|
|
||
| PROBE_DEADLINE = 15.0 # seconds we wait before declaring close() a hang | ||
|
|
||
|
|
||
| def _banner(title: str) -> None: | ||
| print(f"\n{'═' * 70}\n {title}\n{'═' * 70}") | ||
|
|
||
|
|
||
| def _status(ok: bool, msg: str) -> int: | ||
| tag = "✓ PASS" if ok else "✗ FAIL" | ||
| print(f" {tag} — {msg}") | ||
| return 0 if ok else 1 | ||
|
|
||
|
|
||
| # ── Phase 1: deterministic AsyncExecutor.close() repro ─────────────────── | ||
|
|
||
|
|
||
| def _close_in_background(executor, **kwargs) -> threading.Event: | ||
| """Call close() off-thread so a hang doesn't freeze the script.""" | ||
| done = threading.Event() | ||
|
|
||
| def run(): | ||
| try: | ||
| executor.close(**kwargs) | ||
| except Exception as e: | ||
| print(f" (close() raised: {e})") | ||
| finally: | ||
| done.set() | ||
|
|
||
| threading.Thread(target=run, daemon=True). start() | ||
| return done | ||
|
|
||
|
|
||
| def phase1_unit() -> int: | ||
| """Deterministic reproduction of the AsyncExecutor.close() hang.""" | ||
| import anyio # noqa: F401 (prove it's importable) | ||
|
|
||
| from openhands.sdk.utils.async_executor import AsyncExecutor | ||
|
|
||
| rc = 0 | ||
| _banner("Phase 1 — AsyncExecutor.close() with a never-finishing task") | ||
|
|
||
| # Case A: a cancellable task (anyio.sleep_forever). With the fix, | ||
| # cancellation is delivered and close() returns almost instantly. | ||
| # Without the fix, close() waits forever for the task to finish on its own. | ||
| print("\n Case A: cancellable task (anyio.sleep_forever)") | ||
| executor = AsyncExecutor() | ||
| executor.portal.start_task_soon(anyio.sleep_forever) | ||
| time.sleep(0.2) # let the task start | ||
|
|
||
| t0 = time.monotonic() | ||
| done = _close_in_background(executor) | ||
| finished = done.wait(timeout=PROBE_DEADLINE) | ||
| elapsed = time.monotonic() - t0 | ||
|
|
||
| if finished: | ||
| rc |= _status(True, f"close() returned in {elapsed:.2f}s (fix working)") | ||
| else: | ||
| rc |= _status(False, f"close() hung > {PROBE_DEADLINE:.0f}s (BUG reproduced)") | ||
| # best-effort: leave the daemon thread to die with the process | ||
|
|
||
| # Case B: a task blocked in a worker thread (uncancellable). With the fix, | ||
| # close() waits up to DEFAULT_CLOSE_TIMEOUT then abandons the daemon thread. | ||
| # Without the fix, close() hangs forever. | ||
| print("\n Case B: uncancellable task (blocked in worker thread)") | ||
| from anyio.to_thread import run_sync | ||
|
|
||
| async def blocked_in_worker_thread(): | ||
| await run_sync(lambda: time.sleep(60)) | ||
|
|
||
| executor2 = AsyncExecutor() | ||
| executor2.portal.start_task_soon(blocked_in_worker_thread) | ||
| time.sleep(0.2) | ||
|
|
||
| t0 = time.monotonic() | ||
| done2 = _close_in_background(executor2, timeout=2.0) | ||
| finished2 = done2.wait(timeout=PROBE_DEADLINE) | ||
| elapsed2 = time.monotonic() - t0 | ||
|
|
||
| if finished2: | ||
| rc |= _status( | ||
| True, | ||
| f"close() returned in {elapsed2:.2f}s with timeout=2.0 (fix working)", | ||
| ) | ||
| else: | ||
| rc |= _status( | ||
| False, f"close() hung > {PROBE_DEADLINE:.0f}s even with timeout (BUG)" | ||
| ) | ||
|
|
||
| # Case C: idempotent close (no hang on a clean executor). | ||
| print("\n Case C: idempotent close on a clean executor") | ||
| executor3 = AsyncExecutor() | ||
| _ = executor3.portal | ||
| t0 = time.monotonic() | ||
| executor3.close() | ||
| executor3.close() | ||
| elapsed3 = time.monotonic() - t0 | ||
| rc |= _status(True, f"double close() returned in {elapsed3:.2f}s") | ||
|
|
||
| return rc | ||
|
|
||
|
|
||
| # ── Phase 2: HTTP concurrent load against the live backend ──────────────── | ||
|
|
||
|
|
||
| def _http(method: str, url: str, key: str, body: dict | None = None) -> tuple[int, float]: | ||
| """Fire one HTTP request; return (status_code, elapsed_seconds).""" | ||
| data = json.dumps(body).encode() if body is not None else None | ||
| req = urllib.request.Request( | ||
| url, | ||
| data=data, | ||
| method=method, | ||
| headers={ | ||
| "X-Session-API-Key": key, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| ) | ||
| t0 = time.monotonic() | ||
| try: | ||
| with urllib.request.urlopen(req, timeout=20) as resp: | ||
| resp.read() | ||
| return resp.status, time.monotonic() - t0 | ||
| except urllib.error.HTTPError as e: | ||
| return e.code, time.monotonic() - t0 | ||
| except Exception: | ||
| return 0, time.monotonic() - t0 | ||
|
|
||
|
|
||
| def phase2_http(base_url: str, key: str, concurrency: int = 10, rounds: int = 3) -> int: | ||
| """Hammer the backend with concurrent READ traffic. | ||
|
|
||
| Uses search/health/alive — which exercise the lifecycle-lock read-path | ||
| (the fast-path fixed by PR #4513) — without depending on LLM auth, so the | ||
| result isolates the deadlock-fix behaviour from LLM availability. | ||
| """ | ||
| _banner(f"Phase 2 — HTTP concurrent read load ({concurrency} workers × {rounds} rounds)") | ||
|
|
||
| # Preflight | ||
| code, _ = _http("GET", f"{base_url}/health", key) | ||
| if code != 200: | ||
| print(f" ✗ FAIL — backend not healthy (health={code})") | ||
| return 1 | ||
| print(f" preflight /health = {code} ✓") | ||
|
|
||
| latencies: list[float] = [] | ||
| failures = 0 | ||
| total = 0 | ||
|
|
||
| def one_cycle(i: int) -> bool: | ||
| nonlocal failures, total | ||
| ok = True | ||
| # 1. search (exercises the lifecycle-lock read-path fast-path) | ||
| code, t = _http( | ||
| "GET", f"{base_url}/api/conversations/search?limit=5", key | ||
| ) | ||
| latencies.append(t) | ||
| total += 1 | ||
| if code != 200: | ||
| failures += 1 | ||
| ok = False | ||
| # 2. /alive (liveness, no lock) | ||
| code, t = _http("GET", f"{base_url}/alive", key) | ||
| latencies.append(t) | ||
| total += 1 | ||
| if code != 200: | ||
| failures += 1 | ||
| ok = False | ||
| # 3. /server_info (metadata, no lock) | ||
| code, t = _http("GET", f"{base_url}/server_info", key) | ||
| latencies.append(t) | ||
| total += 1 | ||
| if code != 200: | ||
| failures += 1 | ||
| ok = False | ||
| return ok | ||
|
|
||
| for r in range(rounds): | ||
| t0 = time.monotonic() | ||
| with ThreadPoolExecutor(max_workers=concurrency) as pool: | ||
| futs = [pool.submit(one_cycle, r * concurrency + i) for i in range(concurrency)] | ||
| results = [f.result() for f in as_completed(futs)] | ||
| round_elapsed = time.monotonic() - t0 | ||
| ok = sum(results) | ||
| print( | ||
| f" round {r + 1}/{rounds}: {ok}/{concurrency} cycles ok " | ||
| f"in {round_elapsed:.2f}s" | ||
| ) | ||
|
|
||
| if not latencies: | ||
| print(" ✗ FAIL — no requests completed") | ||
| return 1 | ||
|
|
||
| latencies.sort() | ||
| p50 = latencies[len(latencies) // 2] | ||
| p99 = latencies[int(len(latencies) * 0.99)] | ||
| print( | ||
| f"\n {total} requests: {failures} failures, " | ||
| f"p50={p50 * 1000:.0f}ms, p99={p99 * 1000:.0f}ms" | ||
| ) | ||
|
|
||
| if failures > 0: | ||
| return _status(False, f"{failures} requests failed/stalled (backend unhealthy)") | ||
| return _status(True, "all requests succeeded, backend stayed responsive") | ||
|
|
||
|
|
||
| # ── Main ───────────────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| def main() -> int: | ||
| ap = argparse.ArgumentParser(description=__doc__) | ||
| ap.add_argument( | ||
| "--http", metavar="URL", default=None, help="run the HTTP phase against URL" | ||
| ) | ||
| ap.add_argument("--no-unit", action="store_true", help="skip the unit phase") | ||
| ap.add_argument( | ||
| "--api-key", | ||
| default=os.environ.get( | ||
| "WATCHDOG_API_KEY", | ||
| open("/home/gneubig/.openhands/agent-canvas/api-key.txt").read().strip(), | ||
|
neubig marked this conversation as resolved.
Outdated
|
||
| ), | ||
| ) | ||
| args = ap.parse_args() | ||
|
|
||
| rc = 0 | ||
| if not args.no_unit: | ||
| rc |= phase1_unit() | ||
|
|
||
| if args.http: | ||
| rc |= phase2_http(args.http, args.api_key) | ||
|
|
||
| _banner("RESULT") | ||
| if rc == 0: | ||
| print(" ✓ All phases passed — fix is working, no stall.") | ||
| else: | ||
| print(" ✗ A phase failed/stalled — bug reproduced.") | ||
| return rc | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.