Skip to content

perf(libudx): make streams faster than Node.js (udx-native) - #36

Open
ColbySerpa wants to merge 25 commits into
Rightbracket:mainfrom
HORNET-Storage:perf/udx-send-path-v2
Open

ColbySerpa wants to merge 25 commits into
Rightbracket:mainfrom
HORNET-Storage:perf/udx-send-path-v2

Conversation

@ColbySerpa

@ColbySerpa ColbySerpa commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

peeroxide's streams are now faster than Node.js's. Before this, a transfer ran at under 1 MB/s, whatever the network.

Before Now Node.js (udx-native)
Windows, same machine, 50 MB 0.1 MB/s 290 MB/s 240 MB/s
Linux, same machine, 50 MB 0.9 MB/s 900 MB/s 600 MB/s
Simulated 100 Mbit/s link, 40 ms round trip, 10 MB 0.4 MB/s 9.7 MB/s 8.1 MB/s

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

Crate Change
libudx The sending side now follows C libudx: its write queue, congestion control (BBR), pacing, loss detection and timers.
libudx Selective acknowledgements from Node.js peers are read. They were ignored.
libudx A packet's size counts the IP and UDP headers, so full packets are no longer split in two on the way.
libudx On Windows, a socket keeps receiving after a packet it sent goes undelivered (was #34).
libudx Two additions that C libudx doesn't have keep a burst of lost packets from slowing a stream. PROTOCOL.md describes both.
libudx On Linux, packets are marked "don't fragment", as C libudx marks them, through rustix: a new dependency on Linux only, already in Cargo.lock.
libudx A socket asks the system for the send and receive buffers C libudx asks for, through socket2: a new dependency, already in Cargo.lock.
libudx A stream carries on where its sequence numbers wrap, after 2^32 packets. PROTOCOL.md listed that as a known limitation.
libudx An option for servers at very high scale: a stream can be given a limit on what it holds in memory for its reader (set_read_buffer_limit). It is off by default. With it comes the zero-window probe, which PROTOCOL.md listed as missing.
peeroxide-dht An encrypted frame's length and body go out in one write.
peeroxide-dht SecretStream::get_ref reaches the stream under an encrypted connection, to set that limit.
libudx, peeroxide-dht An example each that measures a transfer, to this crate or to a Node.js peer.
peeroxide-dht, peeroxide-cli Two tests that failed from timing alone are fixed: one failed on macOS, one on WSL2.
CI Tests and clippy also run on macOS.

Public API Changes

All changes below are: [x] Additive only (semver-compatible). No existing signature changes. Three behaviours change.

Crate Symbol Change
libudx UdxAsyncStream::effective_mtu New.
libudx UdxStream::set_read_buffer_limit and read_buffer_limit, and both on UdxAsyncStream New. A limit on what a stream holds for its reader. Off by default.
peeroxide-dht SecretStream::get_ref New.
libudx UdxAsyncStream A write returns once its bytes are queued, and flush returns at once. shutdown still waits until everything is acknowledged.
libudx effective_mtu Now the size of the whole IP packet, as in udx-native. Still 1200 to 1500.
libudx UdxSocket::send_to On Linux, a datagram too large for the path is dropped, where it was fragmented.

Testing

  • cargo test --workspace
  • cargo test -p peeroxide-cli --test live_commands -- --ignored
  • cargo clippy --workspace --all-targets -- -D warnings

Also cargo doc, a check with Rust 1.85.0, and cargo 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-native at the other, were measured too, with 0 to 5% of packets lost: each is as fast as two udx-native ends, or faster. The commit messages have every table.

Notes

  • Only Linux has a safe call for "don't fragment". On Windows and macOS, a path that carries less than 1500 bytes still splits full packets in two, as before.
  • Node.js's Linux figure is from its runs with no retransmission timeout. One struck about half its runs there, and each costs it a second.

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 eshork left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. A SACK range that wraps past u32::MAX panics the stream's task, and the writer then hangs forever.

  2. DATA with seq = u32::MAX overflows SACK generation in the socket's shared receive task, which stops receiving for every stream on that socket.

  3. UdxAsyncStream::shutdown() before the firewall hook fires returns Ok before 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.

Comment thread libudx/src/native/stream.rs Outdated
Comment on lines +889 to +897
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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, so terminate() never runs.
  • The oneshots in pending_writes stay alive, so UdxStream::write/shutdown and poll_shutdown wait forever.
  • poll_write stays Pending.
  • 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:

Suggested change
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.

Comment thread libudx/src/native/stream.rs Outdated

if let Some(&first) = iter.next() {
let mut start = first;
let mut end = first + 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread libudx/src/native/stream.rs Outdated
Comment on lines +1379 to +1380
// Duplicate or already-received packet — ignore
if seq < self.next_remote_seq {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
// 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 + 1 and self.next_remote_seq += 1 just below. Both should use wrapping_add(1).
  • had_already in take_in, which uses the same numeric header.seq < guard.next_remote_seq.

Comment thread libudx/src/native/async_stream.rs Outdated
Comment on lines +166 to +169
if !inner.connected {
return Poll::Ready(Ok(()));
}
Poll::Pending => return Poll::Pending,
inner.queue_end()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_writable accepts 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):

Suggested change
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.

Comment on lines +617 to +640
/// 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)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
/// 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)
}

Comment thread libudx/src/native/stream.rs Outdated
}
// What the stream could still deliver, by the acknowledgement
// it has reached.
let undelivered = (guard.connected && !guard.drained()).then_some(guard.remote_acked);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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(..)`.
@ColbySerpa

Copy link
Copy Markdown
Author

Thanks for the careful review, and for writing the fixes out. All three are fixed, in three commits on top of the branch:

  • a7bf755: SACK ranges are checked with sack_is_valid, and the lookup handles a valid range that crosses the wrap.
  • 14a918b: wrapping_add in all three places of sack_ranges, the wrap-aware comparison in receive_data and for had_already in take_in, and wrapping_add where next_remote_seq advances.
  • a8e41f6: UdxAsyncStream::shutdown() waits before the hook fires, through queue_end_awaiting_firewall. queue_end and UdxStream::shutdown() are as they were, and a stream dropped with its hook still armed lingers.

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 sack_is_valid with a window crossing the wrap, and the one that expects the range (u32::MAX, 0). I checked them by taking each fix out again: every time, one of the new tests fails. If your repros cover something these don't, send them and I'll add them.

There are three more commits, which you didn't ask for.

82d3518 makes a stream safe across the wrap of its own sequence numbers too, which PROTOCOL.md listed under Known Limitations: the cumulative ACK (split_off(&ack)), ack_threshold <= ack, an ACK number of zero, and the order in which packets are sent again and ranges are named. It seemed wrong to fix the wrap for what a remote sends and leave it for a stream's own numbers. Its main test sends 1,200 packets over a simulated lossy path with the wrap in thirteen different places, and requires the same packets every time. A transfer is as fast as before.

ea6b524 adds an option, and it is off by default: set_read_buffer_limit puts a limit on what a stream holds in memory for its reader. Most programs won't need it. It's for applications at very high scale: a server with a great many connections at once, taking in large amounts of data faster than it can write it away, can be overwhelmed, because a stream keeps everything a peer sends until its reader takes it, as the Node.js version (udx-native) does. On one stream, 100,000 unread packets were 115 MB. With a limit, a stream advertises the room it has left, the way C libudx does for a program that sets get_read_buffer_size, and turns away what doesn't fit, so what each stream holds is bounded and a sender is held to the reader's pace. A stream without a limit takes everything and advertises 4 MB, as before, and is as fast.

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 PROTOCOL.md listed as missing, so that a closed window opens again. PROTOCOL.md has a new section, Flow Control, with the details.

32c6781 adds SecretStream::get_ref, so that the limit can be set on an encrypted connection.

On the new head, cargo test --workspace, the live tests, clippy, cargo doc, the check on Rust 1.85 and cargo package all pass here, under Linux. The description now mentions socket2, the wrap and the limit, and the changelogs cover all of it.

This branch has not been deployed

No deployments
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.

2 participants