udp: ENOBUFS mapped to UV_EAGAIN causes infinite busy-loop on BSD when interface TX queue is full
In src/unix/udp.c, uv__udp_sendmsgv() and uv__udp_sendmsg1() treat ENOBUFS the same as EAGAIN/EWOULDBLOCK:
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOBUFS)
r = UV_EAGAIN;On UV_EAGAIN, uv__udp_sendmsg() returns without completing the request — the datagram stays on write_queue, the completion callback is not called, and POLLOUT remains armed (because uv__udp_run_completed() only disarms POLLOUT when write_queue is empty).
This is correct for EAGAIN/EWOULDBLOCK, which mean "socket send buffer full." Waiting for POLLOUT (writable) is the right way to make progress in that case.
It is wrong for ENOBUFS on BSD. On FreeBSD (and likely other BSDs), UDP sendmsg() can return ENOBUFS from the interface output queue (if_transmit / drbr_enqueue), not the socket send buffer. The socket send buffer still has room — so the kernel correctly keeps reporting the socket as writable. EVFILT_WRITE / POLLOUT re-fires immediately, uv__udp_io() re-runs uv__udp_sendmsg(), sendmsg() returns ENOBUFS again, the request is retained again — a tight busy-loop with zero forward progress. The application is never notified (no callback, no error).
Impact
Any libuv application doing UDP sends toward destinations reachable over a congested or saturated egress path can wedge an event-loop thread at 100% CPU indefinitely on FreeBSD. We hit this in production via ISC BIND 9 (DNS on FreeBSD 14.3): named pegged all cores at ~0 successful queries/sec with millions of failed sendmsg() per second, causing box-wide transmit starvation. It self-sustains and does not recover until the egress path clears or the socket is closed.
BIND added its own mitigation (GL #4930) — drop new sends when uv_udp_get_send_queue_size() exceeds a threshold. But that only caps queue growth; the requests already retained by libuv keep spinning, because the retain/retry path is entirely inside libuv and never surfaces a completion callback.
Reproducer
Tested on FreeBSD 14.3-RELEASE-p13 and 14.4-RELEASE-p4. Uses a tap interface with a static ARP entry and no reader — the interface queue fills, then every sendmsg() returns ENOBUFS while the socket remains writable.
Full reproducer source: enobufs_repro.c — ~200 lines, no libuv dependency, raw BSD sockets + kqueue.
Setup:
TAPDEV=$(ifconfig tap create)
ifconfig $TAPDEV inet 10.199.199.1/30 up
# open /dev/tapN for carrier, inject ARP reply, don't read:
./tap_helper <unit> &
# (or: arp -s 10.199.199.2 00:11:22:33:44:55 on FreeBSD 14.3)Run:
cc -O2 -o enobufs_repro enobufs_repro.c -lpthread
./enobufs_repro -t 4 -s 10 10.199.199.2Results:
| FreeBSD | Total ENOBUFS | Writable during ENOBUFS | EAGAIN |
|---|---|---|---|
| 14.3-RELEASE-p13 | 7,112,279 | 7,112,279 (100.0%) | 0 |
| 14.4-RELEASE-p4 | 19,942 | 19,942 (100.0%) | 0 |
100% of ENOBUFS events occurred with the socket reported as writable by kqueue. Zero socket-buffer EAGAIN.
Expected behavior
ENOBUFS on a UDP send should not be treated as socket-buffer backpressure that POLLOUT can clear. The request should be completed with an error (UV_ENOBUFS) so the application can apply its own policy — typically dropping the datagram (UDP is best-effort) or retrying with backoff.
Actual behavior
ENOBUFS → UV_EAGAIN → request retained on write_queue, POLLOUT stays armed, socket stays writable → tight uv__io_poll ↔ uv__udp_sendmsg spin, no callback, no progress.
Proposed fix
Remove || errno == ENOBUFS from the UV_EAGAIN mapping in uv__udp_sendmsg1() and uv__udp_sendmsgv(). ENOBUFS then falls through to the normal error path: the request is completed with UV_ENOBUFS status, the callback fires, the queue drains, and POLLOUT disarms. This is safe because:
- UDP is best-effort — dropping an undeliverable datagram is correct
- The application gets notified and can retry with backoff if desired
- It matches the behavior for all other send errors (not just EAGAIN/EWOULDBLOCK)
Notes
- The ENOBUFS-in-the-EAGAIN-set was added in libuv 1.14.1 (2017, Kamil Rytarowski). The original context was likely NetBSD socket-buffer ENOBUFS, where waiting for POLLOUT is correct. The bug is that this conflates socket-buffer pressure with interface-queue saturation on FreeBSD.
- Possibly related to #3486 (proposal to remove
uv_udp_sendin v2). - We have not verified whether Linux exhibits the same spin — its writability semantics under qdisc/interface pressure differ from BSD. Maintainer input welcome.
Source: libuv/libuv