Repository navigation
Description
Describe the bug
When a UDP GSO send fails, noq-udp sets the socket's max_gso_segments to 1. But iroh only reads that value when a connection is created (transports.rs#L471). So a connection that was already open keeps sending GSO batches, noq-udp swallows the errors, and the connection times out.
I hit this with a MediaTek MT7922 wifi card on Ubuntu 24.04 (kernel 6.8). Before 6.11, Linux refuses GSO with EIO when the NIC has no TX checksum offload. Newer kernels fall back to software checksums (commit).
Repro below. The client asks the server, running on the affected machine, for 256 KiB:
- 1.2.0: the server logs
halting segmentation offloadand the client times out after 30 s (2/2 runs) - with the patch below, which reads
max_gso_segments()on everymax_transmit_segments()call: reply in about 60 ms (2/2) - with
enable_segmentation_offload(false): reply in about 30 ms (1/1)
main has the same code.
Cargo.toml and src/main.rs
[dependencies]
iroh = "1.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }//! Repro: iroh keeps sending GSO batches on a connection after noq-udp has disabled GSO.
//!
//! On the affected machine (Linux before 6.11, NIC without TX checksum offload):
//! cargo run --release -- server 4433
//! On any other machine:
//! cargo run --release -- client <server-ip>:4433
//!
//! Passing `--no-gso` to the server turns segmentation offload off, which avoids the bug.
use std::{error::Error, net::ToSocketAddrs, time::Instant};
use iroh::{
Endpoint, EndpointAddr, RelayMode, SecretKey,
endpoint::{Incoming, QuicTransportConfig, presets},
};
const ALPN: &[u8] = b"gso-repro";
const REPLY_SIZE: usize = 256 * 1024;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
tracing_subscriber::fmt()
.with_env_filter("noq_udp=info")
.init();
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("server") => {
let port: u16 = args[2].parse()?;
let use_gso = !args.iter().any(|arg| arg == "--no-gso");
run_server(port, use_gso).await
}
Some("client") => run_client(&args[2]).await,
_ => Err("usage: server <port> [--no-gso] | client <server-ip>:<port>".into()),
}
}
/// Both sides use this key, so the client knows the server's id without copying it over.
fn server_secret_key() -> SecretKey {
SecretKey::from_bytes(&[7; 32])
}
async fn run_server(port: u16, use_gso: bool) -> Result<(), Box<dyn Error>> {
let transport = QuicTransportConfig::builder()
.enable_segmentation_offload(use_gso)
.build();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(server_secret_key())
.relay_mode(RelayMode::Disabled)
.bind_addr(format!("0.0.0.0:{port}"))?
.alpns(vec![ALPN.to_vec()])
.transport_config(transport)
.bind()
.await?;
println!("listening on udp port {port}, gso {use_gso}");
while let Some(incoming) = endpoint.accept().await {
if let Err(err) = serve_one_request(incoming).await {
println!("connection failed: {err}");
}
}
Ok(())
}
/// Answers one request with `REPLY_SIZE` bytes, then waits for the client to close.
async fn serve_one_request(incoming: Incoming) -> Result<(), Box<dyn Error>> {
let connection = incoming.await?;
let (mut send, mut recv) = connection.accept_bi().await?;
let mut request = [0u8; 1];
recv.read_exact(&mut request).await?;
send.write_all(&vec![0u8; REPLY_SIZE]).await?;
let reason = connection.closed().await;
println!("connection closed: {reason}");
Ok(())
}
async fn run_client(server: &str) -> Result<(), Box<dyn Error>> {
let server_ip = server
.to_socket_addrs()?
.next()
.ok_or("cannot resolve the server")?;
let server_addr = EndpointAddr::new(server_secret_key().public()).with_ip_addr(server_ip);
let endpoint = Endpoint::builder(presets::Minimal)
.relay_mode(RelayMode::Disabled)
.bind()
.await?;
let connection = endpoint.connect(server_addr, ALPN).await?;
let (mut send, mut recv) = connection.open_bi().await?;
let started = Instant::now();
send.write_all(&[1]).await?;
let mut reply = vec![0u8; REPLY_SIZE];
let result = recv.read_exact(&mut reply).await;
println!(
"{} KiB reply after {:.1?}: {result:?}",
REPLY_SIZE / 1024,
started.elapsed()
);
result?;
connection.close(0u32.into(), b"done");
endpoint.close().await;
Ok(())
}Relevant Logs
Server, on the affected machine (RUST_LOG=noq_udp=info):
2026-09-23T21:48:00.908248Z INFO noq_udp::imp: `libc::sendmsg` failed with Input/output error (os error 5); halting segmentation offload
Client:
256 KiB reply after 30.1s: Err(ReadError(ConnectionLost(TimedOut)))
Expected behavior
Once noq-udp lowers max_gso_segments, connections that are already open stop sending GSO batches, the same as new connections.
Iroh
Version:
iroh v1.2.0
├── iroh feature "default"
│ └── gso_repro v0.1.0
│ └── gso_repro feature "default" (command-line)
├── iroh feature "fast-apple-datapath"
│ └── iroh feature "default" (*)
├── iroh feature "metrics"
│ └── iroh feature "default" (*)
├── iroh feature "portmapper"
│ └── iroh feature "default" (*)
└── iroh feature "tls-ring"
└── iroh feature "default" (*)
Endpoint configuration: presets::Minimal with RelayMode::Disabled on both sides, a fixed bind_addr on the server, and the client dialing EndpointAddr::new(id).with_ip_addr(addr).
Platform(s)
Desktop:
- OS: Ubuntu 24.04.4 LTS on the server, with a MediaTek MT7922 wifi card (
mt7921e). The client was Fedora 44 and is not affected. - Version: kernel 6.8.0-139-generic
Additional Context / Screenshots / GIFs
The patch I tested:
Patch against 1.2.0
--- a/iroh/src/socket/transports.rs
+++ b/iroh/src/socket/transports.rs
@@ -961,6 +961,15 @@
}
impl TransportsSender {
+ // noq-udp drops a socket's GSO limit to 1 when the kernel refuses a segmented send.
+ fn current_max_transmit_segments(&self) -> NonZeroUsize {
+ #[cfg(not(wasm_browser))]
+ let ip = self.ip.max_transmit_segments();
+ #[cfg(wasm_browser)]
+ let ip = None;
+ ip.map_or(self.max_transmit_segments, |ip| ip.min(self.max_transmit_segments))
+ }
+
#[instrument(name = "poll_send", skip(self, cx, transmit), fields(len = transmit.contents.len()))]
pub(crate) fn poll_send(
mut self: Pin<&mut Self>,
@@ -1300,7 +1309,7 @@
}
fn max_transmit_segments(&self) -> NonZeroUsize {
- self.sender.max_transmit_segments
+ self.sender.current_max_transmit_segments()
}
}
--- a/iroh/src/socket/transports/ip.rs
+++ b/iroh/src/socket/transports/ip.rs
@@ -261,6 +261,7 @@
let sender = self.socket.clone().create_sender();
IpSender {
config: self.config,
+ socket: self.socket.clone(),
sender,
metrics: self.metrics.clone(),
}
@@ -293,12 +294,17 @@
#[pin_project]
pub(super) struct IpSender {
config: Config,
+ socket: Arc<UdpSocket>,
#[pin]
sender: UdpSender,
metrics: Arc<SocketMetrics>,
}
impl IpSender {
+ pub(super) fn max_transmit_segments(&self) -> NonZeroUsize {
+ self.socket.max_gso_segments()
+ }
+
pub(super) fn is_valid_send_addr(&self, src: Option<IpAddr>, dst: &SocketAddr) -> bool {
self.config.is_valid_send_addr(src, *dst)
}
@@ -365,6 +371,10 @@
}
impl IpTransportsSender {
+ pub(super) fn max_transmit_segments(&self) -> Option<NonZeroUsize> {
+ self.v4.iter().chain(&self.v6).map(IpSender::max_transmit_segments).min()
+ }
+
pub(super) fn v4_iter_mut(&mut self) -> impl Iterator<Item = &mut IpSender> {
self.v4.iter_mut()
}enable_segmentation_offload(false) works around it. n0-computer/noq#746 would probably hide this too.
lolwut
- This issue was created by a human that thought critically about
the issue being reported and wrote this as concisely and clearly
as they could. Taking full responsibility for the issue being
accurate. - This issue isn't slop, and is backed up by evidence.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fields🏗 In progress
Activity
fix(remote): turn off iroh GSO until iroh#4555 is fixed (#5819)
matheus23 commentedon Oct 12, 2026
Thank you for letting us know about this issue.
I think there are many ways this could get fixed:
If this were the case, then iroh wouldn't have to add the indirection that causes this "outdated value read" issue.
I think a version of the patch you're referencing is probably correct.
Generally, we should never do this kind of storing of
max_transmit_segments: NonZeroUsizeinTransportsSenderor anywhere else similar. It unfortunately might need to be computed fresh every time.That said, it'd be important to see how much of a performance impact this has.
cibyr commentedon Oct 19, 2026
I recently hit this on this Allwinner board with a 5.4 kernel and XR829 wifi (no TX checksum offload). The net result is that the first Iroh connection times out, but the client eventually retries a new connection and everything after that works.
.transport_config(QuicTransportConfig::builder().enable_segmentation_offload(false).build())on theEndpointbuilder works around it.Having
Sender::max_transmit_segments()read the current value every time, plus having noq split and resend immediately when it getsEIO(as in n0-computer/noq#746 or quinn-rs/quinn#2724) feels like the right fix to me.baszalmstra commentedon Oct 20, 2026
I have hit the same bug on a Jetson Orin Nano (kernel 6.8, rtl88x2ce Wi-Fi), the supplied patch fixes the issue for me. Would you mind opening a PR with the patch? Happy to do that myself and credit you!
fix(iroh): read the GSO segment limit on every transmit
Merge pull request #46 from s-te-ch/iroh-workaround
ThierryCantin-Demers commentedon Oct 27, 2026
Opened #4645 as a draft, I'll make sure it's clean before marking it ready. Following the comment above,
TransportsSenderno longer stores the limit: it reads the IP sockets'max_gso_segments()on everymax_transmit_segments()call, and only the custom transports' limit stays stored.On the performance impact: noq calls
max_transmit_segments()once perdrive_transmit, not per datagram, and the read is an uncontendedRwLockread per IP socket.iroh-benchover loopback shows no measurable difference on two machines, and on the affected machine a machine learning training workload with burn that never finished on main runs normally with GSO on. The numbers are in the PR.@baszalmstra: I went ahead and opened it, but a run of the branch on your Jetson would be useful, since it's a second affected setup.
test(iroh): open senders stop batching after a refused segmented send