Skip to content

seccomp: defer slow notification work off the loop - #340

Merged
congwang-mk merged 8 commits into
mainfrom
notif-park
Nov 5, 2026
Merged

congwang-mk merged 8 commits into
mainfrom
notif-park

Conversation

@congwang-mk

@congwang-mk congwang-mk commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #337. Its chroot unix socket fix is the first commit here unchanged; the rest replaces its off-loop dispatch with a smaller model.

Problem

Supervised connects, FFI callbacks and chroot/COW filesystem handlers ran on the notification loop, so one slow peer, callback or copy stalled every trapped syscall in the sandbox. The existing escape hatch, NotifAction::Defer, made a terminal decision: its Continue skipped later handlers, it was refused on fork and exec, and it failed with EAGAIN past a fixed cap.

Design

  • Defer resumes where it was returned. A deferred handler's Continue runs the next handler; any other result ends the chain. The outcome then takes the same path as an inline decision (pinned opens, fork accounting, policy event, argv freeze), so deferring on fork or exec is safe. All deferred futures of a notification share one 30 second deadline.
  • Synchronous work runs as NotifAction::native(closure) on a process-wide runtime kept apart from the embedder's, whose shutdown would otherwise wait on a closure stuck on a peer. A closure cannot be cancelled, so it has no deadline: failing the syscall early would not stop its side effects. The syscall waits for it as it would for the kernel.
  • No admission failures, no per-sandbox slots. Each trapped task has at most one outstanding notification, so deferred work is bounded by the sandbox's own task count. The process-wide thread cap is only a backstop for closures that outlive a dead child.
  • Child-memory writes are ordered against exec at the write itself. write_child_mem and write_child_mem_force hold the shared side of a per-sandbox gate for each write made by deferred work; the exec freeze holds the exclusive side until the kernel has copied argv. Exec waits microseconds instead of failing, and no call site has to remember a guard.
  • Fork accounting runs once the chain settles on Continue, so no handler can defer a fork after it was counted, and a failed response rolls the slot back.
  • Chroot and COW handlers stay on the loop; only the copy-up leaves it. A handler plans under the COW lock as before. When a write needs a lower file in the upper first, one driver copies it as native work and then runs the write under the lock that found every copy in place. The branch handlers no longer copy at all, so there is one copy path and no copier holds the lock across file I/O. This covers COW and chroot open, rename, link, chmod, chown, truncate and utimensat. Fixes cow: a large copy-up stalls the supervisor for its whole duration #242.
  • Copies are staged, then installed. A copy streams into a staging file in the branch storage and is installed with RENAME_NOREPLACE, so a half-written upper is never visible. A second caller for the same file waits for the first and plans again; a path deleted while it was copied is not resurrected; a quota resync keeps the reservation of a copy in flight. A directory rename walks the lower tree off the loop and plans the whole tree at once, all or nothing. A write is applied only if its notification is still waiting, so a caller interrupted during the copy (a signal, or an exec freeze without WAIT_KILLABLE_RECV) restarts its syscall onto a write not yet done.
  • The listener stays open until the last native closure using its fd number returns.
  • Every FFI callback is native work. sandlock_handler_set_deferred is removed (pre-1.0 hard break). A per-handler run guard waits for running callbacks before the FFI run returns, and callbacks still queued fail with EIO instead of touching freed userdata. Python handlers, sync or async, run off the loop with no flag.

Behavior changes

  • A deferred or async handler that returns Continue now hands the syscall to the next handler instead of ending the chain.
  • FFI and Python callbacks have no deadline: a syscall waits for its callback however long it takes, where a deferred callback used to fail with EIO after 30 seconds.
  • An FFI run waits for running callbacks before returning; a callback that never returns prevents the run from completing.
  • sandlock_handler_set_deferred is gone from the C ABI and the Python SDK.
  • When the branch fails a write it owns (a failed copy, an I/O error), the child gets the errno instead of the syscall continuing to the kernel, which would have changed the lower file. Kernel objects such as devices and FIFOs still pass through.
  • A directory rename that fails during the copy keeps the entries already copied, as overlayfs does. Undoing them could discard a write made through them meanwhile. A refusal while planning (quota, merged-view checks) still stages nothing.

Known gaps

  • Checkpoint and pause do not wait for native work in flight, so a copy-up or FFI callback can still be running when the sandbox is frozen.

Test plan

  • CI run 41945973005 green on all 25 jobs (Rust tests, feature matrix, low-ABI, musl, riscv64 cross-build, cbindgen header, Python, Go)
  • New unit tests: chain resume and termination after a deferral, deadline and nesting, native work ignoring the deadline and running to completion after its waiter is dropped, gate exclusion, FFI run guard drain and queued-callback EIO, staged copies invisible until installed, a waiting second copier, no install over a deleted path, a losing copy keeping the winner's file, quota resync of a staged copy
  • Python e2e: an async handler's continue_() reaches the next handler on the syscall

🤖 Generated with Claude Code

Chroot mode never moves the child's real root or cwd, yet the named unix
gate checked the raw sun_path lexically and returned Continue, leaving the
kernel to resolve it on the host. An absolute virtual path therefore
reached the host's socket of that name (an escape where Landlock cannot
gate unix connect), and a relative one was checked as if anchored at /
but resolved against the cwd the child was exec'ed in, so ./svc.sock from
a granted cwd was refused while tmp/x.sock from an ungranted one passed.

