Repository navigation
seccomp: defer slow notification work off the loop - #340
Merged
Merged
Conversation
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
force-pushed
the
notif-park
branch
2 times, most recently
from
November 4, 2026 23:49
926f2f8 to
70152d8
Compare
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
force-pushed
the
notif-park
branch
from
November 4, 2026 23:54
70152d8 to
7339d7a
Compare
congwang-mk
force-pushed
the
notif-park
branch
7 times, most recently
from
November 5, 2026 03:49
b4539c8 to
5ae3106
Compare
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
force-pushed
the
notif-park
branch
from
November 5, 2026 03:57
5ae3106 to
d279f3d
Compare
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.
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: itsContinueskipped later handlers, it was refused on fork and exec, and it failed withEAGAINpast a fixed cap.Design
Deferresumes where it was returned. A deferred handler'sContinueruns 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.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.write_child_memandwrite_child_mem_forcehold 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.Continue, so no handler can defer a fork after it was counted, and a failed response rolls the slot back.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 withoutWAIT_KILLABLE_RECV) restarts its syscall onto a write not yet done.sandlock_handler_set_deferredis 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 withEIOinstead of touching freed userdata. Python handlers, sync or async, run off the loop with no flag.Behavior changes
Continuenow hands the syscall to the next handler instead of ending the chain.EIOafter 30 seconds.sandlock_handler_set_deferredis gone from the C ABI and the Python SDK.Known gaps
Test plan
continue_()reaches the next handler on the syscall🤖 Generated with Claude Code