diff --git a/src/runtime/fork-state.c b/src/runtime/fork-state.c index 36ed96e7..40dfd65b 100644 --- a/src/runtime/fork-state.c +++ b/src/runtime/fork-state.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -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) @@ -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); + } while (pret < 0 && errno == EINTR); + if (pret < 0) return -1; - sent += chunk; + if (!(pfd.revents & POLLOUT) && + (pfd.revents & (POLLHUP | POLLERR | POLLNVAL))) { + errno = EPIPE; + return -1; + } + if (++stalled > FORK_IPC_SEND_STALL_LIMIT) + usleep(FORK_IPC_SEND_BACKOFF_US); } return 0; } diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index b2fcd6bb..c87ac3eb 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -1542,6 +1542,28 @@ 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. + * + * Failing the clone beats continuing without it: proceeding would leave the + * host one dead child away from being killed by a signal it never handles, + * which is worse than the guest seeing a fork it can retry. + */ + int nosigpipe = 1; + if (setsockopt(sock_fds[0], SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, + sizeof(nosigpipe)) < 0 || + setsockopt(sock_fds[1], SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, + sizeof(nosigpipe)) < 0) { + log_error("clone: SO_NOSIGPIPE failed: %s", strerror(errno)); + close(sock_fds[0]); + close(sock_fds[1]); + return -LINUX_ENOMEM; + } if (is_vfork && pipe(vfork_notify_fds) < 0) { log_error("clone: vfork notify pipe failed: %s", strerror(errno)); close(sock_fds[0]);