Skip to content

control: hold the sandbox name with no fd, so no fork can keep it - #244

Closed
congwang-mk wants to merge 3 commits into
mainfrom
control-parked-name
Closed

congwang-mk wants to merge 3 commits into
mainfrom
control-parked-name

Conversation

@congwang-mk

@congwang-mk congwang-mk commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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 with sandbox '<name>' is already running.

Under the parallel integration suite this broke test_control_name_is_free_when_wait_returns and test_control_parked_child_does_not_pin_other_names in about one run out of four: other tests spawn sandlock ps in 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, calls listen() on it, and sends it to the supervisor with SCM_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. Drop now frees the name as promptly as wait().

The roles split, because a socket held that way cannot be accepted on:

  • 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 sandlock/<uid>/<name>/<child pid>. It sits in the fd table, since it needs accept(), 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 kill needs what it offers, a child pid the kernel vouches for. ps, inspect and every other request find the request socket in /proc/net/unix, which ps reads 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_fds are gone.

Known limits

  • 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. Past that, creating a sandbox fails with an error that names the limit.
  • Each kill stays in the name socket's backlog for the life of the sandbox, which caps kills of one sandbox at somaxconn (4096 by default).
  • The child's pid that ps shows comes from the request socket's name, which the supervisor chose, not from a kernel stamp. kill still uses the stamp.
  • If a child pid were reused while a never-exec copy still pinned the request socket named after it, binding that request socket would collide. It needs pid wraparound, and is not handled.
  • If the supervisor is killed, its name lives until the host's other children exec or exit, as before.
  • Requests now depend on /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_CLOFORK and SOCK_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:

  • nothing is parked in flight, so there is no per-user cap;
  • the supervisor accepts on the name socket again, so there is no kill budget and no lookup through /proc/net/unix, and ps reads the child's pid from a kernel stamp again;
  • the names are fixed, so no pid sits in one and pid reuse cannot collide;
  • a taken name is found before the fork again;
  • a killed supervisor's name dies with it, since no other process ever held a copy, which is better than today.

The child would still create the socket that carries its pid and pass it up, received with MSG_CMSG_CLOFORK so that no window opens. This assumes the flag is honoured wherever an fd table is copied, which for Linux includes unshare(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) and test_control_name_survives_a_lingering_host_child (integration): a raw clone child 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 lowered RLIMIT_NOFILE and gets ETOOMANYREFS, with the name left free.
  • test_control_name_collision_leaves_no_child: a second create() 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_dup2 is removed: the child no longer inherits a pgrp fd, so the collision it guarded cannot happen. What still matters is kept in test_control_created_sandbox_publishes_its_pids.
  • Locally, one run each on the final commit: control unit tests 13 of 13, full integration suite 458 of 458, all CLI suites. Python (426) and Go passed on the first commit of this branch and were not rerun after the two follow-ups.

🤖 Generated with Claude Code

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>
@congwang-mk

Copy link
Copy Markdown
Contributor Author

Superseded by #256

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.

1 participant