Add backend="fabric": cells in worker processes that can be killed - #298
Merged
Merged
Conversation
The sub-interpreter backend leaves one thing unsolved that a multi-tenant deployment cannot live without: a running cell cannot be reclaimed. close() refuses while the guest executes, there is no kill, and an async exception aimed at the thread does not reach the interpreter running in it. In-process, a runaway guest is permanent -- the cell is abandoned and its thread stays pinned for the life of the process. The fabric puts cells somewhere killable. Three levels, each a different kind of boundary: * a cell separates namespaces -- its own sys.modules, builtins and globals; * a worker process is the kill domain, and the only place a memory cap means anything, because sys.getallocatedblocks() is process-global rather than per-interpreter so there is no per-cell figure to limit; * the fabric decides which worker a tenant's cells land in, which is how a deployment chooses its blast radius. A deadline that expires now kills the worker and the sandbox raises saying so. Measured end to end: a runaway cell is reclaimed in its deadline, the worker process is gone, another tenant's cells are untouched, and the wedged tenant gets a fresh worker on its next spawn. Placement is the blast-radius decision, so it is explicit. tenant_isolation defaults to on, meaning a worker only ever hosts one tenant's cells and a kill costs that tenant alone; turning it off packs tenants together for density and makes them share a fate. Callers state which they want rather than getting one silently. Cost of the kill domain, measured on free-threaded 3.14: 1.20 ms for an exec+recv round trip against 0.82 ms in-process, plus a 160 ms worker spawn per tenant that WorkerPool.prewarm moves off the request path. Two defects found and fixed while building it, both in the worker lifecycle: * A worker killed from outside stayed a zombie until something happened to poll it, so a fabric recycling under load would accumulate one per recycle. The reader thread now reaps on EOF, which is the earliest reliable signal the process is finished. * Popen.poll() returns None when it cannot take the waitpid lock, so while the reader thread was inside wait() a dead worker still reported is_alive() -- long enough for placement to hand it a new cell. The worker is now marked unusable when its channel hits EOF, before the reap. Workers are spawned, never forked: forking a process that already hosts sub-interpreters and their threads segfaults. Exceptions crossing back are rebuilt from a fixed name map rather than a dynamic lookup, so a worker running guest code cannot name an arbitrary supervisor class and have it constructed. The fabric is a fault boundary, not a security one -- a guest that escapes its cell owns its worker, and a worker is an ordinary process with the supervisor's privileges. backend="process" remains the boundary mode. Kernel confinement of workers is now a roadmap item rather than a gap with no plan: tenant_isolation makes a worker's policy unambiguous, so seccomp/Landlock could be applied at spawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse
Base automatically changed from
claude/festive-johnson-fd03cl-cell-cost-lint
to
main
September 17, 2026 13:28
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.
This is the one that makes the repo's description true. Stacked on #297 (a one-file lint fix for currently-red
main); merge that first and this retargets tomaincleanly.The gap this closes
#295 landed real sub-interpreter cells but left the one thing a multi-tenant deployment cannot live without: a running cell cannot be reclaimed.
close()refuses while the guest executes, there is nokill, and an async exception aimed at the thread does not reach the interpreter running in it. In-process, a runaway guest is permanent — the cell is abandoned and its thread stays pinned for the life of the process.The fabric puts cells somewhere killable:
sys.modules,builtins, globalsIt actually works
Measured end to end through the public API on free-threaded 3.14:
The runaway is reclaimed at its deadline, the worker process is genuinely gone, the other tenant never noticed, and the wedged tenant is serving again on a fresh worker.
There's a tell in the test suite: #295's runaway tests have to shell out to a fresh interpreter, because a stranded cell hangs pytest at exit.
tests/test_fabric.pyruns its runaway cases inline — the fabric reclaims them.Placement is the blast-radius decision, so it is explicit
tenant_isolation=True(default) means a worker only ever hosts one tenant's cells, so killing it costs that tenant alone. Turning it off packs tenants together for density and makes them share a fate.worker_mem_bytesanswers a gap #295 recorded:sys.getallocatedblocks()is process-global on both free-threaded and GIL builds, so there is no per-cell figure to cap. Worker sizing is the control that exists.What it costs
exec+recvround tripThe kill domain costs ~0.4 ms per round trip over an in-process cell (1.20 ms vs 0.82 ms), plus one worker spawn per tenant that
prewarmmoves off the request path.Two defects found and fixed while building it
Zombie workers. A worker killed from outside stayed a zombie until something happened to poll it, so a fabric recycling under load would accumulate one per recycle. The reader thread now reaps on EOF — the earliest reliable signal the process is finished. (Surfaced because a test helper's
os.kill(pid, 0)probe kept reporting a killed worker as alive.)A dead worker reporting
is_alive().Popen.poll()returnsNonewhen it cannot take the waitpid lock, so while the reader thread sat insidewait()a dead worker still looked alive — long enough for placement to hand it a new cell. The worker is now marked unusable when its channel hits EOF, before the reap.Design notes worth a reviewer's attention
What it is still not
A guest that escapes its cell owns its worker, and a worker is an ordinary process holding the supervisor's privileges. The fabric is a fault boundary, not a security one.
backend="process"remains the boundary mode.Kernel confinement of workers is now a roadmap item with a plan rather than a gap:
tenant_isolationmakes a worker's policy unambiguous, so the process backend's seccomp/Landlock/cgroup layers could be applied at spawn. Also on the roadmap: brokerrequestexecution (capability-scoped per cell, not per worker) and deriving placement from policy.Testing
tests/test_fabric.py— 30 tests: the cell ABI through a worker, placement (tenant isolation on/off, capacity, packing), the kill domain (runaway recycle, bystander unaffected, tenant recovery, external kill surfaces instead of hanging, dead-worker reaping), pre-warming, observability, and the delegated sandbox surface. Plus 4 supervisor-level tests forbackend="fabric"andfabric_report().cells + fabric / py3.14t) gains a fabric step; I ran it against a venv with onlypytest pyyaml platformdirs, matching the job's dependency shape: 30 passed, 1 skipped.pre-commit run --all-filespasses.test_apply_confinement_installs_seccomp_and_allows_normal_syscallsfails in my container on every branch including unmodifiedmain— seccomp is unavailable here. I also sawtest_cpu_quota_is_debug_telemetry_without_watchdogfail in isolation on 3.14t; it does that on the parent branch too and passes in a full run, so it is order-dependent and unrelated.🤖 Generated with Claude Code
https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse
Generated by Claude Code