fix(client): clean up provider when session startup fails - #1145
Conversation
There was a problem hiding this comment.
Stale comment
Reviewed exact head
c3dcb8cd1b0f4741cd5979e23abdff51b7029cacagainst currentmain42cde4c2e5efaef81be92f310fc954fd5bbce3b5. The change correctly closes thenew_session()provider leak identified in #1144: readiness failure now invokes the matching container/runtime stop method, cleanup errors cannot mask the original startup exception, and no child or WebSocket is created. The parametrized tests cover both supported provider shapes, failing cleanup, and first-usenew_session()behavior. No correctness, public-API, or alignment defect found.This is not merge-ready yet: the external branch is behind
main, and only Bugbot ran at this head. Refresh it, approve the external-contributor workflows, and require exact-head Tests, Package CI, and docs checks before merge. Normal maintainer review is also still required.Sent by Cursor Automation: Release
There was a problem hiding this comment.
Stale comment
Re-reviewed exact head
c3dcb8cd1b0f4741cd5979e23abdff51b7029cac; this supersedes the earlier no-defect assessment. The intended readiness-failure cleanup works, and the focused file passes locally (104 passed, 5 skipped), but two lifecycle failures remain.
- After failed readiness, the new handler stops the provider but leaves it owned by the parent. A normal later
parent.close()stops it again; with a non-idempotent provider I reproducedRuntimeError("already stopped")and two stop calls.- If this call successfully starts the provider and the concrete child constructor raises, construction is outside the cleanup region, so the provider remains running. I reproduced zero stop calls before explicit cleanup.
Please track whether
_create_session_client()started the provider in this invocation, guarantee cleanup for every subsequent construction failure without tearing down an already-connected parent's shared provider, and make provider ownership/state prevent a successful stop from being repeated. Add regressions for parent close after readiness failure, cleanup after child-constructor failure, and the already-connected-parent constructor-failure case. The branch also remains behindmain, with only Bugbot on this head; require refreshed exact-head repository CI and maintainer review afterward.Sent by Cursor Automation: Release
There was a problem hiding this comment.
Stale comment
Refresh review vs main
b12450f2(Ben ask):Verdict: still blocked on prior CHANGES_REQUESTED at
c3dcb8cd(~15 behind main; only Bugbot on tip).Readiness-fail stop is correct, but still need:
- Clear provider ownership after successful stop so later
parent.close()does not double-stop- Cleanup if provider start succeeds then child construction fails
- Regressions for those cases + already-connected parent
Please address those and rebase; then I’ll re-review.
Sent by Cursor Automation: Release
|
@JianYan11 let me know if you're working on this. |
There was a problem hiding this comment.
Stale comment
Refresh review (Wed 2026-09-16)
Still BEHIND main (
c3dcb8cd, 15+ commits). Tip is unchanged since the 2026-09-15 CHANGES_REQUESTED — both blockers remain:
- After readiness failure, provider is stopped but still owned → later
parent.close()can double-stop / raise.- Successful provider start + failing child constructor is outside the cleanup
try→ provider leak.No bit-rot from #1169. Needs author fix before any on-repo replacement/refresh. Not eligible for Thursday as-is.
Sent by Cursor Automation: Release
|
@burtenshaw Yes!let me check the comment of cursor bot |
8bc23d5 to
34965c2
Compare
There was a problem hiding this comment.
Re-review after rebase — still CHANGES_REQUESTED
Head 34965c2b (rebased onto main a4798f5d). Focused suite green (test_generic_client 114 passed / 5 skipped).
The readiness-fail cleanup path is still the right idea, but the two Tier 1 bugs from the earlier review remain on this head:
- Double-stop on
close()—_stop_provider_best_effort()does not clearself._provider, soawait close()stops again and non-idempotent providers raise. - Child constructor leak —
try/exceptstill only covers_start_provider_if_needed(); a later ctor failure leaves the started provider running.
Both reproduced live against this SHA. Required fix shape + regressions are unchanged from the 2026-09-16 review (clear ownership fields before stop; wrap the full _create_session_client body; assert no double-stop and ctor-fail cleanup).
Fork PR: repository CI still needs maintainer Approve and run when ready.
Sent by Cursor Automation: Release
There was a problem hiding this comment.
Re-review — APPROVE
Head 15773f3c. Prior Tier 1 items are fixed with a retry-safe _provider_stopped latch (clearer than nuking _provider for restart):
- Double-stop — successful stop sets the latch;
await close()after a failednew_session()no longer re-stops. Reproduced: 1 stop, close OK. - Child ctor leak —
try/exceptnow wraps the full_create_session_clientbody and cleans up when this call started the provider. Reproduced: start+stop on ctor failure.
Also covered: failed cleanup can retry; restart after successful cleanup; connected/shared-provider ctor failures do not stop the running provider; sync path cleanup-once.
Focused suite: 130 passed / 5 skipped. ruff check / ruff format --check clean on the two touched files.
Gate
Fork PR — needs maintainer Approve and run before merge. Package-surface (src/openenv/core/env_client.py); fine as next-cut cargo once CI is green.
Sent by Cursor Automation: Release
Withdrawing APPROVE on 15773f3c — Bugbot’s failed-stop restart leak is real (reproduced). Follow-up REQUEST_CHANGES next.
There was a problem hiding this comment.
Re-review — REQUEST_CHANGES (withdraws prior APPROVE)
Bugbot’s medium finding on 15773f3c is confirmed live.
Tier 1 — failed stop still allows a second start (leak)
_stop_provider only sets _provider_stopped after a successful stop, but always clears _base_url / _ws_url in finally. _stop_provider_best_effort swallows the stop error. The client then looks idle (_ws_url is None, flag False) while the first container/process is still running, so the next new_session() calls start_container()/start() again and orphans the first.
Reproduced on this head:
- Readiness fail → stop raises → second
new_session()starts container#2while#1was never successfully stopped. - Same shape for child-ctor fail after a successful start (Bugbot’s stated path).
test_failed_cleanup_can_be_retried covers retry-via-close(), but does not assert that a later new_session() must not start a second provider until stop has succeeded.
Required fix direction
Do not treat a failed stop as “idle / free to start again.” Concrete options:
- Keep ownership until stop succeeds (do not clear URL state on failed stop when a start actually happened), and/or
- Gate
_start_provider_if_neededon “not started-or-not-stopped” (e.g. refuse or force-stop before a second start), - Add a regression: stop fails once →
new_session()must not callstart*again until a successful stop (or must stop-then-start atomically).
Prior double-stop and ctor-cleanup coverage can stay; this is an additional leak class on the retry-safe latch design.
Sent by Cursor Automation: Release
Prevent a failed provider stop from being followed by another start that can overwrite the only resource handle. Add state-machine and real Docker regression coverage for sync and async lifecycle recovery.
There was a problem hiding this comment.
Re-review — APPROVE
Head ba88f81b fixes the failed-stop restart leak.
_provider_cleanup_pending is set before stop* and cleared only on success. _start_provider_if_needed retries that cleanup first and does not start if stop still raises. Live check: always-failing stop → second new_session() raises, start* stays at 1; stop-then-succeed → stop #2 then start #2 (no orphan).
New coverage: tests/test_core/test_client_provider_cleanup.py (+ opt-in Docker ledger). Combined focused suite: 190 passed / 5 skipped. ruff clean on touched files.
Gate
Fork PR — still needs maintainer Approve and run before merge.
Sent by Cursor Automation: Release
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ba88f81. Configure here.
Withdrawing APPROVE on ba88f81b — Bugbot’s factory-client cleanup finding is confirmed live. Follow-up REQUEST_CHANGES next.
There was a problem hiding this comment.
Re-review — REQUEST_CHANGES (withdraws APPROVE on ba88f81b)
Bugbot high finding is confirmed live.
Tier 1 — cleanup retry stops live factory providers
_start_provider_if_needed now runs _stop_provider() whenever _provider_cleanup_pending before the _ws_url is not None early-return.
Factory clients from from_docker_image / from_env pass base_url + provider, so _start_provider_on_connect is False and a failed close() keeps the URL while setting _provider_cleanup_pending=True. A later connect() / new_session() then:
- Retries stop and kills the still-running provider
- Sees
_ws_urlstill set and returns - Connects to a now-dead address
Reproduced on this head (failed close → pending+URL retained → connect() performs stop#2 then reuses the URL).
Required direction
Only retry cleanup when a new start is actually required (URL absent / provider-owned startup path), or invalidate factory URL state when cleanup becomes pending so reconnect cannot target a killed provider. Add a regression for base_url=…, provider=… failed-close → later connect() must not stop-then-reuse the old URL.
Prior failed-stop→second-start leak fix can stay; this is a new interaction with factory-owned URL retention.
Sent by Cursor Automation: Release
|
@burtenshaw I've kept this follow-up scoped to the regression introduced by this PR. Commit Validation after this change:
The wider audit suggests that stronger lifecycle guarantees need a separate, bounded follow-up—not more state flags in this fix, and not a claim of “absolute safety.” In particular, actual OpenEnv ACA/Modal/Novita/Daytona stop methods clear their sandbox handle in Useful implementation references for that follow-up:
My suggested sequence is: merge only the narrowly validated fix when review is satisfied; separately define provider ownership and cleanup outcomes (including NotFound and unknown outcome), repair adapter handle retention, then unify failed/cancelled acquisition cleanup and sync-wrapper ownership. Use stable resource-ID/liveness assertions and fault injection for failures before and after deletion takes effect, repeated close, shared children, cancellation, and thread teardown. Whether a failed first child should immediately roll back the parent's provider is a contract decision—the parent can still own and close it—so I would not silently change that policy here. Any new public lifecycle API should go through the project's RFC process. These are source-based references and local fault-injection results, not a claim that the external cloud SDKs or every provider have been tested end-to-end. |
There was a problem hiding this comment.
Re-review — APPROVE at 56293948
The open Tier 1 (factory reconnect while cleanup is pending) is fixed.
_start_provider_if_needed now refuses connect() / new_session() when _provider_cleanup_pending and the client already has a factory URL (not _start_provider_on_connect), instead of stopping the live provider and then early-returning onto the cached dead ws URL. Lazy provider-owned startup still retries cleanup-before-restart. New regression test_factory_pending_cleanup_requires_explicit_close covers sync/async × connect/new_session × cleanup still failing/recovered and asserts no provider/WebSocket side effects on rejected retries.
Local: tests/test_core/test_client_provider_cleanup.py 76 passed; test_generic_client.py 130 passed, 5 skipped. Bugbot green on this head.
Residual (accepted / out of scope for this PR, per author): reconnect after a successful factory close() still keeps the cached URL; cloud-adapter handle-loss on failed delete is a separate contract follow-up. Not blocking this narrow fix.
Merge gate: this is a fork PR — only Bugbot has run so far. Needs maintainer Approve and run for test.yml / package CI before merge.
Sent by Cursor Automation: Release
There was a problem hiding this comment.
Re-review — REQUEST_CHANGES (withdraws APPROVE on 56293948)
The factory reconnect fix in 56293948 is correct for that specific Bugbot finding, and the new regression test is good. Withdrawing APPROVE because the PR’s cleanup-pending model still has a Tier 1 stuck-state on this exact head.
Tier 1 — required-arg start_container() validation → permanent _provider_cleanup_pending
Reproduced on 56293948 with a provider whose start_container(image) requires image and whose stop_container() raises:
connect()→_start_provider_if_needed()raises the actionableValueError(never allocated)._connect_async’sexceptseesnot _provider_cleanup_pending, so itsuppress-callsclose()→_stop_provider()sets_provider_cleanup_pending=True, thenstop*raises → pending staysTrue.- Next
connect()retries stop first and surfaces the cleanupRuntimeErrorinstead of the originalValueError.
So a config/validation error permanently poisons the client’s reconnect path. Please either:
- not call
close()/_stop_provider()on a path that never started a resource (e.g. only cleanup when a start was attempted / ownership was taken), or - clear
_provider_cleanup_pendingwhen stop runs against a never-started provider,
and add a regression for required-arg validation + failing stop.
Still good (not re-blocking by themselves)
- Factory pending-cleanup reject (
not _start_provider_on_connect) — fixed; prior thread addressed. - Author’s deferred cloud-adapter handle-loss / successful-factory-close reconnect — out of scope; track separately.
Local focused suites still pass (76 + 130); Bugbot green. Fork CI still needs Approve-and-run once the stuck-pending path is fixed.
Sent by Cursor Automation: Release
There was a problem hiding this comment.
APPROVE at 5629394845a4c57371c0c4e67aa0bdf9a046701a
This withdraws the CHANGES_REQUESTED I left on this same head on 2026-09-20. The change request was wrong, and the reason matters, so here is the evidence rather than just the verdict.
Why the last blocker does not hold
I flagged that a pre-start ValueError (provider with required start_container args) leaves _provider_cleanup_pending latched when stop_container() also raises. The behaviour is real, but I assessed it against an idealised baseline instead of against main. Measured on both trees with the same provider double:
| scenario | main (7e591317) |
this PR |
|---|---|---|
| config error, in-repo-style stop — 1st connect | ValueError (correct) |
ValueError (correct) |
| config error, in-repo-style stop — 2nd connect | ValueError |
ValueError |
config error, in-repo-style stop — stop_container calls |
2 | 1 |
| config error, hostile stop — 1st connect | RuntimeError: stop blew up (real error masked) |
ValueError (real error surfaced) |
| config error, hostile stop — 2nd connect | RuntimeError: stop blew up |
RuntimeError: stop blew up |
redundant close() ×2 after a failed connect |
3 stop_container calls |
1 |
So on the path I called a regression, main is equally wedged and it swallows the actual configuration error. This PR surfaces the real error on the first attempt and matches main afterwards. It also stops calling stop_container() on already-stopped providers, which matters for the non-idempotent providers this PR's own test double models.
The latch additionally requires a provider whose stop_container() raises when nothing was ever started. No provider in this repository can do that — LocalDockerProvider, DockerSwarmProvider, ACAProvider, ModalProvider, DaytonaProvider, NovitaSandboxProvider, HFSandboxProvider and UVProvider all short-circuit on a null handle (if self._sandbox is None: return, if self._container_id is None: return, if self._process is not None: …). It needs a third-party provider of that exact shape.
Verified at this head
- The original #1144 leak is fixed: a provider started by a failing
connect()is started once and stopped once;_provider_cleanup_pendingand_provider_stoppedboth discharge. - Factory clients never reconnect to a dead URL: with cleanup latched, the guard raises and
ws_connectcall count is 0. The provider is not stopped a second time by the guard. - Transient stop failures recover: a stop that fails once then succeeds unlatches, and the next connect allocates a new resource rather than reusing the dead handle.
new_session()does not stop a shared provider: a child-constructor failure on a client with an existing base URL leavesstop_containeruncalled, while the same failure on a client that started the provider itself cleans it up.- 8 adversarial probes, all holding.
tests/test_core/test_client_provider_cleanup.py+test_client_provider_cleanup_docker.py+test_generic_client.py: 206 passed, 9 skipped.- Full CI-equivalent suite: 2858 passed, 95 skipped, 41 deselected.
- CI-equivalent lint (
usort formatthenruff format, thenruff check): no resulting diff, all checks pass.
One residual nit, not blocking
MCPClientBase._connect_async (production mode) still wraps its failure path in a bare await self.close() without the suppress(...) that EnvClient._connect_async gained here. When the new guard fires and the retried stop also fails, the stop error masks the guard's message. Worth a follow-up, but it is strictly better than main, which has no guard at all and silently connects to the dead URL. Safety is preserved either way — ws_connect is never reached.
Merge gate
This is not mergeable yet for reasons outside the author's control: repository CI has never run on this fork branch (only Cursor Bugbot reports), so the required checks are absent and mergeStateStatus is BLOCKED. A maintainer needs to click Approve and run on the workflows, and the PR still needs a maintainer approval. @burtenshaw — this one is ready for that from my side.
Sent by Cursor Automation: Release
|
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. |
Includes #1145 provider cleanup on failed connect. Co-authored-by: benjamin.burtenshaw <benjamin.burtenshaw@huggingface.co>



Summary
Fixes #1144.
Provider-backed
new_session()can fail after a resource has already been allocated: during provider start, readiness, or child construction. Cleanup is attempted immediately while preserving the original setup error.If that cleanup also fails, the client now records an unresolved cleanup obligation. A later
connect()ornew_session()must successfully stop the old resource before starting another one, preventing the provider's single resource handle from being overwritten. Successful cleanup remains idempotent, and a failed sibling construction does not stop a provider already serving the parent or another child.Type of Change
Alignment Checklist
.claude/docs/PRINCIPLES.md; this preserves provider lifecycle ownership..claude/docs/INVARIANTS.md; no API, protocol, or agent/orchestrator boundary changes.RFC Status
Test Plan
test_generic_client.py: 190 passed, 5 skipped.usort check,ruff format --check,ruff check, andgit diff --check.The repository test hook cannot execute on this macOS host because it requires GNU
timeout. Running the equivalent pytest command reaches collection but the local environment lacks the optionalsmolagentsdependency required bytest_coding_codeact_env.py. The scoped core and related lifecycle suites above pass.Claude Code Review
N/A (implemented and checked with OpenAI Codex). AI-assisted contribution; human maintainer review is still needed.
Note
Medium Risk
Changes container/process lifecycle and retry semantics on failure paths; behavior is well-covered by tests but affects resource ownership for all provider-backed clients.
Overview
Fixes provider-backed
EnvClientleaking containers/processes whennew_session()orconnect()fails after a resource is allocated (start, readiness, or child constructor).Lifecycle changes in
env_client.py: The client tracks_provider_cleanup_pendingand_provider_stopped, centralizes teardown in_stop_provider(), and on failed session startup only stops the provider when this call actually started it (not when a sibling session already owns a live server). If stop fails, laterconnect()/new_session()must successfully stop the old resource before allocating again; factory clients with a cached URL get a explicitRuntimeErrorto retryclose()instead of reconnecting.connect()preserves the original setup error when cleanup is already pending, and avoids a redundantclose()on failed cleanup retry.Tests: Large parameterized suites (
test_client_provider_cleanup.py,test_generic_client.pysession cleanup) plus opt-in Docker lifecycle tests assert no double-stop, blocked retries, recovery ordering, and no orphaned containers.Reviewed by Cursor Bugbot for commit 5629394. Bugbot is set up for automated code reviews on this repo. Configure here.