Skip to content

Add a real sub-interpreter backend with a pre-warmed cell pool - #295

Merged
seanwevans merged 3 commits into
claude/festive-johnson-fd03cl-backend-threadfrom
claude/festive-johnson-fd03cl-subinterpreter-backend
Sep 17, 2026
Merged

seanwevans merged 3 commits into
claude/festive-johnson-fd03cl-backend-threadfrom
claude/festive-johnson-fd03cl-subinterpreter-backend

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

Fourth of the series. Stacked on #294 (base is that branch, so the diff here is only this change); merge that one first.

backend="subinterpreter" now runs each guest in its own CPython interpreter via concurrent.interpreters, rather than naming a runtime it did not have. Each cell gets its own sys.modules and its own builtins — which is the point: the import allow-list stops being thread-local bookkeeping inside one shared interpreter and becomes a property of the interpreter, so one tenant's imports, monkey-patches and globals cannot be observed or clobbered by another.

It is still not a boundary against hostile Python. A cell shares the supervisor's address space and ctypes imports cleanly inside one. The docs say so in the same places they always have.

Design, driven by three measured properties

From scripts/cell_cost.py (#292):

Creating a cell is expensive — 10–57 ms and 3.5–13 MiB depending on the import surface, against 0.8 ms to dispatch onto a warm one. So cells are pooled and pre-warmed by CellPool, keyed by a CellSpec (allow-list + pre-imports). A request never pays for creation when a cell warmed the same way exists.

A cell cannot be reset. Releasing one retires it and warms a replacement in the background. Returning it to the pool would carry one tenant's globals into the next.

A running cell cannot be reclaimed. close() refuses while the guest executes, an async exception aimed at the thread does not reach it, and there is no kill. A cell that overruns its deadline is abandoned, not killed: the sandbox raises WallTimeExceeded saying exactly that, the pool counts it, and the thread stays pinned until the process exits. kill() returns False rather than claiming a stop it cannot perform.

Two hazards found while building this

Both fixed here rather than documented as quirks.

1. The queue pickles, so messages cross as JSON. A cross-interpreter queue falls back to pickle for anything not natively shareable — which would have the supervisor unpickling bytes the guest produced, the thing the process backend explicitly refuses to do. Every message is now JSON-encoded in the cell and crosses as a single str, which is natively shareable, so the fallback never engages. A guest posting something unserialisable gets a TypeError at the call instead of an opaque NotShareableError.

2. Closing a running interpreter aborts the process. Losing the close()/running race is not a catchable error:

Fatal Python error: Py_EndInterpreter: not the last thread

That would take every other tenant in the process down with the one lost cell. retire() now asks the runtime whether the interpreter is running before closing, and abandons instead.

A third, smaller one: the import hook has to let CPython's own machinery import pickle and traceback. Blocking those does not produce a policy denial — it produces NotShareableError from two layers down. The allow-list gates guest imports; starving the interpreter's plumbing is not a security control.

Version requirement

Requires CPython 3.14+ and fails closed below it, naming the alternatives, rather than degrading to the thread backend — which isolates differently, and silently substituting one for the other is what #294 set out to stop. It is not the default for that reason.

The private _interpreters on 3.12/3.13 is deliberately not a fallback: destroying interpreters that imported http.client or email.message aborts the process there with munmap_chunk(): invalid pointer, which is exactly the workload a pool generates.

Things the backend refuses rather than stubs

reset() raises with the reason (an interpreter cannot be returned to a pristine state). enable_tracing() raises (a cell has no syscall boundary to trace). snapshot() returns configuration, not guest state, because an interpreter's contents cannot be captured.

Testing

tests/test_subinterpreter_backend.py — 37 tests. The ones that need 3.14 skip below it; the spec, fail-closed path and pool bookkeeping run everywhere, so the 3.11–3.13 matrix still covers the logic it can reach.

Worth noting: the two runaway-cell tests run in a fresh subprocess, not a fork. They strand a thread in a spinning interpreter, so running them in-process hangs pytest at exit — and os.fork() segfaults when the process already hosts sub-interpreters, which it does once other tests have run. A fresh process is the only clean kill domain, which is the same conclusion a deployment reaches.

  • 3.14t free-threaded: 571 passed, 16 skipped
  • 3.13: 544 passed, 43 skipped
  • pre-commit run --all-files passes

test_apply_confinement_installs_seccomp_and_allows_normal_syscalls fails in my container on every branch including unmodified main — seccomp is unavailable here. I also saw two one-off timing flakes (test_wall_time_quota_kills_a_runaway_guest, test_cpu_quota_without_watchdog_is_telemetry) while another suite was competing for CPU; both pass in isolation and on rerun, and neither touches this code.

Follow-up this makes concrete

The ROADMAP now carries the two gaps this exposes: a kill domain (pre-forked worker processes, so a runaway cell costs a worker rather than being unrecoverable), and per-cell resource accounting — there is none, and sys.getallocatedblocks() turns out to be process-global on both free-threaded and GIL builds, so memory has to be capped at the worker/cgroup level.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse


Generated by Claude Code

backend="subinterpreter" now runs each guest in its own CPython interpreter via
concurrent.interpreters, rather than naming a runtime it did not have. Each
cell gets its own sys.modules and its own builtins, which is the point: the
import allow-list stops being thread-local bookkeeping inside one shared
interpreter and becomes a property of the interpreter, so one tenant's imports,
monkey-patches and globals cannot be observed or clobbered by another.

It is still not a boundary against hostile Python -- a cell shares the
supervisor's address space and ctypes imports cleanly inside one -- and the
docs say so in the same places they always have.

Three measured properties of the CPython primitive shape the design:

* Creating a cell costs 10-57ms and 3.5-13MiB depending on its import surface,
  against 0.8ms to dispatch onto a warm one. So cells are pooled and pre-warmed
  by CellPool, keyed by a CellSpec, and a request never pays for creation when
  a cell warmed the same way exists.

* An interpreter cannot be reset. Releasing a cell therefore retires it and
  warms a replacement in the background; returning it to the pool would carry
  one tenant's globals into the next.

* A running interpreter cannot be reclaimed. close() refuses while the guest is
  executing, and an async exception aimed at the thread does not reach it. A
  cell that overruns its deadline is abandoned, not killed: the sandbox raises
  WallTimeExceeded saying so, the pool counts it, and the thread stays pinned
  until the process exits. The API does not pretend otherwise -- kill() returns
  False rather than claiming a stop it cannot perform.

Requires 3.14+ and fails closed below it with a diagnostic naming the
alternatives, rather than degrading to the thread backend, which isolates
differently. The private _interpreters on 3.12/3.13 is not used as a fallback:
destroying interpreters that imported http.client or email.message aborts the
process there, which is exactly the workload a pool generates.

Two hazards found while building this, both fixed here rather than documented
as quirks:

* Messages cross as JSON in a single str. A cross-interpreter queue falls back
  to pickle for anything not natively shareable, which would have the
  supervisor unpickling bytes the guest produced -- the thing the process
  backend explicitly refuses to do. A str is natively shareable, so the
  fallback never engages. It also means a guest posting something
  unserialisable gets a TypeError at the call rather than an opaque
  NotShareableError from the runtime.

* retire() asks the runtime whether the interpreter is running before closing
  it. Losing that race is not a catchable error: CPython aborts the process
  with "Py_EndInterpreter: not the last thread", which would take every other
  tenant down with the lost cell.

The import hook also has to let CPython's own machinery import pickle and
traceback. Blocking those does not produce a policy denial; it produces
NotShareableError from two layers down. The allow-list gates guest imports;
starving the interpreter's plumbing is not a security control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse
@seanwevans
seanwevans force-pushed the claude/festive-johnson-fd03cl-subinterpreter-backend branch from a3b7c16 to f1e7f1e Compare September 16, 2026 23:28
The sub-interpreter backend needs 3.14+, and nothing in the matrix ran it:
unit tests stopped at 3.13 and the only free-threaded job was 3.13t, which
cannot create a cell at all.

Add 3.14 to the unit matrix, and a `sub-interpreter cells / py3.14t` job that
runs the backend suite on a free-threaded build.

That job asserts up front that the interpreter really is a free-threaded 3.14
before running any tests. Every sub-interpreter test skips itself when the
build cannot run it -- which is correct for the 3.11-3.13 matrix, but it means
a runner that silently resolved to an older Python would skip everything and
report the job green having tested nothing. Failing loudly is the difference
between a job that covers the backend and a job that looks like it does.

Dependency installation follows the existing 3.13t job: install without the
runtime deps that may not have free-threaded wheels, then re-add the
pure-Python ones the import path needs. Nothing in this job's scope needs real
crypto, and the suite's stub covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse
…-ci-freethreaded

Cover CPython 3.14 and the sub-interpreter backend in CI
@seanwevans
seanwevans merged commit 2f3902e into claude/festive-johnson-fd03cl-backend-thread Sep 17, 2026
10 of 22 checks passed
@seanwevans
seanwevans deleted the claude/festive-johnson-fd03cl-subinterpreter-backend branch September 17, 2026 01:19
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.

2 participants