Skip to content

Add backend="fabric": cells in worker processes that can be killed - #298

Merged
seanwevans merged 1 commit into
mainfrom
claude/festive-johnson-fd03cl-fabric
Sep 17, 2026
Merged

seanwevans merged 1 commit into
mainfrom
claude/festive-johnson-fd03cl-fabric

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

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 to main cleanly.

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 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:

 Supervisor
   |
   +-- Worker process   <- the kill domain
   |     +-- cell  cell  cell      (one CellPool, many cells)
   |
   +-- Worker process
         +-- cell  cell
Level Isolates Reclaimable
cell sys.modules, builtins, globals no
worker the kill domain, and where a memory cap applies yes — SIGKILL
fabric which worker a tenant lands in n/a

It actually works

Measured end to end through the public API on free-threaded 3.14:

acme  -> 1.4142135623730951
globex-> 3.0
placement: [{'id': 1, 'pid': 7821, 'tenant': 'acme',   'cells': 1, 'alive': True},
            {'id': 2, 'pid': 7824, 'tenant': 'globex', 'cells': 1, 'alive': True}]
acme wedged, reclaimed: cell c1 exceeded 0.5s and did not return. A running sub-inte ...
globex-> still here
acme  -> recovered
stats: {'workers_started': 3, 'workers_killed': 1, 'cells_live': 2, 'tenant_isolation': True}

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.py runs 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.

pool = WorkerPool(
    max_workers=8,
    cells_per_worker=16,
    tenant_isolation=True,
    worker_mem_bytes=2 << 30,   # RLIMIT_AS per worker
)
pool.prewarm("acme", 2)         # pay the ~160 ms spawn before traffic arrives

worker_mem_bytes answers 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

Operation p50
worker spawn (fresh interpreter + pyisolate import + pool) 160.8 ms
first cell for a tenant (spawns its worker) 190.3 ms
further cells in that worker 29.8 ms
exec + recv round trip 1.20 ms

The 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 prewarm moves 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() returns None when it cannot take the waitpid lock, so while the reader thread sat inside wait() 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

  • Workers are spawned, never forked. Forking a process that already hosts sub-interpreters and their threads segfaults — the same reason #295's tests use a fresh interpreter.
  • Exceptions are rebuilt from a fixed name map, not a dynamic lookup. A worker runs guest code; letting it name an arbitrary supervisor class and have it constructed here would be a hole. There's a test for it.
  • Cell operations run on their own thread inside the worker, so one wedged guest cannot stall the control loop and make a busy worker indistinguishable from a wedged one.
  • A pump forwards cell output every 5 ms, so a guest that posts and then blocks is not silent until its operation returns.

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_isolation makes a worker's policy unambiguous, so the process backend's seccomp/Landlock/cgroup layers could be applied at spawn. Also on the roadmap: broker request execution (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 for backend="fabric" and fabric_report().

  • 3.14t free-threaded: 628 passed, 19 skipped
  • 3.13: 574 passed, 73 skipped
  • CI's 3.14t job (renamed cells + fabric / py3.14t) gains a fabric step; I ran it against a venv with only pytest pyyaml platformdirs, matching the job's dependency shape: 30 passed, 1 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 test_cpu_quota_is_debug_telemetry_without_watchdog fail 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

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
@seanwevans
seanwevans merged commit 3078405 into main Sep 17, 2026
10 of 22 checks passed
@seanwevans
seanwevans deleted the claude/festive-johnson-fd03cl-fabric branch September 17, 2026 13:28
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