Stop a listen backlog of 1 losing SIGURG

XA
9h ago
+170-1

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 on main: 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-backlog on 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-sigio and test-listen-backlog pass 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_listen passes backlog + 1 to the host for every backlog from 0 up, so listen(fd, 0) queues 1 and listen(fd, 4) queues 5 on AF_UNIX, matching Linux; negative backlogs go to the host, which reads them as somaxconn.
  • Host backlog 1 is avoided on AF_INET and AF_INET6 because 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 and recv(MSG_OOB) fails), so a TCP listen(fd, 0) queues 2 instead of 1.
  • The SIGURG case of tests/test-sigio.c failed 19 of 40 runs before and 0 of 200 with the fix.
  • Added test-listen-backlog, which uses a per-run mkdtemp socket directory to avoid concurrent-run conflicts; its AF_UNIX cases check the exact depth basket while its TCP cases check only that backlog + 1 fit because macOS TCP sometimes over-queues.