Skip to content

TSI: accept4() on an O_NONBLOCK listener blocks instead of returning EAGAIN, permanently wedging Bun/usockets-based servers #137

Description

@panghy

Summary

In a libkrun guest with TSI networking, accept4(2) on a non-blocking AF_INET listener blocks indefinitely when the backlog is empty, instead of returning EAGAIN as POSIX requires. The libkrun host side already supports non-blocking accepts — tsi_stream.rs's accept() replies -EWOULDBLOCK when req.flags & O_NONBLOCK — but the guest-kernel TSI patch (0010-Transparent-Socket-Impersonation-implementation.patch) never propagates the listener's non-blocking status into the accept request, so the flag never reaches the host. (The connect path documents dropping the flag deliberately — "We can't honor O_NONBLOCK semantics here…" — the accept path appears to inherit the same omission.)

This permanently deadlocks any runtime using the standard accept-until-EAGAIN pattern on its event-loop thread. Bun/usockets is the concrete case: Bun's event loop calls accept4 in a loop after each readiness event, expecting EAGAIN on queue exhaustion. Under TSI the final call blocks forever, the loop thread never returns to epoll_pwait, and the whole process wedges. Real-world impact: opencode acp (Bun-based) hangs unconditionally in libkrun guests. Node/libuv-based servers are unaffected because libuv only calls accept on epoll readiness, one per event, never issuing an empty-queue accept.

Environment

  • macOS ARM64 host (Apple Silicon), libkrun with TSI networking (guest launched via krunvm)
  • Guest: aarch64 Linux with the bundled libkrunfw kernel
  • Reproduced with a stock python3 in the guest — no third-party software required

Minimal repro (python3 in the guest)

All three cases block >5 s where POSIX requires immediate EAGAIN/EWOULDBLOCK:

import socket, fcntl, os

# Case 1: SOCK_NONBLOCK listener, empty queue -> accept() should raise BlockingIOError
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM | socket.SOCK_NONBLOCK)
s.bind(("127.0.0.1", 4096)); s.listen(8)
s.accept()   # BLOCKS indefinitely under TSI; EAGAIN expected

# Case 2 (Bun's exact sequence): one pending connection, accept it (returns
# immediately), then accept again on the now-empty queue -> BLOCKS

# Case 3: blocking socket, then fcntl(F_SETFL, O_NONBLOCK) -> accept still BLOCKS

Case 2 is precisely what Bun/usockets does after accepting a connection; the second accept4 is the wedge.

Observed behavior (strace of opencode 1.18.9 in-guest)

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 22
bind(22, {127.0.0.1:4096}) = 0 ; listen(22, 512) = 0
[T570] connect(25, {127.0.0.1:4096}) = 0        <- self-connection (SSE stream)
accept4(22, …) = 26                             <- accepts it
accept4(22, …                                   <- blocks 68 s on the empty queue
<... accept4 resumed> = ? ERESTARTSYS           <- returns only on SIGTERM

At the stall, the blocked thread's wchan is __skb_wait_for_more_packets — the guest TSI driver waiting for an accept-reply datagram from the host that will never arrive (the host was never told the accept is non-blocking, and no code path replies EWOULDBLOCK without the flag).

We also confirmed no userspace workaround exists: feeding the listener a connection every 100 ms just moves the block to the next accept4 call — the event loop never regains control by construction.

Expected behavior

accept4 on a listener with O_NONBLOCK (via SOCK_NONBLOCK or fcntl) returns -EAGAIN when the backlog is empty, matching regular kernel TCP and matching what the libkrun host side already implements.

Suggested fix locus

tsi_accept in the guest patch should read the socket's non-blocking status and forward it as O_NONBLOCK in the accept request flags, letting the existing host-side -EWOULDBLOCK path do its job.

Possibly related: #89 (lock starvation in tsi_accept while blocked in accept) — a fix that restructures tsi_accept's blocking behavior might address both.

Happy to test a patched libkrunfw build against both the python repro and the real Bun workload.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions