Skip to content

Existing connections keep sending GSO batches after noq-udp disables GSO #4555

Description

@ThierryCantin-Demers

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 offload and the client times out after 30 s (2/2 runs)
  • with the patch below, which reads max_gso_segments() on every max_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.

Activity

  1. moved this to 🚑 Needs Triage in irohon Sep 24, 2026
  2. matheus23 commented on Oct 12, 2026

    @matheus23
    Member

    Thank you for letting us know about this issue.

    I think there are many ways this could get fixed:

    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: NonZeroUsize in TransportsSender or 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.

  3. moved this from 🚑 Needs Triage to 👍 Ready in irohon Oct 12, 2026
  4. added
    ready-for-prConsensus is reached, a PR is welcome. See CONTRIBUTING.md for details.
    on Oct 12, 2026
  5. cibyr commented on Oct 19, 2026

    @cibyr

    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 the Endpoint builder works around it.

    Having Sender::max_transmit_segments() read the current value every time, plus having noq split and resend immediately when it gets EIO (as in n0-computer/noq#746 or quinn-rs/quinn#2724) feels like the right fix to me.

  6. baszalmstra commented on Oct 20, 2026

    @baszalmstra

    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!

  7. added a commit that references this issue on Oct 23, 2026
  8. moved this from 👍 Ready to 🏗 In progress in irohon Oct 27, 2026
  9. ThierryCantin-Demers commented on Oct 27, 2026

    @ThierryCantin-Demers
    Author

    Opened #4645 as a draft, I'll make sure it's clean before marking it ready. Following the comment above, TransportsSender no longer stores the limit: it reads the IP sockets' max_gso_segments() on every max_transmit_segments() call, and only the custom transports' limit stays stored.

    On the performance impact: noq calls max_transmit_segments() once per drive_transmit, not per datagram, and the read is an uncontended RwLock read per IP socket. iroh-bench over 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-prConsensus is reached, a PR is welcome. See CONTRIBUTING.md for details.

    Type

    Projects

    • Status
      🏗 In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions