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.
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 returningEAGAINas POSIX requires. The libkrun host side already supports non-blocking accepts —tsi_stream.rs'saccept()replies-EWOULDBLOCKwhenreq.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
accept4in a loop after each readiness event, expectingEAGAINon queue exhaustion. Under TSI the final call blocks forever, the loop thread never returns toepoll_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
Minimal repro (python3 in the guest)
All three cases block >5 s where POSIX requires immediate
EAGAIN/EWOULDBLOCK:Case 2 is precisely what Bun/usockets does after accepting a connection; the second
accept4is the wedge.Observed behavior (strace of opencode 1.18.9 in-guest)
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 repliesEWOULDBLOCKwithout the flag).We also confirmed no userspace workaround exists: feeding the listener a connection every 100 ms just moves the block to the next
accept4call — the event loop never regains control by construction.Expected behavior
accept4on a listener withO_NONBLOCK(viaSOCK_NONBLOCKorfcntl) returns-EAGAINwhen the backlog is empty, matching regular kernel TCP and matching what the libkrun host side already implements.Suggested fix locus
tsi_acceptin the guest patch should read the socket's non-blocking status and forward it asO_NONBLOCKin the accept request flags, letting the existing host-side-EWOULDBLOCKpath do its job.Possibly related: #89 (lock starvation in
tsi_acceptwhile blocked in accept) — a fix that restructurestsi_accept's blocking behavior might address both.Happy to test a patched libkrunfw build against both the python repro and the real Bun workload.