Stop a listen backlog of 1 losing SIGURG
Fixes #402.
The last case of tests/test-sigio.c ("SIGURG delivered on TCP out-of-band byte without O_ASYNC") failed in about half of the runs. The watcher in src/syscall/asyncio.c is not at fault. In a failing run the knote is armed and no EVFILT_EXCEPT event arrives, because the accepted host socket has no urgent mark to report: TCP_CONNECTION_INFO shows the byte received (tcpi_rxbytes is 1), but SIOCATMARK reports 0 and recv(MSG_OOB | MSG_PEEK) fails with EINVAL. When the mark is set, the event, signal_queue and the guest handler all follow.
The trigger is the host backlog. The test calls listen(lst, 1) and sys_listen handed that value to the host. On macOS 27.0.1 a TCP socket accepted from a listener with backlog 1 loses the urgent mark of the first urgent byte on about half of the connections. This program shows it with plain blocking sockets and no elfuse:
/* cc -o oob-backlog oob-backlog.c && ./oob-backlog 200 1 */
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <unistd.h>
int main(int argc, char **argv)
{
int runs = argc > 1 ? atoi(argv[1]) : 200;
int backlog = argc > 2 ? atoi(argv[2]) : 1;
int prime = argc > 3 ? atoi(argv[3]) : 0;
int missing = 0;
for (int i = 0; i < runs; i++) {
struct sockaddr_in a = {0};
socklen_t alen = sizeof(a);
char c;
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int lst = socket(AF_INET, SOCK_STREAM, 0);
if (bind(lst, (struct sockaddr *) &a, sizeof(a)) < 0 ||
listen(lst, backlog) < 0 ||
getsockname(lst, (struct sockaddr *) &a, &alen) < 0)
return 2;
int cli = socket(AF_INET, SOCK_STREAM, 0);
if (connect(cli, (struct sockaddr *) &a, sizeof(a)) < 0)
return 2;
int srv = accept(lst, NULL, NULL);
if (prime) { /* one in-band byte first */
send(cli, "a", 1, 0);
recv(srv, &c, 1, 0);
}
send(cli, "!", 1, MSG_OOB);
usleep(20000);
if (recv(srv, &c, 1, MSG_OOB | MSG_DONTWAIT) != 1)
missing++;
close(srv);
close(cli);
close(lst);
}
printf("backlog=%d prime=%d runs=%d urgent byte missing=%d\n",
backlog, prime, runs, missing);
return missing != 0;
}
On this host, 200 connections per run:
./oob-backlog 200 1: 94 to 111 connections lose the byte (six runs)../oob-backlog 200 0,200 2,200 128: none../oob-backlog 200 1 1(one in-band byte received first): none.
The loss is per connection: a second urgent byte on the same connection had the same result as the first on 200 of 200 connections. Why the kernel does this is not established; reading the sequence numbers on lo0 needs root.
The second commit gives every listener Linux's accept-queue depth. Linux lets backlog + 1 connections wait (sk_acceptq_is_full, unix_recvq_full), and macOS lets exactly backlog wait. Measured on the host for TCP and AF_UNIX alike, backlog 1 to 128 queues exactly that many, and 0, a negative value or anything above 128 queues 128 (kern.ipc.somaxconn). So listen(fd, 4) queued 4 instead of 5, and listen(fd, 0) queued 128 instead of 1.
sys_listen passes backlog + 1 for every backlog from 0 up and passes a negative one through; both kernels read a negative backlog as somaxconn. On AF_INET and AF_INET6 host backlog 1 is never used, for the urgent-mark loss above, so a TCP listen(fd, 0) queues 2 where Linux queues 1. AF_UNIX gets the Linux depth exactly.
tests/test-listen-backlog.c connects without accepting and counts the connections that complete. The AF_UNIX cases check the exact depth. The TCP cases check that backlog + 1 fit and set no upper bound, because macOS TCP sometimes completes one or two connections past its queue: 1 run in 30 with plain host sockets, and 10 of 80 TCP cases over 40 runs under elfuse.
Rebased on main at 8612033.
./build/elfuse build/test-sigio, 40 runs onmain: 19 failed, each on the SIGURG case.- The same command, 200 runs on this branch: 0 failed.
./build/elfuse build/test-listen-backlog, 50 runs: 0 failed. Before the second commit, 3 of its 5 cases failed.test-listen-backlogon the QEMU Linux reference VM, 3 runs: 0 failed.make check: all sections pass.make test-matrix-elfuse-aarch64: 300 passed, 0 failed, 10 skipped (six need a sysroot lane, four lack a fixture).test-sigioandtest-listen-backlogpass in it.
Summary by cubic
Fixes #402 by aligning listener queue depth with Linux and avoiding a macOS 27.0.1 bug that loses the urgent mark on connections accepted from a backlog-1 listener.
sys_listenpasses backlog + 1 to the host for every backlog from 0 up, solisten(fd, 0)queues 1 andlisten(fd, 4)queues 5 onAF_UNIX, matching Linux; negative backlogs go to the host, which reads them assomaxconn.- Host backlog 1 is avoided on
AF_INETandAF_INET6because macOS 27.0.1 loses the urgent mark of the first urgent byte on about half of connections accepted from a backlog-1 listener (no SIGURG andrecv(MSG_OOB)fails), so a TCPlisten(fd, 0)queues 2 instead of 1. - The SIGURG case of
tests/test-sigio.cfailed 19 of 40 runs before and 0 of 200 with the fix. - Added
test-listen-backlog, which uses a per-runmkdtempsocket directory to avoid concurrent-run conflicts; itsAF_UNIXcases check the exact depth basket while its TCP cases check only that backlog + 1 fit because macOS TCP sometimes over-queues.