Skip to content

sandbox: keep background jobs supervised after main exit - #299

Closed
congwang-mk wants to merge 23 commits into
mainfrom
issue-292-background-supervision
Closed

congwang-mk wants to merge 23 commits into
mainfrom
issue-292-background-supervision

Conversation

@congwang-mk

@congwang-mk congwang-mk commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for the main process used to tear down supervision and terminate background jobs. Separate main-process completion from session shutdown so a caller can wait for a launcher to exit while continuing to use the service it started.

  • Make wait return the main status while retaining background supervision, and expose tracked live process groups.
  • Add explicit shutdown to terminate remaining jobs with SIGKILL, reap descendants, collect output, and settle filesystem changes. Run performs wait followed by shutdown.
  • Use a private subreaper to retain the ancestry needed for supervised syscalls without changing the embedding application's child-adoption settings. The reaper only reaps: shutdown kills through the tracked process groups, which reach every supervised process because setsid and setpgid are trapped, and the reaper's exit marks the session empty. No procfs scanning.
  • Refuse setsid and setpgid when the new group cannot be recorded, so no group forms outside the reach of kill.
  • Tie the reaper to the owning process through a pidfd rather than PDEATHSIG, so a session outlives the thread that created it.
  • Carry the lifecycle through Rust, C, Python, and Go. Wait timeouts leave sessions alive for retry or explicit shutdown; run timeouts still terminate them. Update the sandbox reference and binding documentation.

This changes the wait API: complete output and filesystem changes are collected at shutdown. Python wait timeouts raise TimeoutError. Shutdown is immediate termination, not a graceful stop.

Limits: terminating jobs requires pidfd process-group signals (Linux 6.9). On older kernels shutdown fails if jobs outlive the main process. Under no_supervisor, a job that moves to a new group or session is not tracked; shutdown releases it, still confined, instead of terminating it.

Validated by CI across Rust (x86_64, arm64, low-ABI 22.04, feature matrix, riscv64 cross-build), Python 3.10 to 3.14, and the Go SDK.

Fixes #292

Waiting for the main process also tore down supervision and killed
background jobs. Embedders could not observe command completion while
keeping a service started by that command alive.

Make wait return the main ExitStatus and retain the session. Add
shutdown to terminate remaining jobs, reap children, collect output
and settle COW changes. Expose a snapshot of tracked process groups,
and have run wait and shut down for callers needing a complete result.
Update the internal callers and keep complete-result FFI adapters
working until the bindings expose the new lifecycle.

Use a private subreaper to retain ancestry for supervised syscalls and
reap orphaned descendants without changing the embedder's subreaper
setting. Pin the main PID until the supervisor obtains its pidfd, and
use a shutdown channel to clean up descendants outside tracked groups.

Cover repeated and cancelled waits, background output and COW writes,
embedder child ownership, descriptor exhaustion and descendant cleanup.
The 124 affected integration tests, the reaper regression and workspace
checks pass.

Fixes #292

Signed-off-by: Cong Wang <cwang@multikernel.io>
The C wait adapters still collected a complete result and released
supervision, preventing bindings from retaining background jobs after
the main process exited.

Return status-only results from handle waits and add explicit shutdown
to collect output and filesystem changes. Leave the session alive when
a wait times out so callers can retry or shut it down. Expose tracked
process groups and pause/resume operations through the C API.

Update complete-result test callers and regenerate the C header. Add
coverage for retained background jobs, repeated waits and recovery after
a wait timeout. The complete FFI test suite passes.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Python wait freed the native handle and returned the complete result,
so background jobs could not survive the main process's exit.

Return ExitStatus from Sandbox and Process waits, retaining the session
until shutdown collects output and filesystem changes. Expose process
group inspection. Raise TimeoutError without terminating jobs when a
wait expires, while keeping run timeout cleanup and result reporting.

Serialize native handle access and use bounded waits so another thread
can signal jobs without aliasing the native wait. Coordinate context
exit with an active wait, clean up abandoned sessions, and clear cached
status when restoring a new process. Keep deferred branches available
for commit or abort after shutdown.

Update the Python API documentation and cover session reuse, timeout
recovery, concurrent cleanup and restored status. All 139 lifecycle,
sandbox and checkpoint regression tests pass.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Process.Wait released the native handle after collecting a result,
ending background supervision when the main command finished.

Return a cached ExitStatus from Wait and add Shutdown for the complete
result. Expose ProcessGroups and apply signals to tracked groups through
the native API. Have Run wait and shut down, preserving timeout results
and deferred branch ownership.

Serialize bounded native waits with signals and handle cleanup so Kill
and Close can interrupt a blocked Wait. Let native handle cleanup reap
abandoned sessions instead of signalling a cached numeric process group
that could have been reused after the main process exited.

