Skip to content

Reclaim Modal sandboxes whose create call was interrupted - #2406

Open
nicolaslara wants to merge 1 commit into
PrimeIntellect-ai:mainfrom
nicolaslara:fix/modal-sandbox-orphaned-on-interrupted-create
Open

Reclaim Modal sandboxes whose create call was interrupted#2406
nicolaslara wants to merge 1 commit into
PrimeIntellect-ai:mainfrom
nicolaslara:fix/modal-sandbox-orphaned-on-interrupted-create

Conversation

@nicolaslara

Copy link
Copy Markdown

Symptom

Interrupt an eval that uses the Modal runtime while sandboxes are still being provisioned, and more sandboxes stay alive than the harness ever reports as up. They then run until their 24h timeout expires.

Cause

Modal commits the sandbox and hands it to the scheduler before Sandbox.create returns. A create interrupted in flight therefore still produces a running, billed sandbox. Timeline measured on one such sandbox:

t+0.00s   sandbox created and scheduled server-side
t+0.0xs   caller interrupted, inside `await Sandbox.create.aio(...)`
t+0.50s   task enqueued
t+1.50s   container RUNNING — nobody holds a handle to it

In ModalRuntime.start the handle is only assigned once the call returns:

self._sandbox = await modal.Sandbox.create.aio(...)   # interrupted here

So _sandbox stays None, and both cleanup paths key off exactly that attribute — teardown returns early, and the cleanup atexit backstop no-ops. The sandbox is unreachable from the process that made it.

Cancellation is the common trigger (Ctrl-C, or a rollout cancelled while its batch is still provisioning), and CancelledError is not an Exception, so it also slipped past except Exception in start and left no log line. That is why the "sandbox ... up" count doesn't match what's actually running. A connection dropped on the reply has the same shape.

Fix

The name passed to Sandbox.create is ours and unique per rollout, and Modal enforces name uniqueness per app, so an interrupted create is still addressable. Look it up on the way out and adopt it; the owner's stop then disposes of it like any other sandbox.

_adopt_orphan is a no-op when a handle is already held, so it is also safe on the existing except Exception path — which additionally covers a create that failed after the server created the sandbox.

Verification

Real Modal sandboxes, 12 rollouts started concurrently, batch cancelled mid-create, then every runtime stopped exactly as the owner's finally does. LEAKED is measured with Sandbox.list against the app, not inferred:

Before

interrupted=12 created_server_side=12 no_handle_after_start=3 adopted=0 LEAKED=3
interrupted=12 created_server_side=12 no_handle_after_start=2 adopted=0 LEAKED=2
interrupted=12 created_server_side=12 no_handle_after_start=7 adopted=0 LEAKED=7

After

interrupted=12 created_server_side=11 no_handle_after_start=1 adopted=6 LEAKED=0
interrupted=12 created_server_side=12 no_handle_after_start=0 adopted=9 LEAKED=0
interrupted=12 created_server_side=12 no_handle_after_start=0 adopted=4 LEAKED=0

In run 1 after the fix, one rollout's create never reached the server (created_server_side=11), so there was correctly nothing to adopt.

Cancelling outside the create window is unaffected: the handle is already held and _adopt_orphan returns immediately.

uv run ruff check, uv run ruff format --check, and uv run ty check verifiers all pass. No tests added, per AGENTS.md — the check above was a temporary script.

Two notes for maintainers, not changed here

  • ModalConfig.creates_per_sec defaults to 40.0. Modal's default per-workspace sandbox creation limit is 5/s with a 150-token burst bucket, so this default is 8× over for a workspace that hasn't had its limit raised. Creates then retry with backoff, which keeps them in flight much longer and widens the window this PR closes. Worth either lowering the default or documenting that the limit needs raising.
  • Sandbox.create accepts idle_timeout. It is the only cleanup that survives losing the client entirely — a hard kill, or a second Ctrl-C that kills the event loop before any recovery code runs. Currently only timeout=24h is set, so anything the process can't clean up lives for a day.

Disclosure: I work at Modal. This came out of debugging a leak with a user running verifiers on us.

🤖 Generated with Claude Code

Modal commits a sandbox and hands it to the scheduler before `Sandbox.create`
returns, so a create interrupted in flight still boots and bills a sandbox —
measured at roughly a second after the caller stopped waiting for it. `_sandbox`
is only assigned once the call returns, so an interrupted `start` leaves nothing
for `teardown` or the atexit backstop to act on, and the sandbox runs to its 24h
maximum lifetime.

Cancellation is the common trigger: Ctrl-C on an eval, or a rollout cancelled
while its batch is still provisioning. `CancelledError` is not an `Exception`,
so it also slipped past the existing handler and left no log line.

The name is ours and unique per rollout, so an interrupted create is still
addressable. Look it up on the way out and adopt it, and the owner's `stop`
disposes of it like any other sandbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant