Repository navigation
fix(client): stop provider when async factory is cancelled during connect - #1309
Conversation
There was a problem hiding this comment.
Reviewed at head ee2817c0. The change is narrowly scoped: on CancelledError during connect(), the not-yet-returned client is closed so the provider is released, then the original cancellation is re-raised, and cleanup failures or a second cancel cannot replace it. This matches the existing ownership rule in _connect_async. Merged onto current main (053aa3e6) in a scrubbed local environment: tests/test_core client/provider subset 504 passed, 14 skipped, ruff clean.
Blocked only on fork CI (Approve and run needed). Touches src/openenv/core/env_client.py, so it is a 0.7.1 candidate once exact-head CI is green and the branch is current.
Sent by Cursor Automation: Release
6cfeb7f to
56a1820
Compare
…nect Cancelling `from_docker_image(...)` / `from_env(...)` while the initial WebSocket connection was pending leaked the already-started provider: `_connect_async` only cleans up on `Exception`, and `CancelledError` is a `BaseException`. No client reaches the caller, so nothing could close it. `_BootstrapResult._resolve_async` now closes the client on cancellation and re-raises the original `CancelledError`. A repeated cancellation or a provider stop failure during cleanup does not replace it. Ordinary connection failures keep their existing cleanup and `ConnectionError`. Fixes openenv#1308 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
56a1820 to
a0057d5
Compare
|
Thanks! looks like the PR workflows needs maintenance approval 🙏 |
burtenshaw
left a comment
There was a problem hiding this comment.
Reviewed cancellation ownership and repeated cancellation behavior. The factory closes its unreturned client and preserves the original cancellation; cleanup tests pass (88 passed, 4 skipped), and touched lint/format/import checks pass. No correctness or complexity findings.
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |


Summary
Fixes #1308. Cancelling
from_docker_image(...)orfrom_env(...)while the first WebSocket connection is still pending left the started provider running._connect_asynconly cleans up onException, andasyncio.CancelledErroris aBaseException. The factory raised before returning a client, so the caller had nothing to close._BootstrapResult._resolve_asyncnow catchesCancelledErroraroundclient.connect(). It callsclient.close(), which stops the provider once, and then re-raises the original cancellation, message included. Suppression is limited to that cleanup call, so neither of these replaces the original cancellation:Ordinary connection failures behave as before: one stop, then
ConnectionError.Why this boundary: the fix sits where the factory hands its client over, not in
_connect_async. When no client reaches the caller, the factory still owns cleanup. Putting it in_connect_asyncwould also stop the container when a caller's deadline (for exampleasyncio.wait_for(env.step(...))) cancels a lazy reconnect, and that would be a wider behaviour change.Type of Change
Alignment Checklist
Before submitting, verify:
.claude/docs/PRINCIPLES.mdand this PR aligns with our principles.claude/docs/INVARIANTS.mdand no invariants are violated (internal client lifecycle only; no API, interface or client/server boundary changes)/pre-submit-pr(orbash .claude/hooks/lint.shand tests) and addressed all issuesRFC Status
Test Plan
New regressions in
tests/test_core/test_client_provider_cleanup.py. Each one waits forws_connectto start and then calls a realTask.cancel(). They run with container-style (start_container/stop_container) and runtime-style (start/stop) providers, and withfrom_docker_imageandfrom_env. The existingResourceLedgertreats a duplicate stop as an error.test_factory_cancelled_during_connect_stops_provider_once"factory cancelled"is kept; the task ends cancelledtest_factory_cancelled_again_during_cleanup_keeps_original_cancellationcancel()lands inside a teardown that suspends; the provider is still stopped once and the original cancellation propagatestest_factory_cancellation_survives_provider_cleanup_failureCancelledError, not the cleanup errortest_factory_connect_failure_stops_provider_onceConnectionErrorwith the original cause and stops onceResults:
main(49aa302): the 9 new cancellation tests fail. The 4 ordinary connect-failure tests pass.CancelledErrorin the cleanup suppression, the 3 second-cancellation tests fail.test_client_provider_cleanup.pyandtest_generic_client.py): 218 passed, 9 skipped on Python 3.10, 3.11, 3.12 and 3.14. The tests capture the cancellation inside the task, because on 3.10await taskdrops the message.provider still started: True,stop calls: 0. After:False,1.tests/envs/test_harbor_capture_server.py::test_start_refuses_a_port_held_by_a_non_capture_listener, which fails the same way on unmodifiedmainand looks like a local issue.ruff checkpasses, and the changed files passruff format. Repo-wideusortandruff formatchecks still flag files this PR doesn't touch (tests/envs/test_grid_world.py,tests/envs/test_julia_env.py,src/openenv/core/harness/README.md); these appear onmaintoo.Known limitations (out of scope)
_connect_asyncstartup failure,.sync()bootstrap failure). The resource stays up, and the caller still holds the provider it passed in, so it can stop the provider itself.async with Client(provider=p): cancellation during__aenter__..sync()path when interrupted byKeyboardInterrupt.Claude Code Review
N/A.
/alignment-reviewwas not run. The change was written with Claude Code and checked with/simplify, the mutation check and the tests above.🤖 Generated with Claude Code
Note
Medium Risk
Touches async factory bootstrap and provider lifecycle on cancellation; behavior change is scoped to factory resolution before a client is returned, with regression tests but subtle asyncio edge cases.
Overview
Fixes a leak when
await from_env(...)orawait from_docker_image(...)is cancelled while the first WebSocket connect is still in flight: the provider had already started, butCancelledErrorskipped_connect_async’sexcept Exceptioncleanup and no client was returned for the caller toclose()._BootstrapResult._resolve_asyncnow catches cancellation aroundconnect(), best-effortclose()(one provider stop), then re-raises the original cancellation—including its message—without letting a second cancel or stop failure replace it.Tests cover cancel during connect (both factories and container/runtime providers), double-cancel during teardown, stop failure during cancel cleanup, and unchanged
ConnectionErroron ordinary connect failure.Reviewed by Cursor Bugbot for commit 30ef3c1. Bugbot is set up for automated code reviews on this repo. Configure here.