Repository navigation
perf(libudx): make streams faster than Node.js (udx-native) - #36
ColbySerpa wants to merge 25 commits into
Conversation
On Windows, a UDP socket reports on its next receive that a datagram it sent earlier was not delivered: WSAECONNRESET after an ICMP "port unreachable", and WSAENETRESET after an ICMP "time to live exceeded". The socket itself still works. The receive loop of `UdxSocket` ended at the first receive error. So one datagram to a peer that had gone, or one low-TTL holepunch probe, left the socket deaf: it delivered no more datagrams, and no more packets to the streams registered on it, until another stream was registered. A DHT node shares one socket between its RPC and its streams, so on Windows a node stopped hearing replies and its streams stalled. The loop now carries on after those two errors, as libuv does (`uv__process_udp_recv_req`), and still ends on any other error. Other systems report no such error on an unconnected socket, so nothing changes for them. Tests: a socket, and a stream, still receive after a datagram from their socket was refused by a closed local port. On Windows both fail without this change, where the receive times out, and pass with it. Elsewhere they pass either way. Unit tests cover which errors the loop carries on after.
A stream sends a burst of packets each time it is woken, and what the receiving socket's buffer cannot hold until its task runs is dropped. The system defaults hold some fifty packets on Windows and under two hundred on Linux. A socket now asks for 212,992 bytes to send and 1 MiB to receive, stepping down through 512, 256 and 208 KiB to the largest the system grants, as `udx_socket_bind` does. A refusal is not an error: the socket works with what it gets. New dependency: socket2, which tokio already brings in.
C libudx sends SACK ranges as the payload of a packet that carries nothing else, with `data_offset` zero. This crate puts their length in `data_offset`, and only read ranges sent that way. A stream therefore ignored every selective acknowledgement of a C libudx or Node.js peer, and learned of a lost packet only when its retransmission timer fired. Both forms are read now. C libudx reads both already, so what this crate sends stays as it is. An acknowledgement also carried up to 50 ranges, 400 bytes, which the one-byte `data_offset` cannot say: the length wrapped around, and a peer read some of the ranges or none. It carries at most 31 now.
A stream sent far below what its path carries. Before this series, a 50 MB transfer between two programs on one machine ran at 0.1 MB/s on Windows and 0.9 MB/s on Linux (WSL2), where two Node.js programs on udx-native 1.19.2 reach about 240 MB/s and, in most runs, 24 to 48 MB/s. Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB ran at 0.4 MB/s, against Node's 8.5. Congestion control, pacing, loss detection and the timers are now ports of C libudx's udx_bbr.c, udx_rate.c, win_filter.c and the send path of udx.c, as of holepunchto/libudx 25b063b. The same transfers, written in one call, run at about 190 MB/s on Windows, 690 MB/s on Linux, and 8 to 9 MB/s through the 100 Mbit/s path. Written 64 KB at a time through UdxAsyncStream, which at this commit still waits for each write to be acknowledged, they run at 156 and 399 MB/s, and at 1.4 MB/s through the path. No public API changes. Sending The pacer never held more than two packets, and a stream with more to send waited on a 1 ms timer, which Windows fires every 15.6 ms: two packets per tick. A stream now sends as soon as an acknowledgement, a write or a timer allows: the acknowledgement it owes, then lost packets, then new data, while the pacer has tokens and the window has room. The pacer is C's: on each millisecond of the stream's clock it is refilled with the pacing rate times the milliseconds since the last refill. Everything that has arrived is processed before anything is sent, so one acknowledgement answers a whole batch of packets. Each packet drew its own before, from a task spawned for it. Loss detection Loss was found from gaps below the highest selective acknowledgement, on every acknowledgement. That marked a retransmitted packet lost again each time an acknowledgement arrived before its own, and every copy went out at once from a spawned task, past the pacer and the window. It is now RFC 8985 (RACK-TLP), as in C: a packet is lost when one sent after it has been delivered and the reordering window has passed. A tail loss probe finds the loss of a flight's last packets. Lost packets wait in a queue and go out through the pacer and the window. The retransmission timer resent everything in flight and doubled each time, so a stream whose peer had gone failed after 61 seconds. It now resends the packets that have timed out and is set to twice the timeout, and a packet may time out six times: 13 seconds, as in C. Congestion control BBR, its rate sampling and its windowed filter are ported function by function, in C's units. Among what that corrects: the send interval of a rate sample was always zero, the round-trip time BBR saw was the time since the flight began, the window grew without bound in Startup, and a pacing rate of zero switched pacing off. Where this differs from C Rate samples are measured in microseconds. C counts whole milliseconds on its loop's clock, and gets away with it because its loop sends a pacer's worth of packets in one go with the clock standing still, so a sample spans the whole burst. Here acknowledgements are processed between batches of sends, and when a round trip takes a fraction of a millisecond a sample in whole milliseconds is either unusable (zero) or several times too low (one). With the estimate that low the pacer ran dry on every tick, and on Windows a stream whose pacer is empty waits out a 15.6 ms timer tick. The tail loss probe waits two round trips when the smoothed round-trip time rounds to 0 ms as well. C takes zero to mean that none was measured, and waits a second. Mirrored from C on purpose: the losses an acknowledgement reveals are not in its rate sample, and leaving recovery sets the window to the slow-start threshold, which is 0xffff until Drain. Tests Unit tests drive the new engine without a socket: what goes on the wire is handed back, and every step is given the time it happens at. The sustained-throughput test now has 30 seconds for its 50 MiB, which the code before needed 45 for on Linux and could not do in 120 on Windows. The limit of the unreachable-peer test follows the new 13 seconds.
`transfer` sends a number of megabytes from one program to another over one stream, and each end prints the rate it saw. The sending end can write through `UdxStream::write` or the `AsyncWrite` adapter, in writes of a given size. The receiving end has stream id 2 and the sending end stream id 1, so either end can be a Node.js program on udx-native that uses the same ids. The figures of the previous commit were measured that way.
A write was cut into packets, and the packets numbered, when it was written. The `AsyncWrite` adapter then waited for one write to be acknowledged before it took the next, and `poll_flush` waited for the last. A stream written in pieces moved one piece per round trip. A write now goes into a write queue, as in C libudx, and packets are cut from the queue as they are sent. The adapter takes a write into the queue and returns. It asks its writer to wait only while more than 262,144 bytes on top of the send window are unacknowledged, which is C's rule for `udx_stream_write` and its drain callback. `poll_flush` returns at once. `poll_shutdown` returns, as before, once the end of the stream has been acknowledged, and is now how a writer knows that everything arrived. `UdxStream::write` still returns when its data is acknowledged. Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB written 64 KB at a time through the adapter went from 1.4 MB/s to between 7.8 and 8.3 MB/s, and written 1 KB at a time it reaches 7.3 to 7.6. A Node.js sender on `udx-native` writing 64 KB at a time reaches 8.5 to 8.7 there. What follows from cutting packets late: - A packet is as large as the path's MTU allows when it goes out, so an MTU found larger during a long write applies to the rest of it. - Writes smaller than a packet share one, and the end of the stream rides on the last packet of data. - A packet is numbered when it is cut. Counting a write's packets before cutting them left a number without a packet when the MTU grew in between, and the stream then never completed another write. - The congestion window is no longer scaled when the MTU grows. It counts packets, and C leaves it alone. Since a write no longer says whether it arrived: - A dropped adapter goes on delivering what was written until it is acknowledged, where it sent each queued packet once and stopped. - A stream that gives up on its remote, or is closed, ends a waiting read, and the adapter's reads, writes, `flush` and `shutdown` report why. A test of `peeroxide` wrote to a stream and then blocked its runtime's thread while it waited for Node to receive the write. It now waits without blocking.
`SecretStream::write` wrote the three bytes of a frame's length, then the body, then flushed. Over a UDX stream the length went out in a packet of its own, and until the previous commit each of the two writes waited for the one before it to be acknowledged: two round trips for every message. The frame now goes to the transport in one write.
`secret_transfer` sends messages through a `SecretStream` on a UDX stream, which is what a connection made through the DHT is, and prints the rate. Either end can be a Node.js program that puts `@hyperswarm/secret-stream` on a `udx-native` stream. Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB in 64 KB messages go at 9.0 to 10.0 MB/s between two of it, and in 1 KB messages at 7.8 to 8.5. Two Node.js programs reach 9.3 to 9.8 and 8.3 to 9.7. Built on the commit before the write queue, it carries 64 KB messages at 0.8 MB/s and takes 83 seconds for a megabyte of 1 KB messages. Between two programs on one machine, under Linux, 50 MB in 64 KB messages go at about 700 MB/s. Two Node.js programs reach 23 to 106 MB/s.
A stream counted its MTU as the size of the UDP datagram. The largest size its search settles on is 1500, which was an IP packet of 1528 bytes, so from then on every full packet left as two fragments on any path with an MTU of 1500. As in C libudx, a stream's MTU is now the size of the whole IP packet: a full packet carries the MTU less 48 bytes over IPv4 and less 68 over IPv6 (udx__max_payload), and a probe is an IP packet of the size tried. Between two network namespaces on Linux, over a link with an MTU of 1500, 50 MB left as some 70,000 fragments, at 514 MB/s. Now it leaves in whole packets of 1500 bytes, as from udx-native, at 775 MB/s. A socket's packets are still not marked "don't fragment", so a probe that is too large for a path arrives in pieces, and on a path that carries less than 1500 bytes a stream settles on 1500 as before. A later commit takes that up for Linux. effective_mtu now means what udx-native's stream.mtu means. It still starts at 1200 and ends at 1500 on a path that carries that much. UdxAsyncStream gets the same method, and the transfer example reports it.
A sender learns of a gap from the ranges around it, and an acknowledgement lists the lowest ranges. So what a sender can repair in one round trip is what one acknowledgement names. This crate named 31 ranges at most, C libudx names 51, and a burst of a thousand losses, which the end of Startup produces on a path with a shallow queue, took some thirty round trips to repair. An acknowledgement now names as many ranges as fit a packet of the starting MTU: 144 over IPv4, 141 over IPv6. A list of more than 31 goes out in C libudx's form, with data_offset at zero and the ranges as the whole payload, which C libudx reads to any length. A list of up to 31 also says its length in data_offset, as before, which is all that releases of this crate up to 1.7.4 read. 10 MB through a path of 100 Mbit/s with a 40 ms round trip, medians of twelve runs, in MB/s, without loss and with 1% of packets lost: - between two ends of this crate: 7.8 -> 9.9, and 7.7 -> 9.4. The slowest run with the loss went from 4.5 to 9.1. - a udx-native sender into this crate: 8.5 -> 9.6, and 8.8 -> 9.4. With the loss it sent 86 packets again where it had sent 741. - two udx-native ends: 8.6 and 8.7. The acknowledgements of a transfer take about as many bytes as before, between 1.3 and 2.1 MB, since the repair they are needed for is over sooner. Before this crate limited itself to 31 ranges it sent up to 50 with a length that does not fit data_offset. The changelog said that an acknowledgement carries at most 31 ranges; it now says what was wrong. PROTOCOL.md also records what was measured and left as C libudx has it: the window after a loss in Startup, the one-second floor of the retransmission timeout, and the missing zero-window probe.
A packet went from the socket's receive task to the stream's own task through a channel, and what it carried went on to the reader through another. On a runtime with several threads the first hand-over most often ends on another thread: of the 144,000 packets of a 200 MB transfer, 130,000 did. On Windows the receiving end used about 2 seconds of processor time for those 200 MB where udx-native uses 0.8, and a transfer from udx-native into this crate ran at 204 MB/s where one into udx-native ran at 264. C libudx has no such hand-over: its process_packet runs in the callback that reads the socket. As there, the task that receives a packet now takes in what the packet carries for the reader: - The socket's receive task hands the data to the stream's reader and sends the acknowledgement the stream owes: when the socket is empty, or after 256 packets. Where the socket has no room for it, the stream's own task sends it. - The stream's own task still does all the sending, and nothing else touches the sending side. It is passed what a packet says to that side. An acknowledgement goes to it whole. From a packet with data it gets a copy of the header, and of the ranges if there are any, when the header acknowledges more, names ranges or advertises another window. - The packets of a stream that is not connected yet, or that relays to another stream, and a packet that destroys the stream, go to the stream's own task whole, as before. On Windows the acknowledgements wait one turn of the event loop, and the socket is read once more before they go out, as in C libudx. An acknowledgement costs 6 to 11 microseconds to send there and a turn about 2, so fewer acknowledgements carry a transfer further: 144,000 packets draw about 30,000 where they drew about 50,000, and udx-native sends about 33,000 for the same transfer. A turn that takes longer than 100 microseconds switches the waiting off for the next 64 times. On Linux an acknowledgement costs about 3 microseconds to send, the waiting measured 5% slower, and acknowledgements go out as soon as the socket is empty. Between two programs on one machine, medians in MB/s: Windows, 200 MB, eight rounds plain encrypted udx-native -> this crate 204 -> 259 173 -> 212 this crate -> this crate 209 -> 250 210 -> 254 this crate -> udx-native 252 -> 255 291 -> 288 udx-native -> udx-native 264 234 Linux under WSL2, 500 MB plain, 300 MB encrypted udx-native -> this crate 805 -> 1065 475 -> 572 this crate -> this crate 826 -> 895 787 -> 871 this crate -> udx-native 834 -> 852 uneven, 110 to 520 The receiving end of a plain transfer on Windows used 1.1 to 1.3 seconds of processor time where it used 2.0 to 2.1. Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB, eight runs, nothing changes: 9.85 -> 9.9 MB/s between two ends of this crate and 9.35 -> 9.3 with 1% of packets lost; 9.7 -> 9.6 and 9.35 -> 9.3 from a udx-native sender. Left as it is: a udx-native sender into an encrypted stream of this crate reaches 212 MB/s on Windows, and 234 into udx-native. udx-native decrypts on the thread that reads the socket, so it acknowledges every tenth packet there. This crate acknowledges every fourth, as both do without encryption, and the udx-native sender is slower for each acknowledgement it takes in.
A packet of data was put together in one allocation and copied into a second: one went on the wire, the other was kept until the packet was acknowledged. What is kept and what goes on the wire are now the same bytes, held by both. A packet is put together in a buffer that the batch reuses, and copied into them once. A packet that is sent again goes out from the same bytes. A lost MTU probe gets a copy of its own without the padding, since the padded one may still be on its way out. The stream's own task, which sends, is the one a transfer waits for. On Windows it has no time to spare: 82% of it goes to the system's send call, 10 to 12% to picking and recording what to send, and 7% to the acknowledgements that come back. Left without the second copy, the picking took 0.11 microseconds less a packet, and the acknowledgements, which free what was kept, 0.07 less. Between two programs on one machine, medians in MB/s: Windows, 200 MB, eight rounds plain encrypted this crate -> udx-native 252 -> 260 278 -> 297 this crate -> this crate 249 -> 258 248 -> 261 udx-native -> this crate 265 204 udx-native -> udx-native 263 226 Linux under WSL2, seven rounds 500 MB plain 300 MB encrypted this crate -> this crate 896 -> 953 876 -> 909 this crate -> udx-native 857 -> 911 With this, a plain transfer on Windows runs within 2% of the speed of two udx-native ends whichever end this crate is at, which is less than one run differs from the next.
On Windows a socket's receive task gave the event loop one turn before it
sent the acknowledgements it owed, which came to one acknowledgement for
four or five packets. An acknowledgement costs a datagram to send there,
6 to 11 microseconds, and costs the remote, which is busy sending, the
work of taking it in. udx-native acknowledges every tenth packet of an
encrypted stream, where it decrypts on the thread that reads the socket,
and a udx-native sender was a tenth slower into this crate than into
udx-native for it.
The acknowledgements now wait longer while datagrams keep arriving. A
wait of one turn that a datagram ends says that a flow is under way. From
then on they wait up to 50 microseconds for each next datagram, and go
out once 64 datagrams have been read. A wait that nothing ends says that
the flow has stopped, and the next wait is one turn again. So:
- a path too slow to fill a wait costs one turn for each acknowledgement,
as before;
- a remote that has stopped to wait for an acknowledgement gets it within
50 microseconds;
- a packet that leaves a gap in what has arrived, a packet the stream had
already, and the end of a stream are acknowledged without waiting,
since the remote is waiting to hear of each.
Between two programs on one Windows machine, 200 MB, medians of eight
interleaved rounds in MB/s:
plain encrypted
this crate -> this crate 261 -> 317 269 -> 323
udx-native -> this crate 265 -> 314 210 -> 244
this crate -> udx-native 267 307
udx-native -> udx-native 268 233
A receiver now sends one acknowledgement for about sixty packets of such
a transfer, where it sent one for four or five.
On Linux the waiting makes no difference that can be measured: 937 MB/s
with it against 955 without between two ends of this crate, and 1,092
against 1,109 from a udx-native sender. So it stays switched on for
Windows alone, as the single turn was.
An acknowledgement names a limited number of ranges, the lowest ones: 51
from C libudx, 144 from this crate. After a burst of losses there are
more gaps than that, and the sender learns late of what arrived beyond
them. Two things then went wrong, in C libudx's BBR as in this port of
it. Through a path that loses packets they cost a sender into udx-native
up to a seventh of its speed, and left it behind two udx-native ends.
Late reports were counted as deliveries. As the gaps below them close,
the remote reports hundreds of packets at once that had arrived round
trips before. A rate sample counted them all in its interval, which put
the bandwidth estimate at up to two and a half times what the path
carried. BBR sends by its estimate for the ten round trips the estimate
looks back over, so the stream overfilled the queue again and drew the
next burst. A sample now counts the packets that were delivered in its
interval, as far as their acknowledgement can tell:
- A packet that was sent before one that an earlier acknowledgement had
already acknowledged was reported late. It counts as delivered, for
the window and for the round trips, and is left out of rate samples
and of the allowance for acknowledgements that arrive in bursts.
- An acknowledgement that comes sooner after a packet was sent again
than the lowest round-trip time answers the earlier sending. The
packet is placed by when it was first sent, and no sample starts from
it.
Startup took too long to end. It keeps up to 2.89 times what a path
holds in flight, so a path that queues less than the rest drops it, and
the rule that ends Startup takes at least three round trips to see that
the bandwidth has stopped growing. For as long, the stream overfills
the queue. Startup now also ends once a round trip shows both a queue
and loss:
- a queue: the round trip's lowest round-trip time is above the path's
lowest by an eighth of that, and by no less than 4 ms and no more
than 16, which is how HyStart++ reads a queue (RFC 9406);
- loss: in loss recovery, more than 2% of what was in flight was lost,
with at least eight acknowledgements revealing loss, which is how
BBRv2 reads loss in Startup.
BBRv2 ends Startup on that loss alone. That was measured and not kept:
loss at random above 2% then ends Startup long before the path is full,
and BBR finds the rest of the bandwidth a quarter at a time. Through
the longer path below with 5% of packets lost, a transfer between two
ends of this crate fell from 9.2 to 6.5 MB/s. Loss at random builds no
queue. Asking as well that the estimate has stopped growing was
measured too, and not kept: with 1% of packets lost on the shorter
path, three of five runs into udx-native came to 9.4 MB/s, where the
rule kept comes to 11.45.
Through a relay that behaves like a path of 100 Mbit/s with a queue of
50 ms, between two programs on one Windows machine, 50 MB, medians of
eight runs (six on the longer path) in MB/s:
40 ms round trip, loss: 0% 1% 3% 5%
this crate -> udx-native
before 11.0 10.7 9.9 10.25
after 11.5 11.45 11.35 11.05
this crate -> this crate
before 11.6 11.45 11.4 11.35
after 11.7 11.6 11.5 11.3
udx-native -> this crate 11.6 11.6 11.2 10.75
udx-native -> udx-native 11.3 11.2 10.55 10.75
100 ms round trip, loss: 0% 1% 3% 5%
this crate -> udx-native
before 8.4 8.9 7.95 7.85
after 9.85 9.95 9.3 8.75
this crate -> this crate
before 10.2 9.75 9.2 9.4
after 10.35 9.9 9.65 9.4
udx-native -> this crate 10.25 10.2 9.6 9.25
udx-native -> udx-native 8.4 9.6 8.45 7.95
Without loss, the queue of the shorter path dropped about 320 packets of
the transfer into udx-native, where it dropped about 2,300.
On Linux under WSL2, through the same relay, a sender into udx-native
went from 11.3, 11.05 and 10.65 MB/s at 0%, 1% and 5% loss to 11.6,
11.5 and 11.25 on the shorter path, and from 8.4, 8.9 and 8.2 to 9.6,
10.1 and 8.9 on the longer, where two udx-native ends reach 11.2, 11.55
and 10.95, and 8.2, 9.5 and 8.4.
Between two programs on one machine nothing is lost, and those
transfers are as fast as before.
A stream finds the largest packet its path carries by sending larger and larger ones and seeing which arrive. That needs a packet that is too large to be dropped. A socket's packets were not marked "don't fragment", so every probe arrived, in pieces: the search ended at 1500 bytes whatever the path, and on a path that carries less, every full packet after it travelled as two fragments. C libudx marks the packets of every socket (udx__udp_set_dontfrag) and searches no further than the MTU of the link the remote is reached over (udx__get_link_mtu). Each takes a socket option. For Linux, rustix has safe calls for both, so there: - a socket marks its packets when it is bound: IP_MTU_DISCOVER and IPV6_MTU_DISCOVER are set to PROBE; - on connecting, a stream searches no further than the link's MTU. No other system has a safe call for either, in the standard library, tokio, socket2 or rustix, and the crate forbids unsafe code. Those systems are left as they were, and PROTOCOL.md says what that means. rustix becomes a dependency of libudx on Linux. The workspace's lock file held it already. A datagram sent with UdxSocket::send_to that is larger than the path carries is now dropped on Linux rather than fragmented, as with udx-native. The largest DHT request is an IP packet of 1206 bytes. Between network namespaces on Linux, 50 MB, medians of five or six runs in MB/s: link of MTU 1400 before after this crate -> this crate 516 834 this crate -> udx-native 497 668 udx-native -> this crate 697 udx-native -> udx-native 604 link of 1500, then a hop of 1400 this crate -> this crate 425 738 this crate -> udx-native 430 638 udx-native -> this crate 686 udx-native -> udx-native 603 Before, a stream settled on 1500 bytes in both and its 50 MB left as some 71,500 fragments. Now it settles on 1400 over the link, and on 1392 behind the hop once the router has refused three probes of 1424, as udx-native does, and nothing is fragmented. A link of 1280 gives packets of 1280, and one of 9000 packets of 1500. The figures for two udx-native ends are those of the runs that no retransmission timeout struck. One struck 11 of 19 runs over the link and 2 of 10 behind the hop, and each costs a second: those runs came to under 50 MB/s.
The changelog's first entry gave the speed of a 50 MB transfer between two programs as about 250 MB/s on Windows and 600 MB/s on Linux. Measured again at the end of the series, medians of six to ten runs: - Windows: about 290 MB/s written 64 KB at a time through UdxAsyncStream, and about 325 written in one call. Two Node.js programs on udx-native: about 240. - Linux under WSL2: about 900 MB/s either way. Two udx-native ends: about 600 in the runs that no retransmission timeout strikes, which were 17 of 44, and under 50 in the rest, since a timeout costs them a second. - Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB: 9.7 MB/s, where two udx-native ends reach 8.1. Before the series: 0.1 MB/s on Windows, 0.9 on Linux and 0.4 through the path, as the entry says.
`pair_and_unpair_reset_idle_clock` set an idle timeout of 50 ms, slept
30 ms, paired, slept 30 ms more and expected the session not to be
idle. That holds only if the second sleep ends within 50 ms, and a
sleep ends when the system next runs the thread. On GitHub's macOS
runner it ended too late in three of five runs of the test job:
assertion `left == right` failed: recent pair() activity should
have reset the idle clock
left: [1]
right: []
The test now lets the session sit idle past the timeout, pairs and
sweeps at once, and measures the time from before the pairing to after
the sweep. If that is under the timeout, the sweep ran within the
timeout of the pairing whatever the machine was doing, and it must
have closed nothing. If it is not, the pass says nothing and is run
again. No timing can fail it.
At the lowest priority among 48 spinning processes on 24 processors,
under WSL2, the old test fails 62 of 200 runs and the new one 0 of
200. With the reset taken out of `try_pair`, the new test fails as it
should.
…eload `SharedKnownUsers` reloads its file when the file's modification time differs from the one it saw last. `test_mtime_reload` wrote the file through one cache, wrote it again at once through a second, and expected the first to reload. A file system stamps a file from a clock that ticks every few milliseconds: every 4 ms on WSL2's kernel, up to 10 ms on Linux and 15.6 ms on Windows. Under WSL2 both writes fell within one tick, the modification time stayed as it was, and the test failed in 196 of 200 runs. The test now sleeps 20 ms between the two writes, longer than any of those ticks, as `test_skip_if_unchanged` beside it sleeps 10 ms. Under WSL2 it passes 300 of 300 runs. The cache has the same blind spot outside the test: a write by another process within one tick of its own goes unseen until the file changes again. That is left as it is.
The CI ran on Linux only, and macOS was built only for a release. Some of the transport's code is compiled for one system and not for another, and the first macOS runs found a test that failed there (fixed two commits back). The workspace's tests and clippy now run on macos-15 too, the runner that the release binaries are built on. The Linux jobs keep their names.
eshork
left a comment
There was a problem hiding this comment.
Thanks for this, [@ColbySerpa](https://github.com/ColbySerpa). It's a big step up for libudx, and the C references made the port easy to follow. CI and our local suites, including the live network tests, pass on the head, and the speed numbers are impressive.
We found three issues a remote peer can trigger, and we'd like them fixed before merging:
-
A SACK range that wraps past
u32::MAXpanics the stream's task, and the writer then hangs forever. -
DATA with
seq = u32::MAXoverflows SACK generation in the socket's shared receive task, which stops receiving for every stream on that socket. -
UdxAsyncStream::shutdown()before the firewall hook fires returnsOkbefore anything is acknowledged.
Each inline comment has a suggested fix. I reproduced all three, and I checked the fixes locally: the reproduction tests pass, the workspace suite passes, clippy is clean, and the code builds on Rust 1.85. Reproduction tests are in the details block below; please add them as regression tests.
Please also mention the new socket2 dependency in the PR description.
| if !seq_after(range.end, range.start) { | ||
| continue; | ||
| } | ||
| if pkt.is_mtu_probe && self.mtu_state == MtuState::Search { | ||
| self.on_mtu_probe_acked(); | ||
| // Only the packets still held, however wide the range claims to be. | ||
| let sacked: Vec<u32> = self | ||
| .outgoing | ||
| .range(range.start..range.end) | ||
| .map(|(&seq, _)| seq) | ||
| .collect(); |
There was a problem hiding this comment.
Blocker: a remote peer can panic the stream's processor task here.
seq_after(range.end, range.start) is wrap-aware, so a range like 0xFFFF_FFF0..0x10 passes. BTreeMap::range then panics ("range start is greater than range end"), because start > end numerically and outgoing is non-empty. That is the normal state during a transfer.
What follows:
- The panic kills
process_incoming, soterminate()never runs. - The oneshots in
pending_writesstay alive, soUdxStream::write/shutdownandpoll_shutdownwait forever. poll_writestaysPending.- Under
panic = "abort", the whole process dies.
Main's for seq in range.start..range.end was merely empty for such a range.
Fix: validate each range the way C does (udx_sack_is_valid), then split a valid range that crosses the wrap into two lookups:
| if !seq_after(range.end, range.start) { | |
| continue; | |
| } | |
| if pkt.is_mtu_probe && self.mtu_state == MtuState::Search { | |
| self.on_mtu_probe_acked(); | |
| // Only the packets still held, however wide the range claims to be. | |
| let sacked: Vec<u32> = self | |
| .outgoing | |
| .range(range.start..range.end) | |
| .map(|(&seq, _)| seq) | |
| .collect(); | |
| if !self.sack_is_valid(range) { | |
| continue; | |
| } | |
| // Only the packets still held, however wide the range claims to be. | |
| // A valid range may still cross the wrap of the sequence space. | |
| let sacked: Vec<u32> = if range.start <= range.end { | |
| self.outgoing.range(range.start..range.end).map(|(&seq, _)| seq).collect() | |
| } else { | |
| self.outgoing | |
| .range(range.start..) | |
| .chain(self.outgoing.range(..range.end)) | |
| .map(|(&seq, _)| seq) | |
| .collect() | |
| }; |
with:
/// Whether a SACK range lies within what was sent and is not yet
/// acknowledged. C: `udx_sack_is_valid`.
fn sack_is_valid(&self, range: &SackRange) -> bool {
!seq_after(range.end, self.next_seq)
&& seq_after(range.end, range.start)
&& seq_after(self.next_seq, range.start)
&& !seq_after(self.remote_acked, range.start)
}
Only "skip if start > end" was not enough. It drops ranges that are legitimately wrapped, and still accepts ranges outside the window. Validating keeps us at parity with C's semantics.
Please add a regression test: the first repro in the review body, plus a unit test of sack_is_valid that includes a window crossing the wrap.
|
|
||
| if let Some(&first) = iter.next() { | ||
| let mut start = first; | ||
| let mut end = first + 1; |
There was a problem hiding this comment.
Blocker: one DATA packet with seq = u32::MAX takes down the socket's receive task.
receive_data buffers any out-of-order seq. Then first + 1 (and seq + 1 below) overflows. Main had the same arithmetic. But this PR builds the ack inside the socket's shared receive task (take_in → send_ack → take_ack → build_ack → here), so in debug builds the panic now stops receiving for every stream on the socket and for DHT datagrams. In release builds it wraps and sends {u32::MAX, 0}, which triggers the process_ack panic on a remote running this code.
Fix: use wrapping_add for all three + 1s in this function, i.e. first.wrapping_add(1) and seq.wrapping_add(1). Also make acceptance in receive_data wrap-aware (next comment).
Moving ack building back to the stream's own task would also avoid this, but it gives up one of this PR's wins, so we'd rather fix the arithmetic.
Please add a regression test: the second repro in the review body, plus a unit test with next_remote_seq = u32::MAX - 2, receiving u32::MAX out of order, that expects the range (u32::MAX, 0).
| // Duplicate or already-received packet — ignore | ||
| if seq < self.next_remote_seq { |
There was a problem hiding this comment.
Part of the seq = u32::MAX fix: make this comparison wrap-aware, as C's seq_compare(stream->ack, seq) <= 0 is. Then a seq far "ahead" (≥ 2³¹) is treated as old and dropped, instead of being buffered:
| // Duplicate or already-received packet — ignore | |
| if seq < self.next_remote_seq { | |
| // Duplicate or already-received packet — ignore. C: `seq_compare`. | |
| if seq != self.next_remote_seq && !seq_after(seq, self.next_remote_seq) { |
The same applies to:
self.next_remote_seq = seq + 1andself.next_remote_seq += 1just below. Both should usewrapping_add(1).had_alreadyintake_in, which uses the same numericheader.seq < guard.next_remote_seq.
| if !inner.connected { | ||
| return Poll::Ready(Ok(())); | ||
| } | ||
| Poll::Pending => return Poll::Pending, | ||
| inner.queue_end() |
There was a problem hiding this comment.
Blocker (with the next two comments): UdxAsyncStream::shutdown() returns Ok before anything is acknowledged when the firewall hook hasn't fired yet.
The description says "shutdown still waits until everything is acknowledged". That holds once the stream is connected, but not before the hook fires:
check_writableaccepts writes while the hook is armed.- This early return then reports success without queuing the end or waiting for anything.
I checked it: on a hook-armed stream with no peer, write_all(b"hello") followed by shutdown() returns Ok(()) immediately on this branch. On main, the same shutdown() is still pending after 500 ms, because poll_shutdown waited for the pending write's ack first. That false Ok is the regression.
(Separately, and not new: if the owner drops the stream at that point, the queued bytes are never sent, on main as well. The linger comment below fixes this too, so a shutdown() that now waits correctly isn't undone by a drop.)
For reference: C libudx never reaches this state. udx_stream_write and udx_stream_write_end both return UV_ENOTCONN until udx_stream_connect, and udx-native's _writev throws "Writing while not connected not currently supported". We can't adopt that here without changing the existing behaviour of writes before the hook, so we'd like the end queued after those writes instead.
Fix: keep the early Ok only when there is nothing to deliver. Otherwise queue the end after the writes, through an adapter-only variant of queue_end (next comment):
| if !inner.connected { | |
| return Poll::Ready(Ok(())); | |
| } | |
| Poll::Pending => return Poll::Pending, | |
| inner.queue_end() | |
| // Nothing written, and nowhere to send the end: done. A | |
| // stream waiting on its firewall hook with writes queued | |
| // sends the end after them once the hook lets a remote in. | |
| if !inner.connected && (!inner.awaiting_firewall() || inner.drained()) { | |
| return Poll::Ready(Ok(())); | |
| } | |
| inner.queue_end_awaiting_firewall() |
This needs drained() to be pub(crate), plus a small accessor in stream.rs:
pub(crate) fn awaiting_firewall(&self) -> bool {
self.firewall_hook.is_some()
}If the hook rejects the remote, terminate() fails the end's oneshot, so shutdown() returns an error instead of a false Ok.
Please add regression tests: the third and fourth repros in the review body.
| /// Queue the end of the stream, after everything written so far, and | ||
| /// return a receiver that is told once the remote has acknowledged it. | ||
| /// C: `udx_stream_write_end`. | ||
| pub(crate) fn queue_end(&mut self) -> Result<oneshot::Receiver<Result<()>>> { | ||
| if let Some(error) = self.failure() { | ||
| return Err(UdxError::Io(error)); | ||
| } | ||
| if self.terminated { | ||
| return Err(UdxError::StreamClosed); | ||
| } | ||
| if !self.connected { | ||
| return Err(UdxError::Io(std::io::Error::other("stream not connected"))); | ||
| } | ||
| if self.ended { | ||
| return Err(UdxError::Io(std::io::Error::other("stream already shut down"))); | ||
| } | ||
| if self.udp.is_none() || self.remote_addr.is_none() { | ||
| return Err(UdxError::StreamClosed); | ||
| } | ||
| self.ended = true; | ||
| let udp = self.udp.clone().ok_or(UdxError::StreamClosed)?; | ||
| let remote_addr = self.remote_addr.ok_or(UdxError::StreamClosed)?; | ||
| let remote_id = self.remote_id; | ||
| let (tx, rx) = oneshot::channel(); | ||
| self.push_write(QueuedWrite { data: Vec::new(), taken: 0, end: true, tx: Some(tx) }); | ||
| Ok(rx) | ||
| } |
There was a problem hiding this comment.
For the shutdown()-before-hook fix: please leave queue_end, and with it UdxStream::shutdown(), exactly as it is. It should still fail at once with "stream not connected" before the hook fires. We don't want this PR to change a public method's behaviour, and a graceful public variant can come in a follow-up.
Instead, add a crate-internal variant that only the async adapter uses, which also accepts a stream waiting on its hook:
| /// Queue the end of the stream, after everything written so far, and | |
| /// return a receiver that is told once the remote has acknowledged it. | |
| /// C: `udx_stream_write_end`. | |
| pub(crate) fn queue_end(&mut self) -> Result<oneshot::Receiver<Result<()>>> { | |
| if let Some(error) = self.failure() { | |
| return Err(UdxError::Io(error)); | |
| } | |
| if self.terminated { | |
| return Err(UdxError::StreamClosed); | |
| } | |
| if !self.connected { | |
| return Err(UdxError::Io(std::io::Error::other("stream not connected"))); | |
| } | |
| if self.ended { | |
| return Err(UdxError::Io(std::io::Error::other("stream already shut down"))); | |
| } | |
| if self.udp.is_none() || self.remote_addr.is_none() { | |
| return Err(UdxError::StreamClosed); | |
| } | |
| self.ended = true; | |
| let udp = self.udp.clone().ok_or(UdxError::StreamClosed)?; | |
| let remote_addr = self.remote_addr.ok_or(UdxError::StreamClosed)?; | |
| let remote_id = self.remote_id; | |
| let (tx, rx) = oneshot::channel(); | |
| self.push_write(QueuedWrite { data: Vec::new(), taken: 0, end: true, tx: Some(tx) }); | |
| Ok(rx) | |
| } | |
| /// Queue the end of the stream, after everything written so far, and | |
| /// return a receiver that is told once the remote has acknowledged it. | |
| /// C: `udx_stream_write_end`. | |
| pub(crate) fn queue_end(&mut self) -> Result<oneshot::Receiver<Result<()>>> { | |
| self.queue_end_inner(false) | |
| } | |
| /// As [`queue_end`](Self::queue_end), and also on a stream still waiting | |
| /// on its firewall hook: the end then goes out after what was written, | |
| /// once the hook lets a remote in. For the async adapter's shutdown, | |
| /// whose writes are already queued that way. | |
| pub(crate) fn queue_end_awaiting_firewall(&mut self) -> Result<oneshot::Receiver<Result<()>>> { | |
| self.queue_end_inner(true) | |
| } | |
| fn queue_end_inner(&mut self, awaiting_firewall_ok: bool) -> Result<oneshot::Receiver<Result<()>>> { | |
| if let Some(error) = self.failure() { | |
| return Err(UdxError::Io(error)); | |
| } | |
| if self.terminated { | |
| return Err(UdxError::StreamClosed); | |
| } | |
| let awaiting_firewall = awaiting_firewall_ok && self.firewall_hook.is_some(); | |
| if !self.connected && !awaiting_firewall { | |
| return Err(UdxError::Io(std::io::Error::other("stream not connected"))); | |
| } | |
| if self.ended { | |
| return Err(UdxError::Io(std::io::Error::other("stream already shut down"))); | |
| } | |
| // A packet is addressed when it is sent, so the remote's address is | |
| // needed only once the stream is connected. | |
| if self.udp.is_none() || (self.connected && self.remote_addr.is_none()) { | |
| return Err(UdxError::StreamClosed); | |
| } | |
| self.ended = true; | |
| let (tx, rx) = oneshot::channel(); | |
| self.push_write(QueuedWrite { data: Vec::new(), taken: 0, end: true, tx: Some(tx) }); | |
| Ok(rx) | |
| } |
| } | ||
| // What the stream could still deliver, by the acknowledgement | ||
| // it has reached. | ||
| let undelivered = (guard.connected && !guard.drained()).then_some(guard.remote_acked); |
There was a problem hiding this comment.
For the shutdown()-before-hook fix: a stream that is dropped while its hook is still armed and writes are queued should linger, as a connected one does. Otherwise the processor exits before the hook can fire:
| let undelivered = (guard.connected && !guard.drained()).then_some(guard.remote_acked); | |
| let undelivered = ((guard.connected || guard.firewall_hook.is_some()) && !guard.drained()) | |
| .then_some(guard.remote_acked); |
The wait stays bounded by LINGER_TIMEOUT if no remote ever arrives.
A stream looked up the packets a SACK range names with `BTreeMap::range(start..end)`, after checking only that the range's end comes after its start as sequence numbers compare. A range whose end is after its start that way and before it as a number, such as 0xFFFF_FFF0..0x10, passed the check, and `BTreeMap::range` panics on it. A remote can send one at any time. The panic ended the stream's task before it could close the stream, so whoever was writing to the stream waited for good. A range is now applied only if it lies within what was sent and is not yet acknowledged. That is `udx_sack_is_valid` in C libudx's `main`; the version this port follows, 25b063b, applies a range unchecked. A range that passes may still cross the wrap of the sequence space, when the packets in flight do, so the lookup takes what the range names on both sides of the wrap. The fault and the fix are from eshork's review of Rightbracket#36. The tests are new: one of the check, with what is in flight below the wrap and across it; one that sends the wrapped range to a stream with packets in flight and then completes its write; and one of a range across the wrap.
The receiving side told a packet it already had from a new one by which sequence number was the smaller, and added one to a number without wrapping. Both go wrong where the sequence space wraps, and a remote chooses the numbers it sends. A packet numbered u32::MAX that reached a stream expecting a low number was therefore kept as one that had arrived ahead of a gap, and naming it in an acknowledgement added one to u32::MAX: a panic in a build that checks for overflow, as a debug build does. Since "take a packet in where it is received" an acknowledgement is built in the socket's receive task, so the panic stopped every stream on that socket, and the socket's datagrams too. Sequence numbers are now compared as C libudx compares them (`seq_compare`) and counted up with `wrapping_add`: - where a packet is taken for old or new, in `receive_data` and in `take_in`; - where the number expected next moves on, for a packet in order and for each one that then leaves the buffer; - in all three places of `sack_ranges`. Such a packet is now an old one: it is acknowledged and not kept. The fault and the fixes are from eshork's review of Rightbracket#36. The tests are new, and each of the seven places has one that fails without it.
A stream that waits on its firewall hook takes writes, and sends them once the hook has let a remote in. `UdxAsyncStream::shutdown` on such a stream returned `Ok` as soon as it saw that the stream was not connected. Its caller was told that everything had arrived when nothing had been sent. It now queues the end of the stream after what was written and returns once the remote has acknowledged it, as on a connected stream. If the hook turns the remote away, the stream closes and `shutdown` fails. With nothing written it returns at once, as before. `UdxStream::shutdown` is unchanged: before the hook fires it still fails at once with "stream not connected". The adapter queues the end through a variant of `queue_end` that only it uses. A stream that is dropped in that state with writes queued now lingers as a connected one does: until what was written is acknowledged, or 30 seconds pass without progress. The fault and the fixes are from eshork's review of Rightbracket#36. The tests are new: six in `tests/firewall_hook.rs`, each of which fails without the part it is for.
A stream numbers its packets with 32 bits, so the numbers wrap after 2^32 packets. The buffers of sent and of received packets are keyed by number, and were read in number order. PROTOCOL.md listed that as a known limitation. Where the packets in flight ran across the wrap: - a cumulative acknowledgement of the ones before it also took the ones past it, which have lower numbers. They left the buffer as acknowledged, so one of them that was lost was never sent again, and the stream stalled; - a write whose last packet was past the wrap was reported complete by that same acknowledgement, with its data still on the way or lost; - an acknowledgement number of zero, which follows the highest one, was not read, so a stream whose last packet had the highest number never learned that it had arrived; - the retransmission timer, and loss detection for packets sent at one moment, put packets in line to be sent again by number: the newest first; - an acknowledgement named what had arrived out of order by number, so of more ranges than it holds it left out the ones nearest the gap that the sender has to fill first. All of it is in sequence order now, with number order kept where the packets held do not run across the wrap: the cumulative acknowledgement still splits the buffer once there, and a transfer is as fast as before. 200 MB between two programs on Windows, medians of twelve runs: 300 MB/s before and 314 after in one write, 306 and 311 written 64 KB at a time through the adapter. The tests start a stream a few packets short of the wrap. One sends 1,200 packets over a simulated path that loses, delays and reorders packets and carries nothing for a second, and requires that what passes is the same, packet for packet, wherever the wrap falls in the transfer: thirteen places, from the first flight to the last packet. Another carries data both ways at once between two real streams whose numbers wrap. The rest take the places above one by one. Each change has a test that fails without it.
A stream holds whatever its remote sends until its reader takes it, and always advertises a receive window of 4 MB, as udx-native does: 100,000 packets that nothing read stay in memory, 115 MB of them. That is as it was, and stays the default. `set_read_buffer_limit`, on `UdxStream` and on `UdxAsyncStream`, gives a stream a limit. The stream then advertises the room it has left, which is what C libudx does for a program that sets `get_read_buffer_size`, and turns away what does not fit, so it never holds more than the limit and one full packet, whatever its remote sends. It is for a program whose reader can be slower than the network, such as one that writes what it receives to a disk, and that has to keep what its streams hold in memory within bounds. A window that closes has to open again, and a limit has to hold against a remote that takes no notice of it. Four changes come with the limit: - a stream asks about a window that has no room every retransmission timeout or two, by sending one packet all the same (C: `zwp_timer`). PROTOCOL.md listed the missing probe as a known limitation. While the remote goes on answering that it has no room, a packet it turned away counts neither toward giving up on the remote nor as lost for congestion control, as TCP keeps a connection open through a closed window (RFC 9293, 3.8.6.1); - a window is taken only from a packet that is no older than the one the window in use came in (C: `send_wl1`, `send_wl2`); - an acknowledgement and a heartbeat carry the number of the stream's next packet as their sequence number, as every packet of C libudx does. They carried zero, which C libudx takes for an old packet once it has seen a higher number, so it ignored the window in every acknowledgement. Into a stream with a limit of 128 KB and a slow reader, a udx-native sender sent a third of its packets again that way, 730 of 2,213 in one run and 970 of 2,486 in another, and none now; - a stream takes nothing more once its reader has been handed the end of the stream (C: `UDX_STREAM_ENDED_REMOTE`). A packet numbered after the end was queued behind it, where no reader looks, and so was every further end of stream, which no limit counted. A limit is also the most a remote has on its way at one time, so a low one holds back a transfer over a long round trip. PROTOCOL.md has a section on flow control that says so, with the rest. A remote on C libudx gives up on a packet that has been turned away for about thirteen seconds: a udx-native sender gave up seventeen seconds after a reader had stopped. A remote on this crate goes on asking for as long as it is answered. A stream without a limit behaves as before, and a transfer is as fast: 200 MB between two programs on Windows, medians of thirty runs, 317 MB/s before and 314 after in one write, 313 and 317 written 64 KB at a time through the adapter. The tests are 26 of the engine, one through the adapter and one against udx-native. Some run a whole transfer in simulated time: to a reader slower than the path, to one that stops for a minute, over a path that loses and delays packets with the wrap of the sequence numbers in six places, and from a remote that takes no notice of the window. Each rule has a test that fails without it.
`SecretStream::get_ref` returns the transport the stream runs over, without giving the stream up as `into_inner` does. Over a UDX stream that is where the limit on the stream's read buffer is set: on a `PeerConnection`, `connection.stream.get_ref().set_read_buffer_limit(..)`.
|
Thanks for the careful review, and for writing the fixes out. All three are fixed, in three commits on top of the branch:
Each suggestion is applied as you wrote it. The details block with your reproduction tests didn't come through in the review, so I wrote the tests from your descriptions: 13 of them, including the unit test of There are three more commits, which you didn't ask for.
The Node.js version has no such option on its own side, but a sender on the Node.js version does keep to the limit of a stream on this crate. For that the commit numbers acknowledgements the way C libudx does: numbered zero, the Node.js version ignored the window they carry, and sent a third of its packets again in the new interop test. One difference remains: a sender on the Node.js version gives up on a stream whose reader stops altogether for about seventeen seconds, where a sender on this crate goes on asking. The commit also adds the zero-window probe that
On the new head, |
Summary
peeroxide's streams are now faster than Node.js's. Before this, a transfer ran at under 1 MB/s, whatever the network.
udx-native)unsafecode, and no existing function signature changes.Motivation
A stream sent one packet each time its timer fired: about every millisecond on Linux, and every 15.6 ms on Windows. That held a transfer to about 1 MB/s on Linux and 75 KB/s on Windows.
Changes
libudxlibudxlibudxlibudxlibudxPROTOCOL.mddescribes both.libudxrustix: a new dependency on Linux only, already inCargo.lock.libudxsocket2: a new dependency, already inCargo.lock.libudxPROTOCOL.mdlisted that as a known limitation.libudxset_read_buffer_limit). It is off by default. With it comes the zero-window probe, whichPROTOCOL.mdlisted as missing.peeroxide-dhtpeeroxide-dhtSecretStream::get_refreaches the stream under an encrypted connection, to set that limit.libudx,peeroxide-dhtpeeroxide-dht,peeroxide-cliPublic API Changes
All changes below are: [x] Additive only (semver-compatible). No existing signature changes. Three behaviours change.
libudxUdxAsyncStream::effective_mtulibudxUdxStream::set_read_buffer_limitandread_buffer_limit, and both onUdxAsyncStreampeeroxide-dhtSecretStream::get_reflibudxUdxAsyncStreamflushreturns at once.shutdownstill waits until everything is acknowledged.libudxeffective_mtuudx-native. Still 1200 to 1500.libudxUdxSocket::send_toTesting
cargo test --workspacecargo test -p peeroxide-cli --test live_commands -- --ignoredcargo clippy --workspace --all-targets -- -D warningsAlso
cargo doc, a check with Rust 1.85.0, andcargo package.The CI passes on the fork, on GitHub's Linux and macOS runners.
The speeds are medians of 5 to 12 runs on one machine: Windows 11, and Linux under WSL2. The link is a local relay program that sets rate, delay, queue and loss. Mixed pairs, with this crate at one end and
udx-nativeat the other, were measured too, with 0 to 5% of packets lost: each is as fast as twoudx-nativeends, or faster. The commit messages have every table.Notes