Skip to content

[Bug]: AsyncExecutor.close() can block forever, wedging conversation shutdown #4546

Description

@AaronAbuUsama

Bug Description

AsyncExecutor.close() can block forever. It is called during LocalConversation.close() to release tool executors, so a single stuck portal task wedges conversation shutdown — and with it every subsequent operation that needs the conversation lock.

Two independent causes, both in AsyncExecutor.close() (async_executor.py:104-114):

if portal_cm is not None:
    try:
        portal_cm.__exit__(None, None, None)
  1. Remaining tasks are never cancelled. Passing no exception makes anyio take its graceful path, portal.call(portal.stop, cancel_remaining=False), which waits for in-flight tasks to finish on their own. A task that never completes blocks shutdown permanently — even one that would die instantly if cancelled.
  2. The thread join is unbounded. start_blocking_portal() ends in a bare thread.join() with no timeout (anyio from_thread.py). So even with cancellation, a task that cannot be cancelled — anything awaiting inside a worker thread, where anyio cannot deliver cancellation until the thread returns — hangs the join forever.

Cancelling alone does not fix it, and bounding the join alone leaves cause 1 waiting the full timeout on every close. Both need addressing.

AsyncExecutor backs the browser toolset (BrowserToolExecutor), the MCP client (MCPClient), and ACPAgent, so this is not browser-specific.

Expected Behavior

close() returns promptly. Tasks still on the portal are cancelled; if the portal thread cannot be stopped it is abandoned — it is a daemon thread — and a warning is logged.

Actual Behavior

close() blocks indefinitely. Nothing times out, so the process never recovers.

Save the snippet under Steps to Reproduce as repro_close_hang.py in a checkout of this repo and run it:

uv run python repro_close_hang.py

Observed on main (bb26768) and on 1.42.1:

close() STILL BLOCKED after 10s

Expected output is close() returned. The 10s is only the harness's patience — the call never returns at all.

The same hang is reachable from the test suite. With a bounded close() this passes in ~1s; on main test_close_returns_with_task_still_running blocks until its assertion deadline:

uv run pytest tests/sdk/utils/test_async_executor.py -q

It also escapes pytest's own timeout when it happens in teardown: on main, uv run pytest tests/tools/browser_use/test_browser_executor_e2e.py::TestBrowserExecutorE2E::test_navigate_action -q --timeout=180 never returned and had to be killed externally at 400s.

Steps to Reproduce

import threading, time, anyio
from openhands.sdk.utils.async_executor import AsyncExecutor

ex = AsyncExecutor()
ex.portal.start_task_soon(anyio.sleep_forever)   # a task still in flight
time.sleep(0.5)

done = threading.Event()
threading.Thread(target=lambda: (ex.close(), done.set()), daemon=True).start()
print("close() returned" if done.wait(10) else "close() STILL BLOCKED after 10s")

Prints close() STILL BLOCKED after 10s on main (bb26768) and on 1.42.1. Note anyio.sleep_forever is trivially cancellable — it dies instantly under cancel_remaining=True.

Isolating the two causes:

task on the portal cancel_remaining close()
anyio.sleep_forever() False (current behaviour) hangs
anyio.sleep_forever() True returns
await run_sync(lambda: time.sleep(3600)) True hangs

Production symptom

Hit on an agent-server running the browser tool. LocalConversation.close() closes tool executors in a loop with no timeout (local_conversation.py:2679-2688), reaching BrowserToolExecutor.close(), whose finally calls self._async_executor.close() unbounded (browser_use/impl.py:710-732). The 30s guard on the preceding run_async(self.cleanup, ...) does not cover it.

The server stayed 8h21m in this state. py-spy dump:

Thread 550621 (idle): "asyncio_4"
    _wait_for_tstate_lock (threading.py:1167)
    join (threading.py:1147)
    start_blocking_portal (anyio/from_thread.py:560)
    __exit__ (contextlib.py:144)
    close (openhands/sdk/utils/async_executor.py:112)
    close (openhands/tools/browser_use/impl.py:727)
    close (openhands/sdk/conversation/impl/local_conversation.py:2685)

Thread 555902 (idle): "asyncio-portal-705da41d8410"
    _cancel_all_tasks (asyncio/runners.py:205)
    run_blocking_portal (anyio/from_thread.py:536)

Because the close holds the conversation lock, routes needing a loaded conversation (GET /api/conversations/{id}/events*) blocked forever, while metadata routes (/api/conversations/{id}, /api/conversations/search, /health) kept answering in milliseconds. The service therefore looks healthy while no conversation can be opened. Chromium had already exited; no file lock was held. Only a process restart recovered it.

Acceptance Criteria

  • close() returns even with a task still running on the portal
  • close() returns even when that task cannot be cancelled, and logs a warning
  • close() stays idempotent and safe when the portal was never started

Installation Method

uv (workspace checkout), and uvx --from openhands-agent-server==1.42.1

SDK Version

1.42.1, and main at bb26768

Python Version

3.12.3 (production), 3.13.13 (repo venv)

Operating System

Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:highFor bugs, affecting nearly all users and degrading performance or UX.ready-for-devIssue meets development readiness criteriasessiontools

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions