Skip to content

Stop a listen backlog of 1 losing SIGURG - #432

Open
xalestar wants to merge 2 commits into
sysprog21:mainfrom
xalestar:fix-402-sigurg
Open

xalestar wants to merge 2 commits into
sysprog21:mainfrom
xalestar:fix-402-sigurg

Conversation

@xalestar

@xalestar xalestar commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Written for commit 1f569c6. Summary will update on new commits.

View guided diff

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 1 file

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread src/syscall/net.c Outdated
Comment thread src/syscall/net.c Outdated
* queues backlog + 1 connections (sk_acceptq_is_full) where macOS queues
* backlog, so 2 is also the queue length the guest asked for.
*/
int host_backlog = backlog == 1 ? 2 : backlog;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The second reason in the comment (Linux queues backlog + 1 where macOS queues backlog) holds for every positive backlog, yet only 1 is remapped. So listen(fd, 4) still queues one connection fewer than Linux, and a reader will take this ternary for the general rule. The remap also applies to AF_UNIX listeners, which the urgent-mark bug never touches. Either say that this is the TCP urgent-mark workaround and that matching Linux at 1 is a side effect, or, as a follow-up, map every positive backlog to backlog + 1 below the host somaxconn and pin the queue depth with a test that leaves connections unaccepted.

@xalestar xalestar Oct 14, 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.

Done in aa01142: sys_listen passes backlog + 1 for every backlog from 0 up and leaves a negative one to the host, which reads it as somaxconn the way Linux does. Host backlog 1 is avoided on AF_INET/AF_INET6 only, so AF_UNIX gets the Linux depth exactly and a TCP listen(fd, 0) queues 2 instead of 1. tests/test-listen-backlog.c leaves connections unaccepted and checks the depth; the TCP cases are a lower bound, because macOS TCP sometimes completes one or two connections past its queue even with plain host sockets.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread tests/test-listen-backlog.c Outdated
On macOS 27.0.1 (xnu-13432.1.9) 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. The connection receives the byte, but
SIOCATMARK stays 0, recv(MSG_OOB) fails with EINVAL, and EVFILT_EXCEPT
with NOTE_OOB does not fire, so the watcher in asyncio.c has no event
to turn into SIGURG. A program with plain blocking sockets and no
elfuse loses the mark on 94 to 111 of 200 connections at backlog 1,
and on none at backlog 0, 2 or 128.

sys_listen passes 2 to the host when the guest asks for 1. Linux lets
backlog + 1 connections wait (sk_acceptq_is_full) and macOS lets
exactly backlog connections wait, measured for backlog 1 to 4, so
host backlog 2 holds the 2 connections Linux holds for listen(fd, 1).

Verified: the SIGURG case of tests/test-sigio.c failed 19 of 40 runs
without the change and 0 of 200 with it.

Fixes sysprog21#402
Linux lets backlog + 1 connections wait on a listener
(sk_acceptq_is_full, unix_recvq_full) and macOS lets exactly backlog
wait, so a guest listen(fd, 4) queued one connection fewer than on
Linux, and listen(fd, 0) queued 128 instead of 1, since macOS reads 0
as somaxconn. Measured on the host, for TCP and AF_UNIX alike: backlog
1 to 128 queues exactly that many, and 0, negative, or above 128 queues
128.

sys_listen now passes backlog + 1 for every backlog from 0 up, and
leaves a negative one to the host, which reads it as somaxconn the way
Linux does. Host backlog 1 stays avoided on AF_INET and AF_INET6 only,
for the urgent-mark loss in the previous commit, so a TCP listen(fd, 0)
queues 2 where Linux queues 1. AF_UNIX gets the exact depth.

tests/test-listen-backlog.c connects without accepting and counts what
completes. The AF_UNIX cases check the exact depth. The TCP cases check
a lower bound, because macOS TCP sometimes completes one or two
connections past its queue: 1 run in 30 with plain host sockets.
@xalestar
xalestar force-pushed the fix-402-sigurg branch 2 times, most recently from aa01142 to 1f569c6 Compare October 15, 2026 15:57
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.

test-sigio loses SIGURG on a TCP out-of-band byte about half the time

2 participants