From cefabd234534469b86ca5352a2cfb4c69a7bd6d1 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 22:25:01 +0200 Subject: [PATCH 01/11] netd: a listener's socket that left Listen is handed over or listens again A listener is one smoltcp socket that becomes the connection it accepts, so its port listens only while that socket is in Listen. netd woke the owner only when a pass saw the socket Established, and accept took only Established. A peer whose handshake-closing ACK and FIN land in one pass moves the socket SynReceived -> CloseWait with no pass seeing it Established; an ACK and a reset in one pass leave it Closed. Either way the owner is never woken, the socket never listens again, and every later SYN on the port is answered with a reset, with nothing logged on either side. The rule is `listen::Listening`, one type the pass and accept both use. A socket in Established or CloseWait holds a connection its owner is woken for and takes; a Closed one listens again. `settle` is the one function that reads a listener's socket state, and it writes: it is what puts a Closed socket back into Listen. Taken from bdfc2c52 (userland/netd only); `connection_waiting` is renamed `settle` because it re-listens a closed socket. Host negative controls on listen.rs, each measured on bdfc2c52's tree with a checked patch that built and was restored: CloseWait answered like Listen, Closed not re-listened, accept keeping the wake; each exits 101. Co-Authored-By: Claude Opus 5.5 --- userland/netd/src/listen.rs | 66 +++++++ userland/netd/src/listen/tests.rs | 281 ++++++++++++++++++++++++++++++ userland/netd/src/main.rs | 21 +-- 3 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 userland/netd/src/listen.rs create mode 100644 userland/netd/src/listen/tests.rs diff --git a/userland/netd/src/listen.rs b/userland/netd/src/listen.rs new file mode 100644 index 0000000000..919ed083ed --- /dev/null +++ b/userland/netd/src/listen.rs @@ -0,0 +1,66 @@ +//! Whether a listener's owner is owed a wake, and what its accept finds. +//! +//! **A listener is one smoltcp socket that becomes the connection it +//! accepts**, so the port listens only while that socket is in `Listen`: one +//! that left it is handed to its owner or listens again, or the port answers +//! every other peer with a reset for the rest of the boot. + +use smoltcp::socket::tcp; + +/// A listener's port, and whether its owner holds a wake it has not spent on +/// an accept. +pub struct Listening { + port: u16, + woken: bool, +} + +impl Listening { + pub fn new(port: u16) -> Self { + Self { port, woken: false } + } + + pub fn port(&self) -> u16 { + self.port + } + + /// Whether the owner is owed a wake for `socket`: a connection waits and + /// the owner holds no wake. + pub fn owes_wake(&self, socket: &mut tcp::Socket) -> bool { + settle(socket, self.port) && !self.woken + } + + pub fn woke(&mut self) { + self.woken = true; + } + + /// An accept, which spends the owner's wake whatever it finds: whether + /// `socket` holds a connection to hand over. A wake written for a + /// connection its peer then reset is spent here, so the next connection + /// is announced. + pub fn accept(&mut self, socket: &mut tcp::Socket) -> bool { + self.woken = false; + settle(socket, self.port) + } +} + +/// Puts `socket` back to listening on `port` if its peer reset it before its +/// owner took it, and says whether it holds a connection: a handshake +/// finished, whatever the peer did since. Its FIN included, which can land in +/// the same pass as the handshake's last ACK, so no pass ever sees the socket +/// `Established`. +fn settle(socket: &mut tcp::Socket, port: u16) -> bool { + match socket.state() { + tcp::State::Listen | tcp::State::SynReceived => false, + tcp::State::Established | tcp::State::CloseWait => true, + tcp::State::Closed => { + socket + .listen(port) + .unwrap_or_else(|e| panic!("netd: a closed socket refused to listen on {port}: {e:?}")); + false + } + other => panic!("netd: a listener's socket is {other:?}, which only netd closing or connecting it reaches"), + } +} + +#[cfg(test)] +mod tests; diff --git a/userland/netd/src/listen/tests.rs b/userland/netd/src/listen/tests.rs new file mode 100644 index 0000000000..92afa7b756 --- /dev/null +++ b/userland/netd/src/listen/tests.rs @@ -0,0 +1,281 @@ +//! A listener's socket on a wire: smoltcp's own `Interface` on an Ethernet +//! device whose far end is played here segment by segment, so what is judged +//! is what the port answers the next peer. + +use super::*; +use std::collections::VecDeque; + +use smoltcp::iface::{Config, Interface, PollResult, SocketHandle, SocketSet}; +use smoltcp::phy::{self, ChecksumCapabilities, Device, DeviceCapabilities, Medium}; +use smoltcp::time::Instant; +use smoltcp::wire::{ + ArpOperation, ArpPacket, ArpRepr, EthernetAddress, EthernetFrame, EthernetProtocol, EthernetRepr, + HardwareAddress, IpAddress, IpCidr, IpProtocol, Ipv4Address, Ipv4Packet, Ipv4Repr, TcpControl, TcpPacket, + TcpRepr, TcpSeqNumber, +}; + +const OUR_MAC: EthernetAddress = EthernetAddress([0x02, 0, 0, 0, 0, 0x01]); +const OURS: Ipv4Address = Ipv4Address::new(10, 0, 2, 15); +const PEER_MAC: EthernetAddress = EthernetAddress([0x02, 0, 0, 0, 0, 0x02]); +const PEER: Ipv4Address = Ipv4Address::new(10, 0, 2, 2); +const PORT: u16 = 22; +/// The peer's initial sequence number, on every connection it opens. +const PEER_ISN: u32 = 1000; + +#[derive(Default)] +struct Wire { + inbound: VecDeque>, + outbound: Vec>, +} + +struct Rx(Vec); +struct Tx<'a>(&'a mut Vec>); + +impl phy::RxToken for Rx { + fn consume R>(self, f: F) -> R { + f(&self.0) + } +} + +impl phy::TxToken for Tx<'_> { + fn consume R>(self, len: usize, f: F) -> R { + let mut frame = vec![0u8; len]; + let result = f(&mut frame); + self.0.push(frame); + result + } +} + +impl Device for Wire { + type RxToken<'a> = Rx; + type TxToken<'a> = Tx<'a>; + + fn receive(&mut self, _: Instant) -> Option<(Rx, Tx<'_>)> { + let frame = self.inbound.pop_front()?; + Some((Rx(frame), Tx(&mut self.outbound))) + } + + fn transmit(&mut self, _: Instant) -> Option> { + Some(Tx(&mut self.outbound)) + } + + fn capabilities(&self) -> DeviceCapabilities { + let mut caps = DeviceCapabilities::default(); + caps.max_transmission_unit = 1514; + caps.medium = Medium::Ethernet; + caps + } +} + +/// One TCP segment our side sent: its flags, sequence number and the peer +/// port it went to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Sent { + control: TcpControl, + ack: bool, + seq: u32, + to: u16, +} + +struct Net { + iface: Interface, + wire: Wire, + sockets: SocketSet<'static>, + listener: SocketHandle, + listening: Listening, + sent: Vec, +} + +impl Net { + fn new() -> Self { + let mut wire = Wire::default(); + let mut iface = Interface::new(Config::new(HardwareAddress::Ethernet(OUR_MAC)), &mut wire, Instant::from_millis(0)); + iface.update_ip_addrs(|addrs| addrs.push(IpCidr::new(IpAddress::Ipv4(OURS), 24)).unwrap()); + let mut socket = tcp::Socket::new(tcp::SocketBuffer::new(vec![0; 4096]), tcp::SocketBuffer::new(vec![0; 4096])); + socket.listen(PORT).expect("a fresh socket listens"); + let mut sockets = SocketSet::new(Vec::new()); + let listener = sockets.add(socket); + Self { iface, wire, sockets, listener, listening: Listening::new(PORT), sent: Vec::new() } + } + + fn socket(&mut self) -> &mut tcp::Socket<'static> { + self.sockets.get_mut::(self.listener) + } + + /// One pass of netd's loop: everything the wire holds, in one batch, and + /// the far end's ARP answered. + fn pass(&mut self) { + loop { + while self.iface.poll(Instant::from_millis(0), &mut self.wire, &mut self.sockets) != PollResult::None {} + if self.wire.outbound.is_empty() { + return; + } + for frame in std::mem::take(&mut self.wire.outbound) { + self.far_end(&frame); + } + } + } + + fn far_end(&mut self, frame: &[u8]) { + let eth = EthernetFrame::new_checked(frame).expect("the interface sent an Ethernet frame"); + match eth.ethertype() { + EthernetProtocol::Arp => { + let arp = ArpRepr::parse(&ArpPacket::new_checked(eth.payload()).unwrap()).unwrap(); + let ArpRepr::EthernetIpv4 { operation: ArpOperation::Request, .. } = arp else { return }; + let reply = ArpRepr::EthernetIpv4 { + operation: ArpOperation::Reply, + source_hardware_addr: PEER_MAC, + source_protocol_addr: PEER, + target_hardware_addr: OUR_MAC, + target_protocol_addr: OURS, + }; + let mut out = vec![0u8; 14 + reply.buffer_len()]; + let mut e = EthernetFrame::new_unchecked(&mut out); + EthernetRepr { src_addr: PEER_MAC, dst_addr: OUR_MAC, ethertype: EthernetProtocol::Arp }.emit(&mut e); + reply.emit(&mut ArpPacket::new_unchecked(e.payload_mut())); + self.wire.inbound.push_back(out); + } + EthernetProtocol::Ipv4 => { + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + assert_eq!(ip.next_header(), IpProtocol::Tcp, "a listener sends only TCP"); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + let control = match (tcp.syn(), tcp.fin(), tcp.rst()) { + (true, _, _) => TcpControl::Syn, + (_, true, _) => TcpControl::Fin, + (_, _, true) => TcpControl::Rst, + _ => TcpControl::None, + }; + self.sent.push(Sent { control, ack: tcp.ack(), seq: tcp.seq_number().0 as u32, to: tcp.dst_port() }); + } + other => panic!("the interface sent a frame of type {other}"), + } + } + + /// A segment from the peer's `from` port onto the wire, delivered at the + /// next [`Net::pass`]. + fn send(&mut self, from: u16, control: TcpControl, seq: u32, ack: Option) { + let caps = ChecksumCapabilities::default(); + let tcp = TcpRepr { + src_port: from, + dst_port: PORT, + control, + seq_number: TcpSeqNumber(seq as i32), + ack_number: ack.map(|a| TcpSeqNumber(a as i32)), + window_len: 64000, + window_scale: None, + max_seg_size: None, + sack_permitted: false, + sack_ranges: [None, None, None], + timestamp: None, + payload: &[], + }; + let ip = Ipv4Repr { + src_addr: PEER, + dst_addr: OURS, + next_header: IpProtocol::Tcp, + payload_len: tcp.buffer_len(), + hop_limit: 64, + }; + let mut out = vec![0u8; 14 + ip.buffer_len() + ip.payload_len]; + let mut e = EthernetFrame::new_unchecked(&mut out); + EthernetRepr { src_addr: PEER_MAC, dst_addr: OUR_MAC, ethertype: EthernetProtocol::Ipv4 }.emit(&mut e); + let mut packet = Ipv4Packet::new_unchecked(e.payload_mut()); + ip.emit(&mut packet, &caps); + tcp.emit(&mut TcpPacket::new_unchecked(packet.payload_mut()), &IpAddress::Ipv4(PEER), &IpAddress::Ipv4(OURS), &caps); + self.wire.inbound.push_back(out); + } + + /// The peer's SYN from `from`, and the SYN-ACK's sequence number. + fn syn(&mut self, from: u16) -> u32 { + self.send(from, TcpControl::Syn, PEER_ISN, None); + self.pass(); + self.sent + .iter() + .rev() + .find(|s| s.control == TcpControl::Syn && s.ack && s.to == from) + .unwrap_or_else(|| panic!("a SYN from {from} was answered {:?}", self.sent.last())) + .seq + } + + /// The peer's answer to the SYN-ACK and `then`, in one batch. + fn ack_and(&mut self, from: u16, our_isn: u32, then: TcpControl) { + self.send(from, TcpControl::None, PEER_ISN + 1, Some(our_isn + 1)); + self.send(from, then, PEER_ISN + 1, Some(our_isn + 1)); + self.pass(); + } + + /// Whether the port answers a new peer's SYN with a SYN-ACK: it listens. + fn listens(&mut self, from: u16) -> bool { + self.send(from, TcpControl::Syn, PEER_ISN, None); + self.pass(); + let answer = *self.sent.iter().rev().find(|s| s.to == from).expect("a SYN is answered"); + answer.control == TcpControl::Syn + } + + fn owes_wake(&mut self) -> bool { + self.listening.owes_wake(self.sockets.get_mut::(self.listener)) + } + + fn accept(&mut self) -> bool { + self.listening.accept(self.sockets.get_mut::(self.listener)) + } +} + +#[test] +fn a_finished_handshake_is_owed_one_wake() { + let mut net = Net::new(); + let isn = net.syn(5001); + assert!(!net.owes_wake(), "a SYN alone was taken for a connection"); + net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.owes_wake()); + net.listening.woke(); + assert!(!net.owes_wake(), "a connection its owner holds a wake for was announced twice"); + assert!(net.accept()); +} + +/// **A peer that sends its FIN with the handshake's last ACK is still a +/// connection.** Both land in one pass, so the socket goes from `SynReceived` +/// to `CloseWait` with no pass ever seeing it `Established`. +#[test] +fn a_peer_that_closes_with_its_last_ack_is_a_connection() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.ack_and(5001, isn, TcpControl::Fin); + assert_eq!(net.socket().state(), tcp::State::CloseWait, "the premise: both in one pass"); + assert!(net.owes_wake(), "a connection the peer half-closed was never announced"); + net.listening.woke(); + assert!(net.accept(), "a connection the peer half-closed was not handed over"); +} + +/// A peer that resets before its owner took it frees the port: the next peer +/// is answered a SYN-ACK and not a reset. +#[test] +fn a_peer_that_resets_before_it_is_taken_frees_the_port() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.ack_and(5001, isn, TcpControl::Rst); + assert_eq!(net.socket().state(), tcp::State::Closed, "the premise: both in one pass"); + assert!(!net.owes_wake()); + assert!(net.listens(5002), "the port answered the next peer {:?}", net.sent.last()); +} + +/// A wake written for a connection its peer then reset is spent by the +/// accept that finds nothing, and the next connection is announced. +#[test] +fn a_wake_spent_on_a_reset_connection_announces_the_next() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.owes_wake()); + net.listening.woke(); + net.send(5001, TcpControl::Rst, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(!net.owes_wake(), "a reset connection was announced"); + assert!(!net.accept(), "an accept took a connection its peer had reset"); + let isn = net.syn(5002); + net.send(5002, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.owes_wake(), "the connection after a reset one was never announced"); +} diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index b5a5682bd7..0c4a164032 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -9,6 +9,7 @@ use toyos::say; mod device; mod dhcp; mod i219; +mod listen; mod mdns; mod report; mod resolve; @@ -399,7 +400,7 @@ impl PipedConnection { struct PipedListener { handle: SocketHandle, notify_write: Pipe, - notified: bool, + listening: listen::Listening, } struct PendingPipedConnect { @@ -1223,7 +1224,7 @@ impl NetDaemon { self.piped_listeners.insert(socket_id, PipedListener { handle, notify_write, - notified: false, + listening: listen::Listening::new(port), }); msg.client.result(&TcpBindResponse { @@ -1251,21 +1252,19 @@ impl NetDaemon { msg.client.error(ERR_INVALID_INPUT); return; }; - let Some(listener) = self.piped_listeners.get(&req.socket_id) else { + let Some(listener) = self.piped_listeners.get_mut(&req.socket_id) else { msg.client.error(ERR_NOT_CONNECTED); return; }; let socket = socket_set.get_mut::(listener.handle); - // Not `is_active`: that is already true in SynReceived, where - // `remote_endpoint()` is still None. - if socket.state() != tcp::State::Established { + if !listener.listening.accept(socket) { msg.client.error(ERR_NOT_CONNECTED); return; } let remote = socket.remote_endpoint().unwrap(); - let local_port = socket.local_endpoint().unwrap().port; + let local_port = listener.listening.port(); let remote_addr = match remote.addr { IpAddress::Ipv4(a) => a.octets(), }; @@ -1290,7 +1289,6 @@ impl NetDaemon { if let Some(pl) = self.piped_listeners.get_mut(&req.socket_id) { pl.handle = new_handle; - pl.notified = false; } msg.client.result(&TcpAcceptPipedResponse { @@ -1434,12 +1432,11 @@ impl NetDaemon { let mut dead = Vec::new(); for (&socket_id, listener) in &mut self.piped_listeners { let socket = socket_set.get_mut::(listener.handle); - // Not `is_active`: that is already true in SynReceived, before the - // three-way handshake completes. - let owed = socket.state() == tcp::State::Established && !listener.notified; + let owed = listener.listening.owes_wake(socket); let wake: &[u8] = if owed { &[1] } else { &[] }; match toyos_abi::syscall::write_nonblock(listener.notify_write.as_handle(), wake) { - Ok(_) => listener.notified |= owed, + Ok(_) if owed => listener.listening.woke(), + Ok(_) => {} Err(SyscallError::WouldBlock) if !owed => {} // Its owner has gone, which is the ordinary end of a listener. Err(SyscallError::Gone) => dead.push(socket_id), From 325464d9ca5836b14dee0502d7c360e89e687a09 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 22:34:22 +0200 Subject: [PATCH 02/11] tests: sshd_hasty_peers, port 22 answered after peers that leave at once Eighty peers connect through QEMU's forward and leave at once, half of them with a zero linger so the close is a reset; then `echo` over ssh must be answered. The forward finishes the guest's handshake after the host has closed and sends the FIN straight behind the last ACK, which is how a listener's socket goes SynReceived -> CloseWait with no pass seeing it Established. A netd that wakes its owner only for Established leaves port 22 resetting every SYN for the rest of the boot. It is the committed arm for netd's wiring of `listen::Listening`: the host tests in userland/netd call `Listening` directly and stay green when the pass stops using it. A reset ask is retried at once, since a hasty handshake still in flight shuts the port without sshd hearing of it; a second reset waits on sshd saying something, since a connection netd holds for sshd shuts the port until sshd takes it. A port that stays shut ends the wait as a guest gone quiet, which is the red. No fixed delay: the measured actuator this comes from paced its rounds and retried its ask on a one-second sleep, and neither is kept. Co-Authored-By: Claude Opus 5.5 --- tests/common/ssh.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++ tests/toyos.rs | 4 +++ 2 files changed, 87 insertions(+) diff --git a/tests/common/ssh.rs b/tests/common/ssh.rs index fa6857082f..737474f5da 100644 --- a/tests/common/ssh.rs +++ b/tests/common/ssh.rs @@ -627,6 +627,89 @@ pub fn key_auth_gate(guest: &mut super::qemu::QemuInstance) -> Result<(), String Ok(()) } +/// Rounds of hasty peers, each one peer that closes and one that resets. +const HASTY_ROUNDS: usize = 40; + +/// Peers that connect through the forward and leave at once, and after them +/// `echo` answered on port 22. +/// +/// **A hasty peer's last handshake ACK and its FIN or RST can land in one of +/// netd's passes**: QEMU's forward finishes the guest's handshake after the +/// host has already closed, and sends the FIN straight behind the ACK. The +/// listener's socket then goes from `SynReceived` to `CloseWait` or `Closed` +/// without a pass ever seeing it `Established`, and a netd that wakes sshd +/// only for `Established` leaves port 22 resetting every later SYN. +/// +/// A connect port 22 resets is asked again at once, because a hasty peer's +/// handshake still in flight shuts the port without sshd hearing of it; after +/// a second, only once sshd has said something, because a connection netd +/// holds for sshd shuts the port until sshd takes it. A port that stays shut +/// is a guest that goes quiet. +pub fn hasty_peers_gate(guest: &mut super::qemu::QemuInstance) -> Result<(), String> { + use std::os::fd::AsRawFd; + let identity = Identity::mint(KEY)?; + let port = guest.ssh_port(); + for round in 0..HASTY_ROUNDS { + for resets in [false, true] { + let peer = std::net::TcpStream::connect((HOST, port)) + .map_err(|e| format!("hasty peer {round}: the forward refused the connect: {e}"))?; + if resets { + // A zero linger is what makes the close a reset. + let linger = libc::linger { l_onoff: 1, l_linger: 0 }; + // SAFETY: `peer` owns the descriptor, and `linger` is the + // option's own type, passed with its size. + let rc = unsafe { + libc::setsockopt( + peer.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_LINGER, + (&linger as *const libc::linger).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(format!("hasty peer {round}: SO_LINGER: {}", std::io::Error::last_os_error())); + } + } + drop(peer); + } + } + let mut console = String::new(); + let (mut asks, mut unheard) = (0, 0); + loop { + asks += 1; + match ssh_exec(HOST, port, &identity, "echo in") { + Ok(e) if e.status == Some(0) && e.stdout == b"in\n" => break, + Ok(e) => { + return Err(format!( + "`echo in` after the hasty peers ended {:?} saying {:?}", + e.status, + e.stdout_text() + )) + } + Err(why) if why.contains("Connection reset by peer") => unheard += 1, + Err(why) => return Err(why), + } + if unheard < 2 { + continue; + } + let from = console.len(); + super::qemu::await_guest(guest, &mut console, "sshd to take a connection", |log| { + log[from..].contains("sshd: ") + }) + .map_err(|why| { + format!( + "port 22 reset `echo` twice running after {} hasty peers, and sshd took no \ + connection after: {why}\n{console}", + 2 * HASTY_ROUNDS + ) + })?; + unheard = 0; + } + eprintln!(" [sshd] {} hasty peers, half of them reset, and `echo` answered on ask {asks}", 2 * HASTY_ROUNDS); + Ok(()) +} + /// A megabyte no compressor shortens and no run-length check passes by /// accident. A 64-bit LCG, so the host and nothing else decides the bytes. fn pseudorandom(len: usize) -> Vec { diff --git a/tests/toyos.rs b/tests/toyos.rs index f2ed0e9097..75115dcea1 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -971,6 +971,9 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("sshd_exec", Sched::Parallel, Tier::Fast), ("sshd_files", Sched::Parallel, Tier::Fast), ("sshd_key_auth", Sched::Parallel, Tier::Fast), + // Its own boot: on a netd that strands a hasty peer, port 22 stays shut for + // the rest of it. Every verdict is an exit status or a console line. + ("sshd_hasty_peers", Sched::Parallel, Tier::Fast), // Serial: it measures netd's 2 s handshake deadline against the host's // clock, and counts how many connections survived a 48 ms paced burst // before that deadline could expire any of them. Both are wall-clock @@ -12236,6 +12239,7 @@ fn run_machine_test( let boot = group_boot(held, SSHD_LOGIN, || common::ssh::boot(rust_bins)); common::ssh::key_auth_gate(&mut boot.qemu) } + "sshd_hasty_peers" => common::ssh::hasty_peers_gate(&mut common::ssh::boot(rust_bins)), "console_locale_detect" => console_locale_detect(), "desktop_locale_detect" => desktop_locale_detect(), "desktop_typing_damage" => desktop_typing_damage(), From e1de6368bb14e327d559fd3bb5e1485f1f0eb51a Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 22:46:59 +0200 Subject: [PATCH 03/11] netd: a refused accept spends its owner's wake, owed again once there is room The accept's room refusal and its missing-pipes refusal returned before the accept spent the owner's wake. The owner had already read its wake byte, but netd still held it as given, so no pass announced the waiting connection again: a std server refused for room blocked in its next accept for the rest of the boot while the connection held the port. sshd got out only because it rebinds on any accept error. The rule, in `listen::Listening`: an accept spends the owner's wake whatever it answers, a refusal included, and a wake is owed only for a connection there is room to take. `Listening::accept` takes the room and answers `Accept::{Take, NoRoom, Nothing}`, so the room refusal cannot skip the spend; the missing-pipes refusal comes after it. Room is in the wake condition so that an owner refused for room is woken again when room returns and not on every pass before it, which spending alone would do: each refused accept would be answered by a wake on the next pass. `serve_piped_listeners` moves after `process_pending`, which frees room when a pending connect ends: the wake condition is read after everything in a pass that changes it, with nothing between it and the wait. Arms: the host test an_accept_refused_for_room_is_woken_again_when_room_returns, and the machine test netd_refused_accept (tests/netcase), whose guest refuses an accept for its pipes and one for room and waits on the wake each leaves. Co-Authored-By: Claude Opus 5.5 --- .../src/bin/netd_refused_accept.rs | 161 ++++++++++++++++++ tests/toyos.rs | 29 +++- userland/netd/src/listen.rs | 38 +++-- userland/netd/src/listen/tests.rs | 38 ++++- userland/netd/src/main.rs | 52 +++--- 5 files changed, 276 insertions(+), 42 deletions(-) create mode 100644 tests/toyos-rust-tests/src/bin/netd_refused_accept.rs diff --git a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs new file mode 100644 index 0000000000..e90ff22cde --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs @@ -0,0 +1,161 @@ +//! An accept netd refuses still spends its owner's wake, so the connection it +//! left is announced again: at once after a request that handed netd no +//! pipes, and after a refusal for room once room returns and not before. +//! +//! The host dials this program's listener through the forward, twice: +//! +//! 1. Woken, this program asks for the connection handing netd no pipes, and +//! netd refuses the request. The next wake is the verdict. +//! 2. Woken, this program fills netd's connections to the host until one is +//! refused, and asks for the connection; netd refuses it for room. Once a +//! request netd answered after that refusal says room is still gone, no +//! wake may be waiting. One connection closed, the next wake is the +//! verdict. +//! +//! argv[1] is the port of the harness's host server on `HOST`, and the harness +//! forwards a host port to this guest's `FORWARDED_PORT`. +//! `netd_refused_accept: ok` is the only success line. + +#[path = "../netd_stream.rs"] +mod netd_stream; + +use std::time::Duration; + +use netd_stream::{ask, await_until, Ask, FORWARDED_PORT, HOST}; +use toyos::net::{ + MsgType, NetError, NetdConn, TcpAcceptPipedRequest, TcpAcceptPipedResponse, TcpBound, TcpConnectPipedRequest, + TcpConnectResponse, TcpConnection, TcpSocketId, DATA_FROM_CLIENT, DATA_HANDLES, DATA_TO_CLIENT, +}; +use toyos::poller::READABLE; +use toyos_abi::syscall::SyscallError; + +/// How long netd may take to act on something it has been handed. Orders of +/// magnitude over a pass; a bound, said by name, not a pace. +const WITHIN: Duration = Duration::from_secs(20); + +fn main() { + let port: u16 = std::env::args() + .nth(1) + .and_then(|p| p.parse().ok()) + .expect("usage: netd_refused_accept "); + let listener = toyos::net::tcp_bind([0; 4], FORWARDED_PORT).expect("bind the forwarded port"); + + let dial = dial_in(port); + wake(&listener, "the host's first dial"); + assert_eq!(accept(listener.socket_id, false), Err(NetError::InvalidInput), "an accept handing netd no pipes"); + println!("netd_refused_accept: an accept handing netd no pipes was refused"); + wake(&listener, "the connection an accept handing netd no pipes left"); + accept(listener.socket_id, true).unwrap_or_else(|e| panic!("the connection an accept with no pipes left: {e:?}")); + end(dial); + + let dial = dial_in(port); + wake(&listener, "the host's second dial"); + let mut held = Vec::new(); + let refused = loop { + match connect(port) { + Ok(conn) => { + ask(&conn.tx, Ask::Held(0)); + held.push(conn); + } + Err(e) => break e, + } + }; + assert_eq!(refused, NetError::ResourceExhausted, "a connect after {} held", held.len()); + assert_eq!( + accept(listener.socket_id, true), + Err(NetError::ResourceExhausted), + "an accept with every connection taken" + ); + println!("netd_refused_accept: an accept refused for room, {} connections held", held.len()); + assert_eq!( + connect(port).err(), + Some(NetError::ResourceExhausted), + "a connect after an accept refused for room" + ); + let mut byte = [0u8; 1]; + assert_eq!( + listener.notify.read_nonblock(&mut byte), + Err(SyscallError::WouldBlock), + "netd woke its owner for a connection there is no room to take" + ); + end(held.pop().expect("the cap holds at least the connection before the refusal")); + wake(&listener, "the connection an accept refused for room left, once room returned"); + accept(listener.socket_id, true).unwrap_or_else(|e| panic!("the connection an accept refused for room left: {e:?}")); + end(dial); + held.into_iter().for_each(end); + toyos::net::tcp_close(listener.socket_id).expect("close the listener"); + println!("netd_refused_accept: ok"); +} + +/// A connection to the host server that asks it to dial this guest's +/// forwarded port. +fn dial_in(port: u16) -> TcpConnection { + let dial = toyos::net::tcp_connect(HOST, port, 30_000).expect("connect to the host server"); + ask(&dial.tx, Ask::Dial); + dial +} + +/// Wait for netd's wake on `listener`, and take it. +fn wake(listener: &TcpBound, what: &str) { + let mut byte = [0u8; 1]; + await_until(&listener.notify, READABLE, WITHIN, &format!("a wake for {what}"), || { + match listener.notify.read_nonblock(&mut byte) { + Ok(1) => Some(()), + Ok(_) => panic!("a wake for {what}: netd closed the listener"), + Err(SyscallError::WouldBlock) => None, + Err(e) => panic!("a wake for {what}: {e:?}"), + } + }); +} + +/// netd's answer to an accept on `listener`, handing it a pair of pipes or +/// none. An accepted connection is closed at once. +fn accept(listener: TcpSocketId, pipes: bool) -> Result<(), NetError> { + let request = TcpAcceptPipedRequest { socket_id: listener.0 }; + let pending = if pipes { + let (_rx, _tx, handles) = data_path(); + reach_netd().request_with_handles(&handles, MsgType::TcpAcceptPiped, &request) + } else { + reach_netd().request(MsgType::TcpAcceptPiped, &request) + }; + let resp: TcpAcceptPipedResponse = pending.expect("netd takes the request").response()?; + toyos::net::tcp_close(TcpSocketId(resp.socket_id)).expect("close the accepted connection"); + Ok(()) +} + +/// netd's answer to a connect to the host server. +fn connect(port: u16) -> Result { + let (rx, tx, handles) = data_path(); + let resp: TcpConnectResponse = reach_netd() + .request_with_handles( + &handles, + MsgType::TcpConnectPiped, + &TcpConnectPipedRequest { addr: HOST, port, _pad: 0, timeout_ms: 30_000 }, + ) + .expect("netd takes the request") + .response()?; + Ok(TcpConnection { rx, tx, socket_id: TcpSocketId(resp.socket_id), local_port: resp.local_port }) +} + +/// A connection to netd. **Refused only by the kernel's port queue**, which +/// `toyos::net` spells as netd's own `ResourceExhausted`, so it is no answer +/// of netd's here. +fn reach_netd() -> NetdConn { + NetdConn::connect().unwrap_or_else(|e| panic!("reach netd: {e:?}")) +} + +/// The ends of a duplex data path this side keeps, and the two it hands netd. +fn data_path() -> (toyos::Pipe, toyos::Pipe, [toyos_abi::RawHandle; DATA_HANDLES]) { + let (rx, to_client) = toyos::pipe_pair().expect("the pipe netd writes into"); + let (from_client, tx) = toyos::pipe_pair().expect("the pipe netd reads from"); + let mut handles = [toyos_abi::HANDLE_INVALID; DATA_HANDLES]; + handles[DATA_TO_CLIENT] = to_client.into_raw(); + handles[DATA_FROM_CLIENT] = from_client.into_raw(); + (rx, tx, handles) +} + +fn end(conn: TcpConnection) { + let TcpConnection { rx, tx, socket_id, .. } = conn; + drop((rx, tx)); + toyos::net::tcp_close(socket_id).expect("close a connection"); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 75115dcea1..c7a3cdfe1c 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -307,15 +307,16 @@ const RUST_SKIP: &[&str] = &[ "netd_listener_forgery", // Needs a NIC in front of netd and a host server behind it. // `netd_slow_reader`, `netd_held_open`, `netd_stalled_peer`, - // `netd_udp_refused`, `netd_udp_any_address` and `netd_refused_pipes` run - // them on `tests/netcase`, and `netd_lookup_let_go` on it with its frames - // held. + // `netd_udp_refused`, `netd_udp_any_address`, `netd_refused_pipes` and + // `netd_refused_accept` run them on `tests/netcase`, and + // `netd_lookup_let_go` on it with its frames held. "netd_slow_reader", "netd_held_open", "netd_stalled_peer", "netd_udp_refused", "netd_udp_any_address", "netd_refused_pipes", + "netd_refused_accept", "netd_lookup_let_go", // It asserts nothing at all: it holds a `tests/lancase` boot open for // twenty seconds so the host can reach this machine over the cable. On a @@ -916,6 +917,10 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ // round trip after each case, a named line per refusal and a clean // console; its clocks are liveness guards. ("netd_refused_pipes", Sched::Parallel, Tier::Fast), + // The netcase boot again: an accept netd refuses leaves its owner a wake + // for the connection it left, at once or once room returns. The verdict + // is the guest's wake or its absence; its clocks are liveness guards. + ("netd_refused_accept", Sched::Parallel, Tier::Fast), // The netcase boot again: bytes held back past a full pipe move on the // pipe's room alone, the peer holding the connection open and silent. The // verdict is the guest's byte-for-byte comparison; its clocks are @@ -1686,6 +1691,7 @@ const CARRIES: &[(&str, &[&str])] = &[ ("netd_listener_forgery", &["test_rs_netd_listener_forgery"]), ("netd_slow_reader", &["test_rs_netd_slow_reader"]), ("netd_refused_pipes", &["test_rs_netd_refused_pipes"]), + ("netd_refused_accept", &["test_rs_netd_refused_accept"]), ("netd_held_open", &["test_rs_netd_held_open"]), ("netd_stalled_peer", &["test_rs_netd_stalled_peer"]), ("netd_udp_refused", &["test_rs_netd_udp_refused"]), @@ -10538,6 +10544,22 @@ fn netd_refused_pipes(rust_bins: &[(String, Vec)]) -> Result<(), String> { Ok(()) } +/// An accept netd refuses leaves its owner a wake for the connection it left: +/// the guest's wakes are the verdict. This side carries that netd named the +/// refusal for room and that no program panicked. +fn netd_refused_accept(rust_bins: &[(String, Vec)]) -> Result<(), String> { + let HostRun { result, console, .. } = netcase_against_host(rust_bins, "netd_refused_accept", true, "")?; + if !result.stdout.lines().any(|l| l.trim_end().ends_with("netd_refused_accept: ok")) { + return Err(format!("the guest never said it was done:\n{}", result.stdout)); + } + if !console.contains("netd: refusing accept, ") { + return Err(format!("netd refused an accept for room without saying so:\n{console}")); + } + serial::Serial::named("boot console", console.as_str()).must_be_clean()?; + eprintln!(" [netcase] an accept refused for its pipes and one refused for room each left a wake"); + Ok(()) +} + /// Ctrl+Alt+D at a live desktop: every CPU answers, and the two halves of the /// report agree. /// @@ -16069,6 +16091,7 @@ fn run_machine_test( } "netd_slow_reader" => netd_slow_reader(rust_bins), "netd_refused_pipes" => netd_refused_pipes(rust_bins), + "netd_refused_accept" => netd_refused_accept(rust_bins), "netd_held_open" => netd_held_open(rust_bins), "netd_stalled_peer" => netd_stalled_peer(rust_bins), "netd_udp_refused" => netd_udp_refused(rust_bins), diff --git a/userland/netd/src/listen.rs b/userland/netd/src/listen.rs index 919ed083ed..9116e52d56 100644 --- a/userland/netd/src/listen.rs +++ b/userland/netd/src/listen.rs @@ -4,6 +4,12 @@ //! accepts**, so the port listens only while that socket is in `Listen`: one //! that left it is handed to its owner or listens again, or the port answers //! every other peer with a reset for the rest of the boot. +//! +//! **An accept spends the owner's wake whatever it answers, a refusal +//! included, and a wake is owed only for a connection there is room to +//! take.** An owner refused holds no wake, so the connection it left is +//! announced again, and an owner refused for room is not woken until room +//! returns. use smoltcp::socket::tcp; @@ -14,6 +20,17 @@ pub struct Listening { woken: bool, } +/// What an accept finds. +#[derive(Debug, PartialEq, Eq)] +pub enum Accept { + /// A connection, to hand over. + Take, + /// No room for another connection, whether one waits or not. + NoRoom, + /// No connection. + Nothing, +} + impl Listening { pub fn new(port: u16) -> Self { Self { port, woken: false } @@ -23,23 +40,24 @@ impl Listening { self.port } - /// Whether the owner is owed a wake for `socket`: a connection waits and - /// the owner holds no wake. - pub fn owes_wake(&self, socket: &mut tcp::Socket) -> bool { - settle(socket, self.port) && !self.woken + /// Whether the owner is owed a wake for `socket`: a connection waits, + /// there is `room` to take it, and the owner holds no wake. + pub fn owes_wake(&self, socket: &mut tcp::Socket, room: bool) -> bool { + settle(socket, self.port) && room && !self.woken } pub fn woke(&mut self) { self.woken = true; } - /// An accept, which spends the owner's wake whatever it finds: whether - /// `socket` holds a connection to hand over. A wake written for a - /// connection its peer then reset is spent here, so the next connection - /// is announced. - pub fn accept(&mut self, socket: &mut tcp::Socket) -> bool { + /// An accept, with `room` for another connection or not. + pub fn accept(&mut self, socket: &mut tcp::Socket, room: bool) -> Accept { self.woken = false; - settle(socket, self.port) + match (settle(socket, self.port), room) { + (_, false) => Accept::NoRoom, + (true, true) => Accept::Take, + (false, true) => Accept::Nothing, + } } } diff --git a/userland/netd/src/listen/tests.rs b/userland/netd/src/listen/tests.rs index 92afa7b756..53b6173b2b 100644 --- a/userland/netd/src/listen/tests.rs +++ b/userland/netd/src/listen/tests.rs @@ -213,11 +213,19 @@ impl Net { } fn owes_wake(&mut self) -> bool { - self.listening.owes_wake(self.sockets.get_mut::(self.listener)) + self.owes_wake_with(true) } - fn accept(&mut self) -> bool { - self.listening.accept(self.sockets.get_mut::(self.listener)) + fn owes_wake_with(&mut self, room: bool) -> bool { + self.listening.owes_wake(self.sockets.get_mut::(self.listener), room) + } + + fn accept(&mut self) -> Accept { + self.accept_with(true) + } + + fn accept_with(&mut self, room: bool) -> Accept { + self.listening.accept(self.sockets.get_mut::(self.listener), room) } } @@ -231,7 +239,7 @@ fn a_finished_handshake_is_owed_one_wake() { assert!(net.owes_wake()); net.listening.woke(); assert!(!net.owes_wake(), "a connection its owner holds a wake for was announced twice"); - assert!(net.accept()); + assert_eq!(net.accept(), Accept::Take); } /// **A peer that sends its FIN with the handshake's last ACK is still a @@ -245,7 +253,7 @@ fn a_peer_that_closes_with_its_last_ack_is_a_connection() { assert_eq!(net.socket().state(), tcp::State::CloseWait, "the premise: both in one pass"); assert!(net.owes_wake(), "a connection the peer half-closed was never announced"); net.listening.woke(); - assert!(net.accept(), "a connection the peer half-closed was not handed over"); + assert_eq!(net.accept(), Accept::Take, "a connection the peer half-closed was not handed over"); } /// A peer that resets before its owner took it frees the port: the next peer @@ -273,9 +281,27 @@ fn a_wake_spent_on_a_reset_connection_announces_the_next() { net.send(5001, TcpControl::Rst, PEER_ISN + 1, Some(isn + 1)); net.pass(); assert!(!net.owes_wake(), "a reset connection was announced"); - assert!(!net.accept(), "an accept took a connection its peer had reset"); + assert_eq!(net.accept(), Accept::Nothing, "an accept took a connection its peer had reset"); let isn = net.syn(5002); net.send(5002, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); net.pass(); assert!(net.owes_wake(), "the connection after a reset one was never announced"); } + +/// An accept refused for room spends the owner's wake, and the connection it +/// left is announced again once room returns, and not before. +#[test] +fn an_accept_refused_for_room_is_woken_again_when_room_returns() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(!net.owes_wake_with(false), "the owner was woken for a connection there is no room to take"); + assert!(net.owes_wake()); + net.listening.woke(); + assert_eq!(net.accept_with(false), Accept::NoRoom); + assert!(!net.owes_wake_with(false), "an owner refused for room was woken again with room still gone"); + assert!(net.owes_wake(), "an owner refused for room was never woken again"); + net.listening.woke(); + assert_eq!(net.accept(), Accept::Take); +} diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index 0c4a164032..241467f564 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -1239,37 +1239,40 @@ impl NetDaemon { msg.client.error(ERR_INVALID_INPUT); return; }; - if !self.piped_room() { - say!( - "netd: refusing accept, {} piped connections already (max {})", - self.piped_live(), - self.max_piped_connections, - ); - msg.client.error(ERR_RESOURCE_EXHAUSTED); + let room = self.piped_room(); + let Some(listener) = self.piped_listeners.get_mut(&req.socket_id) else { + msg.client.error(ERR_NOT_CONNECTED); return; + }; + let (old_handle, local_port) = (listener.handle, listener.listening.port()); + match listener.listening.accept(socket_set.get_mut::(old_handle), room) { + listen::Accept::Take => {} + listen::Accept::NoRoom => { + say!( + "netd: refusing accept, {} piped connections already (max {})", + self.piped_live(), + self.max_piped_connections, + ); + msg.client.error(ERR_RESOURCE_EXHAUSTED); + return; + } + listen::Accept::Nothing => { + msg.client.error(ERR_NOT_CONNECTED); + return; + } } + // After the accept: a request refused here has spent its owner's wake + // too, so the connection it leaves is announced again. let Some(pipes) = DataPipes::take(&msg.client) else { msg.client.error(ERR_INVALID_INPUT); return; }; - let Some(listener) = self.piped_listeners.get_mut(&req.socket_id) else { - msg.client.error(ERR_NOT_CONNECTED); - return; - }; - let socket = socket_set.get_mut::(listener.handle); - if !listener.listening.accept(socket) { - msg.client.error(ERR_NOT_CONNECTED); - return; - } - - let remote = socket.remote_endpoint().unwrap(); - let local_port = listener.listening.port(); + let remote = socket_set.get_mut::(old_handle).remote_endpoint().unwrap(); let remote_addr = match remote.addr { IpAddress::Ipv4(a) => a.octets(), }; - let old_handle = listener.handle; let stream_id = self.alloc_id(); self.sockets.insert(stream_id, SocketKind::TcpStream(old_handle)); @@ -1429,10 +1432,11 @@ impl NetDaemon { /// unread. fn serve_piped_listeners(&mut self, socket_set: &mut SocketSet<'_>) { use toyos_abi::syscall::SyscallError; + let room = self.piped_room(); let mut dead = Vec::new(); for (&socket_id, listener) in &mut self.piped_listeners { let socket = socket_set.get_mut::(listener.handle); - let owed = listener.listening.owes_wake(socket); + let owed = listener.listening.owes_wake(socket, room); let wake: &[u8] = if owed { &[1] } else { &[] }; match toyos_abi::syscall::write_nonblock(listener.notify_write.as_handle(), wake) { Ok(_) if owed => listener.listening.woke(), @@ -1736,10 +1740,12 @@ fn main() { daemon.bridge_piped(&mut socket_set); - daemon.serve_piped_listeners(&mut socket_set); - daemon.process_pending(&mut socket_set); + // After everything in a pass that frees room: a wake is owed only + // with room, and nothing after this and before the wait wakes the pass. + daemon.serve_piped_listeners(&mut socket_set); + // smoltcp's own next deadline — a retransmit, a persist probe, a // delayed ACK — and zero when it has a frame to send now. A piped // connection needs nothing else: its peer's bytes wake the NIC, and its From e9481f043ea06f03b0d9fabdb4deb7f2c1b800e0 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 22:47:00 +0200 Subject: [PATCH 04/11] issues: a handshake nobody finishes holds a listener's port shut Measured on smoltcp 0.12's interface over a hand-played wire (the harness of userland/netd/src/listen/tests.rs): after one SYN and 600 s of silence the listener's socket was still SynReceived, having sent 72 SYN-ACKs, and another peer's SYN was answered with a reset. Filed as a defect, and both listener defects are named under the network-stack track, whose holder owns them. Co-Authored-By: Claude Opus 5.5 --- .../toyos-has-its-own-network-stack.md | 2 ++ ...dy-finishes-holds-a-listeners-port-shut.md | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md diff --git a/issues/design-debt/toyos-has-its-own-network-stack.md b/issues/design-debt/toyos-has-its-own-network-stack.md index a43b140d96..ee8fa8a208 100644 --- a/issues/design-debt/toyos-has-its-own-network-stack.md +++ b/issues/design-debt/toyos-has-its-own-network-stack.md @@ -16,6 +16,8 @@ netd runs smoltcp. Its replacement is ToyOS's own stack, built clean-room: reade - Stage 5: netd on one shard, pipe ABI unchanged; smoltcp leaves netd, `Cargo.toml` and `userland/Cargo.lock` in the same PR. - Then multi-core, netring (blocked on the owner's ABI ruling), TCP and IP hardening, IPv6, offloads, soak. +The listener defects are this track's, on smoltcp until stage 5: `issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md` and `issues/hardware/a-connect-between-two-accepts-is-reset.md`. + Owed from the wire specification by stages 3–5: ETH-32, whose subnet broadcast needs the subnet `toyos-net-ip` holds; and those whose layer tags are `[ip]`, `[shell]` or `[udp]`: ETH-11, 12, 22–25, 33; ARP-16–18; IP-25, 26, 35; IPP-01–13; ICMP-32–46; IGMP-23–25, 29; UDP-17, 18; and the policy halves of ETH-10, 14, 19, IP-02, 20–23, 29, IPO-15, 16, ICMP-23, 24, 26 and UDP-22. What `toyos-net-wire` does not yet meet: diff --git a/issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md b/issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md new file mode 100644 index 0000000000..0bd71ff929 --- /dev/null +++ b/issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md @@ -0,0 +1,31 @@ +--- +status: open +kind: defect +opened: 2026-09-27 +--- + +# A handshake nobody finishes holds a listener's port shut + +netd's listener is one smoltcp socket, and the port listens only while that +socket is in `Listen` (`userland/netd/src/listen.rs`). A SYN whose sender never +answers the SYN-ACK (a peer gone, a spoofed source) leaves it in `SynReceived`, +and smoltcp 0.12 retransmits the SYN-ACK without end. + +Measured on smoltcp's interface over a wire played by hand, as +`userland/netd/src/listen/tests.rs` plays it: after one SYN and 600 s of +silence the socket was still `SynReceived`, having sent 72 SYN-ACKs, and +another peer's SYN in that state was answered with a reset. With +`set_timeout(10 s)` the socket went `Closed` at 10 s, which `listen::settle` +turns back into `Listen`. + +So one packet shuts sshd's port for the rest of the boot. Nothing has chosen a +bound on a listener's half-open handshake, and the timeout smoltcp offers also +bounds the connection the socket becomes, so it would have to come off at the +hand-over. + +**Owner**: whoever holds `issues/design-debt/toyos-has-its-own-network-stack.md`, +which carries this and `issues/hardware/a-connect-between-two-accepts-is-reset.md`. + +**Exit**: a listener's half-open handshake let go within a bound, and a test +that sends one SYN and nothing more, then finds the port answering the next +peer with a SYN-ACK. From ff56e30b5edb7ae1b9c003efe2e3240829be3f95 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 23:40:53 +0200 Subject: [PATCH 05/11] netd: the listener's wake and every accept refusal are one rule in listen.rs `Listening::wake` returns the bytes the pass writes and records the wake it returns, so `main.rs` reads no socket state and writes what it is handed. That is sound because a pass whose owed write fails ends the listener. `Listening::accept` takes the pipes the request carried as an `Option` and answers `NoPipes` after spending the wake, so the pipes refusal is ordered against the spend inside the one function and is host-tested (`an_accept_refused_for_its_pipes_is_woken_again`). An accept with nothing waiting answers `Nothing` whatever the room: there is no connection to refuse, and netd no longer says it refused one. `process_pending` returns the room the pass ends with and `serve_piped_listeners` takes it, so serving before the pending work is freed does not compile; the comment that stated the order goes. `netd_refused_accept` loses its pipes phase, which the host suite now carries; it keeps the room phase, the one arm for `main.rs`'s room wiring. Co-Authored-By: Claude Opus 5.5 --- tests/common/ssh.rs | 83 ------------------- .../src/bin/netd_refused_accept.rs | 63 +++++--------- userland/netd/src/listen.rs | 42 +++++----- userland/netd/src/listen/tests.rs | 75 +++++++++-------- userland/netd/src/main.rs | 39 ++++----- 5 files changed, 100 insertions(+), 202 deletions(-) diff --git a/tests/common/ssh.rs b/tests/common/ssh.rs index 737474f5da..fa6857082f 100644 --- a/tests/common/ssh.rs +++ b/tests/common/ssh.rs @@ -627,89 +627,6 @@ pub fn key_auth_gate(guest: &mut super::qemu::QemuInstance) -> Result<(), String Ok(()) } -/// Rounds of hasty peers, each one peer that closes and one that resets. -const HASTY_ROUNDS: usize = 40; - -/// Peers that connect through the forward and leave at once, and after them -/// `echo` answered on port 22. -/// -/// **A hasty peer's last handshake ACK and its FIN or RST can land in one of -/// netd's passes**: QEMU's forward finishes the guest's handshake after the -/// host has already closed, and sends the FIN straight behind the ACK. The -/// listener's socket then goes from `SynReceived` to `CloseWait` or `Closed` -/// without a pass ever seeing it `Established`, and a netd that wakes sshd -/// only for `Established` leaves port 22 resetting every later SYN. -/// -/// A connect port 22 resets is asked again at once, because a hasty peer's -/// handshake still in flight shuts the port without sshd hearing of it; after -/// a second, only once sshd has said something, because a connection netd -/// holds for sshd shuts the port until sshd takes it. A port that stays shut -/// is a guest that goes quiet. -pub fn hasty_peers_gate(guest: &mut super::qemu::QemuInstance) -> Result<(), String> { - use std::os::fd::AsRawFd; - let identity = Identity::mint(KEY)?; - let port = guest.ssh_port(); - for round in 0..HASTY_ROUNDS { - for resets in [false, true] { - let peer = std::net::TcpStream::connect((HOST, port)) - .map_err(|e| format!("hasty peer {round}: the forward refused the connect: {e}"))?; - if resets { - // A zero linger is what makes the close a reset. - let linger = libc::linger { l_onoff: 1, l_linger: 0 }; - // SAFETY: `peer` owns the descriptor, and `linger` is the - // option's own type, passed with its size. - let rc = unsafe { - libc::setsockopt( - peer.as_raw_fd(), - libc::SOL_SOCKET, - libc::SO_LINGER, - (&linger as *const libc::linger).cast(), - std::mem::size_of::() as libc::socklen_t, - ) - }; - if rc != 0 { - return Err(format!("hasty peer {round}: SO_LINGER: {}", std::io::Error::last_os_error())); - } - } - drop(peer); - } - } - let mut console = String::new(); - let (mut asks, mut unheard) = (0, 0); - loop { - asks += 1; - match ssh_exec(HOST, port, &identity, "echo in") { - Ok(e) if e.status == Some(0) && e.stdout == b"in\n" => break, - Ok(e) => { - return Err(format!( - "`echo in` after the hasty peers ended {:?} saying {:?}", - e.status, - e.stdout_text() - )) - } - Err(why) if why.contains("Connection reset by peer") => unheard += 1, - Err(why) => return Err(why), - } - if unheard < 2 { - continue; - } - let from = console.len(); - super::qemu::await_guest(guest, &mut console, "sshd to take a connection", |log| { - log[from..].contains("sshd: ") - }) - .map_err(|why| { - format!( - "port 22 reset `echo` twice running after {} hasty peers, and sshd took no \ - connection after: {why}\n{console}", - 2 * HASTY_ROUNDS - ) - })?; - unheard = 0; - } - eprintln!(" [sshd] {} hasty peers, half of them reset, and `echo` answered on ask {asks}", 2 * HASTY_ROUNDS); - Ok(()) -} - /// A megabyte no compressor shortens and no run-length check passes by /// accident. A 64-bit LCG, so the host and nothing else decides the bytes. fn pseudorandom(len: usize) -> Vec { diff --git a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs index e90ff22cde..aa81913964 100644 --- a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs +++ b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs @@ -1,16 +1,11 @@ -//! An accept netd refuses still spends its owner's wake, so the connection it -//! left is announced again: at once after a request that handed netd no -//! pipes, and after a refusal for room once room returns and not before. +//! An accept netd refuses for room still spends its owner's wake, so the +//! connection it left is announced again once room returns, and not before. //! -//! The host dials this program's listener through the forward, twice: -//! -//! 1. Woken, this program asks for the connection handing netd no pipes, and -//! netd refuses the request. The next wake is the verdict. -//! 2. Woken, this program fills netd's connections to the host until one is -//! refused, and asks for the connection; netd refuses it for room. Once a -//! request netd answered after that refusal says room is still gone, no -//! wake may be waiting. One connection closed, the next wake is the -//! verdict. +//! The host dials this program's listener through the forward. Woken, this +//! program fills netd's connections to the host until one is refused, and +//! asks for the connection; netd refuses it for room. Once a request netd +//! answered after that refusal says room is still gone, no wake may be +//! waiting. One connection closed, the next wake is the verdict. //! //! argv[1] is the port of the harness's host server on `HOST`, and the harness //! forwards a host port to this guest's `FORWARDED_PORT`. @@ -40,16 +35,9 @@ fn main() { .expect("usage: netd_refused_accept "); let listener = toyos::net::tcp_bind([0; 4], FORWARDED_PORT).expect("bind the forwarded port"); - let dial = dial_in(port); - wake(&listener, "the host's first dial"); - assert_eq!(accept(listener.socket_id, false), Err(NetError::InvalidInput), "an accept handing netd no pipes"); - println!("netd_refused_accept: an accept handing netd no pipes was refused"); - wake(&listener, "the connection an accept handing netd no pipes left"); - accept(listener.socket_id, true).unwrap_or_else(|e| panic!("the connection an accept with no pipes left: {e:?}")); - end(dial); - - let dial = dial_in(port); - wake(&listener, "the host's second dial"); + let dial = toyos::net::tcp_connect(HOST, port, 30_000).expect("connect to the host server"); + ask(&dial.tx, Ask::Dial); + wake(&listener, "the host's dial"); let mut held = Vec::new(); let refused = loop { match connect(port) { @@ -62,7 +50,7 @@ fn main() { }; assert_eq!(refused, NetError::ResourceExhausted, "a connect after {} held", held.len()); assert_eq!( - accept(listener.socket_id, true), + accept(listener.socket_id), Err(NetError::ResourceExhausted), "an accept with every connection taken" ); @@ -80,21 +68,13 @@ fn main() { ); end(held.pop().expect("the cap holds at least the connection before the refusal")); wake(&listener, "the connection an accept refused for room left, once room returned"); - accept(listener.socket_id, true).unwrap_or_else(|e| panic!("the connection an accept refused for room left: {e:?}")); + accept(listener.socket_id).unwrap_or_else(|e| panic!("the connection an accept refused for room left: {e:?}")); end(dial); held.into_iter().for_each(end); toyos::net::tcp_close(listener.socket_id).expect("close the listener"); println!("netd_refused_accept: ok"); } -/// A connection to the host server that asks it to dial this guest's -/// forwarded port. -fn dial_in(port: u16) -> TcpConnection { - let dial = toyos::net::tcp_connect(HOST, port, 30_000).expect("connect to the host server"); - ask(&dial.tx, Ask::Dial); - dial -} - /// Wait for netd's wake on `listener`, and take it. fn wake(listener: &TcpBound, what: &str) { let mut byte = [0u8; 1]; @@ -108,17 +88,14 @@ fn wake(listener: &TcpBound, what: &str) { }); } -/// netd's answer to an accept on `listener`, handing it a pair of pipes or -/// none. An accepted connection is closed at once. -fn accept(listener: TcpSocketId, pipes: bool) -> Result<(), NetError> { - let request = TcpAcceptPipedRequest { socket_id: listener.0 }; - let pending = if pipes { - let (_rx, _tx, handles) = data_path(); - reach_netd().request_with_handles(&handles, MsgType::TcpAcceptPiped, &request) - } else { - reach_netd().request(MsgType::TcpAcceptPiped, &request) - }; - let resp: TcpAcceptPipedResponse = pending.expect("netd takes the request").response()?; +/// netd's answer to an accept on `listener`. An accepted connection is closed +/// at once. +fn accept(listener: TcpSocketId) -> Result<(), NetError> { + let (_rx, _tx, handles) = data_path(); + let resp: TcpAcceptPipedResponse = reach_netd() + .request_with_handles(&handles, MsgType::TcpAcceptPiped, &TcpAcceptPipedRequest { socket_id: listener.0 }) + .expect("netd takes the request") + .response()?; toyos::net::tcp_close(TcpSocketId(resp.socket_id)).expect("close the accepted connection"); Ok(()) } diff --git a/userland/netd/src/listen.rs b/userland/netd/src/listen.rs index 9116e52d56..1b7af579cf 100644 --- a/userland/netd/src/listen.rs +++ b/userland/netd/src/listen.rs @@ -20,12 +20,14 @@ pub struct Listening { woken: bool, } -/// What an accept finds. +/// What an accept finds, handed the pipes `P` its request carried. #[derive(Debug, PartialEq, Eq)] -pub enum Accept { - /// A connection, to hand over. - Take, - /// No room for another connection, whether one waits or not. +pub enum Accept

{ + /// A connection, to hand over on the pipes. + Take(P), + /// A request that carried no pipes, whatever waits. + NoPipes, + /// A connection, and no room to take it. NoRoom, /// No connection. Nothing, @@ -40,23 +42,25 @@ impl Listening { self.port } - /// Whether the owner is owed a wake for `socket`: a connection waits, - /// there is `room` to take it, and the owner holds no wake. - pub fn owes_wake(&self, socket: &mut tcp::Socket, room: bool) -> bool { - settle(socket, self.port) && room && !self.woken + /// The bytes to write the owner for `socket`: one wake if a connection + /// waits, there is `room` to take it, and the owner holds no wake, and + /// none otherwise. The wake is held from here on, so the caller ends the + /// listener if the owner is not handed it. + pub fn wake(&mut self, socket: &mut tcp::Socket, room: bool) -> &'static [u8] { + let owed = settle(socket, self.port) && room && !self.woken; + self.woken |= owed; + if owed { &[1] } else { &[] } } - pub fn woke(&mut self) { - self.woken = true; - } - - /// An accept, with `room` for another connection or not. - pub fn accept(&mut self, socket: &mut tcp::Socket, room: bool) -> Accept { + /// An accept, with `room` for another connection or not, and the pipes + /// its request carried. + pub fn accept

(&mut self, socket: &mut tcp::Socket, room: bool, pipes: Option

) -> Accept

{ self.woken = false; - match (settle(socket, self.port), room) { - (_, false) => Accept::NoRoom, - (true, true) => Accept::Take, - (false, true) => Accept::Nothing, + match (settle(socket, self.port), room, pipes) { + (_, _, None) => Accept::NoPipes, + (false, _, Some(_)) => Accept::Nothing, + (true, false, Some(_)) => Accept::NoRoom, + (true, true, Some(pipes)) => Accept::Take(pipes), } } } diff --git a/userland/netd/src/listen/tests.rs b/userland/netd/src/listen/tests.rs index 53b6173b2b..526617b3b3 100644 --- a/userland/netd/src/listen/tests.rs +++ b/userland/netd/src/listen/tests.rs @@ -212,20 +212,17 @@ impl Net { answer.control == TcpControl::Syn } - fn owes_wake(&mut self) -> bool { - self.owes_wake_with(true) - } - - fn owes_wake_with(&mut self, room: bool) -> bool { - self.listening.owes_wake(self.sockets.get_mut::(self.listener), room) - } - - fn accept(&mut self) -> Accept { - self.accept_with(true) + /// Whether the owner is woken on a pass with `room` or without. + fn wakes(&mut self, room: bool) -> bool { + match self.listening.wake(self.sockets.get_mut::(self.listener), room) { + [] => false, + [1] => true, + other => panic!("a wake of {other:?}"), + } } - fn accept_with(&mut self, room: bool) -> Accept { - self.listening.accept(self.sockets.get_mut::(self.listener), room) + fn accept(&mut self, room: bool, pipes: bool) -> Accept<()> { + self.listening.accept(self.sockets.get_mut::(self.listener), room, pipes.then_some(())) } } @@ -233,13 +230,12 @@ impl Net { fn a_finished_handshake_is_owed_one_wake() { let mut net = Net::new(); let isn = net.syn(5001); - assert!(!net.owes_wake(), "a SYN alone was taken for a connection"); + assert!(!net.wakes(true), "a SYN alone was taken for a connection"); net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); net.pass(); - assert!(net.owes_wake()); - net.listening.woke(); - assert!(!net.owes_wake(), "a connection its owner holds a wake for was announced twice"); - assert_eq!(net.accept(), Accept::Take); + assert!(net.wakes(true)); + assert!(!net.wakes(true), "a connection its owner holds a wake for was announced twice"); + assert_eq!(net.accept(true, true), Accept::Take(())); } /// **A peer that sends its FIN with the handshake's last ACK is still a @@ -251,9 +247,8 @@ fn a_peer_that_closes_with_its_last_ack_is_a_connection() { let isn = net.syn(5001); net.ack_and(5001, isn, TcpControl::Fin); assert_eq!(net.socket().state(), tcp::State::CloseWait, "the premise: both in one pass"); - assert!(net.owes_wake(), "a connection the peer half-closed was never announced"); - net.listening.woke(); - assert_eq!(net.accept(), Accept::Take, "a connection the peer half-closed was not handed over"); + assert!(net.wakes(true), "a connection the peer half-closed was never announced"); + assert_eq!(net.accept(true, true), Accept::Take(()), "a connection the peer half-closed was not handed over"); } /// A peer that resets before its owner took it frees the port: the next peer @@ -264,7 +259,7 @@ fn a_peer_that_resets_before_it_is_taken_frees_the_port() { let isn = net.syn(5001); net.ack_and(5001, isn, TcpControl::Rst); assert_eq!(net.socket().state(), tcp::State::Closed, "the premise: both in one pass"); - assert!(!net.owes_wake()); + assert!(!net.wakes(true)); assert!(net.listens(5002), "the port answered the next peer {:?}", net.sent.last()); } @@ -276,16 +271,29 @@ fn a_wake_spent_on_a_reset_connection_announces_the_next() { let isn = net.syn(5001); net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); net.pass(); - assert!(net.owes_wake()); - net.listening.woke(); + assert!(net.wakes(true)); net.send(5001, TcpControl::Rst, PEER_ISN + 1, Some(isn + 1)); net.pass(); - assert!(!net.owes_wake(), "a reset connection was announced"); - assert_eq!(net.accept(), Accept::Nothing, "an accept took a connection its peer had reset"); + assert!(!net.wakes(true), "a reset connection was announced"); + assert_eq!(net.accept(true, true), Accept::Nothing, "an accept took a connection its peer had reset"); let isn = net.syn(5002); net.send(5002, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); net.pass(); - assert!(net.owes_wake(), "the connection after a reset one was never announced"); + assert!(net.wakes(true), "the connection after a reset one was never announced"); +} + +/// An accept that handed netd no pipes spends the owner's wake, and the +/// connection it left is announced again at once. +#[test] +fn an_accept_refused_for_its_pipes_is_woken_again() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.wakes(true)); + assert_eq!(net.accept(true, false), Accept::NoPipes); + assert!(net.wakes(true), "an owner refused for its pipes was never woken again"); + assert_eq!(net.accept(true, true), Accept::Take(())); } /// An accept refused for room spends the owner's wake, and the connection it @@ -293,15 +301,14 @@ fn a_wake_spent_on_a_reset_connection_announces_the_next() { #[test] fn an_accept_refused_for_room_is_woken_again_when_room_returns() { let mut net = Net::new(); + assert_eq!(net.accept(false, true), Accept::Nothing, "an accept with nothing waiting was refused for room"); let isn = net.syn(5001); net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); net.pass(); - assert!(!net.owes_wake_with(false), "the owner was woken for a connection there is no room to take"); - assert!(net.owes_wake()); - net.listening.woke(); - assert_eq!(net.accept_with(false), Accept::NoRoom); - assert!(!net.owes_wake_with(false), "an owner refused for room was woken again with room still gone"); - assert!(net.owes_wake(), "an owner refused for room was never woken again"); - net.listening.woke(); - assert_eq!(net.accept(), Accept::Take); + assert!(!net.wakes(false), "the owner was woken for a connection there is no room to take"); + assert!(net.wakes(true)); + assert_eq!(net.accept(false, true), Accept::NoRoom); + assert!(!net.wakes(false), "an owner refused for room was woken again with room still gone"); + assert!(net.wakes(true), "an owner refused for room was never woken again"); + assert_eq!(net.accept(true, true), Accept::Take(())); } diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index 241467f564..57c2c315d8 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -1239,14 +1239,18 @@ impl NetDaemon { msg.client.error(ERR_INVALID_INPUT); return; }; - let room = self.piped_room(); + let (room, pipes) = (self.piped_room(), DataPipes::take(&msg.client)); let Some(listener) = self.piped_listeners.get_mut(&req.socket_id) else { msg.client.error(ERR_NOT_CONNECTED); return; }; let (old_handle, local_port) = (listener.handle, listener.listening.port()); - match listener.listening.accept(socket_set.get_mut::(old_handle), room) { - listen::Accept::Take => {} + let pipes = match listener.listening.accept(socket_set.get_mut::(old_handle), room, pipes) { + listen::Accept::Take(pipes) => pipes, + listen::Accept::NoPipes => { + msg.client.error(ERR_INVALID_INPUT); + return; + } listen::Accept::NoRoom => { say!( "netd: refusing accept, {} piped connections already (max {})", @@ -1260,12 +1264,6 @@ impl NetDaemon { msg.client.error(ERR_NOT_CONNECTED); return; } - } - // After the accept: a request refused here has spent its owner's wake - // too, so the connection it leaves is announced again. - let Some(pipes) = DataPipes::take(&msg.client) else { - msg.client.error(ERR_INVALID_INPUT); - return; }; let remote = socket_set.get_mut::(old_handle).remote_endpoint().unwrap(); @@ -1430,18 +1428,14 @@ impl NetDaemon { /// is what tells it instead — its notify pipe reads EOF. A full pipe is /// that refusal too: it is an owner that has left a whole pipe of wakes /// unread. - fn serve_piped_listeners(&mut self, socket_set: &mut SocketSet<'_>) { + fn serve_piped_listeners(&mut self, socket_set: &mut SocketSet<'_>, room: bool) { use toyos_abi::syscall::SyscallError; - let room = self.piped_room(); let mut dead = Vec::new(); for (&socket_id, listener) in &mut self.piped_listeners { - let socket = socket_set.get_mut::(listener.handle); - let owed = listener.listening.owes_wake(socket, room); - let wake: &[u8] = if owed { &[1] } else { &[] }; + let wake = listener.listening.wake(socket_set.get_mut::(listener.handle), room); match toyos_abi::syscall::write_nonblock(listener.notify_write.as_handle(), wake) { - Ok(_) if owed => listener.listening.woke(), Ok(_) => {} - Err(SyscallError::WouldBlock) if !owed => {} + Err(SyscallError::WouldBlock) if wake.is_empty() => {} // Its owner has gone, which is the ordinary end of a listener. Err(SyscallError::Gone) => dead.push(socket_id), Err(e) => { @@ -1462,8 +1456,9 @@ impl NetDaemon { } } - /// Process pending async operations (UDP recvs, lookups, piped connects). - fn process_pending(&mut self, socket_set: &mut SocketSet<'_>) { + /// Process pending async operations (UDP recvs, lookups, piped connects), + /// and say whether there is room for another piped connection after them. + fn process_pending(&mut self, socket_set: &mut SocketSet<'_>) -> bool { let now = Instant::now(); for pr in std::mem::take(&mut self.pending_udp_recvs) { @@ -1528,6 +1523,7 @@ impl NetDaemon { } i += 1; } + self.piped_room() } } @@ -1740,11 +1736,8 @@ fn main() { daemon.bridge_piped(&mut socket_set); - daemon.process_pending(&mut socket_set); - - // After everything in a pass that frees room: a wake is owed only - // with room, and nothing after this and before the wait wakes the pass. - daemon.serve_piped_listeners(&mut socket_set); + let room = daemon.process_pending(&mut socket_set); + daemon.serve_piped_listeners(&mut socket_set, room); // smoltcp's own next deadline — a retransmit, a persist probe, a // delayed ACK — and zero when it has a frame to send now. A piped From 19ae86cd2b977c95346b5f3a1f3065fb5c8c21ff Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 23:40:53 +0200 Subject: [PATCH 06/11] tests: delete sshd_hasty_peers Its red is the harness's quiet guard, which is no verdict, and its green can go red on a correct netd: a hasty peer's handshake left in `SynReceived` shuts port 22 until smoltcp retransmits, and the test's reset text and `sshd: ` prefix predicate decide the outcome on timing. The listener's wake rule it guarded is now whole in `listen.rs`, where the host suite reddens on every mutation of it. Co-Authored-By: Claude Opus 5.5 --- tests/toyos.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/toyos.rs b/tests/toyos.rs index c7a3cdfe1c..c24b38a910 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -917,8 +917,8 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ // round trip after each case, a named line per refusal and a clean // console; its clocks are liveness guards. ("netd_refused_pipes", Sched::Parallel, Tier::Fast), - // The netcase boot again: an accept netd refuses leaves its owner a wake - // for the connection it left, at once or once room returns. The verdict + // The netcase boot again: an accept netd refuses for room leaves its owner + // a wake for the connection it left, once room returns. The verdict // is the guest's wake or its absence; its clocks are liveness guards. ("netd_refused_accept", Sched::Parallel, Tier::Fast), // The netcase boot again: bytes held back past a full pipe move on the @@ -976,9 +976,6 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("sshd_exec", Sched::Parallel, Tier::Fast), ("sshd_files", Sched::Parallel, Tier::Fast), ("sshd_key_auth", Sched::Parallel, Tier::Fast), - // Its own boot: on a netd that strands a hasty peer, port 22 stays shut for - // the rest of it. Every verdict is an exit status or a console line. - ("sshd_hasty_peers", Sched::Parallel, Tier::Fast), // Serial: it measures netd's 2 s handshake deadline against the host's // clock, and counts how many connections survived a 48 ms paced burst // before that deadline could expire any of them. Both are wall-clock @@ -10544,8 +10541,8 @@ fn netd_refused_pipes(rust_bins: &[(String, Vec)]) -> Result<(), String> { Ok(()) } -/// An accept netd refuses leaves its owner a wake for the connection it left: -/// the guest's wakes are the verdict. This side carries that netd named the +/// An accept netd refuses for room leaves its owner a wake for the connection +/// it left: the guest's wakes are the verdict. This side carries that netd named the /// refusal for room and that no program panicked. fn netd_refused_accept(rust_bins: &[(String, Vec)]) -> Result<(), String> { let HostRun { result, console, .. } = netcase_against_host(rust_bins, "netd_refused_accept", true, "")?; @@ -10556,7 +10553,7 @@ fn netd_refused_accept(rust_bins: &[(String, Vec)]) -> Result<(), String> { return Err(format!("netd refused an accept for room without saying so:\n{console}")); } serial::Serial::named("boot console", console.as_str()).must_be_clean()?; - eprintln!(" [netcase] an accept refused for its pipes and one refused for room each left a wake"); + eprintln!(" [netcase] an accept refused for room left a wake once room returned"); Ok(()) } @@ -12261,7 +12258,6 @@ fn run_machine_test( let boot = group_boot(held, SSHD_LOGIN, || common::ssh::boot(rust_bins)); common::ssh::key_auth_gate(&mut boot.qemu) } - "sshd_hasty_peers" => common::ssh::hasty_peers_gate(&mut common::ssh::boot(rust_bins)), "console_locale_detect" => console_locale_detect(), "desktop_locale_detect" => desktop_locale_detect(), "desktop_typing_damage" => desktop_typing_damage(), From cdf37178b67b7c8549bafa2288e96640a24b1e3c Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 23:40:53 +0200 Subject: [PATCH 07/11] issues: an accept that never reaches netd strands its listener's owner std's accept reads the wake before it reaches netd, so a failure in between spends a wake netd still counts as held. Filed under the network-stack track. Co-Authored-By: Claude Opus 5.5 --- .../toyos-has-its-own-network-stack.md | 2 +- ...never-reaches-netd-strands-its-listener.md | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 issues/hardware/an-accept-that-never-reaches-netd-strands-its-listener.md diff --git a/issues/design-debt/toyos-has-its-own-network-stack.md b/issues/design-debt/toyos-has-its-own-network-stack.md index ee8fa8a208..a512ac6504 100644 --- a/issues/design-debt/toyos-has-its-own-network-stack.md +++ b/issues/design-debt/toyos-has-its-own-network-stack.md @@ -16,7 +16,7 @@ netd runs smoltcp. Its replacement is ToyOS's own stack, built clean-room: reade - Stage 5: netd on one shard, pipe ABI unchanged; smoltcp leaves netd, `Cargo.toml` and `userland/Cargo.lock` in the same PR. - Then multi-core, netring (blocked on the owner's ABI ruling), TCP and IP hardening, IPv6, offloads, soak. -The listener defects are this track's, on smoltcp until stage 5: `issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md` and `issues/hardware/a-connect-between-two-accepts-is-reset.md`. +The listener defects are this track's: `issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md` and `issues/hardware/a-connect-between-two-accepts-is-reset.md`, on smoltcp until stage 5, and `issues/hardware/an-accept-that-never-reaches-netd-strands-its-listener.md`, in std's accept. Owed from the wire specification by stages 3–5: ETH-32, whose subnet broadcast needs the subnet `toyos-net-ip` holds; and those whose layer tags are `[ip]`, `[shell]` or `[udp]`: ETH-11, 12, 22–25, 33; ARP-16–18; IP-25, 26, 35; IPP-01–13; ICMP-32–46; IGMP-23–25, 29; UDP-17, 18; and the policy halves of ETH-10, 14, 19, IP-02, 20–23, 29, IPO-15, 16, ICMP-23, 24, 26 and UDP-22. diff --git a/issues/hardware/an-accept-that-never-reaches-netd-strands-its-listener.md b/issues/hardware/an-accept-that-never-reaches-netd-strands-its-listener.md new file mode 100644 index 0000000000..0d9d9fb5fc --- /dev/null +++ b/issues/hardware/an-accept-that-never-reaches-netd-strands-its-listener.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-09-27 +--- + +# An accept that never reaches netd strands its listener's owner + +std's `TcpListener::accept` (`rust/library/std/src/sys/net/connection/toyos.rs`) +reads netd's wake byte first, and only then calls `toyos::net::tcp_accept`, +which reaches netd (`NetdConn::connect`) and makes the data path +(`DataPath::create`) before it sends the request. If either fails, or the send +does, the accept returns an error with the wake spent and no request made. netd +spends a wake only on an accept it answers (`userland/netd/src/listen.rs`), so +it still counts the owner as woken, writes no second wake, and the owner's next +`accept` blocks for the rest of the boot while the connection holds the port. + +Read from the code and not reproduced. `NetdConn::connect` fails with +`ResourceExhausted` when the kernel's port queue refuses it, and +`DataPath::create` with `Io` when a pipe cannot be made. + +**Owner**: whoever holds `issues/design-debt/toyos-has-its-own-network-stack.md`. + +**Exit**: no failure between the wake and netd's answer leaves the owner +holding a spent wake netd still counts, and a test in which that step fails and +the next `accept` still returns the waiting connection. From 82b8e071c1ac4f1a8b36e2b7236c565e36e25ee9 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 00:27:13 +0200 Subject: [PATCH 08/11] tests: netd_refused_accept carries no in-guest deadline `wake` blocked on `read_nonblock` against a poller timeout, and the two connects passed a 30s deadline: three clocks inside a test whose verdict a lost wake must fail by hanging the harness, not by racing a guest clock against netd. `wake` now blocks on `listener.notify.read`, refusing `Ok(0)` by name (netd closed the listener), and both connects pass `timeout_ms: 0` (`main.rs:1181` sets no deadline for that). `WITHIN`, `Duration`, `await_until` and `READABLE` go with them, and so does the doc's claim that netd_refused_accept's clocks are liveness guards: it has none now. Co-Authored-By: Claude Sonnet 5 --- .../src/bin/netd_refused_accept.rs | 28 +++++++------------ tests/toyos.rs | 2 +- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs index aa81913964..6402a36f48 100644 --- a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs +++ b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs @@ -14,20 +14,13 @@ #[path = "../netd_stream.rs"] mod netd_stream; -use std::time::Duration; - -use netd_stream::{ask, await_until, Ask, FORWARDED_PORT, HOST}; +use netd_stream::{ask, Ask, FORWARDED_PORT, HOST}; use toyos::net::{ MsgType, NetError, NetdConn, TcpAcceptPipedRequest, TcpAcceptPipedResponse, TcpBound, TcpConnectPipedRequest, TcpConnectResponse, TcpConnection, TcpSocketId, DATA_FROM_CLIENT, DATA_HANDLES, DATA_TO_CLIENT, }; -use toyos::poller::READABLE; use toyos_abi::syscall::SyscallError; -/// How long netd may take to act on something it has been handed. Orders of -/// magnitude over a pass; a bound, said by name, not a pace. -const WITHIN: Duration = Duration::from_secs(20); - fn main() { let port: u16 = std::env::args() .nth(1) @@ -35,7 +28,7 @@ fn main() { .expect("usage: netd_refused_accept "); let listener = toyos::net::tcp_bind([0; 4], FORWARDED_PORT).expect("bind the forwarded port"); - let dial = toyos::net::tcp_connect(HOST, port, 30_000).expect("connect to the host server"); + let dial = toyos::net::tcp_connect(HOST, port, 0).expect("connect to the host server"); ask(&dial.tx, Ask::Dial); wake(&listener, "the host's dial"); let mut held = Vec::new(); @@ -77,15 +70,14 @@ fn main() { /// Wait for netd's wake on `listener`, and take it. fn wake(listener: &TcpBound, what: &str) { + println!("netd_refused_accept: waiting for a wake for {what}"); let mut byte = [0u8; 1]; - await_until(&listener.notify, READABLE, WITHIN, &format!("a wake for {what}"), || { - match listener.notify.read_nonblock(&mut byte) { - Ok(1) => Some(()), - Ok(_) => panic!("a wake for {what}: netd closed the listener"), - Err(SyscallError::WouldBlock) => None, - Err(e) => panic!("a wake for {what}: {e:?}"), - } - }); + match listener.notify.read(&mut byte) { + Ok(1) => {} + Ok(0) => panic!("a wake for {what}: netd closed the listener"), + Ok(n) => panic!("a wake for {what}: read {n} bytes"), + Err(e) => panic!("a wake for {what}: {e:?}"), + } } /// netd's answer to an accept on `listener`. An accepted connection is closed @@ -107,7 +99,7 @@ fn connect(port: u16) -> Result { .request_with_handles( &handles, MsgType::TcpConnectPiped, - &TcpConnectPipedRequest { addr: HOST, port, _pad: 0, timeout_ms: 30_000 }, + &TcpConnectPipedRequest { addr: HOST, port, _pad: 0, timeout_ms: 0 }, ) .expect("netd takes the request") .response()?; diff --git a/tests/toyos.rs b/tests/toyos.rs index c24b38a910..0bc082ba6b 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -919,7 +919,7 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("netd_refused_pipes", Sched::Parallel, Tier::Fast), // The netcase boot again: an accept netd refuses for room leaves its owner // a wake for the connection it left, once room returns. The verdict - // is the guest's wake or its absence; its clocks are liveness guards. + // is the guest's wake or its absence. ("netd_refused_accept", Sched::Parallel, Tier::Fast), // The netcase boot again: bytes held back past a full pipe move on the // pipe's room alone, the peer holding the connection open and silent. The From 5336138eae61749e3a01821c15da1b50b95653a3 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 00:27:13 +0200 Subject: [PATCH 09/11] netd: pin the order inside Listening::wake `settle` must run before `room` and `woken` are read, because it is what relists a socket its peer reset: skip it under `room` false and a reset socket never listens again while room is out, and skip it under `woken` true and a socket reset while its owner holds a wake stays closed until the accept reaches it. Both orders passed every committed test, because `accept` calls `settle` again on its own path and papered over `wake`'s skip. `a_peer_that_resets_before_it_is_taken_frees_the_port` now calls `wakes` with no room, which only reaches the next peer's SYN if `wake` relisted the port; `a_wake_spent_on_a_reset_connection_announces_the_next` now sends the next peer's SYN before the accept, so the accept's own `settle` cannot mask a `wake` that left the socket closed. Co-Authored-By: Claude Sonnet 5 --- userland/netd/src/listen/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userland/netd/src/listen/tests.rs b/userland/netd/src/listen/tests.rs index 526617b3b3..d8fcb66548 100644 --- a/userland/netd/src/listen/tests.rs +++ b/userland/netd/src/listen/tests.rs @@ -259,7 +259,7 @@ fn a_peer_that_resets_before_it_is_taken_frees_the_port() { let isn = net.syn(5001); net.ack_and(5001, isn, TcpControl::Rst); assert_eq!(net.socket().state(), tcp::State::Closed, "the premise: both in one pass"); - assert!(!net.wakes(true)); + assert!(!net.wakes(false)); assert!(net.listens(5002), "the port answered the next peer {:?}", net.sent.last()); } @@ -275,8 +275,8 @@ fn a_wake_spent_on_a_reset_connection_announces_the_next() { net.send(5001, TcpControl::Rst, PEER_ISN + 1, Some(isn + 1)); net.pass(); assert!(!net.wakes(true), "a reset connection was announced"); - assert_eq!(net.accept(true, true), Accept::Nothing, "an accept took a connection its peer had reset"); let isn = net.syn(5002); + assert_eq!(net.accept(true, true), Accept::Nothing, "an accept took a connection its peer had reset"); net.send(5002, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); net.pass(); assert!(net.wakes(true), "the connection after a reset one was never announced"); From dbd8b021c5547feb277596baf9952df54f24ea13 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 00:27:13 +0200 Subject: [PATCH 10/11] netd: name the invariant behind an accept's silent listener lookup `handle_tcp_accept_piped` looks up the listener once and holds no borrow of it across the intervening inserts, so the second lookup before the replacement handle is written cannot fail. The `if let` treated that as a skippable case; `expect` says why it never is. Co-Authored-By: Claude Sonnet 5 --- userland/netd/src/main.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index 57c2c315d8..25b7689578 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -1288,9 +1288,10 @@ impl NetDaemon { let new_handle = socket_set.add(new_listener); self.sockets.insert(req.socket_id, SocketKind::TcpListener(new_handle)); - if let Some(pl) = self.piped_listeners.get_mut(&req.socket_id) { - pl.handle = new_handle; - } + self.piped_listeners + .get_mut(&req.socket_id) + .expect("looked up above; nothing between there and here removes a piped_listeners entry") + .handle = new_handle; msg.client.result(&TcpAcceptPipedResponse { socket_id: stream_id, From 97eaa363a570c57e972245b87a180f4ed52b6d69 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 01:20:40 +0200 Subject: [PATCH 11/11] netd_refused_accept: a one-byte read is 0 or 1, so the arm for more bytes goes Co-Authored-By: Claude Opus 5.5 --- tests/toyos-rust-tests/src/bin/netd_refused_accept.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs index 6402a36f48..7485fcb346 100644 --- a/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs +++ b/tests/toyos-rust-tests/src/bin/netd_refused_accept.rs @@ -74,8 +74,7 @@ fn wake(listener: &TcpBound, what: &str) { let mut byte = [0u8; 1]; match listener.notify.read(&mut byte) { Ok(1) => {} - Ok(0) => panic!("a wake for {what}: netd closed the listener"), - Ok(n) => panic!("a wake for {what}: read {n} bytes"), + Ok(_) => panic!("a wake for {what}: netd closed the listener"), Err(e) => panic!("a wake for {what}: {e:?}"), } }