Skip to content

toyos-net-node: clients' datagram sockets and the machine's mDNS name on ToyOS's own UDP, host-tested and shipped in nothing - #772

Merged
Japabu merged 5 commits into
mainfrom
wt/toyos-ownstack-b
Oct 8, 2026
Merged

Japabu merged 5 commits into
mainfrom
wt/toyos-ownstack-b

Conversation

@Japabu

@Japabu Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Stage B of the cut of the own-stack track's stage 5 (issues/toyos-has-its-own-network-stack.md): what netstack's client UDP handlers and the socket half of its mdns.rs do today, as calls of toyos-net-node over ToyOS's own UDP. Host-tested and shipped in nothing: netstack's modules, its manifest and every image are unchanged. smoltcp is named nowhere in what this adds, by the owner's "no more smoltcp. fast track make it gone".

Built on #771, which has landed: main (26f5ef205) is merged here at 6ef68789d with no hand resolution, and git diff --shortstat origin/main...6ef68789d is stage B alone: 11 files, +1,100, -8.

Behaviour that differs from what ships today

A client's datagram to a broadcast address is refused. On smoltcp, netstack sends a datagram to 255.255.255.255 or a subnet's broadcast like any other. On the node, toyos-net-udp refuses it udp.broadcast-not-permitted unless the socket was permitted, the client is answered invalid input, and nothing leaves. The pipe ABI has no request that carries the permission, and std's UdpSocket::set_broadcast is Ok(()) and reaches netstack with nothing (sdk/std/sys/net/connection.rs:602).

Who sends to broadcast today, by search of this tree outside rust/, issues/ and the stack crates (rg -n "set_broadcast|SO_BROADCAST|Ipv4Addr::BROADCAST|255\.255\.255\.255|255, 255, 255, 255|is_broadcast"): nobody. The only hits are std's stub above and a test string in src/sourcegate.rs. No program under userland/ or tests/ opens a datagram socket of its own at all (rg -l "UdpSocket|udp_bind|udp_send_to|SOCK_DGRAM" finds only std, libc, toyos::net, netstack and two build-crate files, src/icmp.rs and src/metaltalk.rs, which run on the development host). Not searched: the source of third-party C programs built from recipes, which is not in the tree; one of them calling sendto to a broadcast address through libc would be refused after the move. The review ruled the refusal right and the owner ruled on the rest: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". So the track's line gates the move of netstack onto the node on two things, a stage of their own and not this diff: that search posted, and the pipe ABI carrying the permission from std's and libc's setters to Udp::set_broadcast, with a send refused for want of it answered in a word that says permission and not input.

The name is not announced again when the link comes back. Today link-up resets netstack's DHCP client, the address is new again and mdns.rs announces it. Here the lease outlives the link, so the responder sees no new address and announces nothing, where RFC 6762 §8.3 asks for the announcement. Recorded in the track; the exit is toyos-mdns's.

An answer to a 169.254/16 asker leaves by the router. Measured here on the own stack, wrong by RFC 3927, and recorded below under the review's third finding; what smoltcp does with the same query was not measured.

Smaller differences, each [udp]'s own tested rule, none a decision of this diff: a send with no lease held is answered not connected (udp.no-source-address), where today's handler answers whatever smoltcp's send_slice accepted; a payload past 1,472 octets is refused (udp.exceeds-mtu), where today's stack is built without fragmentation; a datagram longer than the client's buffer is cut to it and counted udp.rx-truncated, where today netstack answers ERR_OTHER for it; a full send queue is answered resource exhausted, where today it is ERR_OTHER; a bind to an address the machine does not hold is refused, where today it binds; a send to the machine's own address or to 127/8 is refused (udp.send-to-self, udp.send-loopback): the own stack has no loopback path.

What changed, per decision

  • src/datagram.rs: Node::udp_bind, udp_send_to, udp_recv_from, udp_close. A socket is a DatagramId, which only a bind makes, so no client call can name the DHCP client's socket or the responder's. Refused is the pipe ABI's four words a datagram call can be answered in; the map from [udp]'s rule to the word is one exhaustive match over [udp]'s Counter, so a rule [udp] gains later does not compile here until it has a word. The node does not depend on toyos and writes no error code: the shell maps word to code at the move.
  • src/name.rs: Node::answer_as(now, host) binds 5353, sets TTL 255 (RFC 6762 §11), joins 224.0.0.251, runs the name's pass once, so a lease already held is announced by the call, and from then on drives toyos_mdns::Responder at the end of every pass over the stack's reports: it is told the held lease's address and prefix, or that there is none; what the record is owed later is one more term of Node::next_deadline. An answer goes where the responder says. One [udp] refuses is counted node.name-unsent and dropped. Bind comes before join, so a refused start joins nothing.
  • src/lease.rs: six crate-private methods on Stack (bind, send_to, recv_from, close, set_ttl, join), each one call passed on to the shard. toyos-net-node: the node and its DHCP lease on ToyOS's own stack, host-tested and shipped in nothing #771's review asked this shape of a stage that adds sockets; shard is still reachable only in that file, and none of the six writes the lease.
  • src/lib.rs: the two modules, the re-exports, one field, one counter, the name's deadline, and settle ending in the name's pass (return became break). Node's existing public shape did not move.
  • toyos-net-shard, each a missing piece the node calls: join (Ip::join, with nothing to settle: a join makes no flow eligible and no event), udp_port (the port a bind got, for udp_bind's answer), udp_set_ttl (for §11), udp_close (Udp::close then settle, so what a closed socket had accepted joins the round at once), udp_counters (read by the tests here, and by inspect at the move). No leave: the node never leaves its one group. toyos-net-udp is untouched.
  • Cargo.lock: toyos-net-node gains the edge to toyos-mdns, already a package of the lock. No new dependency and no new fetch.
  • The track records the slice and six things it does not yet meet, each with an exit.

Departures from the cut

  • mDNS starts by its own call, answer_as, not in Node::new: toyos_mdns::Host borrows its label and Node::new's toyos_dhcp::HostName owns it, and neither crate is in this fence. The track records it.
  • The cut's negative control (the join removed) is mutation n1; the others are this diff's own rules.
  • "Carries mdns.rs's one test across": that test is that the loop is woken for exactly what the record owes. Here it is a_held_lease_is_announced_at_once_and_a_second_later, on the node's next_deadline.

The checks (a trust boundary)

Untrusted input. The node slices nothing off the wire: a frame goes through toyos-net-wire in the shard, a query's bytes through toyos-mdns's parser, and a query's source is a destination only after the responder found it on the lease's prefix and [udp] accepted it. no_cut_of_a_query_is_answered delivers every prefix of a query. A client's numbers (address, port, length) are refused by [udp], never trusted: each_refusal_is_answered_in_the_pipes_word_for_it. Time is an argument everywhere; there is no wait.

Independent oracles.

  • tests/lan/mod.rs, outside: every frame the node emits in every test here is parsed by etherparse 0.21.0 (already stage A's dev-dependency), which recomputes the IPv4 header checksum, the lengths, and the UDP, ICMP or IGMP checksum.
  • tests/name.rs: every mDNS message, asked or expected, is written out byte by byte from RFC 1035 §4.1 and RFC 6762 (§3, §5.4, §6, §6.7, §8.3, §10, §10.2, §11, §18) and never by toyos-mdns; the IGMP report is compared against bytes written from RFC 9776 §4 and §4.2 and RFC 2113.
  • tests/datagram.rs: the dynamic range and the port after a draw from RFC 6335 §6 and RFC 6056 §3.3.1; the port unreachable from RFC 1122 §4.1.3.1.

The scripted DHCP server and the peers' frames are the tests' own: consistency, not independence.

Negative control. The whole change reverted is modules that do not exist, so the control is named mutations, one per rule, each applied as a checked patch, built, run and reversed (patches in the comment below):

mutation test builds exit
n1 the group is not joined a_query_in_a_frame_to_the_groups_address_reaches_the_responder 0 101
n1 again the_group_is_joined_and_its_membership_reported 0 101
n2 next_deadline without the name's a_held_lease_is_announced_at_once_and_a_second_later 0 101
n3 every answer goes to the group an_answer_goes_to_the_group_or_to_the_asker_as_the_query_asked 0 101
n4 the TTL left as [udp] has it every_response_leaves_with_ttl_255 0 101
n5 the responder keeps its link when the lease goes the_name_is_answered_only_while_its_lease_is_held 0 101
n6 a refused answer is not counted an_answer_udp_refuses_is_counted_and_dropped 0 101
n7 the responder is told prefix 0 a_query_from_off_the_link_is_not_answered 0 101
n8 joined before the port is bound the_names_port_is_held_by_one_socket 0 101
d1 a named port is ignored a_socket_holds_the_port_it_named_or_the_one_its_draw_picked 0 101
d2 port in use answered invalid input each_refusal_is_answered_in_the_pipes_word_for_it 0 101
d3 close closes nothing a_closed_socket_frees_its_port_and_what_it_accepted_still_leaves 0 101
d4 the source handed over is the destination a_datagram_for_a_bound_port_is_handed_over_with_where_it_came_from 0 101
n9 the responder answers on a clock a second ahead (the review's patch) a_second_query_inside_a_second_waits_for_the_second_to_end 0 101
n10 answer_as runs no pass a_lease_already_held_is_announced_as_the_name_is_told 0 101
s1 the shard's close does not settle a_closed_socket_frees_its_port_and_what_it_accepted_still_leaves 0 101

Gates

One run at 56f03de2a, by the orchestrator (the author builds and runs nothing), script exit 0, the tree clean at its end. The head, 288484ba4, differs from it by one line of the track, the owner's ruling quoted, and the deletion of an issue file the first round filed (git diff --stat 56f03de2a 288484ba4: two files, +1, -23); nothing was run again for it, nor for the merge of main at 6ef68789d, which brings #770 and stage A's own landed head. All 16 mutation runs of the table above are from this run, their patches regenerated against 56f03de2a. The first round's run, at bd325cb82, was the same steps and the first 14 mutation runs, all as expected.

step command exit
lock cargo metadata --locked --format-version 1 0
node tests cargo test --locked --manifest-path userland/netstack/node/Cargo.toml 0
shard tests cargo test --locked -p toyos-net-shard 0
gates cargo test --locked --lib -- hostws:: userlandhost:: sourcegate:: licence:: 0
node clippy cargo clippy --locked --manifest-path userland/netstack/node/Cargo.toml --all-targets -- <adopted> -D warnings 0
shard clippy cargo clippy --locked -p toyos-net-shard --all-targets -- <adopted> -D warnings 0
node tests again, after the mutations as above 0
shard tests again as above 0

The gates step ran 45 tests (running 45 tests, 45 passed, 361 filtered out). The node's run: datagram 5, lease 18, name 13, slirp 3.

Not run here: cargo run -- --ci host and the guest suite are CI's; the commands above are a subset of --ci host. No guest test is added or changed, and no shipped package depends on toyos-net-node or toyos-net-shard.

The first review, answered

  • BLOCKER 1, --ci host green at the head: CI's, once toyos-net-node: the node and its DHCP lease on ToyOS's own stack, host-tested and shipped in nothing #771 has landed and this is ready; no change to the branch.
  • BLOCKER 2, the once-a-second bound untested through the node: a_second_query_inside_a_second_waits_for_the_second_to_end: three queries from port 5353 in one instant, the first multicast at once, the others held, the node's next deadline within the second, exactly one more multicast between 999 ms and 1 s after the first, none after. The review's patch (n9) builds 0 and reds it 101, at the deadline assertion (tests/name.rs:206).
  • BLOCKER 3, the answer to a 169.254/16 asker: measured, and it reads as predicted. an_answer_to_a_link_local_asker_leaves_by_the_router delivers a legacy query from 169.254.3.4 port 53000 on a lease with a router and is green on exactly one datagram: frame to the router's link address, from the leased address port 5353 to 169.254.3.4 port 53000, TTL 255, the legacy answer. The track records it as present-state wrong behaviour (RFC 3927 §2.6.2, §7) with [ip] owning the exit: [ip] sends to 169.254/16 on the link and that test finds the frame at the asker's own link address. name.rs's header now says what the responder takes for on-link.
  • NOTE, the broadcast line: gates the move, carries the search still owed, and quotes the owner's ruling.
  • NOTE, answer_as with a lease held: the call ends in the name's pass; a_lease_already_held_is_announced_as_the_name_is_told, red under n10. The signature did not move.
  • NOTE, Shard::join's settle: deleted; it carried nothing.
  • NOTE, the description: names the datagram sockets and the mDNS name.
  • NOTE, the unicast-response ID: withdrawn by the second review: RFC 6762 §18.1's ID rule is for legacy unicast responses, which the responder already echoes. The file the first round filed for it is deleted.
  • NOTE, the body: the two differences and the link-up wording are above.

No signature the resolver stage builds on moved: Stack's six methods, Node::udp_*, Refused, DatagramId, lan::Lan.

Unsure of, and weak

  • udp.no-ephemeral-port has a word (address in use) and no test that reaches it: 16,384 binds. Recorded in the track.
  • The readers' scenario for the refusal map, which the track says is owed by stage 5, does not exist; the map is tested against the ABI's words as this diff read them. Recorded in the track.
  • A refusal [udp] logs against a client's call (udp.broadcast-not-permitted, udp.send-unspecified-destination) comes out of drain_events only after the node's next receive, fire or link: the datagram calls take no draw and so cannot run the pass that reads the stack's reports. Recorded in the track.
  • The name is not probed for or defended, as today. Recorded in the track.
  • Every peer frame here is the tests' own, and nothing has run on a device or through a real multicast filter: that is the move's, and the T14's.
  • lease.rs grew by 31 lines in a file whose header says it is the lease's one writer; the section's comment says the six calls write none of it.

Net lines of stage B: +1,100, -8: production 312 (node 282, shard 30), tests 778, manifest, lockfile and the track 10.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A

Stage B of the cut of the own-stack track's stage 5: what netstack's
client UDP handlers and the socket half of its mdns module do, as node
calls over ToyOS's own UDP. Host-tested, shipped in nothing; netstack's
modules and manifest are untouched.

datagram: udp_bind, udp_send_to, udp_recv_from, udp_close on a DatagramId
only a bind makes, so no client call names the DHCP client's socket or
the responder's. [udp]'s refusals are answered in the pipe ABI's words
(Refused), by an exhaustive match over [udp]'s counters.

name: Node::answer_as joins 224.0.0.251, binds port 5353 with TTL 255
(RFC 6762 §11) and drives toyos_mdns::Responder from the node's clock
and the held lease. The record's second announcement and an answer §6
held back are a deadline of the node's. An answer [udp] refuses is
counted node.name-unsent.

The stack's socket calls are methods of lease.rs's Stack, as #771's
review asked of a stage that adds sockets: the shard stays unreachable
outside that file.

toyos-net-shard gains what the node calls and it lacked: join,
udp_port, udp_set_ttl, udp_close, udp_counters. No leave: the node
never leaves its one group.

Tests are in their own files (tests/datagram.rs, tests/name.rs,
tests/lan/mod.rs) beside stage A's, which are unchanged. Every emitted
frame is read by etherparse; every mDNS message, asked or expected, is
written out from RFC 1035 §4.1 and RFC 6762 and not by toyos-mdns.

Nothing was built or run by the author: the build request is the
orchestrator's to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

The 14 mutation runs of the body's table, at bd325cb82: each patch applied with git apply after git apply --check, built (cargo test --locked --manifest-path userland/netstack/node/Cargo.toml --no-run, exit 0), run against its named test (exit 101), and reversed with git apply -R, the tree clean after each.

d1-named-port-ignored

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..4ea70286b 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -98,7 +98,7 @@ impl Node {
     /// or, with none named, an ephemeral one, which spends the one draw. Returns the socket and
     /// the port it holds.
     pub fn udp_bind(&mut self, addr: Ipv4Addr, port: Option<Port>, draw: impl FnOnce() -> u32) -> Result<(DatagramId, Port), Refused> {
-        let (id, port) = self.stack.bind(addr, port, draw).map_err(refused)?;
+        let (id, port) = self.stack.bind(addr, port.and(None), draw).map_err(refused)?;
         Ok((DatagramId(id), port))
     }
 

d2-port-in-use-answered-invalid-input

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..a84e21a31 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -56,7 +56,8 @@ fn refused(error: Error) -> Refused {
         Error::Failed(_) => unreachable!("[udp] reported a network error against a socket that is not connected"),
     };
     match rule {
-        Counter::PortInUse | Counter::NoEphemeralPort => Refused::AddrInUse,
+        Counter::NoEphemeralPort => Refused::AddrInUse,
+        Counter::PortInUse => Refused::InvalidInput,
         Counter::NoSourceAddress | Counter::NoRoute | Counter::SourceAddressNotAssigned => Refused::NotConnected,
         Counter::BindAddressNotLocal
         | Counter::SendLoopback

d3-close-closes-nothing

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..9d29f73a0 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -117,6 +117,7 @@ impl Node {
     /// Closes the socket: its port is free at once, what it received is gone, and what it had
     /// accepted still leaves, to [udp]'s bound.
     pub fn udp_close(&mut self, now: Instant, id: DatagramId) -> Result<(), Refused> {
-        self.stack.close(now, id.0).map_err(refused)
+        let _ = (now, id);
+        Ok(())
     }
 }

d4-source-is-the-destination

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..e06bf3ce2 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -111,7 +111,7 @@ impl Node {
     /// longer than `out` is cut to it and the rest is gone, as [udp] counts.
     pub fn udp_recv_from(&mut self, id: DatagramId, out: &mut [u8]) -> Result<Option<Datagram>, Refused> {
         let received = self.stack.recv_from(id.0, out).map_err(refused)?;
-        Ok(received.map(|received| Datagram { len: received.len, source: received.source, source_port: received.source_port }))
+        Ok(received.map(|received| Datagram { len: received.len, source: received.destination, source_port: received.source_port }))
     }
 
     /// Closes the socket: its port is free at once, what it received is gone, and what it had

n1-group-not-joined

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 1498e79d9..64b27026b 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -54,7 +54,6 @@ impl Name {
         };
         let (socket, _) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(PORT), || 0)?;
         stack.set_ttl(socket, ttl, ttl)?;
-        stack.join(now, group);
         Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] })
     }
 

n2-deadline-without-the-name

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index e88047e9d..c8ef56867 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -141,7 +141,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

n3-every-answer-to-the-group

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 1498e79d9..f9ea27f15 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -83,7 +83,7 @@ impl Name {
             let Some(answer) = self.record.answer(query, Asker { addr: received.source.octets(), port }, ms) else { continue };
             let (to, port) = match answer.to {
                 To::Group => (group, PORT),
-                To::Asker => (received.source, port),
+                To::Asker => (group, PORT),
             };
             unsent = unsent.saturating_add(send(stack, now, self.socket, to, port, &answer.bytes));
         }

n4-ttl-left-as-udp-has-it

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 1498e79d9..9cf8a1cff 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -53,7 +53,6 @@ impl Name {
             unreachable!("255 is a TTL and RFC 6762 §3's group is a group")
         };
         let (socket, _) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(PORT), || 0)?;
-        stack.set_ttl(socket, ttl, ttl)?;
         stack.join(now, group);
         Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] })
     }

n5-link-kept-when-the-lease-goes

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 1498e79d9..892d220a0 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -68,7 +68,7 @@ impl Name {
         let link = stack.lease().map(|lease| Link { addr: lease.address.octets(), prefix: lease.prefix_len });
         let (ms, group) = (millis(now), Ipv4Addr::from(GROUP));
         let mut unsent = 0u64;
-        if let Some(record) = self.record.on(link, ms) {
+        if let Some(record) = link.and_then(|link| self.record.on(Some(link), ms)) {
             unsent = unsent.saturating_add(send(stack, now, self.socket, group, PORT, &record));
         }
         loop {

n6-unsent-not-counted

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index e88047e9d..6489463f0 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -177,7 +177,7 @@ impl Node {
         }
         if let Some(name) = &mut self.name {
             let unsent = name.pass(now, &mut self.stack);
-            self.counters.add(Counter::NameUnsent, unsent);
+            let _ = unsent;
         }
     }
 

n7-every-source-on-the-link

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 1498e79d9..235e57967 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -65,7 +65,7 @@ impl Name {
     /// Tells the responder what lease is held, sends what the record is owed now, then answers
     /// every query that arrived. Returns how many messages [udp] refused.
     pub(crate) fn pass(&mut self, now: Instant, stack: &mut Stack) -> u64 {
-        let link = stack.lease().map(|lease| Link { addr: lease.address.octets(), prefix: lease.prefix_len });
+        let link = stack.lease().map(|lease| Link { addr: lease.address.octets(), prefix: 0 });
         let (ms, group) = (millis(now), Ipv4Addr::from(GROUP));
         let mut unsent = 0u64;
         if let Some(record) = self.record.on(link, ms) {

n8-joined-before-the-port-is-bound

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 1498e79d9..9b03918ce 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -52,9 +52,9 @@ impl Name {
         let (Ok(ttl), Some(group)) = (Ttl::new(u8::MAX), MulticastAddr::new(Ipv4Addr::from(GROUP))) else {
             unreachable!("255 is a TTL and RFC 6762 §3's group is a group")
         };
+        stack.join(now, group);
         let (socket, _) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(PORT), || 0)?;
         stack.set_ttl(socket, ttl, ttl)?;
-        stack.join(now, group);
         Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] })
     }
 

s1-shard-closes-without-settling

diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs
index cf2d47ba7..4de1fe7bc 100644
--- a/toyos-net-shard/src/lib.rs
+++ b/toyos-net-shard/src/lib.rs
@@ -554,9 +554,8 @@ impl Shard {
     /// Closes a socket: its port is free at once, and what it had accepted leaves as [udp]'s
     /// closed sender, in the round from this call.
     pub fn udp_close(&mut self, now: Instant, id: SocketId) -> Result<(), toyos_net_udp::Error> {
-        let closed = self.udp.close(id);
-        self.settle(now);
-        closed
+        let _ = now;
+        self.udp.close(id)
     }
 
     /// The DHCP client's socket, on port 68: the one socket that may send from 0.0.0.0 and name

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 1 of wt/toyos-ownstack-b at bd325cb82, stage B alone (git diff origin/wt/toyos-ownstack-a...bd325cb82, base 9a99ee0ba), by .claude/agents/reviewer.md. Read: the diff; datagram.rs, name.rs, lib.rs, lease.rs and the three test files whole at the head; toyos-mdns, toyos-net-udp and the shard whole; toyos-net-ip's input policy, route lookup, IGMP join and UDP egress; today's userland/netstack/src/main.rs UDP handlers and mdns.rs; the run's log, its step logs and the 13 patches. Nothing was built or run.

Net lines (git diff --shortstat): 11 files, +1,032, -7. Production 305 (node 274: datagram.rs 122, name.rs 100, lease.rs +31, lib.rs +21; shard +31), tests 719, manifest, lockfile and the track 8. The growth is accepted: the two modules are what the flip deletes main.rs:945-1127 and mdns.rs for, and I found nothing in them to delete but the one line under NOTE.

BLOCKER

  • Evidence — cargo run -- --ci host green at the head is absent — the pull request is a draft and its host, toolchain and guest checks concluded SKIPPED at bd325cb82; a skip is not a green. Closed by reading CI's host log at the head once the base has landed and the draft is ready, with no change to the branch asked for this finding. In a job that concluded success on that commit the log must show: - green: the build system; - green: the workspace's host members, running toyos-net-shard's targets; - green: userland/netstack/node, its log running tests/datagram.rs with 5 passed, tests/lease.rs 18, tests/name.rs 10 and tests/slirp.rs 3; - green: userland/netstack/mdns, which the node now links; - green: clippy, warnings denied, with the === clippy: line for --manifest-path userland/netstack/node/Cargo.toml --all-targets; - green: the licences of what ships; and the closing [ci] Host: <N> step(s), all green.
  • userland/netstack/node/src/name.rs:83 — the once-a-second bound on multicasting the record (RFC 6762 §6), which is the one limit on how often a neighbour can make this machine multicast to the whole link, has no test through the node — the bound is Responder's and is tested there, but it holds only if the node hands on and answer one clock and wakes for what answer held back, and no node test asks twice inside a second: settled() waits two quiet seconds before every query, and next_deadline's only test (n2) is the announcement. The patch self.record.answer(query, Asker { .. }, ms) to self.record.answer(query, Asker { .. }, ms.saturating_add(1_000)) at name.rs:83 lifts the bound entirely, and by reading I expect all ten tests of tests/name.rs green under it. A new test must turn red under that patch: two queries from port 5353 without the unicast bit in the same instant, the first multicast at once, the second not answered, Node::next_deadline naming the instant one second after the first, and exactly one more multicast leaving when it fires. The implementer runs the patch and posts build and test exits.
  • userland/netstack/node/src/name.rs:86 — an answer to a query whose source is in 169.254/16 leaves in a frame to the router — toyos_mdns::Link::holds takes a link-local source as on every link, the answer for a source port other than 5353 is To::Asker, and toyos-net-ip/src/route.rs's lookup has no connected prefix for 169.254/16 on a leased interface, so it picks the active gateway as next hop: a frame to the router's link address, destination 169.254.x.y, TTL 255, triggered by any frame on the link. RFC 3927 §2.6.2 has a host send to 169.254/16 directly on the link whatever address it holds, and §7 never to a router; the module header's "a destination only once the responder found the source on this link" is not what happens. This is from reading, not from a run, and the branch goes back to measure it: one test delivering to_group(MAC_B, (169.254.3.4, 53_000), ..) on a lease with a router, and what lan.datagrams() then holds, posted. If it reads as predicted, either no such frame leaves, or the track records it as present-state wrong behaviour with that measurement and an exit a test can fail ([ip] sends to 169.254/16 on the link, and the node's test shows the answer at the asker's own link address). toyos-net-ip is outside this stage's fence, so I expect the second.

NOTE

  • issues/toyos-has-its-own-network-stack.md:27 — the broadcast line's exit does not gate the flip and does not carry the search still owed — see the ruling below: the line names the flip as the stage that may not land with this open, and what must be posted first.
  • userland/netstack/node/src/name.rs:96 — answer_as with a lease already held announces nothing until an unrelated receive, fire or link, and next_deadline does not ask for it: no pass runs in the call and owed_at is None until the first on — every test calls it before the link is up, so this is unseen; either the call ends in the pass (it has now and the stack) or the track's line about calls that take no pass names this one too, since the flip decides when it calls it.
  • toyos-net-shard/src/lib.rs:455 — Shard::join's settle has no test that fails without it, and I can name nothing it routes: Ip::join makes no flow eligible and queues no event, and the report leaves by Ip::transmit — say what it carries or delete the line; the five additions have no test in the shard's own package and are reached only through the node's, which s1 shows is enough for udp_close.
  • userland/netstack/node/Cargo.toml:3 — the description still says the crate is one interface and its DHCP lease; it now holds clients' datagram sockets and the mDNS name.
  • userland/netstack/mdns/src/lib.rs:277 (not this diff; to file) — a query from port 5353 with the unicast-response bit is answered with ID 0 whatever ID it carried; RFC 6762 §18.1, as I read it, has a unicast response made for one query carry that query's ID. an_answer_goes_to_the_group_or_to_the_asker_as_the_query_asked asks that query with ID 0, so it cannot tell; ask it with a nonzero ID once the issue is filed and the expectation is the RFC's.
  • pull request body, "Smaller differences" — the list is short of two that the tests here assert: a send with no lease held is answered not connected (udp.no-source-address) where today's handler answers whatever send_slice accepted, and a payload past 1,472 octets is refused where today's stack is built without fragmentation; and "as on smoltcp today" under "Unsure of" reads as if today's netstack did not re-announce on link-up: today dhcp.rs resets the client on link-up, the address is new and mdns.rs announces it, so no announcement on link-up is a difference this stack makes by keeping the lease.

Rulings asked for

Broadcast refused. Right, and the only choice the principles leave. A socket nobody permitted must not broadcast: permitting it by default so that the node matches smoltcp would be a silent default on a trust boundary, and matching smoltcp is not a requirement of anything. What is wrong is not in this diff: std's set_broadcast answers Ok(()) and carries nothing (sdk/std/sys/net/connection.rs:602, confirmed, as is the body's search: the only other hit in the tree is the string in src/sourcegate.rs). After the flip a portable program that calls set_broadcast(true) and sends is told yes and then refused, which is "Existing Rust just works" broken by a lie rather than by a refusal. Before the flip lands, all three: (1) the search the implementer skipped, over the source of every third-party C program an image builds from a recipe, for SO_BROADCAST and a sendto to a broadcast address, with its command and output posted: it is cheap and it decides whether the flip changes a shipped program; (2) either the pipe ABI carries the permission to Udp::set_broadcast, or std's and libc's setters refuse it by name; a setter that says Ok to a permission nothing grants does not survive the flip; (3) if the owner rules broadcast out instead, that is his ruling to give and the track quotes it. The word InvalidInput is the nearest the ABI has; if (2) carries the permission, a refused send wants a word that says permission and not input.

A logged refusal surfacing at the next receive. Acceptable as recorded for a crate that ships in nothing; the exit names both ways out. The flip takes one of them; it does not land on "the next frame will flush it".

The host name handed over twice. Acceptable as recorded. At the flip the shell has to give answer_as a &'static str and Node::new an owned copy of the same label: if that takes a leak, the exit ("one type carries the label to both") is due at the flip and not after it.

No re-announce on link-up. Acceptable as recorded, with the body's wording corrected as above: it is a difference from today, made by the lease surviving the link, and RFC 6762 §8.3 asks for the announcement.

The six methods on Stack. None widens what code outside lease.rs can do to the lease or the address. shard and socket stay private; SocketId's fields are private to toyos-net-udp, so datagram.rs and name.rs can name only a socket a bind gave them and never the acquisition socket; port 68 is taken, and the test binds it and is refused. None of the six reaches add_address, remove_address or set_gateways. Node::shard() hands out &Shard, and every one of the shard's five additions but udp_counters takes &mut self. One thing for whoever calls Stack::join next: it is agreed(..), so a thirty-third group is an unreachable!, not a refusal.

Wire input

By reading, no datagram, mDNS message or IGMP frame reaches a panic or an unbounded allocation through what this diff adds. name.rs's two unreachable!s are two constants and a receive on a socket that is never closed and never connected ([udp] sets a pending error only on a connected socket: icmp_error and unreachable both go through connected); datagram.rs's Error::Failed arm rests on the same fact. Stack::join runs once, after a bind that fails first on a second call (n8). The query buffer is [udp]'s largest payload, the receive queue is [udp]'s 16 datagrams and is emptied on every receive, an answer is one record, and a refused answer is dropped and counted, never retried. An IGMP frame from the wire meets no line of this diff: it is [ip]'s, not re-read here.

Who is answered, and where each rule lives:

rule enforced by tested
source on the link (§11) Responder, told the lease's prefix by the node toyos-mdns, and at the node (n7)
a source that is broadcast, multicast, 0.0.0.0, the subnet's edge or our own [ip]'s input policy, before any socket [ip]'s
link-local source Responder accepts; [ip] routes the answer to the gateway nowhere: BLOCKER 3
TTL 255 out (§11) the node at the node (n4)
TTL 255 in nobody, as §11 allows where the source is checked n/a
unicast or multicast answer (§5.4, §6.7) Responder decides, the node addresses both
multicast at most once a second (§6) Responder, on the node's clock and deadline toyos-mdns only: BLOCKER 2
unicast answers unbounded in rate, one per query, to an on-link source only, about 1.8 times the query's size not a rule either claims

A query sent to the machine's own address or to a broadcast address on port 5353 also reaches the responder, because the socket is bound to any; §5.5 allows the first, and neither is tested.

The oracles

Checked by hand against RFC 1035 §4.1.1 to §4.1.3 and RFC 6762: query() is ID, flags 0, QDCOUNT 1 and three zero counts in twelve octets, then the labels, the root, QTYPE and QCLASS; response() is ID 0, 0x8400 (QR and AA), no question, one answer, and a record of NAME, type 1, class 0x8001, TTL 120, RDLENGTH 4 and the address; legacy_response() echoes the ID and the question and carries class 1 and TTL 10. All three are right. The IGMP report is type 0x22, one group record of type 4 with no source for 224.0.0.251, to 224.0.0.22 at 01:00:5e:00:00:16, TTL 1, ToS 0xC0, Router Alert 94 04 00 00: right.

Independent of the code under test: every expected mDNS message and the IGMP report in tests/name.rs, the group's link address, the ports of a_socket_holds_the_port_it_named_or_the_one_its_draw_picked, and etherparse's reading of every emitted frame. Not independent: the scripted DHCP server, the ARP answers, lan::udp and common::sum, so every frame the node receives is the tests' own reading of RFC 768 and RFC 791 and is checked by nothing but the parser under test; each_refusal_is_answered_in_the_pipes_word_for_it, which holds the map to this diff's own reading of the ABI, as the track says; and no_cut_of_a_query_is_answered, which exercises toyos-mdns's parser and no line of the node's.

The 13 mutations: each patch's hunk is against the head's blobs, each build 0 and test 101, and the step logs show the assertion that fired is the rule's own in the seven I opened (n1 name.rs:137, n2 :170, n5 :220, n7 :232, n8 :279, d3 datagram.rs:178, s1 :174). n5 is caught by the counter and not by the datagram list: with the link kept, [udp] refuses the answer for want of a source, so the "gone" assertion on :219 cannot fail by itself; the counter line is what holds the rule.

CI's newer clippy

I expect no line of stage B to red. redundant_clone: three .clone(), each through a shared reference (tests/lan/mod.rs:206, tests/name.rs:107, :134, the last in a closure called three times). unchecked_time_subtraction: no subtraction on a std::time type; the two - are frame.len() - 14 on a usize, and the durations come from Instant::since and Instant::after. manual_is_multiple_of, manual_isolate_lowest_one: no % and no such shape in the diff.

For the workers branched from here

  • The resolver (from this branch): BLOCKER 2's test and BLOCKER 3's record change no signature; Stack's six methods, Node::udp_*, Refused, DatagramId and lan::Lan stay as they are unless the answer_as NOTE is taken by making the call run the pass, which touches Node::answer_as alone. The resolver's own deadline and pass go where the name's went, next_deadline's chain and the end of settle, and inherit the same gap: a call that takes no draw runs no pass. If the resolver connects its sockets, Error::Failed becomes reachable on them and datagram.rs's unreachable! stays true only because a client's socket is still never connected.
  • Streams (sibling, same crate): nothing here moves Node's existing shape. Both stages edit lease.rs's pass-through section, lib.rs's settle tail and next_deadline; the merge is textual. BLOCKER 3 is [ip]'s routing and applies to a connect to 169.254/16 as well.

SEND BACK

Japabu and others added 2 commits October 8, 2026 19:25
…get their tests, and the link-local answer is held as it is

- a_second_query_inside_a_second_waits_for_the_second_to_end: RFC 6762
  §6's bound reached through the node's clock and deadline.
- an_answer_to_a_link_local_asker_leaves_by_the_router: the present
  state the review predicted from reading, held by a test and recorded
  in the track as wrong, with [ip] owning the exit.
- Node::answer_as ends in the name's pass, so a lease already held is
  announced by the call; a_lease_already_held_is_announced_as_the_name_is_told.
- Shard::join no longer settles: a join makes no flow eligible and no
  event.
- The crate's description names what it now holds.
- The track's broadcast line gates the move and carries the search
  still owed; the mDNS unicast-response ID defect is filed.

Nothing was built or run by the author.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
…s the permission before the move

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round 1's 16 mutation runs at 56f03de2a (n1 twice): each patch applied with git apply after git apply --check, built (cargo test --locked --manifest-path userland/netstack/node/Cargo.toml --no-run, exit 0), run against its named test (exit 101), and reversed with git apply -R, the tree clean after each. The 13 of the first round are regenerated against this head (n6's line moved into name.rs); n9 is the review's patch and n10 is new.

The 169.254/16 reading (the review's third finding). an_answer_to_a_link_local_asker_leaves_by_the_router is green in the same run: a legacy query from 169.254.3.4 port 53000, in a frame from a host on the link to the group's link address, on a lease of 192.0.2.1/24 with router 192.0.2.254, makes exactly one datagram leave, as etherparse read it: a frame to the router's link address (02:00:00:00:00:fe), 192.0.2.1 port 5353 to 169.254.3.4 port 53000, TTL 255, payload the RFC 6762 §6.7 answer with the query's ID. As predicted from reading; recorded in the track as present-state wrong behaviour.

d1-named-port-ignored

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..4ea70286b 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -98,7 +98,7 @@ impl Node {
     /// or, with none named, an ephemeral one, which spends the one draw. Returns the socket and
     /// the port it holds.
     pub fn udp_bind(&mut self, addr: Ipv4Addr, port: Option<Port>, draw: impl FnOnce() -> u32) -> Result<(DatagramId, Port), Refused> {
-        let (id, port) = self.stack.bind(addr, port, draw).map_err(refused)?;
+        let (id, port) = self.stack.bind(addr, port.and(None), draw).map_err(refused)?;
         Ok((DatagramId(id), port))
     }
 

d2-port-in-use-answered-invalid-input

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..a84e21a31 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -56,7 +56,8 @@ fn refused(error: Error) -> Refused {
         Error::Failed(_) => unreachable!("[udp] reported a network error against a socket that is not connected"),
     };
     match rule {
-        Counter::PortInUse | Counter::NoEphemeralPort => Refused::AddrInUse,
+        Counter::NoEphemeralPort => Refused::AddrInUse,
+        Counter::PortInUse => Refused::InvalidInput,
         Counter::NoSourceAddress | Counter::NoRoute | Counter::SourceAddressNotAssigned => Refused::NotConnected,
         Counter::BindAddressNotLocal
         | Counter::SendLoopback

d3-close-closes-nothing

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..9d29f73a0 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -117,6 +117,7 @@ impl Node {
     /// Closes the socket: its port is free at once, what it received is gone, and what it had
     /// accepted still leaves, to [udp]'s bound.
     pub fn udp_close(&mut self, now: Instant, id: DatagramId) -> Result<(), Refused> {
-        self.stack.close(now, id.0).map_err(refused)
+        let _ = (now, id);
+        Ok(())
     }
 }

d4-source-is-the-destination

diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs
index 80b7ab9cf..e06bf3ce2 100644
--- a/userland/netstack/node/src/datagram.rs
+++ b/userland/netstack/node/src/datagram.rs
@@ -111,7 +111,7 @@ impl Node {
     /// longer than `out` is cut to it and the rest is gone, as [udp] counts.
     pub fn udp_recv_from(&mut self, id: DatagramId, out: &mut [u8]) -> Result<Option<Datagram>, Refused> {
         let received = self.stack.recv_from(id.0, out).map_err(refused)?;
-        Ok(received.map(|received| Datagram { len: received.len, source: received.source, source_port: received.source_port }))
+        Ok(received.map(|received| Datagram { len: received.len, source: received.destination, source_port: received.source_port }))
     }
 
     /// Closes the socket: its port is free at once, what it received is gone, and what it had

n1-group-not-joined

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..9c8943284 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -55,7 +55,6 @@ impl Name {
         };
         let (socket, _) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(PORT), || 0)?;
         stack.set_ttl(socket, ttl, ttl)?;
-        stack.join(now, group);
         Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] })
     }
 

n10-answer-as-runs-no-pass

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..151808f39 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -97,7 +97,6 @@ impl Node {
     /// taken, by a client's socket or by an earlier call.
     pub fn answer_as(&mut self, now: Instant, host: Host<'static>) -> Result<(), toyos_net_udp::Error> {
         self.name = Some(Name::new(now, &mut self.stack, host)?);
-        self.serve_name(now);
         Ok(())
     }
 

n2-deadline-without-the-name

diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs
index 23338f8e9..f6b9f8025 100644
--- a/userland/netstack/node/src/lib.rs
+++ b/userland/netstack/node/src/lib.rs
@@ -141,7 +141,7 @@ impl Node {
 
     pub fn next_deadline(&self) -> Option<Instant> {
         let name = self.name.as_ref().and_then(name::Name::next_deadline);
-        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).chain(name).min()
+        self.stack.next_deadline().into_iter().chain(self.client.next_deadline()).min()
     }
 
     /// Every deadline at or before `now`; the frames they make due wait for [`Self::transmit`].

n3-every-answer-to-the-group

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..b32a447d0 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -84,7 +84,7 @@ impl Name {
             let Some(answer) = self.record.answer(query, Asker { addr: received.source.octets(), port }, ms) else { continue };
             let (to, port) = match answer.to {
                 To::Group => (group, PORT),
-                To::Asker => (received.source, port),
+                To::Asker => (group, PORT),
             };
             unsent = unsent.saturating_add(send(stack, now, self.socket, to, port, &answer.bytes));
         }

n4-ttl-left-as-udp-has-it

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..ec0f8adbb 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -54,7 +54,6 @@ impl Name {
             unreachable!("255 is a TTL and RFC 6762 §3's group is a group")
         };
         let (socket, _) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(PORT), || 0)?;
-        stack.set_ttl(socket, ttl, ttl)?;
         stack.join(now, group);
         Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] })
     }

n5-link-kept-when-the-lease-goes

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..ad8061764 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -69,7 +69,7 @@ impl Name {
         let link = stack.lease().map(|lease| Link { addr: lease.address.octets(), prefix: lease.prefix_len });
         let (ms, group) = (millis(now), Ipv4Addr::from(GROUP));
         let mut unsent = 0u64;
-        if let Some(record) = self.record.on(link, ms) {
+        if let Some(record) = link.and_then(|link| self.record.on(Some(link), ms)) {
             unsent = unsent.saturating_add(send(stack, now, self.socket, group, PORT, &record));
         }
         loop {

n6-unsent-not-counted

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..c5c161887 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -105,7 +105,7 @@ impl Node {
     pub(crate) fn serve_name(&mut self, now: Instant) {
         if let Some(name) = &mut self.name {
             let unsent = name.pass(now, &mut self.stack);
-            self.counters.add(crate::Counter::NameUnsent, unsent);
+            let _ = unsent;
         }
     }
 }

n7-every-source-on-the-link

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..d45e52bac 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -66,7 +66,7 @@ impl Name {
     /// Tells the responder what lease is held, sends what the record is owed now, then answers
     /// every query that arrived. Returns how many messages [udp] refused.
     fn pass(&mut self, now: Instant, stack: &mut Stack) -> u64 {
-        let link = stack.lease().map(|lease| Link { addr: lease.address.octets(), prefix: lease.prefix_len });
+        let link = stack.lease().map(|lease| Link { addr: lease.address.octets(), prefix: 0 });
         let (ms, group) = (millis(now), Ipv4Addr::from(GROUP));
         let mut unsent = 0u64;
         if let Some(record) = self.record.on(link, ms) {

n8-joined-before-the-port-is-bound

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..f4fb9ed01 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -53,9 +53,9 @@ impl Name {
         let (Ok(ttl), Some(group)) = (Ttl::new(u8::MAX), MulticastAddr::new(Ipv4Addr::from(GROUP))) else {
             unreachable!("255 is a TTL and RFC 6762 §3's group is a group")
         };
+        stack.join(now, group);
         let (socket, _) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(PORT), || 0)?;
         stack.set_ttl(socket, ttl, ttl)?;
-        stack.join(now, group);
         Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] })
     }
 

n9-answers-on-a-clock-a-second-ahead

diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs
index 20c163243..430380e24 100644
--- a/userland/netstack/node/src/name.rs
+++ b/userland/netstack/node/src/name.rs
@@ -81,7 +81,7 @@ impl Name {
             let Some(query) = self.query.get(..received.len) else { continue };
             // Port 0 is no port to answer at: [udp] refuses the answer.
             let port = received.source_port.map_or(0, Port::get);
-            let Some(answer) = self.record.answer(query, Asker { addr: received.source.octets(), port }, ms) else { continue };
+            let Some(answer) = self.record.answer(query, Asker { addr: received.source.octets(), port }, ms.saturating_add(1_000)) else { continue };
             let (to, port) = match answer.to {
                 To::Group => (group, PORT),
                 To::Asker => (received.source, port),

s1-shard-closes-without-settling

diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs
index 05c4b94b8..c83df218d 100644
--- a/toyos-net-shard/src/lib.rs
+++ b/toyos-net-shard/src/lib.rs
@@ -553,9 +553,8 @@ impl Shard {
     /// Closes a socket: its port is free at once, and what it had accepted leaves as [udp]'s
     /// closed sender, in the round from this call.
     pub fn udp_close(&mut self, now: Instant, id: SocketId) -> Result<(), toyos_net_udp::Error> {
-        let closed = self.udp.close(id);
-        self.settle(now);
-        closed
+        let _ = now;
+        self.udp.close(id)
     }
 
     /// The DHCP client's socket, on port 68: the one socket that may send from 0.0.0.0 and name

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 2 of wt/toyos-ownstack-b at 0a4f49087, by .claude/agents/reviewer.md. Read: git diff bd325cb82 0a4f49087 (7 files, +106, -17) and git diff 56f03de2a 0a4f49087 (one line of the track); name.rs, lib.rs, tests/name.rs and tests/lan/mod.rs whole at the head; toyos-mdns whole; toyos-net-ip's join, change, flush and enqueue; the shard's settle; the run's log at 56f03de2a, its step logs and the n9 and n10 patches; RFC 6762 §18.1 and §6.7 from rfc-editor.org; wt/toyos-ownstack-c at 0b13818a4 against bd325cb82. Nothing was built or run.

Net lines of stage B (git diff --numstat origin/wt/toyos-ownstack-a...0a4f49087): 12 files, +1,122, -8. Production +312, -6 (node datagram.rs 122, name.rs 111, lease.rs +33 -2, lib.rs +16 -3; shard +30 -1), tests +778, manifest, lockfile and the two issues +32, -2. The round added 11 production lines net (serve_name, the call in answer_as) and deleted the shard's settle in join; nothing more to delete in what it added.

The head is one commit past the measured 56f03de2a, and that commit is one line of issues/toyos-has-its-own-network-stack.md: the run at 56f03de2a stands for it.

Round 1's BLOCKERs

  1. OPEN. cargo run -- --ci host green at the head — gh pr checks at 0a4f49087: host, toolchain and guest all skipping (run 37819061858). What closes it is under "Landing" below.
  2. CLOSED. The once-a-second bound through the node — a_second_query_inside_a_second_waits_for_the_second_to_end is green in the node's run (tests/name.rs, 13 passed), and n9, which is round 1's patch character for character (ms to ms.saturating_add(1_000) in the call of Responder::answer), builds 0 and exits 101 with the panic at tests/name.rs:206, "and is a deadline of the node's": the new test's own assertion. Round 1 said the patch lifts the bound entirely; it does not, it holds the second query for two seconds instead of one, and the deadline assertion is the one that sees it, with :212 behind it. An extra multicast at once is :203's, an exact list.
  3. CLOSED. The answer to a 169.254/16 asker — measured: an_answer_to_a_link_local_asker_leaves_by_the_router is green on exactly the frame predicted, one datagram, to the router's link address, from the leased address port 5353 to 169.254.3.4 port 53000, TTL 255, the §6.7 answer with the query's ID. The track records it as wrong by RFC 3927 §2.6.2 and §7, and its exit is a change to that test's to that the present code fails. name.rs's header now says what the responder takes for on-link.

Round 1's NOTEs

  • The broadcast line: CLOSED. The quote is the owner's words verbatim and nothing outside the quotation marks is put in his mouth; the two alternatives round 1 offered (the setters refuse, or broadcast ruled out) are gone, as his ruling removes them; the move is named as what may not land with it open, and the exit is the search posted and the permission carried from std's and libc's setters to Udp::set_broadcast, as a stage of their own, with a refusal that says permission. Neither broader nor narrower than he gave it.
  • answer_as with a lease held: CLOSED. The call ends in serve_name, the same pass settle ends in; n10 (that line removed) builds 0 and exits 101 at tests/name.rs:244, and :246 would fail behind it. The signature is bd325cb82's: pub fn answer_as(&mut self, now: Instant, host: Host<'static>) -> Result<(), toyos_net_udp::Error>. A refused second call leaves the first responder in place and runs its pass once more, which sends nothing new.
  • Shard::join's settle: CLOSED, nothing left unrouted. igmp::join counts, writes the group table, pushes the report onto [ip]'s control queue and arms or cancels an IGMP timer; it pushes no Event, logs no refusal (IgmpTooManyGroups is log.count, not refuse), bumps no generation and makes no flow eligible, and those four are all Shard::settle routes. The report leaves by Ip::transmit and its retransmission is ip.next_deadline(), which Shard::next_deadline reads directly. the_group_is_joined_and_its_membership_reported is green without the line and still red under n1.
  • The description: CLOSED.
  • The unicast-response ID: filed as asked, and round 1 was wrong to ask: the BLOCKER below.
  • The body: CLOSED; both differences and the link-up wording are there.

BLOCKER

  • Evidence — BLOCKER 1 above, open.
  • issues/mdns-answers-a-unicast-response-query-with-id-zero.md:1 — the defect it files is not one, and the misreading is round 1's own — RFC 6762 §18.1, read this round, says "In legacy unicast response messages generated specifically in response to a particular (unicast or multicast) query, the Query Identifier MUST match the ID from the query message"; a legacy unicast response is §6.7's, to a source port other than 5353, and Responder::answer echoes that ID already (userland/netstack/mdns/src/lib.rs:262), held at the node with IDs 0xBEEF and 5. For a §5.4 answer to an asker on port 5353 the RFC gives no ID rule at all, and it asks a multicast query to carry ID 0 in the first place. issues/README.md's defect is "real, reproducible, someone should fix it"; this file cites a rule the RFC does not have and its exit sends somebody to change toyos-mdns and two tests for it. Delete the file. Nothing else in the tree names its slug (git grep at the head finds it only in itself), and the body's two mentions of it go too.

NOTE

  • pull request body, "Unsure of, and weak" and "The first review, answered" — both say the unicast-response query waits on a fix to toyos-mdns; there is nothing to fix (the BLOCKER above).

The test that asks with ID 0

Acceptable, and not a test that cannot fail on its claim. an_answer_goes_to_the_group_or_to_the_asker_as_the_query_asked claims where each answer goes and that the §5.4 one carries what a multicast would; n3 reds it. The ID of that answer is not the node's to decide, the node passes the responder's bytes on, and by the RFC's text ID 0 is what a responder on port 5353 is asked with. It stays as it is.

Landing

BLOCKER 2 of this round needs one commit that deletes the one file. The run at 56f03de2a stands for a head that differs from 0a4f49087 by that deletion alone.

BLOCKER 1 closes on CI's log, and the orchestrator may land on reading it, with no further round, when all of this holds; if any of it does not, it comes back here:

  • toyos-net-node: the node and its DHCP lease on ToyOS's own stack, host-tested and shipped in nothing #771 has landed, origin/main is merged into the branch with no hand resolution, and git diff --shortstat origin/main...<head> reads 11 files, +1,100, -8: stage B's 12 files less the deleted issue, and no other path.
  • The pull request is ready, and host, toolchain and guest / suite each concluded success on that head: none skipped.
  • The host job's log shows - green: the build system; - green: the workspace's host members, running toyos-net-shard's targets; - green: userland/netstack/node, its log running tests/datagram.rs with 5 passed, tests/lease.rs 18, tests/name.rs 13 and tests/slirp.rs 3; - green: userland/netstack/mdns; - green: clippy, warnings denied, with the === clippy: line for --manifest-path userland/netstack/node/Cargo.toml --all-targets; - green: the licences of what ships; and the closing [ci] Host: <N> step(s), all green.

I expect none of the round's new test lines to red CI's newer clippy: no .clone() beyond the three round 1 read, no subtraction on a std::time type, no %.

For the resolver stage (wt/toyos-ownstack-c, built on bd325cb82)

No signature it uses moved: Stack's six methods, Node::udp_*, Refused, DatagramId, lan::Lan, name::millis (which it makes pub(crate)) and Node::answer_as are as they were; it calls neither Name::pass, now private, nor Shard::join. Rebased onto this head it conflicts in text in two places, both resolved by hand and both to be read in its own review: lib.rs's settle tail, where the four lines its self.resolver.pass(..) follows are now self.serve_name(now); (keep that line, then the resolver's); and the track, where this round rewrote the four bullets its three new ones follow and added the 169.254/16 one (keep this head's five, then its three, and its "In the tree" sentence). Its name.rs hunk does not touch this round's. One thing changes in behaviour beside it: answer_as now runs the name's pass and not the resolver's, which is right, a lookup has nothing to do with the name being told.

SEND BACK

…led no defect

RFC 6762 §18.1's rule that a response carries the query's ID is for
legacy unicast responses, which toyos-mdns already echoes; the reviewer
withdrew the reading the file was filed from. Nothing in the tree cited
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu
Japabu marked this pull request as ready for review October 8, 2026 19:03
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 6ef68789d, read against round 2's list (orchestrator): run 37829003218, event pull_request, conclusion success; host, toolchain / build and guest / suite each success, none skipped (the guest and toolchain entries that read skipping are the draft's earlier run). The head is origin/main merged in with no hand resolution; git diff --shortstat origin/main...6ef68789d reads 11 files, +1,100, -8.

From the host job's log:

  • [ci] Host: 79 step(s), all green, no RED: line.
  • === [ci] userland/netstack/node as its own step: tests/datagram.rs 5 passed, tests/lease.rs 18 passed, tests/name.rs 13 passed, tests/slirp.rs 3 passed.
  • === [ci] userland/netstack/mdns ran.
  • === clippy: cargo clippy --manifest-path userland/netstack/node/Cargo.toml --all-targets -- $ADOPTED -D warnings, green under CI's toolchain.

Round 2's open BLOCKER closes on this.

@Japabu
Japabu added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 35d3585 Oct 8, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-ownstack-b branch October 8, 2026 19:43
Japabu added a commit that referenced this pull request Oct 8, 2026
Resolved by hand, each to carry both stages:

- `userland/netstack/node/src/lib.rs`, the modules: `mod name;` of main's
  and `mod streams;` with its `pub use` of this branch's, both kept.
- `userland/netstack/node/src/lib.rs`, `Node::next_deadline`: one chain of
  the stack's, the DHCP client's, the name's and the streams' deadlines.
- `userland/netstack/node/Cargo.toml`, `description`: one sentence naming
  the datagram sockets, the streams and the mDNS name.
- `issues/toyos-has-its-own-network-stack.md`, the stage 5 paragraph: in
  the tree are the lease, the datagram sockets, the mDNS name and streams;
  still to build are the resolver and listeners.
- `issues/toyos-has-its-own-network-stack.md`, "What the node does not yet
  meet": this branch's six lines, then main's six, none changed.

Merged by git and read: `settle` ends in main's `serve_name`, and
`receive` and `fire` run the stream pass after it; `transmit` ends in the
pass over the connects; `lease.rs` has main's datagram forwards and
`mod tcp;`. `Stack`'s TCP forwards stay `tcp_*`; main's datagram forwards
(`bind`, `send_to`, `recv_from`, `close`, `set_ttl`, `join`) have no
prefix and are left as landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
Japabu added a commit that referenced this pull request Oct 8, 2026
Stage D's head carries origin/main at 35d3585 (#772): the node's
datagram sockets and its mDNS name.

Resolved by hand:

- userland/netstack/node/src/lib.rs, the module list: both sides whole,
  in order: datagram, lease, listeners, name, places, streams. Every
  other hunk of the file merged by itself: receive and fire still end in
  bridge after settle, transmit in pass(now, true), and next_deadline is
  stage D's four terms.
- userland/netstack/node/Cargo.toml, description: names what both sides
  put in the package, and says the bound is on streams and listeners.
- the track, the stage 5 paragraph: in the tree are the node, its lease,
  the datagram sockets, the name, streams, listeners and the bound on
  streams and listeners; still to build are the resolver and the move.
- the track, "What the node does not yet meet": stage E's line on
  Tcp::ready and Tcp::orphans and stage D's six lines from main, all
  kept, none reworded.

The node's tests at this tree: exit 0 (datagram 5, lease 18, listeners
22, name 13, slirp 3, streams 34).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant