Skip to content

Rename backend="subinterpreter" to backend="thread" - #294

Merged
seanwevans merged 5 commits into
mainfrom
claude/festive-johnson-fd03cl-backend-thread
Sep 17, 2026
Merged

seanwevans merged 5 commits into
mainfrom
claude/festive-johnson-fd03cl-backend-thread

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

Third of a series of independent PRs. Touches different files from #292 and #293 except for CHANGELOG.md, where the entries land in different sections.

Why

pyisolate/runtime/thread.py has always run guests in a threading.Thread, execing guest source against a restricted __builtins__ mapping. The backend that selects it was named for an implementation it does not have.

The repo is already candid about this — the README, SECURITY.md and the threat model all say so in prose. But prose is the wrong place for it. Nothing in backend="subinterpreter" tells a caller that sys.modules is shared with the supervisor rather than per-interpreter, and that is exactly the difference that decides what isolation they are getting.

What changes

  • The runtime gets its real name: backend="thread", and it is the default.
  • "subinterpreter" still resolves, with a DeprecationWarning, through a DEPRECATED_BACKEND_ALIASES map exported next to SUPPORTED_BACKENDS.

The alias is deliberately not a permanent synonym. The warning tells callers who want today's behaviour to pass "thread":

backend='subinterpreter' is deprecated and currently selects backend='thread',
which runs the guest in a thread of this process rather than in a CPython
sub-interpreter. Pass backend='thread' to keep this behaviour; the
'subinterpreter' name will be reassigned to a real sub-interpreter backend.

That matters for the next PR in the series: reassigning the name later must not silently change what existing callers get.

What does not change

No behaviour. The default backend selects the same runtime it did before, and the boundary claim is untouched — a thread and a sub-interpreter are equally not a boundary against hostile Python, which the docs already said. What they no longer say is that the backend is named for an implementation it is waiting on.

The ROADMAP item that proposed this rename ("…or rename the backend to thread and let this item own the real thing") now owns the real implementation instead, and points at 3.14's concurrent.interpreters rather than the private _interpreters.

Testing

Four new tests in tests/test_supervisor.py:

  • the default backend does not emit a deprecation warning (a rename that warns on every default call would be worse than the problem);
  • the old spelling warns and resolves to the same runtime;
  • the alias is not advertised in SUPPORTED_BACKENDS / IMPLEMENTED_BACKENDS;
  • an unknown backend still raises ValueError without warning.

test_spawn_rejects_unknown_backend used backend="thread" as its stand-in for an unknown backend, which is now the default one — switched to a name that is genuinely not a backend.

Full suite: 534 passed, 14 skipped. The one failure in my container, test_apply_confinement_installs_seccomp_and_allows_normal_syscalls, is pre-existing and environmental (seccomp unavailable) and reproduces on unmodified main. pre-commit run --all-files passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse


Generated by Claude Code

pyisolate/runtime/thread.py has always run guests in a threading.Thread,
exec'ing guest source against a restricted __builtins__ mapping. The backend
that selects it was named for an implementation it does not have, which makes
the mechanism impossible to read off the API: nothing in the name tells you
that sys.modules is shared with the supervisor rather than per-interpreter,
which is the difference that decides what isolation you are getting.

The runtime gets its real name. "subinterpreter" still resolves, with a
DeprecationWarning, through a DEPRECATED_BACKEND_ALIASES map exported next to
SUPPORTED_BACKENDS.

The alias is deliberately not a permanent synonym. The warning tells callers
who want today's behaviour to pass "thread", because the name is reserved for
a real CPython sub-interpreter backend, and reassigning it later must not
silently change what existing callers get.

No behaviour changes: the default backend selects the same runtime it did
before, and the boundary claim is untouched. A thread and a sub-interpreter are
equally not a boundary against hostile Python, which the docs already said;
what they no longer say is that the backend is named for an implementation it
is waiting on.

The README section is rewritten around the rename, and the ROADMAP item that
proposed this rename now owns the real implementation instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse
claude and others added 4 commits September 16, 2026 23:28
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
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
…-subinterpreter-backend

Add a real sub-interpreter backend with a pre-warmed cell pool
@seanwevans
seanwevans merged commit d40f728 into main Sep 17, 2026
10 of 22 checks passed
@seanwevans
seanwevans deleted the claude/festive-johnson-fd03cl-backend-thread 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