Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/runtime/fork-state.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -48,12 +49,20 @@ int fork_ipc_read_all(int fd, void *buf, size_t len)
*/
#define FORK_IPC_FD_CHUNK 120

/* Consecutive waits that fail to place a chunk before the sender starts
* sleeping between attempts, and how long it then sleeps. Only reached if a
* platform asserts POLLOUT with less room free than one control message needs.
*/
#define FORK_IPC_SEND_STALL_LIMIT 8
#define FORK_IPC_SEND_BACKOFF_US 1000

int fork_ipc_send_fds(int sock, const int *fds, int count)
{
if (count <= 0)
return 0;

int sent = 0;
int stalled = 0;
while (sent < count) {
int chunk = count - sent;
if (chunk > FORK_IPC_FD_CHUNK)
Expand Down Expand Up @@ -88,10 +97,49 @@ int fork_ipc_send_fds(int sock, const int *fds, int count)
memcpy(CMSG_DATA(cmsg), fds + sent, (size_t) chunk * sizeof(int));

ssize_t ret = sendmsg(sock, &msg, 0);
int send_errno = errno;
free(cmsg_buf);
if (ret < 0)
if (ret >= 0) {
sent += chunk;
stalled = 0;
continue;
}
if (send_errno != EMSGSIZE) {
errno = send_errno;
return -1;
}

/* EMSGSIZE here is backpressure, not an oversized message: the chunk is
* fixed and small, so the only way it does not fit is that the peer has
* not drained yet. A control message that does not fit is refused
* outright rather than queued, so a blocking sendmsg reports EMSGSIZE
* where a data-only write would block. The receiver is the freshly
* cloned child, draining concurrently, so waiting for writability and
* retrying the same chunk converges. Waiting without a deadline matches
* fork_ipc_write_all, which blocks on the same socket for the same
* peer.
*
* POLLOUT is asserted only once SO_SNDLOWAT bytes are free (2048 on
* macOS, against 492 for a full chunk), so poll blocks here rather than
* returning writable on room too small to use. That is a platform
* tunable, not a guarantee, so sleep once waiting stops making
* progress: a smaller lowat can then only slow the transfer, never spin
* a core.
*/
struct pollfd pfd = {.fd = sock, .events = POLLOUT};
int pret;
do {
pret = poll(&pfd, 1, -1);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} while (pret < 0 && errno == EINTR);
if (pret < 0)
return -1;
sent += chunk;
if (!(pfd.revents & POLLOUT) &&
(pfd.revents & (POLLHUP | POLLERR | POLLNVAL))) {
errno = EPIPE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new wait-and-retry path can now attempt a sendmsg to a freshly cloned child that died while the parent was blocked in poll. That sendmsg returns EPIPE, and the fork IPC socket was created with a bare socketpair(AF_UNIX, SOCK_STREAM, ...) in forkipc.c with no SO_NOSIGPIPE — unlike every other socket in syscall/net.c, which sets it. Host SIGPIPE is unblocked and uses its default terminate disposition (only SIGUSR2/SIGALRM are masked at bring-up), so an EPIPE here raises SIGPIPE on the whole process instead of just failing the clone cleanly. Before this change the sender returned EMSGSIZE quickly and never waited on the child, so this EPIPE/SIGPIPE outcome is newly reachable through the retry loop. Consider setting SO_NOSIGPIPE on the fork socketpair where it is created (forkipc.c), or suppress the signal around the sendmsg retry, so a dead child surfaces as an error return rather than terminating the host.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime/fork-state.c, line 131:

<comment>The new wait-and-retry path can now attempt a `sendmsg` to a freshly cloned child that died while the parent was blocked in `poll`. That `sendmsg` returns `EPIPE`, and the fork IPC socket was created with a bare `socketpair(AF_UNIX, SOCK_STREAM, ...)` in forkipc.c with no `SO_NOSIGPIPE` — unlike every other socket in syscall/net.c, which sets it. Host `SIGPIPE` is unblocked and uses its default terminate disposition (only `SIGUSR2`/`SIGALRM` are masked at bring-up), so an `EPIPE` here raises `SIGPIPE` on the whole process instead of just failing the clone cleanly. Before this change the sender returned `EMSGSIZE` quickly and never waited on the child, so this `EPIPE`/`SIGPIPE` outcome is newly reachable through the retry loop. Consider setting `SO_NOSIGPIPE` on the fork socketpair where it is created (forkipc.c), or suppress the signal around the `sendmsg` retry, so a dead child surfaces as an error return rather than terminating the host.</comment>

<file context>
@@ -88,10 +89,48 @@ int fork_ipc_send_fds(int sock, const int *fds, int count)
-        sent += chunk;
+        if (!(pfd.revents & POLLOUT) &&
+            (pfd.revents & (POLLHUP | POLLERR | POLLNVAL))) {
+            errno = EPIPE;
+            return -1;
+        }
</file context>

@doanbaotrung doanbaotrung Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding (SIGPIPE) — valid, and worse than described. Fixed.

Confirmed by direct test: sendmsg to a dead peer on a bare socketpair(AF_UNIX, SOCK_STREAM) kills the process — the harness exited 141 (128+13), and the line after the send never printed. The setup is exactly as stated: syscall/net.c sets SO_NOSIGPIPE on every guest socket, forkipc.c set it on none, and bring-up masks only SIGUSR2/SIGALRM ([proc.c:2981](third_party/elfuse/src/syscall/proc.c:2981)), so SIGPIPE keeps its default terminate disposition on vCPU threads.

One correction to the framing: this isn't newly reachable. The first sendmsg could always race a dying child, and fork_ipc_write_all's plain write() on the same socket has the same exposure and predates this PR. What the retry loop changes is the width of the window — it now deliberately waits on the child. So the bug is pre-existing, this PR makes it far more likely to fire, and the fix closes both paths at once.

Fixed at the socketpair rather than around the retry: SO_NOSIGPIPE on both ends in [forkipc.c](third_party/elfuse/src/runtime/forkipc.c:1541). It rides on the file description, so the spawned child inherits it. Re-ran the same test with the option set: A: survived, errno=Broken pipe, exit 0. A dead child now fails the clone instead of terminating the host.

return -1;
}
if (++stalled > FORK_IPC_SEND_STALL_LIMIT)
usleep(FORK_IPC_SEND_BACKOFF_US);
}
return 0;
}
Expand Down
13 changes: 13 additions & 0 deletions src/runtime/forkipc.c
Original file line number Diff line number Diff line change
Expand Up @@ -1542,6 +1542,19 @@ int64_t sys_clone(hv_vcpu_t vcpu,
log_error("clone: socketpair failed: %s", strerror(errno));
return -LINUX_ENOMEM;
}

/* A fork-child that dies mid-handshake makes every send on this socket
* raise SIGPIPE, which elfuse leaves at its default terminate disposition
* (only SIGUSR2 and SIGALRM are masked at bring-up), so the whole host
* process would die instead of the clone failing. Suppress it per-socket
* the way syscall/net.c does for guest sockets; the option rides on the
* file description, so the spawned child inherits it.
*/
int nosigpipe = 1;
setsockopt(sock_fds[0], SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I think it is better to check the return value of setsockopt().

sizeof(nosigpipe));
setsockopt(sock_fds[1], SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe,
sizeof(nosigpipe));
if (is_vfork && pipe(vfork_notify_fds) < 0) {
log_error("clone: vfork notify pipe failed: %s", strerror(errno));
close(sock_fds[0]);
Expand Down
Loading