Serve chroot connect/sendto/sendmsg/sendmmsg on-behalf like non-chroot:
build the virtual path against the tracked virtual cwd, pin the inode
inside the root (mount aware), and check write access on where it really
sits. The trap now stays on under chroot even with Landlock ABI v9.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk
congwang-mk force-pushed the notif-park branch 2 times, most recently from 926f2f8 to 70152d8 Compare November 4, 2026 23:49
A deferred handler made a terminal decision: its Continue skipped every
later handler, and the supervisor refused deferral on fork and exec
because answering later would bypass fork accounting and the argv
freeze. Deferrals beyond a fixed cap failed with EAGAIN even though each
trapped task can only have one notification outstanding.

Let a deferral resume where it was returned. A deferred handler's
Continue runs the next handler, any other result ends the chain, and
the outcome then takes the same path as an inline decision. Fork
accounting moves out of the chain to run once it settles on Continue,
so no handler can defer a fork after it was counted. All deferrals of
a notification share one deadline, admission no longer fails, a
panicked deferral still answers its caller, and settled deferrals are
drained while notifications keep arriving.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Supervised connects ran on the notification loop after approval, so a
slow peer or a full listener backlog stalled every other trapped syscall
in the sandbox.

Let a deferral carry a synchronous closure. It runs on a process-wide
runtime kept apart from the embedder's, whose shutdown would otherwise
wait for a closure stuck on a peer. A closure cannot be cancelled, so it
has no deadline: failing the syscall early would not stop its side
effects. The syscall waits for it as it would for the kernel, and since
a trapped task has at most one notification outstanding, the sandbox's
own task count bounds its closures. Both supervised connects now run
there.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk congwang-mk changed the title seccomp: park slow notification work off the loop seccomp: defer slow notification work off the loop Nov 4, 2026
@congwang-mk
congwang-mk force-pushed the notif-park branch 7 times, most recently from b4539c8 to 5ae3106 Compare November 5, 2026 03:49
Deferred work keeps running while the loop validates an execve's argv
under the freeze. A write it makes into a CLONE_VM peer's memory can land
on that argv after validation and before the kernel copies it, and the
deferred sendmmsg completion already wrote msg_len that way.

Give each sandbox a gate. Deferred futures and native closures run with it
installed, write_child_mem and write_child_mem_force hold its shared side
for each single write, and the exec freeze holds the exclusive side until
the freeze is released. Writes are the only access that can change argv
and each holds the gate for one copy, so an exec waits microseconds and
never fails because unrelated work is still running.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A native closure runs to completion even after its child is gone, so it
can outlive the supervisor and still use the listener's raw fd number
for child-memory access. Once the supervisor closed the listener that
number could be reused by another sandbox's listener.

Share the listener between the supervisor and the native closures it
defers, so its fd stays open until the last closure returns.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A C callback was awaited on the notification loop unless the caller
flagged it deferred, and a deferred callback made a terminal decision
that skipped later handlers and was refused on fork and exec. Callers
had to choose between stalling every trapped syscall and changing what
their Continue meant.

Defer every callback as native work, where Continue resumes the chain
like any other deferral, and remove sandlock_handler_set_deferred. Like any
native work a callback has no deadline, and it runs to completion even
after its child is gone, but the caller's userdata may only be borrowed
until the run returns, so each run holds a guard per handler: dropping
it waits for running callbacks and releases the container, and
callbacks still queued fail with EIO instead of reaching freed userdata.
Python async handlers no longer need a flag, and a sync handler no
longer stalls the supervisor.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A copy-up of a large lower file ran under the COW lock on the
supervisor loop, so one big write stalled every syscall of the sandbox
(#242), in COW and chroot mode alike. Handlers now plan on the loop as
before, and only the copy leaves it: the dispatcher copies what a write
needs into the upper as native work, then runs the write under the
lock that found every copy in place. The branch handlers no longer
copy at all, so there is one copy path and no copier ever holds the
lock across file I/O.

A copy streams into a staging file in the branch storage and is
installed with RENAME_NOREPLACE, so a half-written upper is never
visible. A second caller for the same file waits for the first and
plans again, a path deleted while it was copied is not resurrected, and
a quota resync keeps the reservation of a copy still in flight. A
directory rename walks the lower tree off the loop and plans the whole
tree at once, all or nothing, so a refusal still stages nothing.

A caller interrupted while its copy ran restarts the syscall, so the
write is applied only if the notification is still waiting. Without a
killable wait an exec freeze is such an interruption, and a rename or
link done anyway would make the restarted call fail.

Fixes #242

Signed-off-by: Cong Wang <cwang@multikernel.io>
Handlers keep running after the sandbox is stopped: a task whose
notification was already received cannot stop until it is answered, and
deferred work runs on regardless. An inline handler, an FFI callback or a
deferred future could therefore write child memory while a checkpoint
reads it, and the image would mix memory from before and after the
write. The capture now holds the sandbox's memory gate exclusively, the
same gate that orders deferred writes against an exec freeze, and the
notification loop installs that gate for its inline handlers too, so
every write waits until the capture is done.

The gate is taken only once the target is ptrace-stopped. The seize
waits for the handler of whatever the target was trapped in to answer,
and taking the gate earlier would block that handler's last write and
deadlock. The exec freeze writes no child memory while it holds the
gate, so the loop never waits on itself. Work that never writes child
memory, such as a copy-up, is not held up.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk
congwang-mk merged commit 451100d into main Nov 5, 2026
25 checks passed
@congwang-mk
congwang-mk deleted the notif-park branch November 5, 2026 04:18
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.

cow: a large copy-up stalls the supervisor for its whole duration

1 participant