Repository navigation
fix(opensandbox): delete the sandbox of a create the caller gave up on - #4799
gwarmstrong wants to merge 14 commits into
Conversation
…ed its own output execd keeps background-command output under /tmp/opensandbox-execd. A command that empties /tmp (terminal-bench-science's rdkit-ic-constraints verifier resets /tmp, /var/tmp and /dev/shm between worker calls) deletes that file, so the post-completion log read failed with "error open combined output file ... no such file or directory" and the trial was reported as an infrastructure error. The status read has already returned the exit code, so keep it and report no output for that one error; any other log-read failure still raises. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
A create request that times out (or is cancelled) client-side can still make its sandbox on the server. The SDK can only clean up a sandbox whose id it received, so the sandbox ran untracked until its TTL while the retry made another one. A 70-task Terminal-Bench-Science oracle run left two such verifier sandboxes behind. Every attempt of one create() call now carries the same create-id metadata label (under the attribution key prefix). After a failed attempt, and again after a retry succeeds (an earlier request can land late), the provider lists that label and deletes every sandbox it did not return. The cleanup is best effort and only logs its own failures. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
A saturated sandbox can answer some background-command status reads and time out on others. Each poll then recovers within its retries, so the command looks alive until its own timeout, which is eight hours for a Terminal-Bench-Science agent. operations.status_failure_window_s (off by default) ends such a command: once status_failure_limit (5) reads fail within the window, the poll raises SandboxUnresponsiveError, a SandboxBackendUnreachableError, so callers treat the run as a retryable infrastructure failure and an OOM-killed sandbox is still named as such. A non-retryable read error such as SANDBOX::ENDED still ends the poll at once. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
|
Thanks for tracking this leak down. The diagnosis is right, but I'd like to propose a different fix that needs no Where the leak actually isIn the SDK (0.1.15), Proposal: never cancel the create requestRun create = asyncio.ensure_future(Sandbox.create(**kwargs))
try:
sandbox = await asyncio.wait_for(asyncio.shield(create), timeout=timeout_s)
except BaseException:
self._reap_abandoned_create(create) # awaits `create`; on success -> self._cleanup_failed_create_handle(handle)
raise
Compared with the label approach:
One requirement: the create request timeout ( E2E evidenceI tested this against a live OpenSandbox cell (
With the shield, the caller still got Not covered: many creates at once, and connections that drop before the response arrives. Suggested path
Happy to push the prototype branch if it helps. |
…ve sandbox" This reverts commit 12f14bd. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…ttempts" This reverts commit c61327c. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…it deleted its own output" This reverts commit 2b8b38b. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…-failure-handling Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
A create request that times out client-side or is cancelled can still make its sandbox on the server. Once the create request has returned the id, the SDK kills the sandbox on any failure, so the only leak is a request interrupted before the server answers: its reply, carrying the id, is dropped and the sandbox runs untracked until its TTL. Never cancel the request instead. _create_once waits on it through asyncio.shield; when the timeout fires or the caller cancels, the caller gets its error on time and a tracked task waits for the server's answer and deletes the sandbox. aclose() waits for those tasks for operations.close_timeout_s, then cancels the rest. Alternative to the create-id label and list-and-kill sweep in NVIDIA-NeMo#4799. Validated against a live OpenSandbox cell: main leaked a sandbox for every timed-out, cancelled, or all-retries-failed create; with this change none leaked. 500 concurrent creates add about 0.07s per create on the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Hemil Desai <hemildesai@users.noreply.github.com> (cherry picked from commit e68e1dd) Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
An abandoned create request is left to finish and its sandbox deleted, which only works if the request outlives the server's create, so say to keep create.request_timeout_s above it. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
|
Thanks @hemildesai, this is a much better fix. I've switched the PR to your approach:
The description is updated to match. |
|
/ok to test 13b2b36 |
|
🌿 Preview your docs: https://nvidia-preview-pr-4799-62d8e44de7c0.docs.buildwithfern.com/nemo/gym |
NVIDIA-NeMo#3807 added an attempt-key sweep to _create_once: a create that times out, is cancelled or hits the SDK's request timeout lists the sandboxes carrying its key and deletes them. The shielded request from this branch sits under it, so both run: the sweep deletes what the server has recorded, and the abandoned request deletes the sandbox the server answers with later. Both sides added methods after _create_once; both are kept. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…equest together The paragraph asked to keep create.request_timeout_s above the server's create time, which main now caps at ready_timeout_s on purpose; a request the SDK cuts off is found by its attempt key instead. The test of an abandoned create now has the sweep find nothing, the case the request is kept for. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…ts sandbox The server never answers a create whose Pending sandbox the attempt-key sweep deleted, so the request it was left to finish stayed open until the SDK's request timeout, and aclose() waited up to operations.close_timeout_s for it. Live, every abandoned create held aclose() for the full 120 s request timeout. The request is now cancelled when the sweep found its sandbox, and kept only when the sweep found none: the server had not recorded the sandbox yet, or the lookup failed. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
|
@hemildesai since your review, main picked up #3807, which added an attempt-key sweep to One interaction showed up live. The server never answers a create whose The docs paragraph no longer asks for |
|
/ok to test 7ba7057 |
…-failure-handling Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
|
/ok to test 7269de9 |
Main now raises typed failures (provider_create_timeout, provider_create_pending) in place of OpenSandboxCreateTimeoutError and OpenSandboxCreatePendingError, and moved the provider tests to tests/unit_tests/sandbox/. The abandoned-create tests now assert the failure kinds, and _raise_timed_out_create keeps main's docstring plus the note on its request argument. Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
|
/ok to test 62d8e44 |
What does this PR do?
A create request that times out on the client, or that the caller cancels, can still make its sandbox on the server, and the provider never learns its id. #3807 (now on
main) handles this with an attempt-key sweep: it lists the sandboxes carrying the attempt's metadata key and deletes them. The sweep misses a sandbox the server records after the lookup, and finds nothing when the lookup itself fails.This PR adds @hemildesai's approach under the sweep, with his commit cherry-picked. The create request is not cancelled when the caller gives up. The provider waits on it through
asyncio.shield, so the caller still gets its timeout or cancellation on time. Then:Pendingsandbox was deleted, so waiting on it would hold the request open until the SDK's request timeout.aclose()waits for those tasks up tooperations.close_timeout_s, then cancels the rest. The OpenSandbox docs describe both steps.No issue: found in Terminal-Bench-Science runs, where a 70-task oracle run on OpenSandbox left two sandboxes running until their TTL.
Validation
tests/unit_tests/test_opensandbox_provider.py: 160 passed after merging main (2026-10-29, 7269de9); 159 before. That includes Hemil's tests and a new one:aclose()cancelling creates the server never answers.The other OpenSandbox unit test files pass too, except two
--helpscript tests that needaiohttpon the host Python.Live on OpenSandbox at this head: a create timing out after 1 s, a caller cancelling after 1 s, and three attempts that all time out. Each time the sweep found the
Pendingsandbox and deleted it,aclose()returned at once, and no sandbox was left. When the request was left open instead, the same run heldaclose()for the full 120 s request timeout. The found-nothing path doesn't occur on our cell, so only the unit test covers it.Re-checked live after merging main (7269de9, 2026-10-29), with the same three cases: a create timing out after 1 s, a caller cancelling after 1 s, and three attempts that all time out. Each
Pendingsandbox was swept and deleted (five in all),aclose()returned in under 0.03 s, and no sandbox was left.Rollouts
N/A (provider error handling).
Compatibility and benchmark impact
No settings change. A timed-out or cancelled create whose sandbox the sweep did not find keeps its request open in the background until the server answers, then deletes the sandbox.
aclose()waits up tooperations.close_timeout_sfor those requests.