control: hold the sandbox name with no fd, so no fork can keep it - #244
Closed
congwang-mk wants to merge 3 commits into
Closed
congwang-mk wants to merge 3 commits into
congwang-mk wants to merge 3 commits into
Conversation
A sandbox name is an abstract unix socket, bound while any fd refers to it. fork() copies every fd, and sandlock could only close the copies in the children it forks itself. A host that spawns processes of its own holds the name of every live sandbox in each child until that child execs, and a child that never execs holds it for life. Reusing a name in that window failed with "sandbox is already running"; under the parallel integration suite this broke the test_control name reuse tests in about one run out of four. Userspace cannot stop such a copy from existing: Linux has no close-on-fork, atfork handlers miss vfork, posix_spawn, raw clone and _Fork(), and an abstract name cannot be unbound. So the name socket no longer lives in an fd table at all. The child creates it, binds it and calls listen() on it, then sends it to the supervisor with SCM_RIGHTS, and the supervisor leaves the message unreceived. A file in flight is one reference, which fork does not multiply, and receiving the message with no room for the fd makes the kernel drop it, so the name is free the moment the supervisor lets go, whoever copied its fds. Drop now frees the name as promptly as wait() does. A socket held that way cannot be accepted on, so the roles split. The name socket is the mutex and carries the child's pid, which is its process group; this replaces the pgrp socket. Requests go to a second socket named after the child's pid, which clients learn from the name socket. That one sits in the fd table, but its name is never asked for twice, and it refuses connections from the moment it is released, so a lingering copy neither blocks anything nor looks alive. The child binds the name, so a collision is now found just after the fork rather than before it. The live fd registry and fork_without_control_fds are gone, and so is the fd layout test for the inherited pgrp socket, which the child no longer has. Known limits: nobody accepts on the name socket, so each pid lookup stays in its backlog for the life of the sandbox, which caps lookups at somaxconn. If the supervisor is killed, the name lives until the host's other children exec or exit, as before. Signed-off-by: Cong Wang <cwang@multikernel.io>
Nobody accepts on the name socket, so every connection to it stays in its backlog until the sandbox ends. Each ps spent three of those per sandbox, and a full backlog makes a blocking connect wait forever: a sandbox polled once a second would have had ps, inspect and kill hang on it after about twenty minutes. Only kill needs what the name socket offers, a child pid the kernel vouches for. Everything that gets polled now finds the request socket in /proc/net/unix, which ps reads anyway, and takes the child's pid from that socket's name, so it costs the sandbox nothing however often it runs. A released request socket can still be listed while a copy of its fd lingers somewhere; it refuses connections, so it is skipped. The connect to the name socket is now non-blocking, so a backlog that does fill up, after some four thousand kills of one sandbox, is an error message and not a hang. Signed-off-by: Cong Wang <cwang@multikernel.io>
The child binds the name, so a collision is found after the fork, and the losing child was left for drop to kill. Make the failure look like one from before the fork instead: a child whose publish fails exits at once, having run nothing, and the parent reaps it on the spot and clears its pid, so drop cannot signal a pid that has since been reused. Any publish failure now fails the create, not only a taken name. The one to expect is ETOOMANYREFS: every live sandbox keeps one fd in flight, and the kernel caps those per user at the sender's RLIMIT_NOFILE unless it has CAP_SYS_RESOURCE. That used to leave the sandbox running with no name, so no mutex and no way to find it; it is now an error that names the limit. The child also reports a send the kernel refused, which it did not before. Signed-off-by: Cong Wang <cwang@multikernel.io>
This was referenced Sep 21, 2026
Contributor
Author
|
Superseded by #256 |
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.
A sandbox name is an abstract unix socket, and an abstract name stays bound while any fd refers to its socket.
fork()copies every fd, and sandlock could only close the copies in the children it forks itself. A host that spawns processes of its own therefore holds the name of every live sandbox in each child until that child execs, and a child that never execs holds it for life. Reusing a name in that window fails withsandbox '<name>' is already running.Under the parallel integration suite this broke
test_control_name_is_free_when_wait_returnsandtest_control_parked_child_does_not_pin_other_namesin about one run out of four: other tests spawnsandlock psin polling loops, and each spawn sits between fork and exec for a moment with a copy of every control fd. Any embedder that spawns processes while reusing sandbox names can hit the same thing.Supersedes #243, whose wait for the last copy to go away can block forever on a child that never execs.
Why not something simpler
Userspace cannot stop such a copy from existing. Linux has no close-on-fork, atfork handlers miss vfork, posix_spawn, raw clone and
_Fork(), an abstract name cannot be unbound, and seccomp on the host could not touch the child's fd table. The remaining ways to keep the fd out of reach were a thread with a private fd table or a file lock on disk, and this had to stay thread free and diskless.The design
The name socket is held with no fd. The child creates
sandlock/<uid>/<name>, binds it, callslisten()on it, and sends it to the supervisor withSCM_RIGHTS. The supervisor leaves that message unreceived (NameVault). A file in flight is one reference, which fork does not multiply: a forked child copies the socketpair's fds, not what is queued in it. Receiving the message with no room for the fd makes the kernel drop it, so the name is free the moment the supervisor lets go, whoever copied its fds.Dropnow frees the name as promptly aswait().The roles split, because a socket held that way cannot be accepted on:
sandlock/<uid>/<name>/<child pid>. It sits in the fd table, since it needsaccept(), but its name is never asked for twice, and it refuses connections from the moment it is released, so a lingering copy neither blocks anything nor looks alive.Polling stays off the name socket. Nobody accepts on it, so every connection to it stays in its backlog until the sandbox ends, and a full backlog makes a blocking connect wait forever. Only
killneeds what it offers, a child pid the kernel vouches for.ps,inspectand every other request find the request socket in/proc/net/unix, whichpsreads anyway, and cost the sandbox nothing. The connect to the name socket is non-blocking, so a backlog that does fill up is an error message and not a hang.A child that cannot publish its name gives up by itself. The child binds the name, so a collision is found just after the fork. The losing child exits at once, having run nothing, and the parent reaps it on the spot and clears its pid, so the caller is left where a failure before the fork would have left it.
The live fd registry and
fork_without_control_fdsare gone.Known limits
RLIMIT_NOFILEunless it hasCAP_SYS_RESOURCE. Past that, creating a sandbox fails with an error that names the limit.killstays in the name socket's backlog for the life of the sandbox, which caps kills of one sandbox atsomaxconn(4096 by default).psshows comes from the request socket's name, which the supervisor chose, not from a kernel stamp.killstill uses the stamp./proc/net/unix. A nested sandlock that sees it virtualized was not tested.What would make this unnecessary
A close-on-fork flag. POSIX.1-2024 has
O_CLOFORK,FD_CLOFORKandSOCK_CLOFORK, and FreeBSD 15 and NetBSD 11 implement them. Linux does not:dup_fd()copies every fd unconditionally, and the patches posted in 2011 and 2020 were not merged.With such a flag both sockets would go back to being ordinary fds that the supervisor binds before the fork, flagged close-on-fork, and no child of the host could copy them, however it was made. Every limit above would go with it:
/proc/net/unix, andpsreads the child's pid from a kernel stamp again;The child would still create the socket that carries its pid and pass it up, received with
MSG_CMSG_CLOFORKso that no window opens. This assumes the flag is honoured wherever an fd table is copied, which for Linux includesunshare(CLONE_FILES). Until the minimum supported kernel has such a flag, this PR is the userspace way to get the same property.Tests
a_fork_made_by_the_host_cannot_hold_the_name(unit) andtest_control_name_survives_a_lingering_host_child(integration): a rawclonechild that copies every fd and never execs is alive while the name is reused right after release. The reuse succeeds and nothing waits for the child.only_kernel_stamped_lookups_use_up_the_name_socket: fills the name socket's backlog, gets the error instead of a hang, and the listing path still works.the_in_flight_limit_is_an_error_and_leaves_the_name_free: pushes the user's in-flight count past a loweredRLIMIT_NOFILEand getsETOOMANYREFS, with the name left free.test_control_name_collision_leaves_no_child: a secondcreate()on a taken name fails, reports no pid, and leaves the owner running.a_released_request_socket_refuses_connections,finds_the_request_sockets_of_one_name, and the existing control tests moved to the new API.test_control_pgrp_published_before_extra_fd_dup2is removed: the child no longer inherits a pgrp fd, so the collision it guarded cannot happen. What still matters is kept intest_control_created_sandbox_publishes_its_pids.🤖 Generated with Claude Code