Skip to content

fix: stop waiting on Queue.join() when the event consumer can die - #4199

Closed
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/queue-join-deadlock-on-handler-base-exception
Closed

fix: stop waiting on Queue.join() when the event consumer can die#4199
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/queue-join-deadlock-on-handler-base-exception

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Summary

Three streaming dispatchers hand user events to a background consumer task and then wait on asyncio.Queue.join() before returning. Each wraps the user callback in except Exception, so a BaseException escapes and ends the consumer. The outstanding task_done() calls can then never arrive, and join() blocks forever — the run hangs with no exception and no timeout.

asyncio.CancelledError is the realistic trigger: any asyncio.timeout(), wait_for, or task cancellation inside a user's handler raises one.

The join() is also redundant in all three cases. The sentinel is the last item queued and the consumer only returns after consuming it, so awaiting the consumer task already implies every event was handled — and it surfaces a dead consumer instead of waiting on it. Each fix is therefore a removal, not a new mechanism.

Site Impact
Agent.as_tool(on_stream=...) tool invocation hangs permanently
codex_tool._consume_events() hangs, and strands active tracing spans — span.finish() runs after the join
sandbox/memory/manager.flush() hangs before phase-two consolidation

Ordinary Exceptions from handlers are unaffected: they stay logged and swallowed exactly as before.

This is the same failure shape as #4170, which added an error sentinel so the STT waiters could not hang on a dead websocket listener.

Test plan

New regression tests, each parametrized over a custom BaseException and asyncio.CancelledError:

  • tests/test_agent_as_tool.py::test_agent_as_tool_streaming_does_not_hang_when_handler_raises_base_exception
  • tests/extensions/experiemental/codex/test_codex_tool.py::test_codex_tool_consume_events_does_not_hang_on_base_exception

Plus test_agent_as_tool_streaming_reports_every_event_before_returning, which pins the property that made dropping join() safe: the handler still observes every event before the tool returns. It awaits inside the handler so a missing wait would let the tool return first.

On main the four hang tests fail on the 5s guard; with the fix the same selection drops from 10.9s to 0.4s:

# main
FAILED ...test_agent_as_tool_streaming_does_not_hang_when_handler_raises_base_exception[base_exception]
FAILED ...test_agent_as_tool_streaming_does_not_hang_when_handler_raises_base_exception[cancelled_error]
2 failed, 1 passed, 55 deselected in 10.94s

# with fix
3 passed, 55 deselected in 0.42s

Verification from the repository root:

Command Result
make format clean
make lint all checks passed
make mypy 5 errors, all pre-existing on main, none in the touched files
make pyright 1 error, pre-existing on main (src/agents/sandbox/util/tar_utils.py:161)
uv run pytest tests/test_agent_as_tool.py tests/extensions/experiemental/codex/ 190 passed
make tests 5648 passed

The full-suite run was done on Windows, where some sandbox symlink and tracing/realtime timing tests fail independently of this change. I diffed the failing set against a clean main checkout in the same environment: the two sets are identical (56 vs 56, no differences either way).

One verification gap, stated plainly: tests/sandbox/test_memory.py cannot be collected on Windows (ImportError: UnixLocalSandbox is not supported on Windows), so I could not execute a regression test for the sandbox/memory/manager.py site locally and did not add one I could not run. I did reproduce the deadlock for that site structurally — driving its exact worker/queue/sentinel shape, the worker dies on a BaseException and join() blocks with two unfinished items — and the change there is the same removal as the other two. Happy to add a test for it if you would rather have one, or to drop that hunk from this PR and leave it for someone who can run the file.

Issue number

Closes #4198

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

The verification script is a bash script that shells out to make; I ran the underlying steps individually instead, with the results above.


@seratch — this is the same shape you fixed for the STT listener in #4170, so I went looking for siblings of that pattern and these three were the ones where a consumer death can strand a Queue.join().

Worth your call on one point: dropping join() means a BaseException from a handler now propagates out of the tool instead of hanging, which is a behavior change at that boundary even though the previous behavior was an unrecoverable hang. If you would rather keep handler failures fully contained, the alternative is widening the wrapper to except BaseException and re-raising CancelledError only — I did not do that because swallowing cancellation is usually worse than surfacing it.

Copilot AI review requested due to automatic review settings August 5, 2026 08:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes a deadlock in several streaming/background-consumer patterns where the producer waits on asyncio.Queue.join() even if the consumer task can terminate early (notably on BaseException such as asyncio.CancelledError), causing the run/tool call to hang indefinitely. The PR removes the redundant Queue.join() waits and relies on awaiting the consumer task (which already implies the sentinel has been consumed) to both avoid hangs and surface dead consumers.

Changes:

  • Replace await queue.join() with awaiting the consumer/worker task in three call sites to prevent permanent hangs when the consumer dies.
  • Add regression tests for Agent.as_tool(on_stream=...) and Codex _consume_events() to ensure BaseException/CancelledError in handlers does not deadlock.
  • Add an ordering/property test to ensure dropping join() does not let tool invocation return before streamed events are handled.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/agents/agent.py Stops waiting on Queue.join() for Agent.as_tool(on_stream=...) streaming dispatch; awaits the dispatcher task instead.
src/agents/extensions/experimental/codex/codex_tool.py Removes Queue.join() from Codex event dispatch cleanup and awaits the dispatcher task to prevent deadlocks.
src/agents/sandbox/memory/manager.py Removes Queue.join() from sandbox memory flush() and awaits the worker task after enqueueing _STOP.
tests/test_agent_as_tool.py Adds regression coverage for non-hanging behavior on BaseException/CancelledError and a property test about handler completion before return.
tests/extensions/experiemental/codex/test_codex_tool.py Adds a regression test ensuring Codex _consume_events() does not hang when the stream handler raises BaseException/CancelledError.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1108 to 1117
if event_queue is not None:
await event_queue.put(None)
await event_queue.join()
if dispatch_task is not None:
# Wait on the dispatcher itself rather than on the queue draining. The sentinel is
# the last item queued and the dispatcher only returns after consuming it, so
# awaiting the task already implies every event was handled. Waiting on `join()`
# instead would hang forever when a handler raised a BaseException such as
# CancelledError, because the dispatcher is then gone and the outstanding
# `task_done()` calls can never arrive -- stranding the open spans below too.
await dispatch_task
Comment on lines 131 to 142
self._ensure_worker()
for rollout_file in rollout_files:
self._queue.put_nowait(rollout_file)
await self._queue.join()
if self._worker_task is not None:
# Wait on the worker rather than on the queue draining. The worker consumes
# FIFO and only returns on _STOP, which is queued behind every rollout, so
# awaiting it already implies each one was processed. Waiting on `join()`
# instead would hang forever if the worker died on a BaseException, because
# the outstanding `task_done()` calls can never arrive.
self._queue.put_nowait(_STOP)
await self._worker_task
self._worker_task = None
Comment on lines +3132 to +3133
async def test_agent_as_tool_streaming_reports_every_event_before_returning() -> None:
"""Dropping `join()` must not let the tool return before handlers have run."""

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f24d44dc4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# instead would hang forever when a handler raised a BaseException such as
# CancelledError, because the dispatcher is then gone and the outstanding
# `task_done()` calls can never arrive -- stranding the open spans below too.
await dispatch_task

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Finish Codex spans before re-raising dispatcher failures

When on_stream raises a BaseException while active_spans still contains a command execution span, awaiting the failed dispatcher here re-raises before the cleanup block below runs. In a stream that then errors or ends before the matching item.completed, the “Ensure any open spans are closed even on failure” loop is skipped, so the command span remains unfinished and unexported; run that span cleanup in a finally around this await before re-raising the handler failure.

Useful? React with 👍 / 👎.

Three streaming dispatchers hand user events to a background task and then
wait for asyncio.Queue.join() before returning. Each dispatcher catches only
Exception, so a handler raising a BaseException -- CancelledError being the
realistic one, from a timeout or cancellation inside the callback -- kills the
consumer. The outstanding task_done() calls can then never arrive and join()
blocks forever, hanging the run with no error and no timeout.

In every case join() is also redundant. The sentinel is queued last and the
consumer only returns after consuming it, so awaiting the consumer task
already implies every event was handled, and it surfaces a dead consumer
instead of waiting on it.

- Agent.as_tool(on_stream=...) hung the tool invocation.
- Codex tool _consume_events() hung and left its tracing spans unfinished,
  because the span cleanup runs after the join.
- The sandbox memory manager flush hung before its phase-two consolidation.

Same failure shape as the STT listener fix in openai#4170.
@abhay-codes07
abhay-codes07 force-pushed the fix/queue-join-deadlock-on-handler-base-exception branch from 1f24d44 to 44ccfb2 Compare August 5, 2026 09:01
@seratch

seratch commented Aug 5, 2026

Copy link
Copy Markdown
Member

Thanks for identifying this issue and for the solid core approach. I have taken the fix into a maintainer-authored follow-up #4201 that builds on your Queue.join() analysis while extending the implementation to supervise producers and consumers together, preserve cancellation behavior, guarantee Codex span cleanup, and cover the sandbox memory path.

Your contribution is credited with a Co-authored-by trailer. I am closing this PR to avoid maintaining two competing implementations. Thank you again for the investigation and patch.

@seratch seratch closed this Aug 5, 2026
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.

Agent.as_tool(on_stream=...) deadlocks forever when the handler raises a BaseException

3 participants