Update the Go API documentation and add session lifetime, concurrent
close, run timeout and finalizer regressions. All 55 Go tests pass with
the race detector enabled.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Main-process completion and session completion are now separate events.
The sandbox reference needs to explain when background supervision,
captured output and staged filesystem changes remain active.

Document the shared create, wait, signal, shutdown and run semantics
without binding-specific examples. Explain timeout behavior, tracked
group snapshots, output ownership and COW settlement at session
completion. Clarify that shutdown uses SIGKILL and that releasing the
owning handle also cleans up its jobs.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Shutdown asked the private reaper to terminate descendants, then also
sent pidfd group signals. Kernels such as Linux 6.8 reject that flag
with EINVAL, causing even a completed echo command to fail under a
degraded policy.

Use the reaper's shutdown channel for sessions it owns and wait for it
to finish reaping before releasing supervision or settling COW. Keep
policy callback cancellation and the legacy cleanup path for sessions
without a reaper. Skip group signalling on repeated shutdown as well.

Add a subprocess regression that rejects pidfd group signals through
seccomp. Cover complete runs, repeated shutdown and cleanup of a child
that outlives the main process in a separate session. All 87 selected
protection, process-group, policy, streaming and COW tests pass.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The lifecycle changes routed kill through sandlock_handle_kill without
registering its ctypes signature. The default integer conversion can
truncate a native handle to 32 bits, crashing checkpoint and lifecycle
tests on hosts where allocations use higher addresses.

Declare the handle argument as c_void_p alongside pause and resume.
Add a regression that checks pointer-width argument handling and kills
a live sandbox from a fresh interpreter.

The full Python suite passes: 659 tests passed and 15 skipped.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The session reaper killed leftover descendants by reading
/proc/thread-self/children. That needs CONFIG_PROC_CHILDREN and a usable
procfs, and when the read failed the reaper waited forever, hanging
shutdown and Drop.

Every supervised process is in a tracked process group, since setsid
and setpgid are trapped, so the leader pidfds already reach all of
them. Let the reaper only reap: it reports the main status along with
whether anything else remains, and exits once it has no children. Its
exit is a kernel-stamped sign that the session is empty, and shutdown
awaits it through a pidfd instead of polling waitpid.

Jobs outside tracked groups (new groups under no_supervisor, or any job
on kernels without pidfd group signals) cannot be killed without
procfs. Shutdown asks the reaper to finish, releasing them still
confined, and stops the capture drains so a released writer cannot
hold the result hostage. Finish is a byte, not EOF, because children
parked before exec may hold a copy of the pipe.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Kill reaches the sandbox only through recorded process groups. When
pidfd_open failed for a new leader, or the parent check could not
confirm the target, setsid and setpgid were let through unrecorded,
so a group could form that no kill or shutdown would ever reach.

Fail closed instead: return the pidfd_open error, and ESRCH for a
target that is not the caller or a live child of it, matching what
the kernel itself would report for a non-child.

Signed-off-by: Cong Wang <cwang@multikernel.io>
PDEATHSIG fires when the parent thread exits, not the parent process.
The FFI forks the reaper on the caller's thread, so a binding that
created a sandbox from a short-lived thread lost the session when that
thread ended: the reaper was killed, the main process followed, and
background jobs were released unsupervised.

Watch a pidfd of the owning process instead, opened before checking
that the owner is still the parent. When the owner dies the reaper
kills the main process, still its unreaped child, and finishes. The
main process keeps PDEATHSIG, since the reaper is single-threaded.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Bindings could only wait through the handle, which a blocking wait
borrows mutably. To let another thread signal the session meanwhile,
they waited in 50ms slices under a lock, adding latency and spinning
the runtime for every slice.

Add sandlock_handle_main_exit_fd: it prepares the wait (closing
untaken stdin and starting the output drains, so a chatty child cannot
fill its pipe) and returns a pidfd duplicate that turns readable once
the main process exits. A binding blocks on it without the handle and
then calls sandlock_handle_wait, which no longer blocks.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Waits polled the native handle in 50ms slices so kill could interleave.
Poll the main-exit descriptor without holding the lock instead, then
collect the status, which returns at once. Since no native call now
holds the handle across a wait, process_groups and ports no longer
refuse while another thread waits.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Process.Wait looped over 50ms native waits so Kill and Close could take
the handle in between. Block in poll on the main-exit descriptor
without the mutex, retrying on EINTR from Go's runtime signals, then
collect the status. Ports no longer reports empty during a Wait.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Shutdown and Drop re-sent SIGKILL every 10ms until no tracked group had
members left. One sweep is enough: the kernel restarts a fork that
races a group kill, and a process killed while parked in a trapped
setsid never completes it. With every supervised process in a swept
group, the reaper exits on its own once the last one is reaped, and its
pidfd reports that.

Only without a supervisor, or when the sweep itself failed, can jobs
outside the sweep remain; finish the reaper right away then. That mode
has no COW branch that must outwait them. Clones still poll, since no
reaper reports the end of their descendants.

Signed-off-by: Cong Wang <cwang@multikernel.io>
server_closes_on_another_uid_without_answering flaked with EPIPE: the
server checks SO_PEERCRED on accept and closes a foreign peer at once,
which can land before the client finishes writing its request, and the
helper unwrapped the write.

Ignore write errors in the helper. A server that answers still has to
produce the response the own-uid test checks, so nothing is masked.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Sandbox.run creates its handle with sandlock_create_for_run, whose
current_thread runtime advances the supervisor only inside a native
call. Polling the main-exit descriptor from Python starved it: trapped
syscalls were never answered, the child never exited, and the Python
suite hung in CI.

Wait natively in run, as the Go binding does; nothing else shares that
handle. Make sandlock_handle_main_exit_fd refuse current_thread handles
so the same mistake fails instead of hanging.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A wait that timed out returned a result whose reason was TIMEOUT, the
same reason a run reports after killing the process for exceeding its
timeout. One reason then meant both "still running" and "killed", and a
caller had to know which call produced it. Return 1 from
sandlock_handle_wait_timeout when the timeout elapses and hand the
status back through an out-pointer, so TIMEOUT only ever describes a
process that was killed.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The sandbox child is now forked by the session reaper, which sets
PR_SET_PDEATHSIG and checks getppid() itself before returning into
do_create. confine_child then captured getppid() right after that fork
and compared it again, a check that could never fail, and set the same
death signal a second time.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Sandbox and Process each carried their own lock, idle condition,
waiting flag, cached status and copies of the same wait, shutdown and
close logic. Move that into _Session, which both now own.

shutdown also both reserved the handle and held the lock across the
native call. The FFI forbids concurrent handle calls, so the lock is
the part that matters; the reservation only made a concurrent wait
fail. Keep the lock, and reserve only around wait, the one call that
blocks outside it.

run() reuses the shared result decoding instead of a copy of it.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Sandbox.__del__ ran the whole of __exit__, which can block on an idle
condition for a wait in progress and also shuts down a popen Process
that has its own finalizer. Neither applies when the sandbox is
collected: a running wait() keeps it reachable. Free the session handle
and any pending branch directly; the native drop kills what is left.

Cover the case the finalizer exists for: a sandbox dropped after wait()
must not leave its background jobs running.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Kernels before 6.9 reject PIDFD_SIGNAL_PROCESS_GROUP with EINVAL, so
every group sweep failed there. shutdown() could then end no session
that had a job left, and owns() refused job-control moves into the
sandbox's own groups.

Fall back to kill(-pgid) while the group's leader has not exited: its
pid is still held, so no other group can have the number. Once the
leader is gone the number may be reused, but a probe that finds no
group at all is still conclusive, because an empty group can never
regain members. Only a group whose leader is gone and that still has
members stays out of reach, and shutdown reports it.

Signed-off-by: Cong Wang <cwang@multikernel.io>
sandlock kill sent the control request, then also called
killpg(child_pid) and SIGKILLed the supervisor. The child pid was
recorded at startup. Now that a session outlives its main process, that
pid can be reaped and reused long before the name is released, so the
killpg could hit an unrelated group. Killing the supervisor
unconditionally also took down an embedding host on success, and on a
kernel where some groups were out of reach it released the remaining
jobs while the CLI still reported success.

Kill only through the tracked groups, which the control loop now holds
with or without a seccomp supervisor. Report a refusal instead of
claiming success. Only a supervisor that accepts the connection but
never answers is killed, through a pidfd opened while SO_PEERCRED vouches
for it, and the session reaper then kills the main process.

Signed-off-by: Cong Wang <cwang@multikernel.io>
ps showed the main pid from the registry and read its uptime and
command line from /proc. Background jobs now keep a session alive after
main exits, and its pid is then free for reuse, so ps could describe an
unrelated process.

The info verb now reports whether main has exited, from the main pidfd
the control loop holds. ps reads /proc for the pid only while the
supervisor vouches that main is alive, and otherwise shows the session
as running with "(main exited)" in place of a pid.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk
congwang-mk marked this pull request as draft October 15, 2026 21:33
@congwang-mk
congwang-mk deleted the issue-292-background-supervision branch October 16, 2026 00:09
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.

sandbox: keep background jobs supervised after the main process exits

1 participant