Skip to content

client: set TCP_NODELAY on accepted sockets - #34

Open
VizzleTF wants to merge 1 commit into
openwrt:masterfrom
VizzleTF:client-tcp-nodelay
Open

client: set TCP_NODELAY on accepted sockets#34
VizzleTF wants to merge 1 commit into
openwrt:masterfrom
VizzleTF:client-tcp-nodelay

Conversation

@VizzleTF

Copy link
Copy Markdown

Summary

uhttpd does not set TCP_NODELAY on accepted sockets, and it writes a response as one write() per header line plus one for the body. The kernel then holds the small final segment: tcp_nagle_check() lets a partial segment out only if TCP_NODELAY or TCP_CORK is set, or every small packet sent so far has been acknowledged (tcp_minshall_check()). On a keep-alive connection the previous response is still unacknowledged, so the next small body waits for the peer's delayed ACK. Every request after the first on a connection pays it.

Reproducer

A static file and plain curl, nothing else:

printf hello > /www/tiny.txt
curl -s -o /dev/null -w 'req1 %{time_total}s\n' http://ROUTER/tiny.txt \
  --next -s -o /dev/null -w 'req2 %{time_total}s\n' http://ROUTER/tiny.txt \
  --next -s -o /dev/null -w 'req3 %{time_total}s\n' http://ROUTER/tiny.txt
for i in 1 2 3; do curl -s -o /dev/null --no-keepalive \
  -w 'fresh %{time_total}s\n' http://ROUTER/tiny.txt; done
25.12 (x86_64) 24.10.7 (uhttpd 2025.07.06~7e64e8ba-r4)
req1, first on the connection 0.4 ms 1.0 ms
req2 41.5 ms 42.2 ms
req3 44.6 ms 48.8 ms
fresh connection each time 0.3-0.4 ms 0.24-0.33 ms

strace shows uhttpd is not the slow part: it wrote response 2 within 0.4 ms of response 1, and the client's next request arrived 42.5 ms after that write. The window is the peer's delayed ACK timer, so it depends on the client. These are Linux peers.

With the patch

Both builds come from the 25.12.5 release SDK, and the unpatched one is byte-identical to the published uhttpd-2026.06.16~7b1bec45-r1.apk. Same box, http_keepalive at its default 20:

3 sequential requests, one connection req1 req2 req3
unpatched 0.35 ms 42.9 ms 44.8 ms
patched 0.32 ms 0.087 ms 0.075 ms

LuCI is where it shows, since its ubus replies run a few hundred bytes and a page issues several. Warm in-place navigation over five config pages went from 540 ms to 284 ms with keep-alive untouched.

uci set uhttpd.main.http_keepalive=0 clears the stall at http too, but keep-alive matters for TLS, and with it off six HTTPS page loads at 120 ms RTT went from 9.8 s to 29.4 s.

Trade-off

Each of the eight writes now leaves as its own segment. Counted with tcpdump: four sequential requests to that static file go from 13 to 32 server-to-client segments, and a full LuCI page load from 752 to 1211 packets for the same payload. A few dozen extra packets per page view against 40 ms stalls.

uh_accept_client() leaves the accepted socket with Nagle enabled, and a
response is emitted as one write() per header line plus one for the body.
The kernel therefore holds the small final segment: tcp_nagle_check()
allows a partial segment only if TCP_NODELAY is set, TCP_CORK is set, or
every small packet sent so far has been acknowledged (Minshall's
modification, tcp_minshall_check()). On a connection that has just
answered a request, the previous response is still unacknowledged, so the
next small body waits for the peer's delayed ACK. Every request after the
first on a keep-alive connection pays that.

Reproduced without LuCI, ubus, a session or a browser - a 5-byte static
file and four sequential requests on one connection, on 25.12 (x86_64) and
24.10.7 (uhttpd 2025.07.06~7e64e8ba-r4):

  req1 0.4 ms / 1.0 ms      (first on the connection)
  req2 41.5 ms / 42.2 ms
  req3 44.6 ms / 48.8 ms
  req4 44.6 ms / -
  fresh connection each time: 0.3-0.4 ms / 0.24-0.33 ms

strace shows uhttpd is not the slow part: it wrote response 2 within 0.4 ms
of response 1, while the client's next request only arrived 42.5 ms after
that write. The stall window is the peer's delayed ACK timer, so it depends
on the client; the figures above are Linux peers.

Both builds come from the 25.12.5 release SDK, and the unpatched build is
byte-identical to the published uhttpd-2026.06.16~7b1bec45-r1.apk, so the
before column is what is shipping. Same box, http_keepalive at its default:

  unpatched  0.35 / 42.9  / 44.8  / 44.8  ms
  patched    0.32 / 0.087 / 0.075 / 0.075 ms

LuCI is where this is visible, since its ubus JSON-RPC replies run a few
hundred bytes and a page issues several: warm in-place navigation over five
config pages went from 540 ms to 284 ms with keep-alive untouched.

The trade-off is packet count, and it is measured rather than assumed.
Disabling Nagle lets each of the eight writes leave as its own segment: the
four-request reproducer goes from 13 to 32 server-to-client segments, and a
full LuCI page load from 752 to 1211 packets for the same payload. For an
admin interface that is a few dozen extra packets per page view against
40 ms stalls, but the real answer is to coalesce the response into fewer
writes as well - nginx does both, enabling TCP_NODELAY when a connection
enters the keep-alive state. Worth noting for that: ustream already carries
a `more` flag from ustream_write() down to the backend, and ustream_fd_write()
ignores it, using write() rather than send() with MSG_MORE - while
ustream_vprintf() hardcodes more=false, so no printf-based header emission
can express it today.

Signed-off-by: VizzleTF <vizzlef@gmail.com>
@VizzleTF VizzleTF changed the title даваclient: set TCP_NODELAY on accepted connections client: set TCP_NODELAY on accepted sockets Jul 26, 2026
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.

1 participant