Skip to content

fix(providers): teardown terminates a sandbox by id, so one that already finished is counted down - #208

Merged
earakely-scale merged 3 commits into
mainfrom
edgararakelyan/fd-3946-modal-finished-sandbox
Oct 14, 2026
Merged

earakely-scale merged 3 commits into
mainfrom
edgararakelyan/fd-3946-modal-finished-sandbox

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A sandbox can finish before teardown reaches it: killed, out of memory, or past its timeout. On Modal, teardown then logged two warnings for a sandbox that was already down:

  • teardown: sb-… (modal) was not terminated: ConflictError: Sandbox has already finished with status terminated
  • episode …: ['sb-…'] may still be up

"may still be up" is how a real leak is reported, so a false one on every dead env hides the true ones.

Cause: both teardown paths (teardown_run and the teardown_sandboxes step) got a handle with get_sandbox before terminating. Modal's get_sandbox reads the sandbox's tunnels, which Modal refuses for a finished sandbox.

Why not fix get_sandbox: this PR's first version had get_sandbox return a handle for a finished sandbox. That would hide dead containers from env reattach, which relies on get_sandbox raising: _reattach_containers raises for a dead server and skips a dead database, and MultiEnv raises for a dead database.

Fix: a new SandboxProvider.terminate_sandbox(sandbox_id) terminates a sandbox the provider made, by id. A sandbox that has already finished or is gone counts as terminated. The base default is get_sandbox then terminate().

Provider terminate_sandbox Already finished or gone
modal, modal_vm Sandbox.from_id then terminate(), with no tunnels and no exec Modal's terminate() is a no-op once finished; NotFoundError counts as gone
e2b the class-level kill(id), with no connect kill returns False
tenki client.get then the session close TenkiSandbox.terminate does, now shared as close_session SessionNotFoundError, or an ended state
tensorlake connect then terminate(), skipping info() SandboxNotFoundError
sail_vm through get_sandbox, since its terminate() also deletes the model-key policy that only the live Sailbox's applied policy names NotFoundError
smol_vm Machine.list, then connect and delete() for a listed machine, skipping the Docker setup and host configuration the machine isn't listed
local, chained the base default the provider's own terminate()

Two choices worth a look:

  • The chained provider keeps the base default. Its get_sandbox finds the provider that owns the sandbox. Looping over each provider's terminate_sandbox instead would let the first provider count another provider's sandbox as "gone".
  • A record that doesn't name its backend still goes through a live handle. For the same reason, terminate_sandbox is only called when the record names the backend that made the sandbox. A record without one, which no current deploy path writes, still goes through the slot provider's get_sandbox. Local work-folder removal now keys on the provider's ON_THIS_MACHINE.

Known limits:

  • SAIL: a Sailbox that's already gone leaves its model-key policies behind, since nothing can name them any more.
  • smol: the SDK starts a stopped local machine when it connects, and it has no delete-by-name.

Lint commit: a separate commit clears the six ruff findings that main's unit job currently fails on. They're in SmolVM's checks: F811 on an imported fixture, and one placeholder-less f-string. The commit can be split out if preferred.

Test plan

  • Unit tests for every provider:

    • a live sandbox is terminated through the SDK without get_sandbox's extras: no tunnels, no info(), no connect for E2B, no Docker setup for smol;
    • a gone or finished sandbox raises nothing;
    • Modal's get_sandbox still raises for a finished sandbox, which is the reattach contract.
  • Teardown tests:

    • a typed record is terminated by id without a handle, and an untyped one through a handle;
    • a teardown_run regression test through the real Modal provider, with only the SDK faked: a finished sandbox lands in terminated, nothing is in still_up, and no tunnel is read.
  • With main's teardown restored, the three new teardown tests fail.

  • Full unit suite: 8119 passed. The one failure is smol_vm_provider_test::test_vm_host_entry_updates_without_dropping_other_guest_names, which fails on main too on macOS because BSD sed differs from GNU's. It's skipped in CI, where the smol extra isn't installed, which also means this PR's new smol test only runs where smolmachines is installed.

  • ruff check is clean.

  • Real backends, from Linux VMs, with this commit installed:

    Backend Live sandbox Again, once gone Killed out-of-band, then teardown_run Unknown id Warnings
    Modal terminated ok killed (137) and timed out (124) in terminated, nothing failed ok 0
    SAIL terminated ok terminated, nothing failed ok 0
    SmolVM (local, KVM host) terminated ok terminated, nothing failed ok 0
    • Modal reattach contract: get_sandbox on a killed sandbox still raises ConflictError.
    • Modal soak rounds, 32 concurrent PortSim episodes per round: two kill rounds (4 env sandboxes killed mid-episode each), a steady round and an interrupt round. All pass, with 0 "was not terminated" and 0 "may still be up", where 0.9.1326 gave 4 per kill round. No sandbox is left.
    • Local Docker soak: kill, steady and interrupt rounds all pass, with 0 warnings and no containers, networks or ports left.
    • E2B, Tenki and Tensorlake are covered by unit tests only, as no credentials were available for a real run.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable new defect was established.

Summary

Adds SandboxProvider.terminate_sandbox and routes typed teardown records through it.

  • Teardown stops named sandboxes by ID.

No actionable new issue was established. No previous review threads were supplied.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Recorded sandbox] --> B{Backend recorded?}
  B -->|Yes| C[Build recorded provider]
  C --> D[terminate_sandbox]
  D --> E[Backend-specific termination]
  B -->|No| F[Use configured provider]
  F --> G[get_sandbox]
  G --> H[Handle terminate]
  E --> I[Record successful teardown]
  H --> I
  E -->|Error| J[Report possible leftover]
  H -->|Error| J
Loading

Reviews (2) · Last reviewed commit: "fix(providers): teardown terminates a sa..." · Reviewed by Greptile

earakely-scale and others added 3 commits October 14, 2026 14:26
…ad of reporting it up

Teardown gets a handle with get_sandbox before it terminates. The Modal providers' get_sandbox read the
sandbox's tunnels, which Modal refuses for a sandbox that has finished (killed, out of memory, past its
timeout) with ConflictError, so teardown logged "was not terminated" and "may still be up" for a sandbox
that was down.

get_sandbox now returns a handle without tunnels when the tunnels call conflicts and poll() has an exit
code. Modal's terminate() is a no-op on a finished sandbox, so teardown counts it as down. Modal raises
ConflictError for other refusals too, so a conflict while poll() says running is raised as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's unit job fails at its lint step: the SmolVM checks import the local_stack fixture with a noqa for F401
but take it as a parameter, which ruff reports as F811, and one test has an f-string without placeholders. The
checks now import it as ``local_stack as local_stack``, the explicit re-export another test already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ady finished is counted down

Teardown got a handle with get_sandbox before it terminated a sandbox. Modal's get_sandbox reads the sandbox's
tunnels, which Modal refuses for a sandbox that has finished, so teardown reported a killed or timed-out sandbox
as "may still be up". Making get_sandbox return a handle for a finished sandbox instead (this PR's first
version) would have hidden dead containers from env reattach, which relies on get_sandbox raising.

SandboxProvider.terminate_sandbox(sandbox_id) terminates a sandbox the provider made, by id; one that has
already finished or is gone counts as terminated. The base default is get_sandbox then terminate. Every
provider in the repo implements it without get_sandbox's extras:
- modal, modal_vm: Sandbox.from_id then terminate (a no-op once finished), with NotFoundError counted as gone.
- e2b: the class-level kill by id, which returns False for a sandbox already absent.
- tenki: client.get then the session close TenkiSandbox.terminate does, now shared as close_session.
- tensorlake: connect then terminate, skipping info(), with SandboxNotFoundError counted as gone.
- sail_vm: through get_sandbox, since its terminate also deletes the model-key policy only the live Sailbox's
  applied policy names; NotFoundError counts as gone.
- smol_vm: a machine Machine.list doesn't list is gone; a listed one is connected and deleted, skipping the
  Docker setup and host configuration get_sandbox runs.
- local and chained: the base default. A chain's get_sandbox finds the owner; a per-provider terminate_sandbox
  loop would let the first provider count another provider's sandbox as gone.

Both teardown paths call terminate_sandbox for a record that names its backend. A record that doesn't still
goes through the slot provider's live handle, since "not found" from a provider that didn't make the sandbox
doesn't mean it's gone. Local work-folder removal keys on the provider's ON_THIS_MACHINE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale force-pushed the edgararakelyan/fd-3946-modal-finished-sandbox branch from b6277c4 to ef41dd9 Compare October 14, 2026 22:06
@earakely-scale earakely-scale changed the title fix(providers): tear down a Modal sandbox that already finished instead of reporting it up fix(providers): teardown terminates a sandbox by id, so one that already finished is counted down Oct 14, 2026
@earakely-scale
earakely-scale merged commit d898fc3 into main Oct 14, 2026
16 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-3946-modal-finished-sandbox branch October 14, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant