Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions images/system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,8 @@ drives = ["pci:04:03:*"]
receives = ["sound-output"]
syscap = ["rt"]

# The network stack, over the link a NIC driver brings on `link`; it claims no
# device. It serves once the first driver says hello, and leaves a machine
# none says hello on within 30 s.
# The network stack, over every link a NIC driver brings on `link`; it claims
# no device. It serves from the start, links or none.
[programs.netstack]
service = true
serves = ["netstack", "link"]
Expand Down
9 changes: 5 additions & 4 deletions issues/first-match-selection-that-remains.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ does so visibly. Two do, and both are deliberate:
- **NVMe.** blockd serves one controller, the first its row's claim names
(`userland/blockd`); a machine with two NVMe drives loses the second.
Making this an enumerate-all is a block-service change, not a PCI one.
- **The four virtio drivers.** Each takes the first device with its
(vendor, device) pair. A second NIC or a second GPU would be dropped. These
are QEMU-only devices — no virtio function appears on the T14 — so the
exposure is a test-shape one, and no profile declares two of anything virtio.
- **The virtio drivers but the NIC's.** Each takes the first device with its
(vendor, device) pair, and a second GPU would be dropped. These are
QEMU-only devices — no virtio function appears on the T14 — so the exposure
is a test-shape one. The NIC's driver is one process per function, and
netstack runs every link they bring: `Profile::HeadlessTwoNics` has two.

Neither is a defect today. Both become one the moment a second such device is
reachable, and the enumerate-all they would need now exists.
43 changes: 0 additions & 43 deletions issues/netstack-drives-one-card.md

This file was deleted.

14 changes: 10 additions & 4 deletions issues/there-is-no-wifi.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,15 +216,21 @@ stage 3 of `issues/the-lan-is-not-yet-production-grade.md`, whole.
second each way full rate needs
(https://github.com/ToyOSOrg/evidence/blob/4a8d02243d626df929fe05365e991873574e0dc2/s2/README.md).

**P5, netstack over several links.** Owner: the orchestrator; its defect is
`issues/netstack-drives-one-card.md`. Depends on: P4.
**P5, netstack over several links: built.** Owner: the orchestrator.
Depends on: P4.
- **Exit:**
- host tests of rank (wired before wireless, then arrival), failover and
return, resolvers, mDNS per link, and a flow bound to a lost address;
- a guest with two virtio NICs, in which the first's link is set off over
QMP and traffic leaves on the second;
- `issues/netstack-drives-one-card.md` closed, and the NIC taken out of
the virtio bullet of `issues/first-match-selection-that-remains.md`.
- its defect closed, and the NIC taken out of the virtio bullet of
`issues/first-match-selection-that-remains.md`.
- **What it is.** One stack over every link a driver brings, up to eight:
each an interface of `ip`'s with a rank (a hello says Wired or Wireless),
its own lease, DHCP client and mDNS responder under one host name; lookups
ask the resolvers of the first link up holding a lease. A connection whose
address goes ends `AddressGone`; one whose link only goes down keeps it.
netstack serves from the start, so `LINK_BOUND` and `OneLink` are gone.
- Its T14 row, both links leasing with the wired one the default route, is
W6's: it needs a radio.

Expand Down
27 changes: 27 additions & 0 deletions issues/two-links-leased-one-address-the-second-holds-none.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
status: open
kind: defect
opened: 2026-11-01
---

# Two links leased one address: the second never holds one

netstack runs every link a driver brings (`issues/there-is-no-wifi.md`, P5),
and [ip] keeps an address unique across its interfaces: a second interface
adding an address the first holds is refused `addr.duplicate`. Two
separate networks can lease the same address, two home routers each handing
out 192.168.1.10, and then the second link's probe is refused, its client
counts `node.address-refused` and starts its exchange over, and the same
server offers it the same address again: the second link never holds an
address, its client asks for the boot, and its DHCP lines repeat.

Nothing breaks the first link, and the uniqueness rule is what keeps
`toyos-net-tcp`'s `lose_address` and the strong host model's source
selection sound: one address names one interface.

**Owner**: the orchestrator.

**Exit**: the second link's client declines the address (RFC 2131 §3.1, a
DECLINE) and asks for another, as it would for one ACD found in use, so that
the second link leases a different address where its server has one; with a
host test of two links offered one address.
9 changes: 6 additions & 3 deletions lib/link/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ use crate::ends::{Driver, Fault};
use crate::layout::{RX_DEPTH, SLOT_BYTES, TX_DEPTH};
use crate::region::Region;
use crate::state::{Counters, Group, Link};
use crate::wire::{self, Address, Hello, Refusal};
use crate::wire::{self, Address, Hello, Medium, Refusal};
use crate::PORT;

/// The device under a link, as its driver drives it.
pub trait Nic {
/// What the device's link is carried over.
const MEDIUM: Medium;

fn mac(&self) -> [u8; 6];

/// The claim, for the poller: readable means an interrupt has landed.
Expand Down Expand Up @@ -131,12 +134,12 @@ struct Session<'a> {

impl<'a> Session<'a> {
/// A fresh region and its end, said hello with over `conn`.
fn open(label: &'a str, nic: &impl Nic, conn: Connection) -> Self {
fn open<N: Nic>(label: &'a str, nic: &N, conn: Connection) -> Self {
let region = Region::create().unwrap_or_else(|why| panic!("{label}: no region for a link: {why:?}"));
let end = Driver::new(region.words());
let mac = Address::new(nic.mac())
.unwrap_or_else(|| panic!("{label}: the device's address is a group's, which no link carries"));
let hello = Hello { mac, label: label.into() };
let hello = Hello { mac, medium: N::MEDIUM, label: label.into() };
let shared = region.share().unwrap_or_else(|why| panic!("{label}: the link's region would not share: {why:?}"));
// A connection that will not take the hello is one whose stack has
// gone: its hang-up is read in the first pass.
Expand Down
11 changes: 9 additions & 2 deletions lib/link/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ pub enum Link {
/// The device reports no link at all, as virtio's network device without
/// `VIRTIO_NET_F_STATUS` does; the stack takes it as up.
Unreported,
/// The device reports its link up and no speed, as virtio's network
/// device with `VIRTIO_NET_F_STATUS` and without `VIRTIO_NET_F_SPEED_DUPLEX`.
UpWithoutSpeed,
}

const DOWN: u32 = 0;
const UNREPORTED: u32 = 1;
const UP: u32 = 2;
const UP_WITHOUT_SPEED: u32 = 3;
const FULL_DUPLEX: u32 = 1 << 2;
const MBPS_SHIFT: u32 = 8;

Expand All @@ -27,6 +31,7 @@ impl Link {
match self {
Self::Down => DOWN,
Self::Unreported => UNREPORTED,
Self::UpWithoutSpeed => UP_WITHOUT_SPEED,
Self::Up { mbps, full_duplex } => {
let mbps = mbps.clamp(1, u32::MAX >> MBPS_SHIFT);
UP | if full_duplex { FULL_DUPLEX } else { 0 } | mbps << MBPS_SHIFT
Expand All @@ -41,6 +46,7 @@ impl Link {
match word & 0b11 {
DOWN if word == DOWN => Some(Self::Down),
UNREPORTED if word == UNREPORTED => Some(Self::Unreported),
UP_WITHOUT_SPEED if word == UP_WITHOUT_SPEED => Some(Self::UpWithoutSpeed),
UP if reserved == 0 && mbps > 0 => Some(Self::Up { mbps, full_duplex: word & FULL_DUPLEX != 0 }),
_ => None,
}
Expand Down Expand Up @@ -175,6 +181,7 @@ mod tests {
for link in [
Link::Down,
Link::Unreported,
Link::UpWithoutSpeed,
Link::Up { mbps: 10, full_duplex: false },
Link::Up { mbps: 1000, full_duplex: true },
Link::Up { mbps: u32::MAX >> 8, full_duplex: true },
Expand All @@ -185,10 +192,10 @@ mod tests {
}

/// Every word no driver writes is refused: an up link of no speed, a
/// reserved bit, a down or unreported link carrying fields, a fourth tag.
/// reserved bit, a down, unreported or speedless link carrying fields.
#[test]
fn a_word_no_driver_writes_names_no_link() {
for word in [UP, UP | FULL_DUPLEX, UP | 1 << 3 | 1 << 8, DOWN | 1 << 8, UNREPORTED | FULL_DUPLEX, 3, 3 | 1 << 8, u32::MAX] {
for word in [UP, UP | FULL_DUPLEX, UP | 1 << 3 | 1 << 8, DOWN | 1 << 8, UNREPORTED | FULL_DUPLEX, UP_WITHOUT_SPEED | 1 << 8, UP_WITHOUT_SPEED | FULL_DUPLEX, u32::MAX] {
assert_eq!(Link::decode(word), None, "{word:#x}");
}
}
Expand Down
70 changes: 54 additions & 16 deletions lib/link/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,43 +35,74 @@ pub const MSG_REFUSED: u32 = 3;
/// The longest driver name a hello carries.
pub const MAX_LABEL: usize = 32;

/// Who a driver is: its device's address, which is an individual one, and its
/// name for the stack's lines: ASCII letters, digits and `-`.
/// What a link is carried over, which the stack ranks links by: wired before
/// wireless.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Medium {
Wired,
Wireless,
}

impl Medium {
fn encode(self) -> u8 {
match self {
Self::Wired => 1,
Self::Wireless => 2,
}
}

fn decode(byte: u8) -> Option<Self> {
match byte {
1 => Some(Self::Wired),
2 => Some(Self::Wireless),
_ => None,
}
}
}

/// Who a driver is: its device's address, which is an individual one, what
/// the link is carried over, and its name for the stack's lines: ASCII
/// letters, digits and `-`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hello {
pub mac: Address,
pub medium: Medium,
pub label: String,
}

impl Hello {
pub const MAX_BYTES: usize = 6 + MAX_LABEL;
pub const MAX_BYTES: usize = 7 + MAX_LABEL;

pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(6 + self.label.len());
let mut out = Vec::with_capacity(7 + self.label.len());
out.extend_from_slice(&self.mac.bytes());
out.push(self.medium.encode());
out.extend_from_slice(self.label.as_bytes());
out
}

/// `None` for bytes [`Self::encode`] does not make: too short, a group's
/// address, or a name that is empty, too long or not one.
/// address, no medium, or a name that is empty, too long or not one.
pub fn decode(bytes: &[u8]) -> Option<Self> {
let (mac, label) = bytes.split_first_chunk::<6>()?;
let (mac, rest) = bytes.split_first_chunk::<6>()?;
let mac = Address::new(*mac)?;
let (&medium, label) = rest.split_first()?;
let medium = Medium::decode(medium)?;
let named = |b: &u8| b.is_ascii_alphanumeric() || *b == b'-';
if label.is_empty() || label.len() > MAX_LABEL || !label.iter().all(named) {
return None;
}
Some(Self { mac, label: String::from(core::str::from_utf8(label).ok()?) })
Some(Self { mac, medium, label: String::from(core::str::from_utf8(label).ok()?) })
}
}

/// Why the stack would not open a link.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
/// The stack drives one link, and its address is another's, or a link of
/// this address is open and alive (`issues/netstack-drives-one-card.md`).
OneLink,
/// A link of this address is open and alive.
Open,
/// The stack drives the most links it drives.
Full,
/// The stack ended this address's last link for what its driver wrote.
/// The driver ends on it, so its supervisor's restart budget bounds a
/// driver that keeps writing what no driver writes.
Expand All @@ -83,18 +114,20 @@ pub enum Refusal {
impl Refusal {
pub fn encode(self) -> [u8; 4] {
match self {
Self::OneLink => 1u32,
Self::Open => 1u32,
Self::Malformed => 2,
Self::Faulted => 3,
Self::Full => 4,
}
.to_le_bytes()
}

pub fn decode(bytes: &[u8]) -> Option<Self> {
match u32::from_le_bytes(bytes.try_into().ok()?) {
1 => Some(Self::OneLink),
1 => Some(Self::Open),
2 => Some(Self::Malformed),
3 => Some(Self::Faulted),
4 => Some(Self::Full),
_ => None,
}
}
Expand All @@ -110,18 +143,23 @@ mod tests {

#[test]
fn a_hello_reads_back_as_written() {
let hello = Hello { mac: mac([0x52, 0x54, 0, 0x12, 0x34, 0x56]), label: "virtio-net".into() };
let hello = Hello { mac: mac([0x52, 0x54, 0, 0x12, 0x34, 0x56]), medium: Medium::Wired, label: "virtio-net".into() };
assert_eq!(Hello::decode(&hello.encode()), Some(hello));
let longest = Hello { mac: mac([2; 6]), label: "a".repeat(MAX_LABEL) };
let longest = Hello { mac: mac([2; 6]), medium: Medium::Wireless, label: "a".repeat(MAX_LABEL) };
assert_eq!(longest.encode().len(), Hello::MAX_BYTES);
assert_eq!(Hello::decode(&longest.encode()), Some(longest));
}

/// Each way a hello's bytes are not one a driver makes is refused, a
/// group's address among them: the node is made with the address.
#[test]
fn a_hello_no_driver_writes_is_refused() {
let named = |mac: [u8; 6], label: &[u8]| [mac.as_slice(), label].concat();
let named = |mac: [u8; 6], label: &[u8]| [mac.as_slice(), &[1], label].concat();
assert_eq!(Hello::decode(&[2, 0, 0, 0, 0]), None, "short of an address");
assert_eq!(Hello::decode(&[2, 0, 0, 0, 0, 0]), None, "no medium");
for medium in [0, 3, 0xff] {
assert_eq!(Hello::decode(&[[2, 0, 0, 0, 0, 0, medium].as_slice(), b"i219"].concat()), None, "medium {medium}");
}
assert_eq!(Hello::decode(&named([0x01, 0x00, 0x5e, 0, 0, 0xfb], b"i219")), None, "a group's address");
assert_eq!(Hello::decode(&named([0xff; 6], b"i219")), None, "the broadcast address");
assert_eq!(Hello::decode(&named([2; 6], &[b'a'; MAX_LABEL + 1])), None, "a name too long");
Expand All @@ -133,7 +171,7 @@ mod tests {

#[test]
fn every_refusal_reads_back_and_no_other_word_does() {
for why in [Refusal::OneLink, Refusal::Malformed, Refusal::Faulted] {
for why in [Refusal::Open, Refusal::Full, Refusal::Malformed, Refusal::Faulted] {
assert_eq!(Refusal::decode(&why.encode()), Some(why));
}
assert_eq!(Refusal::decode(&0u32.to_le_bytes()), None);
Expand Down
16 changes: 15 additions & 1 deletion lib/virtio/src/pci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,15 @@ impl<R: Registers> Wires<R> {
/// Where in the BAR the 32-bit device-specific field `at` is, read or
/// written 32 bits wide as §4.1.3.1 has one.
fn device32(&mut self, at: usize) -> Result<usize, Refusal> {
self.device_field(at, 4)
}

/// Where the `width`-byte field `at` bytes into the device-specific
/// structure is, refusing one that is not a whole aligned field of it
/// (§4.1.3.1).
fn device_field(&mut self, at: usize, width: usize) -> Result<usize, Refusal> {
let Region { at: base, bytes } = self.device;
if at.checked_add(4).is_none_or(|end| end > bytes) || !at.is_multiple_of(4) {
if at.checked_add(width).is_none_or(|end| end > bytes) || !at.is_multiple_of(width) {
return Err(self.refuse(Refusal::PastDeviceConfig { at, bytes }));
}
Ok(base + at)
Expand Down Expand Up @@ -576,6 +583,13 @@ impl<R: Registers> Live<R> {
Ok(self.wires.regs.read32(at))
}

/// The 16-bit field `at` bytes into the device-specific structure, read
/// 16 bits wide (§4.1.3.1). A refusal sets `FAILED`.
pub fn device_read16(&mut self, at: usize) -> Result<u16, Refusal> {
let at = self.wires.device_field(at, 2)?;
Ok(self.wires.regs.read16(at))
}

/// Write the 32-bit field `at` bytes into the device-specific structure.
pub fn device_write32(&mut self, at: usize, value: u32) -> Result<(), Refusal> {
let at = self.wires.device32(at)?;
Expand Down
Loading