Add a real sub-interpreter backend with a pre-warmed cell pool - #295
Merged
Conversation
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
force-pushed
the
claude/festive-johnson-fd03cl-subinterpreter-backend
branch
from
September 16, 2026 23:28
a3b7c16 to
f1e7f1e
Compare
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
merged commit Sep 17, 2026
2f3902e
into
claude/festive-johnson-fd03cl-backend-thread
10 of 22 checks passed
seanwevans
deleted the
claude/festive-johnson-fd03cl-subinterpreter-backend
branch
September 17, 2026 01:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viaconcurrent.interpreters, rather than naming a runtime it did not have. Each cell gets its ownsys.modulesand its ownbuiltins— 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
ctypesimports 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 aCellSpec(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 nokill. A cell that overruns its deadline is abandoned, not killed: the sandbox raisesWallTimeExceededsaying exactly that, the pool counts it, and the thread stays pinned until the process exits.kill()returnsFalserather 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
picklefor 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 singlestr, which is natively shareable, so the fallback never engages. A guest posting something unserialisable gets aTypeErrorat the call instead of an opaqueNotShareableError.2. Closing a running interpreter aborts the process. Losing the
close()/running race is not a catchable error: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
pickleandtraceback. Blocking those does not produce a policy denial — it producesNotShareableErrorfrom 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
_interpreterson 3.12/3.13 is deliberately not a fallback: destroying interpreters that importedhttp.clientoremail.messageaborts the process there withmunmap_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.pre-commit run --all-filespassestest_apply_confinement_installs_seccomp_and_allows_normal_syscallsfails in my container on every branch including unmodifiedmain— 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