← All writing

Rust / Networking / Protocol design

udpstp and the packets that never left

Six packets planned. Two accepted. Follow udpstp's sender from an unfinished send to the capacity, loss and delay on screen.

Put udpstp at both ends of a network path to measure capacity, loss and delay under load. Run authenticated tests in either direction, read the terminal tables, or collect JSON to compare paths and configuration changes.

The sender's job sounds straightforward. Take the chosen rate and send the packets. Then the operating system accepts only part of a send plan, or a timer wakes after several bursts were due.

Those small interruptions reach all the way to the results. A packet waiting inside the sender should not count as network loss. A late timer should not release an unlimited backlog onto the path.

Six planned, two accepted

udpstp implements the UDP Speed Test Protocol in RFC 9946. In a search test, the server adjusts the sending rate from feedback about loss and delay. The server sends downstream traffic, the client sends upstream traffic, and the server chooses the rate in both directions.

Traffic is scheduled in bursts, groups of load datagrams sent back to back. Each packet carries a sequence number. A gap tells the receiver that something is missing.

Suppose a plan contains six datagrams, numbered 100 through 105. The kernel accepts only the first two.

01 / PARTIAL SEND

Keep the packets that never left.

First attempt 6 planned · 2 accepted

100sent
101sent
102waiting
103waiting
104waiting
105waiting

Same sequence numbers.
Fresh send timestamps.

Next plan Unsent tail goes first

102retry
103retry
104retry
105retry
New traffic
when allowed
The kernel accepted 100 and 101. The next plan begins with 102–105, keeping their numbers and refreshing their timestamps. Waiting inside the sender creates no gap in the receiver's packet sequence.

Starting the next plan at 106 would create a gap for the receiver to interpret as loss. But packets 102 through 105 never entered the network. udpstp keeps that unsent tail, called deferred datagrams, at the front of the next plan.

The core's report_sent method records it in one line:

sender.deferred = (sent < plan.datagrams()).then(|| plan.tail(sent));

sent is the number of datagrams the kernel accepted. If that is smaller than the plan, tail keeps the rest. The runtime reports the count before asking the core for another plan, so the next decision starts with what actually happened.

Keep the number, refresh the timestamp

A deferred datagram needs a fresh send timestamp. Reusing its first planned timestamp would include the wait inside the sender in the apparent network delay.

udpstp writes packet headers just before each send attempt. Deferred datagrams keep their sequence numbers and get the new plan's timestamp.

The partial-send tests check both properties. One has the host accept just one datagram out of four on every tenth plan. The packet sequence stays continuous, the receiver reports no loss, reordering or duplication, and each timestamp matches its send attempt.

If the host keeps accepting too little, adding a new burst to every plan would grow the unsent tail indefinitely. When at least a burst's worth is still waiting, udpstp withholds the new burst. Existing work goes first, and withheld traffic is counted separately.

A late timer is a different problem

Now suppose the socket is ready, but the sending thread wakes late. Several bursts have become due.

Sending them all immediately would recover the packet count but concentrate old traffic into a later part of the measurement. The same bytes, delivered in a different pattern, can create different queueing and loss.

Skipping every overdue burst goes too far the other way. Some sending-rate rows schedule a burst every 100 microseconds. A timer a few hundred microseconds late would repeatedly leave traffic unsent.

udpstp uses a catch-up budget. Bursts less than the budget late remain eligible to send. Older ones are skipped, with the most recent missed burst always kept. The budget is 1 millisecond on Linux and macOS, and 2 milliseconds on Windows.

02 / CATCH-UP BUDGET

Catch up within a window.

0.1 msbetween bursts

1 mscatch-up budget

3.05 mssender wakes

Older bursts: skip Recent bursts: eligible
Which pending bursts fit inside the catch-up budget?Bursts are due every 0.1 ms from 0 to 3 ms. At a 3.05 ms wake-up, the bursts from 0 through 2 ms are skipped. Ten bursts from 2.1 through 3 ms are less than 1 ms late and remain eligible to send. Wake-up Less than 1 ms late 0.0 ms1.0 ms2.0 ms3.0 ms
Each marker is a scheduled burst. Ten fall inside the window at this wake-up. A burst exactly at the budget is skipped unless it is the most recent missed burst, which is always kept.

The catch-up tests use a 100-microsecond schedule to exercise both sides. After a 450-microsecond stall, the four or five overdue bursts all go out. A 3,050-microsecond stall sends the ten recent bursts inside the default budget and skips the older ones. The test cases also check the exact budget boundary.

Skipped bursts take no sequence numbers. They reduce the traffic the sender supplied without creating fictitious gaps at the receiver. The sender catches up within a time bound instead of trying to repay every missed deadline.

Follow the traffic into the result

udpstp reports the sender's rate alongside the capacity measured at the receiving end. The chosen rate describes the intended load. The sender bit rate describes what was supplied.

In an upstream test, the client knows its own sending history. Downstream, it estimates the server's rate from packet sequence numbers and timestamps. The results guide explains how that estimate is marked.

Skipped and withheld burst warnings identify an upstream client that fell behind or a host that could not accept datagrams fast enough. A server records those events in its logs. That gives you something specific to investigate when the delivered rate stops rising.

The Rust protocol core owns send plans, sequence numbers and the decisions about deferred or skipped work. The runtime executes those plans and reports the outcome. Keeping those accounts separate makes sender shortfalls visible in the measurement.

Put it on a path you want to measure

udpstp is available for Linux, macOS and Windows. Follow getting started to install it at both ends, share a private key file and start the server. Then run the client:

udpstp client --key-file keys.toml --max-hops 12 server.example.net

Choose MaxHops for your path. The default test measures downstream for 10 seconds. Add --direction upstream for the other direction, or --format json to collect structured results.

Compare capacity, loss and delay together. When the sender falls behind, its own accounts have a place in the result too. Even the packets that never left have a job to do.

More from the workshop

Explore all writing →Try udpstp →