Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ opened: 2026-10-24

# [tcp] opens a connection's window slower than Linux in its first 300 ms

On the T14's wired path to the AMD bench's Wi-Fi peer, a ToyOS TCP sender
delivers 2.1–3.3 MB in a new connection's first 100 ms and 24–25 MB by
300 ms, where the bench's Linux delivers 6.4–6.5 MB and 29.7 MB. From then on
both run at line rate. The upload row reads 112 MB/s against Linux's 113 in
the same session (#1102's A/B, at the end of this record). Received TCP is at
parity on that path.
On the T14's wired path to the AMD bench's Wi-Fi peer, a paced ToyOS TCP
sender (#1114) delivers 6.07 MB in a new connection's first 100 ms and
29.2 MB by 300 ms. In the same session the bench's Linux delivered
6.10–6.38 MB and 28.6–29.6 MB. The upload row reads 114 MB/s against
Linux's 113. Only the 100 ms mark is short, by 30 KB in one boot (the
parity boot, at the end of this record). Before #1114, ToyOS delivered
2.0–3.3 MB and 23.8–25.6 MB. Received TCP is at parity on that path.

This record began as the upload at a third of Linux's (41 MB/s against 113).
What follows is that history, the causes found and removed, and the residual.
Expand Down Expand Up @@ -243,8 +244,76 @@ readings, `mhz` just after it and `mhz_settled` before it, were about
1.5 GHz (settled 1.5 or 4.2 GHz), and Linux still moved 113 MB/s. At most,
the clock matters through what ToyOS spends per byte sent.

**#1112's session, 2026-10-29**
([evidence](https://github.com/ToyOSOrg/evidence/tree/96c693b58d242d8ec236e0805b34b714306ec4c2/r1/t14)).
B was #1102's head, C per-segment RTT samples (#1112), D C with L = ∞ and
no pacing, a diagnostic arm that does not land. Three of each, B C D B C D
B C D, with Linux at both ends:

| | upload p50, MB/s | received by 100 ms | 300 ms | 1 s |
|---|---|---|---|---|
| Linux (2 runs) | 113, 114 | 5.9–6.0 MB | 27.2–29.0 MB | 108–110 MB |
| B (3 runs) | 112, 112, 112 | 2.1–2.4 MB | 24.0–24.7 MB | 105.5–105.8 MB |
| C (3 runs) | 112, 112, 112 | 2.2–3.2 MB | 24.3–25.6 MB | 105.2–106.6 MB |
| D (3 runs) | 114, 113, 114 | 5.2–6.4 MB | 28.3–29.7 MB | 109.9–111.2 MB |

- L = 8 is what holds the ramp. Without it D reached Linux's marks, and
nothing was resent.
- CSS entries were 2.0–2.7 times the rounds that entered CSS, in D as in B
and C. Most of them are flaps within a round (s_cc_023), and removing them
was not needed for parity.
- Finer RTT samples (C) did not change the ramp.

RFC 9406 §4.3 gives L = ∞ only to a paced sender. Pull request #1114
paces [tcp] (RFC 9002 §7.7) and removes the cap.

**#1114's A/B, 2026-10-31**
([evidence](https://github.com/ToyOSOrg/evidence/tree/58dcdecab592116996c0e3cc84e4e3c8b7aa50c7/r4/t14)).
C was #1112 after its first review, P #1114's pacing with N = 2 in slow
start, Q P with N = 1.25 throughout. Three of each, C P Q C P Q C P Q, with
Linux at both ends, on trees from before netstack's drivers became their
own processes (#1106):

| | upload p50, MB/s | received by 100 ms | 300 ms | 1 s |
|---|---|---|---|---|
| Linux (2 runs) | 112, 112 | 5.7 MB | 26.1–26.2 MB | 105.7–106.4 MB |
| C (3 runs) | 112, 112, 112 | 2.0–3.1 MB | 23.8–25.4 MB | 105.4–106.8 MB |
| P (3 runs) | 114, 114, 114 | 6.3–6.6 MB | 29.3–29.9 MB | 110.9–111.1 MB |
| Q (3 runs) | 114, 114, 114 | 5.9–6.3 MB | 29.1–29.5 MB | 110.2–111.0 MB |

- P beat C at 300 ms in every pair, and resent nothing.
- P beat Q at 100 ms in every pair, by 0.1–0.5 MB, so N = 2 in slow start
stays.
- In C every RTT sample but two came from a segment's departure.
- That session's bucket could hold up to a millisecond at the rate, which on
a short round trip is more than the window. #1114's pacer, as of
3a0744d34, holds at most half the window. The parity read waits on a boot
of that head, on current main.

**The parity boot, 2026-11-01**
([evidence](https://github.com/ToyOSOrg/evidence/tree/8f08a8e9399817fc669e395dc717ee236a18ed35/r7/t14)).
One boot of #1114 at 3a0744d34, on main after #1106, with Linux before and
after it:

| | upload p50, MB/s | received by 100 ms | 300 ms | 1 s | 2 s |
|---|---|---|---|---|---|
| Linux (2 runs) | 113, 113 | 6.10, 6.38 MB | 28.63, 29.56 MB | 109.1, 110.5 MB | 224.6, 226.1 MB |
| ToyOS | 114 | 6.07 MB | 29.19 MB | 110.7 MB | 227.0 MB |

- The rule set before the run asked for the 100 ms and 300 ms marks at or
above the lower Linux run's, p50 within 1 MB/s, and nothing resent. The
300 ms mark, p50 and loss passed. The 100 ms mark missed by 30 KB, 0.5 %,
so the rule is not met and this record stays open.
- That gap is smaller than the spread of the marks across runs: Linux's two
runs differ by 280 KB, and the A/B's three P runs by 260 KB. One boot does
not separate it from noise.
- Nothing was resent, and no RTO, recovery or loss probe occurred. Every RTT
sample came from a departure (33,319, none from the echo). CSS entered in
134 rounds, 420 times, and never exited.
- The pacer held 73 ms over the row, against 33–41 ms with the A/B's
bucket. The half-window bound holds more on the short round trip, as
expected.

**Exit**: `osbench_lan` on the T14 reads `lan.wired.tcp.upload` at Linux's
in the same session, with the first 300 ms read at the receiver no slower, or
the cause is named and owned by its own issue. The next leads are the CSS
oscillation on this path (RFC 9406 §4.3 lets an implementation tune
`MIN_RTT_THRESH` and `N_RTT_SAMPLE`) and pacing with L = ∞ (§4.3).
the cause is named and owned by its own issue.
22 changes: 11 additions & 11 deletions system/netstack/shard/tcp/src/cc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ const CUBE_DENOMINATOR: i128 = 5_000_000_000_000_000_000;
/// The farthest from `K` the cubic is evaluated, so the cube stays inside `i128`.
const CUBE_SPAN_US: i128 = 1 << 34;

/// RFC 9406 §4.3's constants. L is the non-paced value: this sender does not pace.
const L: u32 = 8;
/// RFC 9406 §4.3's constants; L is infinity, as for a paced sender (`pace`).
const MIN_RTT_THRESH: Duration = Duration::from_millis(4);
const MAX_RTT_THRESH: Duration = Duration::from_millis(16);
const MIN_RTT_DIVISOR: u32 = 8;
Expand Down Expand Up @@ -103,9 +102,8 @@ fn rtt_thresh(last_round_min: Duration) -> Duration {

impl HyStart {
/// What the ACK grows cwnd by when the window was in use, of `bytes` newly acknowledged.
fn growth(&self, bytes: u32, smss: u32) -> u32 {
let capped = bytes.min(smss.saturating_mul(L));
if self.css.is_some() { capped / CSS_GROWTH_DIVISOR } else { capped }
fn growth(&self, bytes: u32) -> u32 {
if self.css.is_some() { bytes / CSS_GROWTH_DIVISOR } else { bytes }
}

/// The ACK's RTT sample and round, after its growth.
Expand Down Expand Up @@ -180,8 +178,9 @@ pub struct Cc {
pub ssthresh: u32,
pub smss: u32,
/// The window is in use: cwnd stopped the sender's last transmit opportunity with data
/// waiting, or the caller's credit ended it with at least half of cwnd in flight (`hand_off`).
/// Every ACK until the next opportunity grows the window, however many one pass brings.
/// waiting, or the caller's credit or the pacer ended it with at least half of cwnd in flight
/// (`hand_off`). Every ACK until the next opportunity grows the window, however many one pass
/// brings.
pub limited: bool,
w_max: Option<u32>,
/// RFC 9438 §4.3's cwnd_prior: cwnd when ssthresh was last set, and before that the initial
Expand Down Expand Up @@ -217,7 +216,7 @@ impl Cc {
let since_last = self.last_ack.replace(now).map_or(Duration::ZERO, |last| now.since(last));
if self.ssthresh == SSTHRESH_INITIAL {
if grow {
self.cwnd = self.cwnd.saturating_add(self.hystart.growth(acked, self.smss));
self.cwnd = self.cwnd.saturating_add(self.hystart.growth(acked));
}
let change = self.hystart.observe(ack);
if change == Some(SlowStart::Ended) {
Expand Down Expand Up @@ -569,14 +568,15 @@ mod tests {
assert_eq!(r.cc.ssthresh, SSTHRESH_INITIAL);
}

/// RFC 9406 runs only in the initial slow start: after a loss, slow start is RFC 5681 §3.1's,
/// one SMSS per ACK at most.
/// RFC 9406 runs only in the initial slow start, where a paced sender grows by all an ACK
/// acknowledged (L = infinity, §4.3): after a loss, slow start is RFC 5681 §3.1's, one SMSS per
/// ACK at most.
#[test]
fn s_cc_022_l_applies_only_to_the_initial_slow_start() {
let mut cc = Cc::new(1460, 0);
cc.limited = true;
cc.on_ack(&ack(at(0), 20_000, None));
assert_eq!(cc.cwnd, 14_600 + 8 * 1460);
assert_eq!(cc.cwnd, 14_600 + 20_000);
cc.on_timeout(cc.cwnd, false);
cc.limited = true;
cc.on_ack(&ack(at(1), 20_000, None));
Expand Down
44 changes: 33 additions & 11 deletions system/netstack/shard/tcp/src/conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use toyos_net_wire::tcp::{SackBlock, SynOptions, TcpFlags, TcpOptions, TcpSegmen

use crate::cc::{self, Cc, SlowStart};
use crate::counters::{Counter, Log};
use crate::pace::Pacer;
use crate::ring::{Lent, Ring};
use crate::rtt::{Rtt, RTO_AFTER_HANDSHAKE_LOSS, RTO_MAX};
use crate::rx::{Placed, Rx};
Expand Down Expand Up @@ -351,13 +352,13 @@ pub struct Sync {
timeout_rtx: bool,
/// When the new data in flight left, while none of it has been resent since (RFC 6298 §3,
/// Karn): the samples an ACK gives at the clock's own resolution (RFC 8985 §6.1). Every send
/// is timed. A send at the same instant as the last joins its run, and so does any send after
/// a run shorter than SMSS until an ACK lands in the run. Only the oldest run can be both short
/// and closed, so the record holds at most a run for each SMSS in flight and two more. An ACK
/// of a whole run is timed from its last send, and one partway into a run that left at one
/// instant from that instant. In a run of several instants, the first ACK partway is timed
/// from its first send, which overstates it by less than the joins took before that ACK ended
/// them; a later partway ACK is not timed.
/// is timed. A send at the same instant as the last joins its run, and so does one shorter than
/// SMSS after a run shorter than SMSS until an ACK lands in the run, so a burst after a short
/// header starts its own run and its ACKs are each timed. The record holds at most two runs
/// for each SMSS in flight and two more. An ACK of a whole run is timed from its last send, and
/// one partway into a run that left at one instant from that instant. In a run of several
/// instants, the first ACK partway is timed from its first send, which overstates it by less
/// than the joins took before that ACK ended them; a later partway ACK is not timed.
departures: VecDeque<Departure>,
/// RFC 8985 §7: when the loss probe is due, whether it is, the probe in flight (SND.NXT once
/// it left and whether it was a retransmission), and whether an RTT sample came since the last.
Expand All @@ -368,6 +369,9 @@ pub struct Sync {
persist: Option<Persist>,
sws: Option<Instant>,
sws_fired: bool,
pacer: Pacer,
/// When the pacer releases the new data it last held.
paced: Option<Instant>,
last_data_sent: Option<Instant>,
progress_since: Option<Instant>,
acked_since: Option<Instant>,
Expand All @@ -389,8 +393,9 @@ pub struct Sync {
retransmission_held: Option<SendLimit>,
}

/// What a sender's new data waits on: RFC 4898 §4's `tcpEStatsPerfSndLimTime{Snd,Rwin,Cwnd}`.
/// Unlike the RFC, which divides all of a connection's time among the three, a span here runs from
/// What a sender's new data waits on: RFC 4898 §4's `tcpEStatsPerfSndLimTime{Snd,Rwin,Cwnd}`, and
/// the pacer.
/// Unlike the RFC, which divides all of a connection's time among its three, a span here runs from
/// a transmit opportunity that left new data unsent to the next one that sends some: time spent
/// sending, or waiting for the caller's next opportunity, is charged to none.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand All @@ -401,6 +406,8 @@ enum SendLimit {
Window,
/// The congestion window, or loss recovery's pipe.
Congestion,
/// The pacer, with room in both windows.
Pacing,
}

impl SendLimit {
Expand All @@ -409,6 +416,7 @@ impl SendLimit {
Self::User => Counter::SendLimitedByUserNanos,
Self::Window => Counter::SendLimitedByWindowNanos,
Self::Congestion => Counter::SendLimitedByCongestionNanos,
Self::Pacing => Counter::SendLimitedByPacingNanos,
}
}
}
Expand Down Expand Up @@ -477,6 +485,8 @@ impl Sync {
persist: None,
sws: None,
sws_fired: false,
pacer: Pacer::default(),
paced: None,
last_data_sent: None,
progress_since: None,
acked_since: None,
Expand Down Expand Up @@ -1127,6 +1137,7 @@ impl Sync {
self.persist.filter(|p| !p.due).map(|p| p.at),
self.keepalive_at(ctx),
self.sws.filter(|_| !self.sws_fired),
self.paced,
self.rx.delayed,
self.give_up_at(ctx),
self.orphan_since.map(|at| at.after(limits::ORPHAN_IDLE)),
Expand Down Expand Up @@ -1169,6 +1180,9 @@ impl Sync {
self.sws = None;
self.sws_fired = true;
}
if self.paced.is_some_and(|at| at <= now) {
self.paced = None;
}
self.rx.delayed_due(now);
Tick::Keep
}
Expand Down Expand Up @@ -1233,7 +1247,8 @@ impl Sync {
} else {
Some(self.declined)
};
if let Some(limit) = held {
// A pacing hold leaves the window as the last send found it (`hand_off`).
if let Some(limit) = held.filter(|&limit| limit != SendLimit::Pacing) {
self.cc.limited = limit == SendLimit::Congestion;
}
let was = self.held_by.map(|(limit, _)| limit);
Expand Down Expand Up @@ -1331,6 +1346,7 @@ impl Sync {
self.tx.nxt = end;
}
if len > 0 {
self.pacer.spend(now, len, &self.cc, self.rtt.srtt());
self.last_data_sent = Some(now);
self.sws = None;
self.sws_fired = false;
Expand All @@ -1355,7 +1371,7 @@ impl Sync {
} else if end.after(old) {
let smss = self.smss();
match self.departures.back_mut() {
Some(run) if run.end == start && (run.last == now || (!run.sampled && run.end.since(run.start) < smss)) => {
Some(run) if run.end == start && (run.last == now || (!run.sampled && run.end.since(run.start) < smss && len < smss)) => {
(run.end, run.last) = (end, now);
}
_ => self.departures.push_back(Departure { start, end, first: now, last: now, sampled: false }),
Expand Down Expand Up @@ -1485,6 +1501,7 @@ impl Sync {
/// New data and the FIN (RFC 9293 §3.7.4 Nagle, §3.8.6.2.1 sender silly-window avoidance),
/// within SND.UNA + min(cwnd, SND.WND) (RFC 5681 §2).
fn new_data<T>(&mut self, ctx: &mut Ctx<'_>, exit: &mut dyn Exit<T>) -> Result<bool, NotReady> {
self.paced = None;
let unsent = self.tx.unsent();
if unsent == 0 && !self.tx.fin_unsent() {
self.declined = SendLimit::User;
Expand Down Expand Up @@ -1546,6 +1563,11 @@ impl Sync {
return Ok(false);
}
let len = u32::try_from(usable).unwrap_or(0);
if let Some(at) = self.pacer.hold(now, len, &self.cc, self.rtt.srtt()) {
self.declined = SendLimit::Pacing;
self.paced = Some(at);
return Ok(false);
}
let beyond_cwnd = limited_transmit && signed(len) > cwnd.saturating_sub(signed(self.tx.flight()));
let fin = self.fin_fits(start.add(len)) && signed(len) < w_rcv;
self.emit(ctx, exit, start, len, fin, blocks)?;
Expand Down
1 change: 1 addition & 0 deletions system/netstack/shard/tcp/src/counters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ toyos_net_wire::counters! {
SendLimitedByUserNanos = "tcp.send-limited.user.ns";
SendLimitedByWindowNanos = "tcp.send-limited.window.ns";
SendLimitedByCongestionNanos = "tcp.send-limited.congestion.ns";
SendLimitedByPacingNanos = "tcp.send-limited.pacing.ns";
CongestionStops = "tcp.congestion-stops";
CongestionStopsCwndBytes = "tcp.congestion-stops.cwnd.bytes";
CongestionStopsSrttMicros = "tcp.congestion-stops.srtt.us";
Expand Down
12 changes: 7 additions & 5 deletions system/netstack/shard/tcp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! ToyOS's TCP (RFC 9293) with RFC 5961's blind-attack checks, MSS, window scaling, timestamps
//! with PAWS (RFC 7323), SACK and D-SACK (RFC 2018, 2883), RFC 6298's timer, CUBIC (RFC 9438),
//! NewReno (RFC 6582) and SACK-based recovery (RFC 6675), keyed initial sequence numbers and ports
//! (RFC 6528, 6056), and TIME-WAIT hardened against assassination (RFC 1337). Pure: the caller hands
//! in the time, the secrets, parsed segments, classified ICMP errors, user calls and transmit
//! credit; nothing here reads a clock, draws randomness or does I/O.
//! with PAWS (RFC 7323), SACK and D-SACK (RFC 2018, 2883), RFC 6298's timer, CUBIC (RFC 9438)
//! after HyStart++ (RFC 9406), pacing (RFC 9002 §7.7), NewReno (RFC 6582) and SACK-based recovery
//! (RFC 6675), keyed initial sequence numbers and ports (RFC 6528, 6056), and TIME-WAIT hardened
//! against assassination (RFC 1337). Pure: the caller hands in the time, the secrets, parsed
//! segments, classified ICMP errors, user calls and transmit credit; nothing here reads a clock,
//! draws randomness or does I/O.
//!
//! **Pull egress.** A segment exists only while [`Tcp::transmit_owed`] or [`Tcp::serve`] hands it
//! to the caller's sink, built from the state of that moment, once the caller has answered that its
Expand Down Expand Up @@ -38,6 +39,7 @@ mod cc;
mod conn;
mod counters;
mod open;
mod pace;
#[cfg(test)]
mod props;
mod ring;
Expand Down
Loading