all posts

How to control sandbox lifetime: TTL, idle, and cleanup

Ajay Kumar··8 min read

Every team that ships an agent product has the same month-three conversation. The bill is higher than the work justifies, and the reason turns out to be sandboxes nobody killed — created by a worker that crashed on step four, or by a conversation the user closed, or by a test suite that failed before its teardown.

This is entirely preventable, and it costs about ten lines. Here are the five controls, what each one actually does to the bill, and how to combine them. The API is PandaStack's; the reasoning applies anywhere.

TTL: the one that always works

A TTL is wall-clock seconds until the platform kills the sandbox. It is the only cleanup mechanism enforced by something other than your own code, which is precisely why it's the one that matters. Your finally block does not run when the process is OOM-killed; the TTL does.

from pandastack import Sandbox

# Set it at creation. Always.
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=1800)

# Extend it when a long job legitimately needs longer
sandbox.set_ttl(7200)

print(sandbox.lifecycle())
# {"ttl_seconds": 7200, "persistent": False, "idle_seconds": 12}

Pick the TTL from the task, not from caution. A code tool in a chat gets thirty minutes. A CI job gets fifteen. A long training run gets hours and a monitoring alert. The instinct to set everything to twenty-four hours 'just in case' converts an abandoned sandbox from a rounding error into a real number.

TTL is refreshed by activity on the sandbox, so a 30-minute TTL doesn't kill a sandbox that's been working for 45. It kills one that's been ignored for 30 — which is exactly the population you're trying to reap.

Pause: stop the clock without losing anything

Pause halts the vCPU and leaves memory untouched. Nothing is written to disk, the process tree is intact, and resume is effectively instant. The use case is narrow and genuinely useful: an agent that has finished a step and is waiting for a human to approve the next one.

sandbox.pause()          # vCPU stopped; memory still resident
# ... wait for the human ...
sandbox.resume()         # back where it was, immediately

What pause saves depends on how you're billed. Under active-CPU metering, a paused sandbox stops the CPU charge but keeps the memory charge, because the memory is still occupying a host. If you're paying a flat provisioned rate, pausing saves you nothing at all — worth knowing before you build a workflow around it.

Hibernate: free the host, keep the state

Hibernate dumps memory and disk to a snapshot and stops the VM entirely. The host's RAM and vCPUs are released. Waking restores from the snapshot, which on a snapshot-restore platform is fast enough to be invisible in a request — roughly a couple of hundred milliseconds rather than a cold boot.

sandbox.hibernate()      # memory + disk to snapshot, VM stopped
# ... minutes or hours later ...
sandbox.wake()           # restored, same state

The catch is storage. A hibernated sandbox with 2 GB of memory is roughly 2 GB on disk, and you're paying for that disk for as long as it exists. Hibernation is the right answer for a workspace someone will come back to. It's the wrong answer for a finished job — that's what kill is for.

Persistent: opt out of TTL deliberately

Marking a sandbox persistent takes it out of TTL reaping. That's the right call for a long-lived developer workspace or a database VM, and the wrong call for anything created per request — a persistent sandbox created by a runaway loop is a persistent bill.

sandbox.set_persistent(True)     # no TTL kill
sandbox.set_persistent(False)    # back under TTL

Persistent sandboxes are still subject to the idle sweeper: after a few minutes with no activity they're hibernated, and the next request wakes them implicitly. The state survives; the host resources don't sit idle. That's the behaviour you want for a workspace someone uses twice a day.

Kill: the one people forget

Deleting the sandbox releases everything — the network slot, the rootfs, the memory. It's the only operation that takes the cost to zero rather than reducing it.

The reliable way to do this is a language feature rather than discipline, because discipline fails on the exception path.

# Python: context manager
with Sandbox.create(template="code-interpreter", ttl_seconds=1800) as sandbox:
    sandbox.exec("python3 analyse.py")
# killed on the way out, including on exception
// TypeScript: explicit resource management
{
  await using sandbox = await Sandbox.create({
    template: "code-interpreter",
    ttlSeconds: 1800,
  });
  await sandbox.exec("npm test");
}  // kill() called automatically

Putting them together

Four patterns cover nearly every application.

  1. One-shot job — TTL matched to the expected runtime plus generous headroom, plus a context manager. The TTL is the backstop; the context manager is the mechanism.
  2. Chat conversation — a sandbox per session, created lazily on the first code call, TTL around thirty minutes refreshed by use, explicit kill on an end-session endpoint.
  3. Developer workspace — persistent, no TTL, and let idle hibernation handle the overnight and weekend gaps.
  4. Fan-out — a short TTL on every branch and an explicit kill on the ones you don't promote. This is where costs get away from people fastest: sixteen forks that each live an hour because nobody reaped the fifteen losers.

Finding what's already running

Before optimising anything, look. The answer is usually a specific worker or a specific test suite, not a general problem.

from pandastack import Sandbox

for sb in Sandbox.list():
    lc = sb.lifecycle()
    if lc["idle_seconds"] > 3600:
        print(sb.id, sb.template, sb.info.get("metadata"), lc)
        # sb.kill()

Which is the argument for setting metadata at creation. A list of orphaned UUIDs tells you nothing; a list of orphaned UUIDs tagged with the job id and the service that created them tells you exactly which code path is leaking.

sandbox = Sandbox.create(
    template="code-interpreter",
    ttl_seconds=1800,
    metadata={"service": "chat-api", "session": session_id, "env": "prod"},
)

One thing that changes all of this

How much any of this matters depends on the billing model, and it's worth checking before you design around it.

Under flat provisioned pricing, an idle sandbox costs the same as a busy one, so lifetime management is the whole game and you'll be pushed toward pooling and reuse — which reintroduces the isolation problems you were avoiding.

Under per-second metering on active CPU and resident memory, idle is genuinely cheap. On PandaStack the rates are $0.000015 per active vCPU-second and $0.0000045 per working-set GiB-second, so a sandbox sitting idle waiting for a model to respond costs close to nothing. That changes the design: per-session isolation becomes the cheap option as well as the safe one, and the thing worth policing is sandboxes that keep burning CPU after their work is done, not sandboxes that merely exist.

The short version

  1. Always set a TTL at creation. It's the only cleanup your code can't fail to run.
  2. Use the context manager or await using for the happy path — discipline doesn't survive exceptions.
  3. Pause for a human-in-the-loop wait; hibernate for a workspace someone returns to; kill for anything finished.
  4. Reserve persistent for genuinely long-lived things, never for per-request work.
  5. Tag every sandbox with metadata so a leak points at the code path that caused it.
  6. Check whether your platform bills for existence or for use before you build a pooling layer you may not need.

Frequently asked questions

What TTL should I set on an agent sandbox?

Set it from the task rather than from caution. A code-execution tool inside a chat conversation is well served by around thirty minutes, since the TTL refreshes on activity and only reaps sandboxes that have genuinely been abandoned. A CI job should get slightly more than its worst observed runtime. A long training or batch job needs hours and deserves a monitoring alert rather than a generous TTL. The failure mode to avoid is setting everything to twenty-four hours defensively — that turns every leaked sandbox from a rounding error into a real line on the bill.

What's the difference between pausing and hibernating a sandbox?

Pause stops the vCPU and leaves memory resident on the host, so resume is effectively instantaneous and nothing is written to disk — it suits an agent waiting on human approval for the next step. Hibernate writes memory and disk to a snapshot and stops the VM entirely, releasing the host's RAM and CPU, and waking restores from that snapshot. Hibernation frees far more resource but leaves you paying for snapshot storage roughly the size of the sandbox's memory, so it fits a workspace someone will return to rather than a job that has finished. A finished job should simply be killed.

How do I make sure sandboxes are cleaned up when my process crashes?

Do not rely on your process at all. A finally block, a signal handler, and an atexit hook all fail the same way — the process is killed by the kernel for exceeding memory, or the container is terminated, and none of your cleanup code runs. The TTL set at creation is enforced by the platform rather than by you, so it survives every one of those cases. Use the language's scope-based cleanup for the normal path because it releases resources immediately, and treat the TTL as the guarantee that a leak is bounded rather than permanent.

Should I pool and reuse sandboxes instead of creating one per request?

Only if creation is slow or existence is expensive, and both are properties of the platform rather than facts about sandboxes. Pooling means users share an environment, so one user's files, variables, and installed packages are visible to the next — a privacy problem and a correctness problem at once, and it requires a reset step that is easy to get subtly wrong. If creation takes a fraction of a second and billing is per-second on actual usage, one sandbox per request or per session is simpler, safer, and usually cheaper than the pool you would have to maintain.

How do I find sandboxes that are already leaking?

List them and sort by idle time — the lifecycle call reports seconds since the last exec, filesystem, or network request, so anything idle for hours is a candidate. The useful part is what you do next, and that depends on having tagged sandboxes with metadata when they were created. A list of orphaned identifiers tells you that you have a leak; a list tagged with the service, the job id, and the environment tells you which code path is causing it, which is almost always one specific worker or one test suite rather than a general problem.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.