Skip to content

toyos-net-node: a query known not to have reached its resolver is not waited out, an ICMP error ends nothing, and a burst of lookups reaches the wire - #781

Merged
Japabu merged 7 commits into
mainfrom
wt/toyos-move-resolver
Oct 9, 2026
Merged

Japabu merged 7 commits into
mainfrom
wt/toyos-move-resolver

Conversation

@Japabu

@Japabu Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Stage "resolver" of the move off smoltcp: the two lines of issues/toyos-has-its-own-network-stack.md that said "Exit, before the move" about the node's resolver. toyos-dns ships (netstack's resolver on smoltcp links it); toyos-net-node, toyos-net-ip and toyos-net-udp ship in nothing, so no guest test reaches the new behaviour and none is added.

Head 8330a5c75: the reviewed b2d903719 with origin/main merged in twice, at 3fd6a2ac7 (head c1dc7b176: #779 the ip rules, #775 streams, #777 listeners, #782, #780 and #786) and then at 0d6cd9a60 (#778, #785 and #783, the broadcast permission). git diff b2d903719 8330a5c75 -- toyos-dns/src/lib.rs userland/netstack/node/src/resolve.rs is empty. Of this stage's twelve files, git diff c1dc7b176 8330a5c75 names two: the track, and userland/netstack/src/main.rs, whose hunks there are #783's, line for line those of git diff 3fd6a2ac7 0d6cd9a60. Each merge had one conflict, in the track file, resolved by hand with every line of both sides standing. Round 3 of the review (of 71db83dc1), round 2 (of d9a5cf2e5) and round 1 (of 20b21432b) are answered in the three sections after next.

What changed, per decision

1. A lookup is told what its caller knows, and what its caller was told, in two calls.

  • toyos_dns::Lookup::on_unreached(asked, now, id): the caller knows of its own knowledge that the query never reached its server. The query is let go (its answer is read no more, so the node closes its socket).
  • toyos_dns::Lookup::on_report(asked, now, id): the network said so. Nothing is let go and nothing ends: the query's answer is still read.
  • Either, of the newest query, asks the next server at once, where that server may be asked. RFC 1035 §4.2.1: "Queries sent using UDP may be lost, and hence a retransmission strategy is required", "The client should try other servers and server addresses before repeating a query to a specific address of a server" and "the minimum retransmission interval should be 2-5 seconds". The interval is between repetitions of a query that may have been lost; the next server is another address, which the same section tries first.
  • The interval is each server's, and a server is its address, however often the list names it. Each server carries when it may next be asked: WAIT_MS after its last query, WAIT_MS after the caller last knew it was not reached, and at once when it has answered (a failure is an answer, read to the strength of one: the query's port, its id and its question). Every path that asks, the wait's end, a server failure, on_unreached and on_report, is the one ask. It sends where the next server may be asked. Otherwise it waits while any query's answer is still read: until the next server's turn, which is then when the lookup wakes, or, with every query sent, until the last one's wait is over. It ends at once only with no query left. There is no count of queries in a row any more.
  • What reports alone cost. They end nothing and let nothing go, and they do shorten a silent lookup's life: with two resolvers that never answer, a report of every query takes it from 12 s to 6 s (each resolver asked at 0, 2 s and 4 s where it was one resolver every 2 s); with one resolver not at all. Every query is read to that end, so what is lost is an answer later than WAIT_MS after a resolver's last query.
  • A lookup ends Failure::Unreachable only when no query's answer is still read, which only on_unreached and server failures can bring about. A server failure answered earlier still names the end (ServerFailed), and it ends a lookup only when no query is read or the last query's wait is over.
  • The node says each word where it has it (userland/netstack/node/src/resolve.rs). Knowledge: a query [udp] refuses in the resolver's own call (udp.no-route at connect; counted node.query-unsent), and [ip]'s own report that the query's next hop failed (SocketError::NextHopFailed; counted node.query-failed). A report: an ICMP error on the query's connected socket that [udp] classes refused or prohibited (SocketError::Refused, SocketError::Prohibited; counted node.query-failed). I count both under the one counter the node has, because adding one is lib.rs, outside the fence.
  • A lookup none of whose resolvers could be sent a query starts and ends in Node::resolve: it returns its id, and its end is in the next take_resolved.
  • The two exits of the track. a_query_udp_refuses_is_counted_holds_no_port_and_the_next_resolver_is_asked_at_once finds its answer at 0 ms (it was WAIT_MS). a_query_the_network_reports_unreachable_is_counted_and_ends_a_lookup_with_no_other_resolver finds its lookup ended at 3 s, [ip]'s report, Failure::Unreachable (it was ROUNDS x WAIT_MS, TimedOut).

2. toyos-net-udp's SocketError::Unreachable is two words: Prohibited and NextHopFailed. It was both an ICMP prohibition (codes 9, 10, 13) and [ip]'s own report of a failed next hop, and the resolver has to tell a datagram's word from [ip]'s. Neither new word keeps the old name, so every reader stopped compiling until it chose. The readers, all touched: toyos-net-udp/tests/recv.rs (US-049, now Prohibited), toyos-net-udp/tests/send.rs (US-027, now NextHopFailed), toyos-net-shard/tests/udp.rs (ICD-012 through the shard, now NextHopFailed), and the node's resolver. The node's datagram.rs matches Error::Failed(_) and is untouched; the shard's src/lib.rs names no SocketError and is untouched. No fork clone or checkout names the type (toyos-net-udp is in this tree alone).

3. A burst of lookups at a resolver not yet resolved is whole on the wire once ARP answers: PENDING_PER_NEIGHBOUR is 16, a stopgap.

  • RFC 4861 §7.2.2: "the sender MUST, for each neighbor, retain a small queue of packets waiting for address resolution to complete. The queue MUST hold at least one packet, and MAY contain more. However, the number of queued packets per neighbor SHOULD be limited to some small value. When a queue overflows, the new arrival SHOULD replace the oldest entry."
  • The node's resolver asserts at compile time PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS. Mutation m8 is that assertion failing the build.
  • It makes the track's exit test pass and leaves its rule false for any second sender, and it sizes a limit of [ip]'s by a constant of the resolver's. The fix is the slice's in which datagram senders wait on the hop, and is on the track (below) with what it deletes.
  • a_burst_at_a_resolver_not_yet_resolved_is_on_the_wire_once_the_resolver_answers_arp finds all 16 queries, in the order the lookups started, at 0 ms, with nb.pending-overflow at 0.

Round 3 of the review, finding by finding

BLOCKER 6, a send started the interval of one place in the list and every other writer keyed it by address. Fixed, by the rule that deletes the split: the send calls self.free(to, now + WAIT_MS). ask writes no slot any more; an interval has one key.

  • In toyos-dns: a_server_named_twice_is_one_server_and_is_asked_once_a_wait_whatever_is_reported. &[S1, S1]: on_report of the newest query, twice, the moment each is sent, is Step::Wait with due() at that query's WAIT_MS; S1 is asked at 0, 2 s, 4 s, 6 s, 8 s and 10 s and at no other time, and the lookup ends TimedOut at 12 s.
  • The node's form: a_resolver_the_lease_names_twice_is_asked_once_a_wait_whatever_it_reports. A lease that names the refusing resolver twice: one query on the wire a WAIT_MS, six in all, every report counted.
  • Red first. Round 4, step r0-red-first-at-71db83dc1, cargo test --locked --no-fail-fast -p toyos-dns -p toyos-net-node -- <the three tests>, exit 101. Its tree is the merge b10a788df with the tests added, which I ran instead of 71db83dc1 itself because this round's builds were to follow the merge of The host's toolchains live in one store outside every checkout, and every compiler is keyed #769; git diff 71db83dc1 b10a788df is empty for toyos-dns, the node, [udp], [ip] and the shard, and the log's second line says so. toyos-dns: left: Ask { asked: Asked(1), to: [10, 0, 2, 3], .. }, right: Wait. The node: left: [0, 0, 2000, 2000, 4000, 4000], right: [0, 2000, 4000, 6000, 8000, 10000].
  • The mutation that restores the per-slot write is m26, red on both tests and on nothing else.

NOTEs.

  • self.due = self.due.max(free): the assignment is the rule, and the max is deleted. Where the newest query is not waited for and the next server may not be asked yet, the lookup wakes at that server's turn. The test at the review's order is a_lookup_whose_newest_query_is_not_waited_for_wakes_at_the_next_servers_turn ([S1, S2], q1 at 0, reported at 1.5 s, q2 reported at 1.6 s: due() is 2 s and S1 is asked then, three queries still read); it is red at round 3's source in the same r0 (left: 3500, right: 2000), and m27 puts the max back and is red on it alone.
  • The field's doc says a server may be asked at once when it has answered any query, an older one's late failure included.
  • The body names main where it is, and "no server that has not answered is sent a second query inside WAIT_MS, whoever reports or fails what" is true of this head for a list that repeats an address too.

Round 2 of the review, finding by finding

BLOCKER 5, reports and another resolver's failures chained with no wait and ended the lookup with an answer on its way. Fixed, by deleting the state that made it. run is gone and so is the split between the first query of a run and a query at once; the interval is each server's (decision 1). Both things the review names are false at this head: a lookup does not end by server failure while a query is still read and under its wait, and no server that has not answered is sent a second query inside WAIT_MS, whoever reports or fails what.

  • The review's order in toyos-dns: reports_and_another_servers_failures_hurry_no_server_that_has_not_answered_and_end_no_lookup_early. Two servers, S1's every query reported the moment it is sent, S2 failing each of its own at once: each server is asked at 0, 2 s and 4 s and at no other time, the lookup ends ServerFailed(5) at 6 s with S1's three queries read to the end; and S1's answer to q1 at WAIT_MS - 1 ends the lookup Ok.
  • The known side: a_server_not_reached_is_not_asked_again_at_once_because_another_failed. S1's query known unreached and S2 failing: S1 is not asked again, and with no query read the lookup ends by S2's word; with a query to S1 still read it waits for it.
  • The node's form: forged_reports_and_another_resolvers_failures_end_nothing_and_the_answer_behind_them_is_taken. A lease of the answering resolver and one that answers every query RCODE 5; every query that leaves for the first is reported in its name the moment it is on the wire, until none follows. One query has left for each resolver, the lookup has not ended, and the first resolver's answer at WAIT_MS - 1 ends it Ok.
  • Red first. Round 3, step r0-red-first-at-d9a5cf2e5: the tree of d9a5cf2e5 with those three tests, cargo test --locked --no-fail-fast -p toyos-dns -p toyos-net-node -- <the three tests>, exit 101: the two toyos-dns tests left: Ask { asked: Asked(2), to: [10, 0, 2, 3], .. } against right: Wait and right: Done(Err(ServerFailed(5))); the node's left: (3, 3, 0), right: (1, 1, 0), three queries to each resolver in the lookup's first millisecond.
  • What the rule moved besides. A server known unreached keeps its interval from that knowledge, so one server found unreached a wait or more after its query is still not asked again at once (rfc1035_4_2_1_no_server_is_asked_again_at_once_a_lookup_that_reached_none_ends, and the transmit-opportunity test's 6 s, both red without it: m22). Where that leaves the next server not yet askable when the newest query's wait ends, the wait reaches to its turn while an answer is read: a_server_known_unreached_is_asked_again_a_wait_after_that_and_the_lookup_waits_for_its_turn (m23). A server failure of a lookup's last query, with older queries read, now waits their wait out where the base ended at once (m25).

NOTEs. The body says what reports alone cost (decision 1). "No server is asked twice inside WAIT_MS, whoever reports what" and "no lookup ended", which finding 5 made false of round 2's head, are gone as round 2 wrote them; what this head keeps is said in decision 1 and held by the three tests above. The displacement of [ip]'s word by an ICMP error is a clause of the track's first residual, with that residual's exit. The track lists the owning slice with stage 5's slices, "datagram senders that wait on the hop, after #777", and the three lines name it; "the move's node stage" is gone from the track.

Round 1 of the review, finding by finding

BLOCKER 1, one forged ICMP message ended a query and a lookup. Fixed, by the review's option (a). Decision 1 and 2 above. No ruling that another resolver's behaviour is the design is assumed anywhere.

  • The test the review names: a_forged_report_of_the_querys_own_addresses_and_ports_ends_nothing_and_the_answer_behind_it_is_taken. A port unreachable and then a prohibition (code 13), each in the resolver's name and quoting the query's exact addresses and ports, reach the query's socket (udp.icmp-error-delivered moves, the premise); neither ends the lookup, the socket is still held, no second query leaves, and the resolver's answer behind them ends the lookup Ok.
  • Its control, red first. Round 2, step r0-red-first-at-20b21432b: the tree of 20b21432b with that test added, cargo test --locked --no-fail-fast -p toyos-dns -p toyos-net-node -- <the four tests>, exit 101: left: [Resolved { id: LookupId(0), result: Err(Failed(Unreachable)) }], right: [].
  • reports_alone_end_no_lookup_and_ask_no_resolver_twice_inside_a_wait: a lease whose one resolver truly refuses every query by ICMP. The lookup's queries leave at 0, 2 s and 4 s, it ends TimedOut at 6 s, three reports counted. That is the cost of this option, and it is the base's behaviour for such a resolver.
  • In toyos-dns: rfc8085_5_2_a_reported_query_is_still_read_and_the_next_server_is_asked_at_once, rfc8085_5_2_reports_alone_end_no_lookup_and_ask_no_server_twice_inside_a_wait (every query reported twice the moment it is sent, with one server and with two), rfc8085_5_2_a_report_of_an_older_query_asks_nobody, a_reported_query_the_caller_then_knows_reached_nobody_is_let_go.
  • In toyos-net-udp, each word's test: US-049 (Prohibited), US-027 (NextHopFailed), and ICD-012 through the shard. m16, m17 and m20 swap the words one way each and are red there and at the node.

BLOCKER 2, Lookup ended Unreachable while an older query was still read. Fixed. The end is in one place, ask, and is reached only with no query left in sent or with every query sent and the last one's wait over.

  • The review's test: a_lookup_whose_newest_query_reached_nobody_waits_for_an_older_querys_answer (one server, q1 waited out, q2 unreached: Step::Wait, waiting() == [q1], due unchanged, q1's answer a millisecond later ends the lookup Ok).
  • The two-server order: a_lookup_that_then_reached_neither_server_waits_for_an_older_querys_answer (q1 to S1 waited out, q2 to S2 unreached, q3 to S1 unreached: Step::Wait, waiting() == [q1], S2 asked at due, q1's answer then ends the lookup Ok).
  • A third order I found on the way (item 3 below): a_lookup_whose_last_query_reached_nobody_waits_for_the_answers_still_read. The last query a lookup has, unreached, ended it at once under the queries before it.
  • Red first. The same step r0-red-first-at-20b21432b, exit 101: all three left: Done(Err(Unreachable)), right: Wait.

BLOCKER 3, the alias test narrowed on a guess. Measured, and restored as it was.

  • Round 2, step b3-the-bases-alias-test-at-20b21432b: the test's body as on origin/main (two resolvers, the alias at 10.5 s) on 20b21432b, cargo test --locked -p toyos-net-node --test resolve an_alias_answered_late, exit 101: left: Some(Err(Failed(TimedOut))), right: Some(Ok([[203, 0, 113, 7]])), the answering resolver having heard queries at 0, 4 s and 8 s, as the review read.
  • So the base's body was red on round 1's code, and not for the reason round 1 gave. The silent resolver's last query, the lookup's sixth, left at 10 s, [ip] refused it on a failed next hop, and its report ended the lookup TimedOut before 10.5 s while three queries to the answering resolver were read: finding 2's defect, in its third order.
  • At this head the base's body is green and the test is the base's again, byte for byte (git diff origin/main...8330a5c75 -- userland/netstack/node/tests/resolve.rs has no hunk in it). It now also holds that a query reported unreached between two waited ones leaves the alias restart alone. m18 turns it red.

BLOCKER 4, evidence. Open through every round and not mine to close: a draft, no host job and no guest test has run. What I ran is under "Gates".

NOTEs.

  • The track's two residual lines name their owner, the slice in which datagram senders wait on the hop, and the second names the design in the review's words and an exit a test reads: every query on the wire, PENDING_PER_NEIGHBOUR at the specification's 8 again, and the compile-time assertion, the nud.rs change and the departure line deleted. The ids NUD-25 and IP-D5 in that line are the review's, which round 2 confirmed.
  • The cost of 16 is on the track: four unresolved next hops fill PENDING_TOTAL where eight did.
  • The departure line names the limit, limits.ip.nud.pending_per_neighbour, with NUD-04, says the test holds the rule and no longer the scenario's numbers, and says NUD-05 passes unchanged only because its eight neighbours of eight are PENDING_TOTAL.
  • The unreachable! arm in userland/netstack/src/main.rs: no change asked, none made. It is still true: the shipped resolver calls neither on_unreached nor on_report.
  • This body's sentence on the alias test is replaced by what item 3 measured.

What moved outside the brief's list, and why

  • userland/netstack/src/main.rs, three lines: its match on toyos_dns::Failure is exhaustive, so the new variant needs an arm. The shipped resolver never calls on_unreached, so the arm is unreachable! and says why.
  • toyos-net-ip/tests/nud.rs, one test: s_ip_nud_004_overflow_drops_the_oldest sends two more than the constant. The track records the departure.
  • toyos-net-shard/tests/udp.rs, one line: a reader of the old word.

What is left, on the track

  • A query [ip] refuses at its transmit opportunity is read late, and until it is read an ICMP error can take its place on the socket ([udp] holds one pending error a socket, the latest: the query is then waited out and not let go, which costs the early end and never an answer). [ip] refuses a datagram to a next hop it holds failed inside Node::transmit, which carries no lookup on, so the resolver reads the report at the node's next receive, fire or link: at the latest when the query's wait ends. a_query_ip_refuses_at_its_transmit_opportunity_is_read_at_the_nodes_next_wake holds it. Not a regression: what ships waits every such query out. Owner: the slice in which datagram senders wait on the hop.
  • [ip]'s queue is every sender's. A client's datagram to the same next hop in that window takes the oldest query's place. a_clients_datagram_behind_the_burst_takes_the_oldest_querys_place_in_ips_queue holds it. A regression against smoltcp for more than 16 datagrams at one cold next hop; exit before the move. Owner: the same slice.

Checks (a trust boundary)

  • Wire input. The one new path from the wire is an ICMP error reaching a query's socket. [ip] reads it through toyos-net-wire and checks its quote; [udp] hands it only to the connected socket whose whole 4-tuple it quotes, and only for the refused and prohibited classes. The resolver reads no byte of it: it learns SocketError::Refused or Prohibited from recv_from. Forging one takes the query's source port (one of 16,384) and buys one query to the next resolver a wait early, at most one a resolver a round, which is the 12 s to 6 s above: no query let go, no socket closed, no lookup ended, no answer. a_report_that_is_not_about_the_query_or_is_a_hint_ends_no_wait sends four reports that must not even count. Nothing new can panic on input: both calls take a value the caller made, and the node crate forbids indexing, arithmetic side effects, unwrap and expect.
  • Oracles. RFC 1035 §4.2.1, RFC 8085 §5.2, RFC 792, RFC 1122 §3.2.2.1 and §4.1.3.3, RFC 4861 §7.2.2, quoted at each test. Every ICMP message is spelled by hand from RFC 792's layout with the tests' RFC 1071 sum; every frame the node emits is read by etherparse. The recorded dns.google reply still replays.
  • Time is an argument everywhere; no wait was added.
  • Negative controls. Each is a checked patch applied to the clean head, built, run with cargo test --locked --no-fail-fast -p toyos-dns -p toyos-net-node -p toyos-net-ip -p toyos-net-udp -p toyos-net-shard, and reversed by one script that refuses a dirty tree before and after. Patches and script are in the round-4 comment below. At b2d903719, and m0 again at c1dc7b176, its patch made against main as it now is, since it is the one patch that names a file the merge touched (userland/netstack/src/main.rs):
mutation build tests red
m0: the whole source change reverted onto the base, tests as here 101 not run does not compile: the tests name on_unreached, on_report and the two words
m1: the node waits a query [udp] refused out 0 101 the 0 ms test; a_lookup_udp_refuses_every_resolver_of_ends_in_the_call_that_started_it
m2: the node waits a query [ip] reports out 0 101 the 3 s test; the transmit-opportunity test
m3: on_unreached asks nobody 0 101 eight toyos-dns tests, three node tests
m4: a server is asked inside its interval 0 101 twelve toyos-dns tests and seven node tests, finding 5's three and finding 6's two among them
m5: an older unreached query asks too 0 101 an_older_query_that_reached_nobody_does_not_hurry_the_next_server and one more of toyos-dns
m6: a query does not start its server's interval 0 101 five toyos-dns tests and four node tests, finding 5's review order and node form and finding 6's two among them
m7: an unreached query's answer is still read 0 101 ten toyos-dns tests, three node tests
m8: the queue back at 8 101 not run the node's compile-time assertion: evaluation panicked: assertion failed: PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS
m9: the queue at 8 and the assertion removed 0 101 the burst test; the client's-datagram test
m10: [udp] delivers a hint to the socket 0 101 a_report_that_is_not_about_the_query_or_is_a_hint_ends_no_wait; US-049
m11: Unreachable outranks a server failure 0 101 three toyos-dns tests, a_server_that_answered_with_a_failure_is_what_a_lookup_that_then_reached_nobody_reports among them
m12: an end inside resolve is dropped 0 101 a_lookup_udp_refuses_every_resolver_of_ends_in_the_call_that_started_it
m13: [udp] matches a report by the port alone 0 101 a_report_that_is_not_about_the_query_or_is_a_hint_ends_no_wait; US-052
m14: the node takes an ICMP error for knowledge 0 101 the four node tests of forged and true reports
m15: on_report lets the query go 0 101 six toyos-dns tests; the same four node tests
m16: [udp] says a prohibition in [ip]'s word 0 101 US-049; the forged-report test
m17: [udp] says [ip]'s word as a prohibition 0 101 US-027; ICD-012 through the shard; the 3 s test; the transmit-opportunity test
m18: a lookup ends while a query is read and under its wait 0 101 nine toyos-dns tests, the three of finding 2 and both of finding 5 among them; the four node tests of reports
m19: an older query's report asks too 0 101 rfc8085_5_2_a_report_of_an_older_query_asks_nobody
m20: [udp] says a refusal in [ip]'s word 0 101 US-046, US-050, US-051; US-046 through the shard; the four node tests of reports
m21: a server that answered keeps its interval 0 101 a_lookup_whose_every_server_failed_says_how and two more of toyos-dns
m22: a server known unreached may be asked at once 0 101 two toyos-dns tests; the transmit-opportunity test
m23: the wait does not reach the next server's turn 0 101 a_server_known_unreached_is_asked_again_a_wait_after_that_and_the_lookup_waits_for_its_turn and the test of the wake; the alias test
m24: an alias keeps the old name's intervals 0 101 two toyos-dns tests of an alias asked again; the alias test
m25: a failure or a known-unreached query ends a spent lookup under its wait 0 101 four toyos-dns tests, finding 5's review order among them; the node's two reports-alone tests
m26: a query starts the interval of its place in the list (finding 6) 0 101 a_server_named_twice_is_one_server_and_is_asked_once_a_wait_whatever_is_reported; a_resolver_the_lease_names_twice_is_asked_once_a_wait_whatever_it_reports
m27: the wake is the later of the turn and the newest query's wait 0 101 a_lookup_whose_newest_query_is_not_waited_for_wakes_at_the_next_servers_turn

m10, m13, m16, m17 and m20 mutate toyos-net-udp: the controls of the trust claim the resolver's header rests on.

  • A mutation that stayed green, and its test. In round 2, m5 was green (exit 0) before an_older_query_that_reached_nobody_does_not_hurry_the_next_server existed (round 2, step g5-m5-without-its-test); the test is kept and m5 is red on it here. No mutation of rounds 3 and 4 stayed green.
  • Red-first, said plainly. The four tests of findings 1 and 2 compile against round 1's source and are red on it by assertion (round 2's r0), finding 5's three against round 2's (round 3's r0), and finding 6's against round 3's (round 4's r0). The tests that name on_report or the two new words do not compile against the base or round 1, so their controls are m14 to m27. m0 is the whole source change reverted onto the base under the tests as they stand: a build failure.

Gates, at 8330a5c75

command exit
cargo run -- --build-only 0
cargo run -- --ci host (Host: 78 step(s), all green) 0
cargo test --locked -p toyos-dns (62 passed, 0 failed) 0
cargo test --locked -p toyos-net-node --test resolve (32 passed, 0 failed) 0

Their logs are in the orchestrator's scratchpad under the stage's merge2/, each with its exit beside it.

Gates, at c1dc7b176 and before, not run again at 8330a5c75

command exit
cargo metadata --locked --format-version 1 0
cargo test --locked -p toyos-dns (62 passed, 0 failed) 0
cargo test --locked -p toyos-net-node (135 passed, 0 failed: datagram 5, host 1, lease 19, listeners 28, name 13, resolve 32, slirp 3, streams 34; the 71 of b2d903719 and 64 of main's, streams from #775, listeners, host and one of lease from #777) 0
cargo test --locked -p toyos-net-ip (271 passed, 0 failed: the 262 and #779's nine, six in addr, two in nbr, one in nud) 0
cargo test --locked -p toyos-net-udp (59 passed, 0 failed) 0
cargo test --locked -p toyos-net-shard (47 passed, 0 failed) 0
cargo test --locked -p toyos-net-testnet -p toyos-net-tcp -p toyos-mdns -p toyos-dhcp (475 passed, 0 failed: the 464 and eleven of [tcp]'s, take 5 from #775 and held 6 from #777) 0
cargo test --locked --manifest-path userland/netstack/Cargo.toml (29 passed, 0 failed; the shipped resolver's nine among them; the 27 and #782's two in virtio_net.rs) 0
cargo test --locked --lib -- hostws:: userlandhost:: sourcegate:: licence:: from the root package (45 passed) 0
cargo clippy --locked -p toyos-dns -p toyos-net-node -p toyos-net-ip -p toyos-net-udp -p toyos-net-shard --all-targets --keep-going -- <src/clippy.rs's ADOPTED as -W> -D warnings 0
cargo run -- --ci host (Host: 78 step(s), all green; the eight tests round 4's review names each ok in its log) 0
cargo run -- --build-only 0
m0 at c1dc7b176: the whole source change reverted onto main at 3fd6a2ac7, cargo test --locked --no-run of the five crates 101, as it must be
round 4, step r0-red-first-at-71db83dc1: finding 6's two tests and the test of the wake on round 3's source 101, as it must be
round 3, step r0-red-first-at-d9a5cf2e5: finding 5's three tests on round 2's source 101, as it must be
round 2, step r0-red-first-at-20b21432b: the four tests of findings 1 and 2 on round 1's source 101, as it must be
round 2, step b3-the-bases-alias-test-at-20b21432b: the base's alias test on round 1's source 101

Every step's log at c1dc7b176, the command first and the exit code last, is in the orchestrator's scratchpad under the stage's merge1/logs/: one file a gate, named by its step. Round 4's, and every mutation's but m0's, are in logs4/, round 3's in logs3/, round 2's in logs2/ and round 1's in logs/.

Not run by me, as briefed: any guest test, anything on metal. Neither merge changed a line this stage ships: toyos-dns/src/lib.rs is byte-identical to b2d903719, and its three lines of userland/netstack/src/main.rs merged without a conflict into #782's file and then #783's. No clippy shape of the tree lints userland/netstack (a guest package); its three lines did not change after round 1 and were compiled by its host tests and by the image build.

Growth

git diff --shortstat origin/main...8330a5c75: 12 files, +881 -112. Production: toyos-dns/src/lib.rs +103 -19, node/src/resolve.rs +84 -46, toyos-net-udp/src/lib.rs +10 -4, toyos-net-ip/src/lib.rs +1 -1, main.rs +3. Tests: toyos-dns/src/tests.rs +367, node/tests/resolve.rs +302 -33, nud.rs +4 -3, and one line each of toyos-net-udp/tests/recv.rs, send.rs and toyos-net-shard/tests/udp.rs. The track: +4 -3 lines.

Round 4 alone, in this stage's files (git diff --shortstat 71db83dc1 b2d903719 -- toyos-dns userland/netstack/node): 3 files, +72 -8; production is toyos-dns/src/lib.rs +12 -8, of which two lines are code (the send's free, and due = free with its max gone) and the rest the docs of the two fields and the contract.

Unsure of

  • A server that answers a failure is asked again at once, in its turn, as on the base: with one resolver that answers SERVFAIL, it is asked ROUNDS times with no interval. The interval is kept for a server that has not answered, which is the rule the review named; a failure is matched like an answer, so it is not an off-path sender's to give.
  • The rotation is fixed. Where the next server in turn may not be asked yet and another could be, the lookup waits for the turn and does not skip. Skipping would ask a reachable server more often than ROUNDS allows it or need a count a server; I kept the base's order.
  • issues/netstack-resolver-asks-a-dead-primary-first-on-every-lookup.md describes what ships and is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A

…ookups waits whole for its resolver's link address

Two lines of the network stack's track, each "before the move".

A query that did not reach its resolver was waited out for WAIT_MS like one
nobody answered. toyos_dns::Lookup has a word for it now, on_unreached: the
query is let go and the next server asked at once, and once every server has
been asked in turn and none reached the lookup ends Failure::Unreachable
rather than repeat a query with no interval (RFC 1035 §4.2.1). The node's
resolver says it for a query [udp] refuses in the call (no route) and for one
the network reports to the query's connected socket ([ip] gave its next hop
up, or ICMP refused or prohibited it). A lookup none of whose resolvers could
be sent a query has started and ended in Node::resolve.

A lookup behind one silent resolver ends at 3 s, [ip]'s report, where it took
ROUNDS x WAIT_MS; behind an unrouted first resolver it is answered at the
millisecond it started, where it took a WAIT_MS.

The shipped netstack never says the word (smoltcp reports nothing to it), so
its match on Failure gains an arm that cannot be reached and says so.

Lookups started together at a resolver not yet resolved lost all but the
newest eight queries to [ip]'s queue for that next hop. The queue holds 16,
and the node's resolver asserts at compile time that this is no fewer than
MAX_LOOKUPS; the interface's bound of 64 held datagrams is unchanged. Holding
the queries back in the resolver until the neighbour resolves needs the
next-hop question [tcp] asks, which the node's stack does not hand on: that
is lease.rs and the shard, outside this stage. NUD-04's test reads the
constant.

What is left is on the track: a query [ip] refuses at its transmit
opportunity is read at the node's next wake, and a client's datagram to the
same next hop shares the queue.

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

Mutation patches and the script that ran them, at 20b21432b. Each patch applies to the clean head with git apply; run.sh applies, builds, runs, and reverses each.

run.sh:

#!/bin/sh
# Each mutation: a checked patch applied to the clean committed tree, shown to
# build (or not), run, and reversed. Logs: $S/logs/<mutation>-builds.log and
# $S/logs/<mutation>.log, the command first and the exit code last.
W=<worktree>
S=<scratchpad>/orch/ownstack/resolver
P="-p toyos-dns -p toyos-net-node -p toyos-net-ip"
cd "$W" || exit 2
echo "HEAD=$(git rev-parse HEAD)"
clean() { [ -z "$(git status --porcelain --ignore-submodules=none)" ]; }
clean || { echo "the worktree is not clean; nothing run"; exit 2; }
for patch in "$S"/mutations/${1:-m}*.patch; do
    name=$(basename "$patch" .patch)
    git apply --check "$patch" || { echo "$name does not apply"; exit 2; }
    git apply "$patch"
    # shellcheck disable=SC2086
    { echo "\$ cargo test --locked --no-run $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-run $P; echo "EXIT=$?"; } > "$S/logs/$name-builds.log" 2>&1
    built=$(tail -1 "$S/logs/$name-builds.log")
    if [ "$built" = "EXIT=0" ]; then
        # shellcheck disable=SC2086
        { echo "\$ cargo test --locked --no-fail-fast $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-fail-fast $P; echo "EXIT=$?"; } > "$S/logs/$name.log" 2>&1
        ran=$(tail -1 "$S/logs/$name.log")
    else
        ran="not run"
    fi
    git apply -R "$patch"
    clean || { echo "$name left the tree dirty"; exit 2; }
    echo "$name builds $built tests $ran"
done
echo "HEAD=$(git rev-parse HEAD) clean=$(clean && echo yes || echo no)"
echo "MUTATIONS DONE"

m0-whole-source-change-reverted.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..1f46cdaa9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -431,12 +431,8 @@ pub enum Failure {
     NoSuchName,
     /// The name exists and has no `A` record (RFC 2308 §2.2).
     NoAddress,
-    /// No server answered, and a query that may have reached one was given
-    /// its [`WAIT_MS`].
+    /// Every query went unanswered for [`WAIT_MS`].
     TimedOut,
-    /// Every server was asked in turn and no query reached one
-    /// ([`Lookup::on_unreached`]): there is no answer to wait for.
-    Unreachable,
     /// The answer did not fit a UDP reply (TC).
     Truncated,
     /// Every server that answered could not answer, the last with this RCODE.
@@ -482,17 +478,6 @@ struct Sent {
 /// any query this lookup sent for the name now asked is read, so an answer
 /// late past its query's wait still ends the lookup; it is read only on the
 /// query's own port, with the query's ID, from the server the query went to.
-///
-/// **A wait is for an answer that can come.** A query the caller reports did
-/// not reach its server ([`Lookup::on_unreached`]) is let go, and the next
-/// server is asked at once: RFC 1035 §4.2.1's interval of two to five seconds
-/// is between repetitions of a query that may have been lost on its way, and
-/// the same section has the other servers tried first. Once every server has
-/// been asked in turn and none was reached, the lookup ends with
-/// [`Failure::Unreachable`] and repeats nothing: asking a server again at
-/// once would be the retransmission without an interval the RFC warns of, and
-/// a wait would be for no answer. Whether such a report is true is the
-/// caller's to judge; this type takes its word.
 #[derive(Debug)]
 pub struct Lookup {
     /// The name now asked: the one given, or the last alias followed.
@@ -501,16 +486,12 @@ pub struct Lookup {
     aliases: usize,
     servers: Vec<[u8; 4]>,
     /// Queries sent for `name`, oldest first; one answered with a server
-    /// failure, or reported not to have reached its server, is taken out.
-    /// [`Lookup::waiting`] is this list.
+    /// failure is taken out. [`Lookup::waiting`] is this list.
     sent: Vec<Sent>,
     /// The [`Asked`] the next query gets: none is given twice in a lookup.
     next: u32,
     /// Queries sent for `name`, answered or not.
     asked: usize,
-    /// Queries in a row, the newest last, that did not reach their server.
-    /// Once they are as many as the servers, nobody is left to ask at once.
-    unreached: usize,
     /// When the newest query is given up on.
     due: u64,
     /// The RCODE of the last server failure, which is what a lookup that runs
@@ -532,11 +513,10 @@ impl Lookup {
             sent: Vec::new(),
             next: 0,
             asked: 0,
-            unreached: 0,
             due: now,
             failed: None,
         };
-        let step = lookup.ask(0, now, id);
+        let step = lookup.ask(now, id);
         Some((lookup, step))
     }
 
@@ -551,16 +531,11 @@ impl Lookup {
         self.sent.iter().map(|s| s.asked)
     }
 
-    /// The next query for `name`, the `unreached` before it in a row having
-    /// reached no server; or the end, where every query has been sent or
-    /// every server was just asked and none reached.
-    fn ask(&mut self, unreached: usize, now: u64, id: impl FnOnce() -> u16) -> Step {
-        self.unreached = unreached;
-        let nobody = unreached == self.servers.len();
-        if nobody || self.asked == ROUNDS * self.servers.len() {
+    /// The next query for `name`, or the end where every one has been sent.
+    fn ask(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
+        if self.asked == ROUNDS * self.servers.len() {
             return Step::Done(Err(match self.failed {
                 Some(rcode) => Failure::ServerFailed(rcode),
-                None if nobody => Failure::Unreachable,
                 None => Failure::TimedOut,
             }));
         }
@@ -580,24 +555,7 @@ impl Lookup {
         if now < self.due {
             return Step::Wait;
         }
-        self.ask(0, now, id)
-    }
-
-    /// The query `asked` did not reach its server, the caller learnt at
-    /// `now`: it was never sent, or the network reported it back. Its answer
-    /// is read no more. `id` draws the ID of the query this sends.
-    pub fn on_unreached(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
-            return Step::Wait;
-        };
-        self.sent.remove(which);
-        // The next server is asked now, unless a newer query is still
-        // waiting for its own answer.
-        if which == self.sent.len() {
-            self.ask(self.unreached + 1, now, id)
-        } else {
-            Step::Wait
-        }
+        self.ask(now, id)
     }
 
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was
@@ -630,7 +588,7 @@ impl Lookup {
                 // The next server is asked now, unless a newer query is still
                 // waiting for its own answer.
                 if which == self.sent.len() {
-                    self.ask(0, now, id)
+                    self.ask(now, id)
                 } else {
                     Step::Wait
                 }
@@ -647,7 +605,7 @@ impl Lookup {
                     self.sent.clear();
                     self.asked = 0;
                     self.failed = None;
-                    self.ask(0, now, id)
+                    self.ask(now, id)
                 }
             },
         }
diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 38ffa8148..d8a5cfc23 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -10,18 +10,9 @@
 //!   query other than the one whose socket it reached.
 //! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets
 //!   the query go, ends, or is let go itself, and its port is free from then.
-//! - **A query that did not reach its resolver is not waited for**: the lookup is told, and asks
-//!   its next resolver at once or ends (`toyos_dns::Lookup::on_unreached`). One [udp] refuses
-//!   never left: it is counted and holds no socket. One the network reports back is counted and
-//!   its socket closed: [ip] found no next hop for it, or a host or router refused it by ICMP.
-//!   [udp] hands a connected socket only an error that quotes the socket's own addresses and
-//!   ports, and of ICMP's only one that says refused or prohibited, so forging a report takes the
-//!   query's port, and buys the lookup's early end and never an answer.
-//! - **Every lookup's first query can wait in [ip] for one next hop at once**, where no link
-//!   address is known yet: [ip] holds `PENDING_PER_NEIGHBOUR` datagrams for a next hop it is
-//!   resolving and keeps the newest (RFC 4861 §7.2.2), which is no fewer than the lookups held
-//!   here. A datagram another socket sends to that next hop meanwhile takes a place in the same
-//!   queue.
+//! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits
+//!   its wait out as for any query nobody answered. So does one the network reports back, its
+//!   resolver unreachable or its port refused: counted, and waited out.
 //! - **A lookup asks the resolvers of the lease it started under, and ends with that lease's
 //!   word**: when the held lease names other resolvers, or none is held.
 //! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame.
@@ -36,7 +27,6 @@ use alloc::vec::Vec;
 use core::net::Ipv4Addr;
 
 use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT};
-use toyos_net_ip::limits::nud::PENDING_PER_NEIGHBOUR;
 use toyos_net_udp::{Error, SocketId};
 use toyos_net_wire::Instant;
 
@@ -44,8 +34,6 @@ use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
-
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct LookupId(u64);
@@ -117,48 +105,42 @@ fn close(stack: &mut Stack, now: Instant, socket: SocketId) {
     }
 }
 
-/// What waits at a query's socket.
-enum Heard {
-    /// A datagram of this length, now in the reply buffer.
-    Reply(usize),
-    /// The network's report that the query did not reach its resolver.
-    Unreached,
-}
-
-/// The first of `queries` with something at its socket, and what: a reply is now in `reply`.
-fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8]) -> Option<(Query, Heard)> {
+/// The length of the next reply waiting at one of `queries`' sockets, now in `reply`, and the
+/// query it is for. An error the network reported against a query is counted and read past.
+fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8], counters: &mut Counters) -> Option<(Query, usize)> {
     for query in queries {
-        match stack.recv_from(query.socket, reply) {
-            Ok(Some(received)) => return Some((*query, Heard::Reply(received.len))),
-            Ok(None) => {}
-            Err(Error::Failed(_)) => return Some((*query, Heard::Unreached)),
-            Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+        loop {
+            match stack.recv_from(query.socket, reply) {
+                Ok(Some(received)) => return Some((*query, received.len)),
+                Ok(None) => break,
+                Err(Error::Failed(_)) => counters.add(Counter::QueryFailed, 1),
+                Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+            }
         }
     }
     None
 }
 
-/// Carries out `step` for `asking`, and every step the lookup answers a query [udp] refused
-/// with, then closes the socket of every query the lookup no longer reads an answer for. Returns
-/// how the lookup ended, if it did.
-fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
-    let done = loop {
-        match step {
-            Step::Ask { asked, to, query } => {
-                // An any-address bind that names no port is refused only for want of a free one.
-                let Ok((socket, _)) = stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) else { break Some(Err(Ended::NoPort)) };
+/// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer
+/// reads an answer for. Returns how the lookup ended, if it did.
+fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
+    let done = match step {
+        // An any-address bind that names no port is refused only for want of a free one.
+        Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) {
+            Ok((socket, _)) => {
                 let resolver = Ipv4Addr::from(to);
                 if stack.connect(now, socket, resolver, PORT).is_ok() && stack.send_to(now, socket, resolver, PORT, &query).is_ok() {
                     asking.queries.push(Query { asked, socket, to });
-                    break None;
+                } else {
+                    counters.add(Counter::QueryUnsent, 1);
+                    close(stack, now, socket);
                 }
-                counters.add(Counter::QueryUnsent, 1);
-                close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                None
             }
-            Step::Wait => break None,
-            Step::Done(result) => break Some(result.map_err(Ended::Failed)),
-        }
+            Err(_) => Some(Err(Ended::NoPort)),
+        },
+        Step::Wait => None,
+        Step::Done(result) => Some(result.map_err(Ended::Failed)),
     };
     let lookup = &asking.lookup;
     asking.queries.retain(|query| {
@@ -192,19 +174,16 @@ impl Resolver {
         let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect();
         let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") };
         let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() };
-        let done = act(&mut asking, step, now, stack, counters, &mut *draw);
-        if done == Some(Err(Ended::NoPort)) {
-            return Err(NotStarted::ResourceExhausted);
-        }
-        self.next = self.next.wrapping_add(1);
-        let started = asking.id;
-        match done {
-            None => self.asking.push(asking),
-            // No resolver could be sent a query: the lookup has its id and its end at once, and
-            // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+        match act(&mut asking, step, now, stack, counters, &mut *draw) {
+            None => {
+                self.next = self.next.wrapping_add(1);
+                let started = asking.id;
+                self.asking.push(asking);
+                Ok(started)
+            }
+            Some(Err(Ended::NoPort)) => Err(NotStarted::ResourceExhausted),
+            Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"),
         }
-        Ok(started)
     }
 
     /// Ends every lookup whose lease went or names other resolvers, reads every reply that
@@ -217,16 +196,11 @@ impl Resolver {
             let named = stack.lease().is_some_and(|lease| lease.dns == asking.resolvers);
             let mut done = if named { None } else { Some(Err(Ended::LeaseChanged)) };
             while done.is_none() {
-                let Some((query, heard)) = waiting(&asking.queries, stack, reply.as_mut_slice()) else { break };
-                let step = match heard {
-                    // The socket is connected: [udp] delivered this from port 53 of the resolver
-                    // the query went to, or not at all.
-                    Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
-                    Heard::Unreached => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
-                    }
-                };
+                let Some((query, len)) = waiting(&asking.queries, stack, reply.as_mut_slice(), counters) else { break };
+                let message = reply.get(..len).unwrap_or_default();
+                // The socket is connected: [udp] delivered this from port 53 of the resolver the
+                // query went to, or not at all.
+                let step = asking.lookup.on_datagram(query.asked, query.to, PORT, message, ms, || id(&mut *draw));
                 done = act(asking, step, now, stack, counters, &mut *draw);
             }
             if done.is_none() {
@@ -257,11 +231,9 @@ impl Resolver {
 }
 
 impl Node {
-    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws a
-    /// query, its id and then its port: for the first query, and for each asked in its place
-    /// because [udp] refused the one before. A call refused for want of a port has spent them;
-    /// any other refused call spends none. A lookup no resolver could be sent a query of has
-    /// started and ended: its end is in the next [`Self::take_resolved`].
+    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws:
+    /// the first query's id, then its port. A call refused for want of a port has spent both;
+    /// any other refused call spends none.
     pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result<LookupId, NotStarted> {
         self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw)
     }
diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index 21c30f688..0bd5fcc9f 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1550,9 +1550,6 @@ impl Netstack {
                 // whether the name or only its address is missing.
                 Err(Ended::Failed(Failure::NoSuchName | Failure::NoAddress)) => answer_lookup(&client, &[]),
                 Err(Ended::Failed(Failure::TimedOut)) => client.error(ERR_TIMED_OUT),
-                Err(Ended::Failed(Failure::Unreachable)) => {
-                    unreachable!("netstack: no lookup here is told a query did not reach its server, and {name}'s ended so")
-                }
                 Err(Ended::Failed(why @ (Failure::Truncated | Failure::ServerFailed(_) | Failure::TooManyAliases))) => {
                     say!("netstack: a lookup of {name} ended without an answer: {why:?}");
                     client.error(ERR_OTHER);

m1-node-a-refused-query-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 38ffa8148..11f2e156f 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -154,7 +154,7 @@ fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, cou
                 }
                 counters.add(Counter::QueryUnsent, 1);
                 close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                break None;
             }
             Step::Wait => break None,
             Step::Done(result) => break Some(result.map_err(Ended::Failed)),

m2-node-a-reported-query-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 38ffa8148..a5cb9ade8 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -224,7 +224,7 @@ impl Resolver {
                     Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
                     Heard::Unreached => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
+                        Step::Wait
                     }
                 };
                 done = act(asking, step, now, stack, counters, &mut *draw);

m3-dns-unreached-asks-nobody.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..39f3136cf 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -593,11 +593,8 @@ impl Lookup {
         self.sent.remove(which);
         // The next server is asked now, unless a newer query is still
         // waiting for its own answer.
-        if which == self.sent.len() {
-            self.ask(self.unreached + 1, now, id)
-        } else {
-            Step::Wait
-        }
+        let _ = (now, id);
+        Step::Wait
     }
 
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was

m4-dns-a-lookup-that-reached-nobody-asks-on.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..7e09b1292 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -556,7 +556,7 @@ impl Lookup {
     /// every server was just asked and none reached.
     fn ask(&mut self, unreached: usize, now: u64, id: impl FnOnce() -> u16) -> Step {
         self.unreached = unreached;
-        let nobody = unreached == self.servers.len();
+        let nobody = false;
         if nobody || self.asked == ROUNDS * self.servers.len() {
             return Step::Done(Err(match self.failed {
                 Some(rcode) => Failure::ServerFailed(rcode),

m5-dns-an-older-unreached-query-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..7b0568397 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -593,11 +593,7 @@ impl Lookup {
         self.sent.remove(which);
         // The next server is asked now, unless a newer query is still
         // waiting for its own answer.
-        if which == self.sent.len() {
-            self.ask(self.unreached + 1, now, id)
-        } else {
-            Step::Wait
-        }
+        self.ask(self.unreached + 1, now, id)
     }
 
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was

m6-dns-a-wait-does-not-reset-the-count.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..266591b8e 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -580,7 +580,7 @@ impl Lookup {
         if now < self.due {
             return Step::Wait;
         }
-        self.ask(0, now, id)
+        self.ask(self.unreached, now, id)
     }
 
     /// The query `asked` did not reach its server, the caller learnt at

m7-dns-an-unreached-query-is-still-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..060be307d 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -590,10 +590,7 @@ impl Lookup {
         let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
             return Step::Wait;
         };
-        self.sent.remove(which);
-        // The next server is asked now, unless a newer query is still
-        // waiting for its own answer.
-        if which == self.sent.len() {
+        if which + 1 == self.sent.len() {
             self.ask(self.unreached + 1, now, id)
         } else {
             Step::Wait

m8-ip-queue-of-8.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }

m9-ip-queue-of-8-and-no-assertion.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 38ffa8148..cda74f8c2 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -44,7 +44,7 @@ use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
+const _: usize = PENDING_PER_NEIGHBOUR;
 
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]

m10-udp-a-hint-is-delivered.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index a5fa0f73a..d0dcdc734 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -660,7 +660,8 @@ impl Udp {
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
             ErrorClass::Prohibited => SocketError::Unreachable,
-            ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::PathMtu => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::Soft => SocketError::Unreachable,
         };
         socket.pending = Some(pending);
         self.count(Counter::IcmpErrorDelivered);

m11-dns-unreachable-outranks-a-server-failure.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 2ed5ceb41..226a71635 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -559,8 +559,8 @@ impl Lookup {
         let nobody = unreached == self.servers.len();
         if nobody || self.asked == ROUNDS * self.servers.len() {
             return Step::Done(Err(match self.failed {
+                _ if nobody => Failure::Unreachable,
                 Some(rcode) => Failure::ServerFailed(rcode),
-                None if nobody => Failure::Unreachable,
                 None => Failure::TimedOut,
             }));
         }

m12-node-an-end-in-the-call-is-dropped.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 38ffa8148..ed5e70dfb 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -202,7 +202,7 @@ impl Resolver {
             None => self.asking.push(asking),
             // No resolver could be sent a query: the lookup has its id and its end at once, and
             // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+            Some(_) => {}
         }
         Ok(started)
     }

m13-udp-a-report-is-matched-by-the-port-alone.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index a5fa0f73a..1ec1ef8d8 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -637,7 +637,8 @@ impl Udp {
     fn connected(&mut self, flow: &Flow) -> Option<&mut Socket> {
         let index = *self.ports.get(&flow.source_port)?;
         self.at(index).filter(|s| {
-            s.binding == Binding::Connected { local: flow.source, peer: flow.destination, peer_port: flow.destination_port }
+            let _ = flow;
+            matches!(s.binding, Binding::Connected { .. })
         })
     }
 

What it printed:

HEAD=20b21432b27316d607729b839c2556f0483bc404
m0-whole-source-change-reverted builds EXIT=101 tests not run
m1-node-a-refused-query-is-waited-out builds EXIT=0 tests EXIT=101
m10-udp-a-hint-is-delivered builds EXIT=0 tests EXIT=101
m11-dns-unreachable-outranks-a-server-failure builds EXIT=0 tests EXIT=101
m12-node-an-end-in-the-call-is-dropped builds EXIT=0 tests EXIT=101
m13-udp-a-report-is-matched-by-the-port-alone builds EXIT=0 tests EXIT=101
m2-node-a-reported-query-is-waited-out builds EXIT=0 tests EXIT=101
m3-dns-unreached-asks-nobody builds EXIT=0 tests EXIT=101
m4-dns-a-lookup-that-reached-nobody-asks-on builds EXIT=0 tests EXIT=101
m5-dns-an-older-unreached-query-asks-too builds EXIT=0 tests EXIT=101
m6-dns-a-wait-does-not-reset-the-count builds EXIT=0 tests EXIT=101
m7-dns-an-unreached-query-is-still-read builds EXIT=0 tests EXIT=101
m8-ip-queue-of-8 builds EXIT=101 tests not run
m9-ip-queue-of-8-and-no-assertion builds EXIT=0 tests EXIT=101
HEAD=20b21432b27316d607729b839c2556f0483bc404 clean=yes
MUTATIONS DONE
RUN-SCRIPT-EXIT=0

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 1, of 20b21432b against origin/main at a4416fe6c. Read: the log, the diff, every changed file whole at the head, toyos-net-udp's and toyos-net-ip's ICMP and neighbour-queue paths, the implementer's step logs and mutation patches, the readers' [ip] specification. Nothing was built or run.

Growth: git diff --shortstat origin/main...20b21432b is 8 files, +431 -99. Production +128 -55 (toyos-dns/src/lib.rs +51 -9, node/src/resolve.rs +73 -45, toyos-net-ip/src/lib.rs +1 -1, netstack/src/main.rs +3). Tests +300 -42. The track +3 -2.

BLOCKER

  1. userland/netstack/node/src/resolve.rs:134, :225-228; toyos-net-udp/src/lib.rs:660-666; toyos-net-ip/src/icmp.rs:71-93 — one unauthenticated ICMP message ends a query, and with it a lookup — a hole on the trust boundary the base did not have. [ip] accepts an error whose quote names one of our addresses and eight transport octets; [udp] matches the quoted 4-tuple and the class. Of that tuple an off-path sender lacks only the source port: one of 16,384, where an answer takes the port and the id (RFC 5452 §9). On a hit the resolver stops reading the query's answer, closes its socket, and with every resolver hit ends the lookup Unreachable; a true answer already on its way then finds no socket. The cost is 16,384 small datagrams a resolver a lookup. The base counted such a report and read past it, so this is a regression in resistance, and the body's own "Unsure of" names it. a_report_that_is_not_about_the_query_or_is_a_hint_ends_no_wait and m10/m13 hold that a wrong tuple is refused, not that a right guess is hard. RFC 1122 §4.1.3.3 has UDP pass the error up; what the application does with it is this branch's choice, and RFC 8085 §5.2 asks that the response be robust to forged and transient reports and that they not abort the communication. The track's exit asked for two things only: [udp]'s refusal in the call, and [ip]'s own report of its next hop at 3 s. Both are local knowledge and neither needs the wire. Sent back to do one of: (a) a report from the wire asks the next resolver at once and lets nothing go — the query's socket stays read and the lookup does not end on wire reports alone — which needs [udp] to tell [ip]'s local failure from an ICMP error (SocketError::Unreachable is both today, lib.rs:662 and :672); or (b) [ip] and [udp] check the quote to the strength of an answer (the quoted UDP length and checksum cover the id). Either lands with a test in which a forged port-unreachable that quotes the query's exact tuple arrives, the resolver's true answer follows, and the lookup ends with that answer. If the owner rules glibc's behaviour the design, that is his ruling to give and the track's to quote; it is not this branch's to assume.

  2. toyos-dns/src/lib.rs:559-565, :589-601 — Lookup ends Unreachable while an older query is still read, against its own contract ("A wait is for an answer that can come"; the variant's doc: "no query reached one"). start(&[S1]), on_time(WAIT_MS) sends q2; on_unreached(q2) gives which == sent.len() and ask(1), nobody is true, and the lookup is Done(Err(Unreachable)) with q1 in sent, never reported, its answer possibly a millisecond away. The same with two servers: q1 to S1 waited out, q2 to S2 unreached, q3 to S1 unreached. No test holds this order: an_older_query_that_reached_nobody_asks_nobody_and_ends_nothing reports q1 first. Owed: the lookup ends on unreached reports only when no query is still read, and otherwise waits to due; and a test that fails on the present code: one server, q1 waited out, q2 reported unreached, expecting Step::Wait, waiting() == [q1], and q1's answer at the next millisecond ending the lookup Ok. The node's 3 s test is unaffected: [ip] reports the oldest first and sent is empty at the last report.

  3. userland/netstack/node/tests/resolve.rs:582 — an_alias_answered_late_restarts_the_lookup_and_lets_the_old_names_queries_go lost its second resolver and went from 10.5 s to 4.5 s on a reason the body states and no log shows. By reading the head, the old form is unchanged: queries to the answering resolver leave at 0, 4 s and 8 s; the silent one's are reported at 5 s (an older query, Step::Wait) and at 8 s (the report of the query [ip] refused at 6 s, read at the wake that also ends its wait, so the next query still leaves at 8 s); the alias arrives at 10.5 s; the names are the same four and udp.rx-no-socket moves by the same two. A test narrowed on a guess that one run would have settled. Owed: the base's body of that test run at this head, command, exit code and log in the pull request body. If it is green it stays as it was, and it then also holds that a query reported unreached between two waited ones leaves the alias restart alone, which no node test holds now. If it is red, the log says where, and the re-timing stands on that.

  4. Evidence — cargo run -- --ci host has no measurement at this head, and neither has any guest test. toyos-dns ships in netstack and userland/netstack/src/main.rs changed, so every guest test that resolves a name reaches the change. The three checks on the pull request are SKIPPED (a draft), which is not green. What they must show, at the head that lands: host exit 0 with its log, read whole, and guest / suite green with toolchain concluded and not skipped. With findings 1 to 3 open the orchestrator may not land on reading them; once those are closed by a later round, this one closes on his reading of the two logs at that head without another review.

NOTE

  • issues/toyos-has-its-own-network-stack.md:33-34 — neither residual names the stage that owns it, and the second's exit is "before the move unless the owner rules RFC 4861's small queue the design": a condition on a ruling nobody has asked for is not an exit something can read. "Before the move" is the right exit: it is a regression that would ship. The owner is the plan's node stage, after toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777, where the fence on lease.rs and toyos-net-shard/src/lib.rs lifts and [udp]'s datagram calls already go through Stack and Shard. The design the line should name: [udp]'s serve asks [ip] the next-hop question before it hands a datagram over, as [tcp]'s exit does with Hop (scenario NUD-25, decision IP-D5); a socket whose oldest datagram's hop is pending keeps it in its own queue, which TX_DATAGRAMS and TX_BYTES already bound and refuse in the call; it is offered again when the hop resolves and told when it fails. [ip] then queues only its own datagrams. The first residual belongs to the same stage (Node::transmit is lib.rs).
  • toyos-net-ip/src/lib.rs:111, userland/netstack/node/src/resolve.rs:47 — 16 is the stopgap and not the fix: it makes the plan's exit test pass and leaves its rule false for any second sender, as a_clients_datagram_behind_the_burst_takes_the_oldest_querys_place_in_ips_queue shows. It also sizes a limit of [ip]'s by a constant of the resolver's. Cost, by arithmetic from the constants: up to 16 frames of at most 1,514 octets a neighbour, about 24 KB, where it was 12 KB; PENDING_TOTAL keeps the interface at 64 frames, about 97 KB, unchanged; four unresolved next hops now fill the interface's total where eight did. The track should say that the stage above takes the constant back to the specification's 8 and deletes the assertion, the nud.rs hunk and the departure line with it.
  • issues/toyos-has-its-own-network-stack.md:48, toyos-net-ip/tests/nud.rs:68-80 — the scenario does fix the number: NUD-04 names ten datagrams, the third to the tenth out and two overflowed, and the specification's limit limits.ip.nud.pending_per_neighbour is 8. The test at the head sends 18 and carries the id of a scenario it no longer runs; it still holds the rule (the oldest is dropped, counted), and the value is pinned only by the node's two burst tests (m9; 17 or more reds the client's-datagram premise). The departure line names the scenario alone; it should name the limit too, and that NUD-05 passes unchanged only because eight neighbours of eight are PENDING_TOTAL.
  • userland/netstack/src/main.rs:1553-1555 — the unreachable! arm is true by construction: userland/netstack/src/resolve.rs calls Lookup::start, on_datagram and on_time only, and ask yields Unreachable only for unreached == servers.len(), which on_unreached alone can pass above 0 (start refuses no servers). A panic is right over a mapped word no path can produce. No change asked.
  • Controls — m0 red by build is acceptable here because m1, m2 and m9 each restore one base behaviour under the tests as they stand and each is red by assertion in its log (left: 2000, right: 0; Some(Err(Failed(TimedOut))); the newest eight names). m8's build log shows the assertion's own message. The set does not cover findings 1 and 2, which is why they are findings.
  • Merges, by git merge-tree in a scratch object store: with wt/toyos-ownstack-d (49e38ca4a) and wt/toyos-ownstack-e (c27cc36aa) clean; with wt/toyos-move-ip (95b889b15) one content conflict, in the track only (nud.rs merges by itself, and that branch's new scenario reads the constant). A clean text merge is no measurement of the merged tree: ownstack-e puts places under udp_bind, which this branch's port_held and client's-datagram test call, so host at the merge queue's head is the reading that counts.
  • Pull request body — "Its old timeline ... rested on that resolver's queries being waited out" is unmeasured and, by finding 3, likely false of the tree.

SEND BACK

…d only when no query is still read

Round 1 of the review of #781.

A report from the wire is anyone's word: an off-path sender who guesses a
query's source port can quote its addresses and ports, and needs no id.
toyos-net-udp's SocketError::Unreachable was both an ICMP prohibition and
[ip]'s own report that a next hop failed; it is two words now, Prohibited
and NextHopFailed. The node's resolver lets a query go only on what it
knows: [udp]'s refusal in the call, and NextHopFailed. An ICMP error
(Refused, Prohibited) goes to the new toyos_dns::Lookup::on_report, which
asks the next server at once where one is left to ask, lets no query go
and ends nothing: the query's socket stays open and its answer is read.

Lookup counts the queries sent in a row with no wait between them, and
sends at once only while that run is shorter than the servers, so no
server is asked twice inside WAIT_MS whoever reports what. When nobody is
left to ask at once it ends Unreachable only if no query is still read,
and otherwise waits the newest query's wait out: before, an unreached
newest query ended the lookup under older queries whose answers were a
millisecond away, and an unreached last query ended it TimedOut early.

an_alias_answered_late_restarts_the_lookup_and_lets_the_old_names_queries_go
is its base's again, two resolvers and 10.5 s: on 20b2143 that body was
red (TimedOut at the silent resolver's last query, reported unreached
while three queries to the answering resolver were read), which was the
defect above and not a property of the test.

The track's two residuals name their owner, the move's node stage after
#777, the design it takes for [ip]'s queue, and what it deletes with it;
the departure line names NUD-04's limit and why NUD-05 still passes.

Readers of the old word: toyos-net-udp's US-049 and US-027, the shard's
ICD-012 test, and the node's resolver. The node's datagram.rs matches
Error::Failed(_) and is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu Japabu changed the title toyos-dns, toyos-net-node: a lookup is told a query never reached its resolver, and a burst of lookups waits whole for its resolver's link address toyos-net-node: a query known not to have reached its resolver is not waited out, an ICMP error ends nothing, and a burst of lookups reaches the wire Oct 8, 2026
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2: mutation patches and the script that ran them, at d9a5cf2e5. Each m patch applies to the clean head with git apply; run.sh applies, builds, runs and reverses each. g5 is m5 with the test it asked for taken out, run by hand the same way (exit 0). r0 and b3 apply to 20b21432b, round 1's head: the four tests of findings 1 and 2 as they stand here, and the alias test's body as on origin/main; both runs are exit 101.

run.sh:

#!/bin/sh
# Each mutation: a checked patch applied to the clean committed tree, shown to
# build (or not), run, and reversed. Logs: $S/logs2/<mutation>-builds.log and
# $S/logs2/<mutation>.log, the command first and the exit code last.
W=<worktree>
S=<scratchpad>/orch/ownstack/resolver
P="-p toyos-dns -p toyos-net-node -p toyos-net-ip -p toyos-net-udp -p toyos-net-shard"
cd "$W" || exit 2
echo "HEAD=$(git rev-parse HEAD)"
clean() { [ -z "$(git status --porcelain --ignore-submodules=none)" ]; }
clean || { echo "the worktree is not clean; nothing run"; exit 2; }
for patch in "$S"/mutations2/${1:-m}*.patch; do
    name=$(basename "$patch" .patch)
    git apply --check "$patch" || { echo "$name does not apply"; exit 2; }
    git apply "$patch"
    # shellcheck disable=SC2086
    { echo "\$ cargo test --locked --no-run $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-run $P; echo "EXIT=$?"; } > "$S/logs2/$name-builds.log" 2>&1
    built=$(tail -1 "$S/logs2/$name-builds.log")
    if [ "$built" = "EXIT=0" ]; then
        # shellcheck disable=SC2086
        { echo "\$ cargo test --locked --no-fail-fast $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-fail-fast $P; echo "EXIT=$?"; } > "$S/logs2/$name.log" 2>&1
        ran=$(tail -1 "$S/logs2/$name.log")
    else
        ran="not run"
    fi
    git apply -R "$patch"
    clean || { echo "$name left the tree dirty"; exit 2; }
    echo "$name builds $built tests $ran"
done
echo "HEAD=$(git rev-parse HEAD) clean=$(clean && echo yes || echo no)"
echo "MUTATIONS DONE"

m0-whole-source-change-reverted.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..1f46cdaa9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -431,12 +431,8 @@ pub enum Failure {
     NoSuchName,
     /// The name exists and has no `A` record (RFC 2308 §2.2).
     NoAddress,
-    /// No server answered, and a query that may have reached one was given
-    /// its [`WAIT_MS`].
+    /// Every query went unanswered for [`WAIT_MS`].
     TimedOut,
-    /// No query reached a server ([`Lookup::on_unreached`]) and none is left
-    /// to send at once: there is no answer to wait for.
-    Unreachable,
     /// The answer did not fit a UDP reply (TC).
     Truncated,
     /// Every server that answered could not answer, the last with this RCODE.
@@ -482,21 +478,6 @@ struct Sent {
 /// any query this lookup sent for the name now asked is read, so an answer
 /// late past its query's wait still ends the lookup; it is read only on the
 /// query's own port, with the query's ID, from the server the query went to.
-///
-/// **A wait is for an answer that can come.** When the newest query is known
-/// or said not to have reached its server, the next server is asked at once:
-/// RFC 1035 §4.2.1's interval of two to five seconds is between repetitions
-/// of a query that may have been lost on its way, and the same section has
-/// the other servers tried first. No server is asked twice without that
-/// interval: once each has been asked in turn, the lookup waits out the
-/// newest query like any other.
-///
-/// **What the caller knows and what it was told are two calls.** A query the
-/// caller itself knows never reached its server ([`Lookup::on_unreached`]) is
-/// let go, and a lookup with no query left whose answer is read ends with
-/// [`Failure::Unreachable`]. A report from the network
-/// ([`Lookup::on_report`]) is anyone's word (RFC 8085 §5.2): it lets no query
-/// go and ends nothing, so the answer to a query reported so is still read.
 #[derive(Debug)]
 pub struct Lookup {
     /// The name now asked: the one given, or the last alias followed.
@@ -505,17 +486,12 @@ pub struct Lookup {
     aliases: usize,
     servers: Vec<[u8; 4]>,
     /// Queries sent for `name`, oldest first; one answered with a server
-    /// failure, or known not to have reached its server, is taken out.
-    /// [`Lookup::waiting`] is this list, which holds a query while the
-    /// lookup lasts.
+    /// failure is taken out. [`Lookup::waiting`] is this list.
     sent: Vec<Sent>,
     /// The [`Asked`] the next query gets: none is given twice in a lookup.
     next: u32,
     /// Queries sent for `name`, answered or not.
     asked: usize,
-    /// Queries in a row, the newest last, with no wait between them. Once
-    /// they are as many as the servers, nobody is left to ask at once.
-    run: usize,
     /// When the newest query is given up on.
     due: u64,
     /// The RCODE of the last server failure, which is what a lookup that runs
@@ -537,7 +513,6 @@ impl Lookup {
             sent: Vec::new(),
             next: 0,
             asked: 0,
-            run: 0,
             due: now,
             failed: None,
         };
@@ -556,20 +531,16 @@ impl Lookup {
         self.sent.iter().map(|s| s.asked)
     }
 
-    /// The next query for `name`, the first of a run; or the end, where
-    /// every query has been sent.
+    /// The next query for `name`, or the end where every one has been sent.
     fn ask(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
         if self.asked == ROUNDS * self.servers.len() {
-            return Step::Done(Err(self.failed.map_or(Failure::TimedOut, Failure::ServerFailed)));
+            return Step::Done(Err(match self.failed {
+                Some(rcode) => Failure::ServerFailed(rcode),
+                None => Failure::TimedOut,
+            }));
         }
-        self.run = 0;
-        self.send(now, id)
-    }
-
-    fn send(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
         let to = self.servers[self.asked % self.servers.len()];
         self.asked += 1;
-        self.run += 1;
         let asked = Asked(self.next);
         self.next += 1;
         let id = id();
@@ -578,20 +549,6 @@ impl Lookup {
         Step::Ask { asked, to, query: query(id, &self.name) }
     }
 
-    /// The newest query is not waited for: the next server's query now,
-    /// where one is left to ask at once; or the wait for the queries still
-    /// read; or, with none, the end.
-    fn at_once(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
-        if self.run < self.servers.len() && self.asked < ROUNDS * self.servers.len() {
-            return self.send(now, id);
-        }
-        if !self.sent.is_empty() {
-            return Step::Wait;
-        }
-        // Every query left the list answered with a failure or unreached.
-        Step::Done(Err(self.failed.map_or(Failure::Unreachable, Failure::ServerFailed)))
-    }
-
     /// The time is `now`: the newest query has had its [`WAIT_MS`] where it
     /// is due. `id` draws the ID of the query this sends.
     pub fn on_time(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
@@ -601,32 +558,6 @@ impl Lookup {
         self.ask(now, id)
     }
 
-    /// The caller knows at `now`, of its own knowledge, that the query
-    /// `asked` did not reach its server: it was never sent. Its answer is
-    /// read no more. `id` draws the ID of the query this sends.
-    pub fn on_unreached(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
-            return Step::Wait;
-        };
-        self.sent.remove(which);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.at_once(now, id)
-    }
-
-    /// The network said at `now` that the query `asked` did not reach its
-    /// server. Its answer is still read. `id` draws the ID of the query this
-    /// sends.
-    pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
-            return Step::Wait;
-        }
-        self.at_once(now, id)
-    }
-
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was
     /// sent from. `id` draws the ID of the query this sends.
     pub fn on_datagram(
diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..a5fa0f73a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -113,17 +113,11 @@ pub enum Binding {
     Connected { local: Ipv4Addr, peer: Ipv4Addr, peer_port: Port },
 }
 
-/// An error against a connected socket's datagrams: its next call returns it once. Two are what
-/// an ICMP message said, which anyone who knows the socket's addresses and ports can send; one
-/// is [ip]'s own.
+/// An error the network reported against a connected socket: its next call returns it once.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum SocketError {
-    /// An ICMP error said the peer refuses the protocol or the port.
     Refused,
-    /// An ICMP error said the way to the peer is administratively prohibited.
-    Prohibited,
-    /// [ip] found no link address for the next hop of a datagram it held: nothing left.
-    NextHopFailed,
+    Unreachable,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -665,7 +659,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::Unreachable,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);
@@ -675,7 +669,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Unreachable);
         }
     }
 
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d8a5cfc23 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -10,21 +10,9 @@
 //!   query other than the one whose socket it reached.
 //! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets
 //!   the query go, ends, or is let go itself, and its port is free from then.
-//! - **A query the node knows did not reach its resolver is let go**
-//!   (`toyos_dns::Lookup::on_unreached`): the lookup asks its next resolver at once, or ends
-//!   where no query's answer is read any more. The node knows it of a query [udp] refuses in the
-//!   call, which never left, is counted and holds no socket; and of one [ip] reports it found no
-//!   next hop for, which is counted and its socket closed.
-//! - **An ICMP error is a report and not knowledge** (`toyos_dns::Lookup::on_report`): [udp]
-//!   hands a query's socket one that quotes the socket's addresses and ports and says refused or
-//!   prohibited, which takes an off-path sender the query's port to forge and not its id (RFC
-//!   8085 §5.2). It is counted, and the next resolver is asked at once; the query's socket stays
-//!   open and its answer is read, and no number of them ends a lookup.
-//! - **Every lookup's first query can wait in [ip] for one next hop at once**, where no link
-//!   address is known yet: [ip] holds `PENDING_PER_NEIGHBOUR` datagrams for a next hop it is
-//!   resolving and keeps the newest (RFC 4861 §7.2.2), which is no fewer than the lookups held
-//!   here. A datagram another socket sends to that next hop meanwhile takes a place in the same
-//!   queue.
+//! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits
+//!   its wait out as for any query nobody answered. So does one the network reports back, its
+//!   resolver unreachable or its port refused: counted, and waited out.
 //! - **A lookup asks the resolvers of the lease it started under, and ends with that lease's
 //!   word**: when the held lease names other resolvers, or none is held.
 //! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame.
@@ -39,16 +27,13 @@ use alloc::vec::Vec;
 use core::net::Ipv4Addr;
 
 use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT};
-use toyos_net_ip::limits::nud::PENDING_PER_NEIGHBOUR;
-use toyos_net_udp::{Error, SocketError, SocketId};
+use toyos_net_udp::{Error, SocketId};
 use toyos_net_wire::Instant;
 
 use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
-
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct LookupId(u64);
@@ -120,51 +105,42 @@ fn close(stack: &mut Stack, now: Instant, socket: SocketId) {
     }
 }
 
-/// What waits at a query's socket.
-enum Heard {
-    /// A datagram of this length, now in the reply buffer.
-    Reply(usize),
-    /// [ip]'s word that the query never left.
-    Unreached,
-    /// An ICMP error's word that the query was refused.
-    Reported,
-}
-
-/// The first of `queries` with something at its socket, and what: a reply is now in `reply`.
-fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8]) -> Option<(Query, Heard)> {
+/// The length of the next reply waiting at one of `queries`' sockets, now in `reply`, and the
+/// query it is for. An error the network reported against a query is counted and read past.
+fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8], counters: &mut Counters) -> Option<(Query, usize)> {
     for query in queries {
-        match stack.recv_from(query.socket, reply) {
-            Ok(Some(received)) => return Some((*query, Heard::Reply(received.len))),
-            Ok(None) => {}
-            Err(Error::Failed(SocketError::NextHopFailed)) => return Some((*query, Heard::Unreached)),
-            Err(Error::Failed(SocketError::Refused | SocketError::Prohibited)) => return Some((*query, Heard::Reported)),
-            Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+        loop {
+            match stack.recv_from(query.socket, reply) {
+                Ok(Some(received)) => return Some((*query, received.len)),
+                Ok(None) => break,
+                Err(Error::Failed(_)) => counters.add(Counter::QueryFailed, 1),
+                Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+            }
         }
     }
     None
 }
 
-/// Carries out `step` for `asking`, and every step the lookup answers a query [udp] refused
-/// with, then closes the socket of every query the lookup no longer reads an answer for. Returns
-/// how the lookup ended, if it did.
-fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
-    let done = loop {
-        match step {
-            Step::Ask { asked, to, query } => {
-                // An any-address bind that names no port is refused only for want of a free one.
-                let Ok((socket, _)) = stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) else { break Some(Err(Ended::NoPort)) };
+/// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer
+/// reads an answer for. Returns how the lookup ended, if it did.
+fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
+    let done = match step {
+        // An any-address bind that names no port is refused only for want of a free one.
+        Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) {
+            Ok((socket, _)) => {
                 let resolver = Ipv4Addr::from(to);
                 if stack.connect(now, socket, resolver, PORT).is_ok() && stack.send_to(now, socket, resolver, PORT, &query).is_ok() {
                     asking.queries.push(Query { asked, socket, to });
-                    break None;
+                } else {
+                    counters.add(Counter::QueryUnsent, 1);
+                    close(stack, now, socket);
                 }
-                counters.add(Counter::QueryUnsent, 1);
-                close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                None
             }
-            Step::Wait => break None,
-            Step::Done(result) => break Some(result.map_err(Ended::Failed)),
-        }
+            Err(_) => Some(Err(Ended::NoPort)),
+        },
+        Step::Wait => None,
+        Step::Done(result) => Some(result.map_err(Ended::Failed)),
     };
     let lookup = &asking.lookup;
     asking.queries.retain(|query| {
@@ -198,19 +174,16 @@ impl Resolver {
         let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect();
         let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") };
         let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() };
-        let done = act(&mut asking, step, now, stack, counters, &mut *draw);
-        if done == Some(Err(Ended::NoPort)) {
-            return Err(NotStarted::ResourceExhausted);
-        }
-        self.next = self.next.wrapping_add(1);
-        let started = asking.id;
-        match done {
-            None => self.asking.push(asking),
-            // No resolver could be sent a query: the lookup has its id and its end at once, and
-            // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+        match act(&mut asking, step, now, stack, counters, &mut *draw) {
+            None => {
+                self.next = self.next.wrapping_add(1);
+                let started = asking.id;
+                self.asking.push(asking);
+                Ok(started)
+            }
+            Some(Err(Ended::NoPort)) => Err(NotStarted::ResourceExhausted),
+            Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"),
         }
-        Ok(started)
     }
 
     /// Ends every lookup whose lease went or names other resolvers, reads every reply that
@@ -223,20 +196,11 @@ impl Resolver {
             let named = stack.lease().is_some_and(|lease| lease.dns == asking.resolvers);
             let mut done = if named { None } else { Some(Err(Ended::LeaseChanged)) };
             while done.is_none() {
-                let Some((query, heard)) = waiting(&asking.queries, stack, reply.as_mut_slice()) else { break };
-                let step = match heard {
-                    // The socket is connected: [udp] delivered this from port 53 of the resolver
-                    // the query went to, or not at all.
-                    Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
-                    Heard::Unreached => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
-                    }
-                    Heard::Reported => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
-                    }
-                };
+                let Some((query, len)) = waiting(&asking.queries, stack, reply.as_mut_slice(), counters) else { break };
+                let message = reply.get(..len).unwrap_or_default();
+                // The socket is connected: [udp] delivered this from port 53 of the resolver the
+                // query went to, or not at all.
+                let step = asking.lookup.on_datagram(query.asked, query.to, PORT, message, ms, || id(&mut *draw));
                 done = act(asking, step, now, stack, counters, &mut *draw);
             }
             if done.is_none() {
@@ -267,11 +231,9 @@ impl Resolver {
 }
 
 impl Node {
-    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws a
-    /// query, its id and then its port: for the first query, and for each asked in its place
-    /// because [udp] refused the one before. A call refused for want of a port has spent them;
-    /// any other refused call spends none. A lookup no resolver could be sent a query of has
-    /// started and ended: its end is in the next [`Self::take_resolved`].
+    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws:
+    /// the first query's id, then its port. A call refused for want of a port has spent both;
+    /// any other refused call spends none.
     pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result<LookupId, NotStarted> {
         self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw)
     }
diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index 21c30f688..0bd5fcc9f 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1550,9 +1550,6 @@ impl Netstack {
                 // whether the name or only its address is missing.
                 Err(Ended::Failed(Failure::NoSuchName | Failure::NoAddress)) => answer_lookup(&client, &[]),
                 Err(Ended::Failed(Failure::TimedOut)) => client.error(ERR_TIMED_OUT),
-                Err(Ended::Failed(Failure::Unreachable)) => {
-                    unreachable!("netstack: no lookup here is told a query did not reach its server, and {name}'s ended so")
-                }
                 Err(Ended::Failed(why @ (Failure::Truncated | Failure::ServerFailed(_) | Failure::TooManyAliases))) => {
                     say!("netstack: a lookup of {name} ended without an answer: {why:?}");
                     client.error(ERR_OTHER);

m1-node-a-refused-query-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..be7c09da3 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -160,7 +160,7 @@ fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, cou
                 }
                 counters.add(Counter::QueryUnsent, 1);
                 close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                break None;
             }
             Step::Wait => break None,
             Step::Done(result) => break Some(result.map_err(Ended::Failed)),

m2-node-a-query-ip-reports-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d4b2e7efe 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -230,7 +230,7 @@ impl Resolver {
                     Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
                     Heard::Unreached => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
+                        Step::Wait
                     }
                     Heard::Reported => {
                         counters.add(Counter::QueryFailed, 1);

m3-dns-unreached-asks-nobody.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..4cfae4ed0 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -610,10 +610,8 @@ impl Lookup {
         };
         self.sent.remove(which);
         // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.at_once(now, id)
+        let _ = (now, id);
+        Step::Wait
     }
 
     /// The network said at `now` that the query `asked` did not reach its

m4-dns-a-server-is-asked-again-at-once.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..fa8d4b366 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -582,7 +582,7 @@ impl Lookup {
     /// where one is left to ask at once; or the wait for the queries still
     /// read; or, with none, the end.
     fn at_once(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
-        if self.run < self.servers.len() && self.asked < ROUNDS * self.servers.len() {
+        if self.asked < ROUNDS * self.servers.len() {
             return self.send(now, id);
         }
         if !self.sent.is_empty() {

m5-dns-an-older-unreached-query-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..ce96251be 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -609,10 +609,6 @@ impl Lookup {
             return Step::Wait;
         };
         self.sent.remove(which);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
         self.at_once(now, id)
     }
 

m6-dns-a-wait-does-not-end-the-run.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..1d8cdff83 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -562,7 +562,6 @@ impl Lookup {
         if self.asked == ROUNDS * self.servers.len() {
             return Step::Done(Err(self.failed.map_or(Failure::TimedOut, Failure::ServerFailed)));
         }
-        self.run = 0;
         self.send(now, id)
     }
 

m7-dns-an-unreached-query-is-still-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..301511ed8 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -608,9 +608,7 @@ impl Lookup {
         let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
             return Step::Wait;
         };
-        self.sent.remove(which);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
+        if which + 1 < self.sent.len() {
             return Step::Wait;
         }
         self.at_once(now, id)

m8-ip-queue-of-8.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }

m9-ip-queue-of-8-and-no-assertion.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..990bcdc37 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -47,7 +47,7 @@ use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
+const _: usize = PENDING_PER_NEIGHBOUR;
 
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]

m10-udp-a-hint-is-delivered.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8d8a26938 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -666,7 +666,8 @@ impl Udp {
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
             ErrorClass::Prohibited => SocketError::Prohibited,
-            ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::PathMtu => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::Soft => SocketError::Prohibited,
         };
         socket.pending = Some(pending);
         self.count(Counter::IcmpErrorDelivered);

m11-dns-unreachable-outranks-a-server-failure.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..ce3c58a18 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -589,7 +589,7 @@ impl Lookup {
             return Step::Wait;
         }
         // Every query left the list answered with a failure or unreached.
-        Step::Done(Err(self.failed.map_or(Failure::Unreachable, Failure::ServerFailed)))
+        Step::Done(Err(Failure::Unreachable))
     }
 
     /// The time is `now`: the newest query has had its [`WAIT_MS`] where it

m12-node-an-end-in-the-call-is-dropped.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..0fdade7bb 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -208,7 +208,7 @@ impl Resolver {
             None => self.asking.push(asking),
             // No resolver could be sent a query: the lookup has its id and its end at once, and
             // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+            Some(_) => {}
         }
         Ok(started)
     }

m13-udp-a-report-is-matched-by-the-port-alone.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8fe43316e 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -643,7 +643,8 @@ impl Udp {
     fn connected(&mut self, flow: &Flow) -> Option<&mut Socket> {
         let index = *self.ports.get(&flow.source_port)?;
         self.at(index).filter(|s| {
-            s.binding == Binding::Connected { local: flow.source, peer: flow.destination, peer_port: flow.destination_port }
+            let _ = flow;
+            matches!(s.binding, Binding::Connected { .. })
         })
     }
 

m14-node-an-icmp-error-is-taken-for-knowledge.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..99d3f8c5f 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -234,7 +234,7 @@ impl Resolver {
                     }
                     Heard::Reported => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
+                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
                     }
                 };
                 done = act(asking, step, now, stack, counters, &mut *draw);

m15-dns-a-reported-query-is-let-go.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..28360c22d 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -624,6 +624,7 @@ impl Lookup {
         if self.sent.last().is_none_or(|s| s.asked != asked) {
             return Step::Wait;
         }
+        self.sent.pop();
         self.at_once(now, id)
     }
 

m16-udp-a-prohibition-is-said-in-ips-word.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..855f0c92a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -665,7 +665,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::NextHopFailed,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);

m17-udp-ips-word-is-said-as-a-prohibition.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..798a9f404 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -675,7 +675,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Prohibited);
         }
     }
 

m18-dns-a-lookup-ends-while-a-query-is-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..62f9d0151 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -585,9 +585,6 @@ impl Lookup {
         if self.run < self.servers.len() && self.asked < ROUNDS * self.servers.len() {
             return self.send(now, id);
         }
-        if !self.sent.is_empty() {
-            return Step::Wait;
-        }
         // Every query left the list answered with a failure or unreached.
         Step::Done(Err(self.failed.map_or(Failure::Unreachable, Failure::ServerFailed)))
     }

m19-dns-an-older-querys-report-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..e30303cad 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -621,7 +621,7 @@ impl Lookup {
     /// sends.
     pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
         // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
+        if !self.sent.iter().any(|s| s.asked == asked) {
             return Step::Wait;
         }
         self.at_once(now, id)

m20-udp-a-refusal-is-said-in-ips-word.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8bb7cdd14 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -664,7 +664,7 @@ impl Udp {
             return self.count(counter);
         };
         let pending = match class {
-            ErrorClass::Refused => SocketError::Refused,
+            ErrorClass::Refused => SocketError::NextHopFailed,
             ErrorClass::Prohibited => SocketError::Prohibited,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };

r0-the-four-tests-of-findings-1-and-2-onto-20b21432b.patch:

diff --git a/toyos-dns/src/tests.rs b/toyos-dns/src/tests.rs
index 0f7b9a36a..fec9b8a51 100644
--- a/toyos-dns/src/tests.rs
+++ b/toyos-dns/src/tests.rs
@@ -927,6 +927,55 @@ fn an_older_query_that_reached_nobody_asks_nobody_and_ends_nothing() {
     assert_eq!(lookup.on_unreached(q2, WAIT_MS + 2, undrawn), Step::Done(Err(Failure::Unreachable)), "the newest reached nobody either");
 }
 
+// The newest query reached nobody, and nobody is left to ask at once: the
+// lookup ends only where no older query's answer is still read, and otherwise
+// waits the newest one's wait out for it.
+#[test]
+fn a_lookup_whose_newest_query_reached_nobody_waits_for_an_older_querys_answer() {
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 1).unwrap();
+    let (q1, ..) = asked(&first);
+    let second = lookup.on_time(WAIT_MS, || 2);
+    assert_eq!(lookup.on_unreached(asked(&second).0, WAIT_MS, undrawn), Step::Wait, "the first query may yet be answered");
+    assert_eq!(waiting(&lookup), [q1]);
+    assert_eq!(lookup.due(), 2 * WAIT_MS, "the wait is the one the query that reached nobody began");
+    let ok = reply(1, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]);
+    assert_eq!(lookup.on_datagram(q1, S1, PORT, &ok, WAIT_MS + 1, undrawn), Step::Done(Ok(vec![[1, 2, 3, 4]])));
+}
+
+#[test]
+fn a_lookup_that_then_reached_neither_server_waits_for_an_older_querys_answer() {
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
+    let (q1, ..) = asked(&first);
+    let second = lookup.on_time(WAIT_MS, || 2);
+    assert_eq!(to(&second), (S2, 2));
+    let third = lookup.on_unreached(asked(&second).0, WAIT_MS, || 3);
+    assert_eq!(to(&third), (S1, 3), "S1 again, a wait after its first query");
+    assert_eq!(lookup.on_unreached(asked(&third).0, WAIT_MS, undrawn), Step::Wait, "the first query may yet be answered");
+    assert_eq!(waiting(&lookup), [q1]);
+    assert_eq!(lookup.due(), 2 * WAIT_MS);
+    assert_eq!(lookup.on_time(2 * WAIT_MS - 1, undrawn), Step::Wait);
+    assert_eq!(to(&lookup.on_time(2 * WAIT_MS, || 4)), (S2, 4), "S2's turn, a wait after it was last asked");
+    let ok = reply(1, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]);
+    assert_eq!(lookup.on_datagram(q1, S1, PORT, &ok, 2 * WAIT_MS + 1, undrawn), Step::Done(Ok(vec![[1, 2, 3, 4]])));
+}
+
+// And the last query a lookup has: the queries before it are read until its
+// wait is over, as if it had left.
+#[test]
+fn a_lookup_whose_last_query_reached_nobody_waits_for_the_answers_still_read() {
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1], 0, || 1).unwrap();
+    let (q1, ..) = asked(&first);
+    let mut last = first;
+    for round in 1..ROUNDS as u64 {
+        last = lookup.on_time(round * WAIT_MS, || 2);
+    }
+    let end = ROUNDS as u64 * WAIT_MS;
+    assert_eq!(lookup.on_unreached(asked(&last).0, end - WAIT_MS, undrawn), Step::Wait, "the queries before it may yet be answered");
+    assert_eq!(waiting(&lookup).first(), Some(&q1));
+    assert_eq!(lookup.on_time(end - 1, undrawn), Step::Wait);
+    assert_eq!(lookup.on_time(end, undrawn), Step::Done(Err(Failure::TimedOut)), "queries that may have reached the server were waited for");
+}
+
 #[test]
 fn a_server_that_answered_with_a_failure_is_what_a_lookup_that_then_reached_nobody_reports() {
     let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
diff --git a/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs
index 437bfb0ec..c9892861b 100644
--- a/userland/netstack/node/tests/resolve.rs
+++ b/userland/netstack/node/tests/resolve.rs
@@ -36,6 +36,8 @@ const UNROUTED_TOO: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 54);
 /// RFC 792: a destination unreachable's codes.
 const HOST_UNREACHABLE: u8 = 1;
 const PORT_UNREACHABLE: u8 = 3;
+/// RFC 1812 §5.2.7.1: communication administratively prohibited.
+const PROHIBITED: u8 = 13;
 const ADDRESS: [u8; 4] = [203, 0, 113, 7];
 /// The address every reply that must not be read carries.
 const FORGED: [u8; 4] = [203, 0, 113, 66];
@@ -574,6 +576,32 @@ fn a_report_that_is_not_about_the_query_or_is_a_hint_ends_no_wait() {
     assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]);
 }
 
+// RFC 8085 §5.2: "applications SHOULD appropriately validate the payload of ICMP messages to
+// ensure these are received in response to transmitted traffic", and such a message "SHOULD NOT
+// abort the communication": what a report quotes, an off-path sender can guess. A port
+// unreachable and a prohibition in the resolver's name that quote the query's own addresses and
+// ports each reach the query's socket, which is the premise, and end nothing: each is counted,
+// the socket is still the query's, and the resolver's answer behind them ends the lookup.
+#[test]
+fn a_forged_report_of_the_querys_own_addresses_and_ports_ends_nothing_and_the_answer_behind_it_is_taken() {
+    let mut net = Net::leased(&[ANSWERS], Some(R));
+    let id = net.resolve("www.example").unwrap();
+    net.pass();
+    let asked = net.queried[0].clone();
+    for (reported, code) in [(1, PORT_UNREACHABLE), (2, PROHIBITED)] {
+        let delivered = net.lan.counted(Rule::IcmpErrorDelivered);
+        net.lan.deliver(&reports(code, (ANSWERS, MAC_S), &asked.udp));
+        assert_eq!(net.lan.counted(Rule::IcmpErrorDelivered), delivered + 1, "the premise: the report of code {code} reached the query's socket");
+        assert_eq!(net.lan.node.take_resolved(), NONE, "a report of code {code} from the wire ended the lookup");
+        assert_eq!(net.lan.node.counters().get(Counter::QueryFailed), reported);
+        assert!(net.port_held(asked.udp.source_port), "the reported query's socket, after code {code}");
+    }
+    net.pass();
+    assert_eq!(net.queried.len(), 1, "one resolver: a report asked it again inside its wait");
+    net.resolver_says(asked.udp.source_port, &gives(&asked.udp.payload, ADDRESS));
+    assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]);
+}
+
 // RFC 1034 §5.3.3: an alias with no address is asked again at its end, every query afresh. The
 // resolver answers only after three queries have left, a wait apart, and with an alias alone:
 // the queries for the old name are let go, so the two other answers to them, arriving with the

b3-the-bases-alias-test-onto-20b21432b.patch:

diff --git a/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs
index 437bfb0ec..aef85ec60 100644
--- a/userland/netstack/node/tests/resolve.rs
+++ b/userland/netstack/node/tests/resolve.rs
@@ -575,19 +575,19 @@ fn a_report_that_is_not_about_the_query_or_is_a_hint_ends_no_wait() {
 }
 
 // RFC 1034 §5.3.3: an alias with no address is asked again at its end, every query afresh. The
-// resolver answers only after three queries have left, a wait apart, and with an alias alone:
-// the queries for the old name are let go, so the two other answers to them, arriving with the
-// first, find no socket (RFC 1122 §4.1.3.1), and the alias's address ends the lookup.
+// first resolver answers only after six queries have left, three of them to it, and with an alias
+// alone: the queries for the old name are let go, so the two other answers to them, arriving
+// with the first, find no socket (RFC 1122 §4.1.3.1), and the alias's address ends the lookup.
 #[test]
 fn an_alias_answered_late_restarts_the_lookup_and_lets_the_old_names_queries_go() {
-    let mut net = Net::leased(&[ANSWERS], Some(R));
-    net.zone.push(("www.example", 4_500, Says::Alias("cdn.example")));
+    let mut net = Net::leased(&[ANSWERS, SILENT], Some(R));
+    net.zone.push(("www.example", 10_500, Says::Alias("cdn.example")));
     net.zone.push(("cdn.example", 0, Says::Address(ADDRESS)));
     let nobodys = net.lan.counted(Rule::RxNoSocket);
     let id = net.resolve("www.example").unwrap();
     let ended = net.run(id, 40_000);
     assert_eq!(ended, Some(Ok(vec![ADDRESS])), "the resolver heard {:?}", net.queried);
-    assert_eq!(net.ms(), 4_500);
+    assert_eq!(net.ms(), 10_500);
     let names: Vec<&str> = net.queried.iter().map(|query| query.name.as_str()).collect();
     assert_eq!(names, ["www.example", "www.example", "www.example", "cdn.example"]);
     assert_eq!(net.lan.counted(Rule::RxNoSocket), nobodys + 2, "the old name's other two answers");

g5-m5-without-its-test.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 0fe0260d1..ce96251be 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -609,10 +609,6 @@ impl Lookup {
             return Step::Wait;
         };
         self.sent.remove(which);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
         self.at_once(now, id)
     }
 
diff --git a/toyos-dns/src/tests.rs b/toyos-dns/src/tests.rs
index 7bfc26d59..82ff61567 100644
--- a/toyos-dns/src/tests.rs
+++ b/toyos-dns/src/tests.rs
@@ -926,17 +926,6 @@ fn an_older_query_that_reached_nobody_asks_nobody_and_ends_nothing() {
     assert_eq!(lookup.on_unreached(q2, WAIT_MS + 2, undrawn), Step::Done(Err(Failure::Unreachable)), "the newest reached nobody either");
 }
 
-// With a server left to ask at once, too: it is the newest query's wait.
-#[test]
-fn an_older_query_that_reached_nobody_does_not_hurry_the_next_server() {
-    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
-    let second = lookup.on_time(WAIT_MS, || 2);
-    assert_eq!(to(&second), (S2, 2));
-    assert_eq!(lookup.on_unreached(asked(&first).0, WAIT_MS + 1, undrawn), Step::Wait, "the query to S2 still waits for its own answer");
-    assert_eq!(waiting(&lookup), [asked(&second).0]);
-    assert_eq!(lookup.due(), 2 * WAIT_MS);
-}
-
 // The newest query reached nobody, and nobody is left to ask at once: the
 // lookup ends only where no older query's answer is still read, and otherwise
 // waits the newest one's wait out for it.

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 2, of d9a5cf2e5 against origin/main at a4416fe6c. Read: git diff 20b21432b d9a5cf2e5 as new code, toyos-dns/src/lib.rs and the node's resolve.rs whole at the head, [udp]'s pending-error paths, [ip]'s error classes, the implementer's round-2 step logs (each gate log whole; each mutation log's command, head, exit code and red tests), the readers' [ip] specification for the ids, and trial merges in a scratch object store. Nothing was built or run.

Growth: git diff --shortstat origin/main...d9a5cf2e5 is 12 files, +665 -99. Production +174 -58 (toyos-dns/src/lib.rs +76 -7, node/src/resolve.rs +84 -46, toyos-net-udp/src/lib.rs +10 -4, toyos-net-ip/src/lib.rs +1 -1, netstack/src/main.rs +3). Tests +488 -39. The track +3 -2. The growth of toyos-dns is the second call and ask in three; accepted, subject to finding 5.

Round 1's BLOCKERs

  1. OPEN, narrowed; see finding 5. What was asked is done as far as it was measured: on_report lets nothing go, the node's waiting chooses between the two words with no wildcard arm, the socket stays read, and the forged-tuple test is red on 20b21432b by assertion (step r0-red-first-at-20b21432b, exit 101, left: [Resolved { .. Err(Failed(Unreachable)) }], right: []) and green at the head (cargo test --locked -p toyos-net-node, exit 0, 69 passed). m14, m15, m16, m17 and m20 are red by assertion at the head. But a report from the wire still ends a lookup early with a true answer on its way, by a path the round's own run opens: finding 5.
  2. CLOSED. at_once ends only with sent empty. r0: the three tests red on 20b21432b, each left: Done(Err(Unreachable)), right: Wait; green at the head (cargo test --locked -p toyos-dns, exit 0, 57 passed); m18 turns them red again.
  3. CLOSED. Step b3-the-bases-alias-test-at-20b21432b: the base's body on round 1's source, exit 101, left: Some(Err(Failed(TimedOut))), queries to the answering resolver at 0, 4 s and 8 s. Round 1's reading of that head was wrong and the measurement says why: it was finding 2's defect in its third order. At the head git diff origin/main...d9a5cf2e5 -- userland/netstack/node/tests/resolve.rs has no hunk in that test and it is green in the node log; m18 reds it.
  4. OPEN. No host and no guest measurement at this head; the three checks read skipping.

BLOCKER

  1. toyos-dns/src/lib.rs:659-660 with :561-567 and :622-628 — a server failure of the newest query calls ask, which resets run, so reports and another resolver's failures chain without a wait, a resolver that has not answered is asked again inside its WAIT_MS, and the lookup ends with its queries still read. With [S1, S2], ROUNDS 3: start sends q1 to S1; on_report(q1) sends q2 to S2 (run 2); S2's failure of q2 is which == sent.len(), ask sets run to 0 and sends q3 to S1; on_report(q3) sends q4 to S2; its failure sends q5 to S1; on_report(q5) sends q6; its failure finds asked == ROUNDS * 2 and returns Done(Err(ServerFailed)), with waiting() == [q1, q3, q5], six queries and the end inside S2's three round trips. On origin/main the same resolvers end that lookup at 6 s (S1 at 0, 2 s, 4 s; S2 at 2 s, 4 s, 6 s) and S1's answer to q1 at any time before then is taken. So an off-path sender who hits the port of three queries, where the lease's other resolver answers a server failure (toyos-dns/src/lib.rs:373: any RCODE but 0 and 3, REFUSED among them), ends the lookup before S1's true answer arrives: round 1's finding 1 with one more premise, and the premise is not the sender's to need twice. No sender at all is needed for the burst: a first resolver that truly answers port unreachable and a second that answers REFUSED get three queries each with no interval, which is what the type's own contract says cannot happen ("No server is asked twice without that interval", :490) and the body's "whoever reports what". The same root on the known side: on_unreached of S1's queries with S2 failing asks S2 three times at once. No test holds either order: rfc8085_5_2_reports_alone_... has no server answer, and a_server_that_answered_with_a_failure_is_what_... asks again only the server that answered. The body's "Unsure of" names the restart of the run "as on the base" for one resolver; with two it is not the base's. Owed: a toyos-dns test of the order above that is red at this head, in which S1's answer to q1 at WAIT_MS - 1 ends the lookup Ok and no server that has not answered is sent a second query inside WAIT_MS; and the node's form of it, forged reports quoting each query to the answering resolver, a second resolver that answers a server failure, the true answer behind them taken. Which rule closes it is the implementer's; a lookup that ends by server failure while a query to another server is read and under its wait is the thing to make false.

  2. Evidence, as above. At the head that lands: host exit 0 with its log read whole, toyos-dns, toyos-net-udp, toyos-net-ip, toyos-net-shard, toyos-net-node and netstack's own host tests in it; guest / suite green with toolchain concluded and not skipped, since toyos-dns ships in netstack and every guest test that resolves a name reaches ask, send and on_time as rewritten. With finding 5 open the orchestrator may not land on reading them; the fix is new code on the trust boundary and takes another round.

NOTE

  • on_report, the rest of what was asked. One report sends at most one query, and only for the lookup's newest query; a second report of the same query finds a newer one and asks nobody. Server failures aside (finding 5), a run is at most one query a resolver, then the lookup waits to due, so reports alone send no more than the ROUNDS x servers queries a silent lookup sends, and each resolver's queries stay WAIT_MS apart. They do shorten a lookup's life: with two resolvers from 12 s to 6 s, with one not at all, which rfc8085_5_2_reports_alone_... asserts ((now, ended) == (ROUNDS * WAIT_MS, TimedOut)). Every query is read to that end, so what is lost is an answer later than WAIT_MS after a resolver's last query. That is option (a) as round 1 gave it and is accepted; the body says "one query to the next resolver a wait early" and should say the 12 s to 6 s.
  • The two words. Prohibited is right for all [ip] classes to it, codes 9, 10 and 13, each an administrative prohibition; Refused is codes 2 and 3. Every reader chose rightly: US-049 (code 13) Prohibited, US-027 and ICD-012 NextHopFailed, the resolver NextHopFailed as knowledge and the other two as a report. datagram.rs keeps Error::Failed(_) as unreachable! for sockets that are never connected, which is true of both new words. None of wt/toyos-move-ip, wt/toyos-ownstack-d and wt/toyos-ownstack-e adds a reader of the old word.
  • issues/toyos-has-its-own-network-stack.md:33 — the displacement the body leaves under "Unsure of" belongs in this residual and not only in the body: [udp] holds one pending error a socket and the latest wins, so an ICMP error that quotes the query's tuple, arriving between Node::transmit and the node's next receive, fire or link, replaces NextHopFailed with a report. By the code the claim is true: Reported reaches only on_report, which removes nothing from sent and closes no socket, so the cost is the early end and never an answer; and settle reads every other report in the call that made it, so the window is this residual's alone. Its exit already closes it. One clause there, with that exit.
  • issues/toyos-has-its-own-network-stack.md:33-34, :48 — "the move's node stage" is named as owner three times and the track lists no stage of that name: line 20 has stage 5's slices and "one change moves netd onto it". Round 1 took the name from the plan, which is not in the tree. The track names the slice where it lists them, or these lines name one it lists.
  • NUD-25 and IP-D5 are confirmed in the readers' [ip] specification and say what line 34 cites them for: a [tcp] resolve answered pending and then told, and [tcp] flows waiting for resolution outside [ip]. NUD-04's "out 3 ... 10" and the limit's 8 are as line 48 has them.
  • m5. Step g5-m5-without-its-test is exit 0 at this head with the test removed, and m5 with the test is exit 101 on an_older_query_that_reached_nobody_does_not_hurry_the_next_server alone: the test is the one thing that holds the rule, and it is kept.
  • Round 1's other NOTEs are closed in the track and the body as written.
  • Merges, by git merge-tree: with origin/main (a4416fe6c), wt/toyos-ownstack-d (49e38ca4a) and wt/toyos-ownstack-e (e737052a5) clean; with wt/toyos-move-ip (95b889b15) one conflict, in the track alone, nud.rs merging by itself. This branch is sent back, so none of the three waits for it: toyos-net-ip: 169.254/16 is on the link whatever address is held, and a silent next hop holds up no other #779, toyos-net-node: streams, a connection of the own stack's bridged to its client's two pipes (stage D) #775 and toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777 land in their own order and this branch merges main after them. The hand resolution is then new text in the track only, with every hunk of toyos-net-ip: 169.254/16 is on the link whatever address is held, and a silent next hop holds up no other #779's side accounted for: the two residual lines it deletes stay deleted and its two added lines stay. No source file needs a hand. What a clean text merge does not measure, and host at the merged head does: toyos-net-ip: 169.254/16 is on the link whatever address is held, and a silent next hop holds up no other #779's new nud.rs scenario under a queue of 16; and under toyos-net-node: listeners on the own stack's accept queue, and one bound on the streams, listeners and datagram sockets clients make the node hold (stage E) #777 this branch's port_held and the client's bind in the client's-datagram test, which then take a place each (the helper panics on any refusal but AddrInUse, so a miss is red and not silent).
  • Pull request body — "No server is asked twice inside WAIT_MS, whoever reports what" and "no lookup ended" are false of the head by finding 5.

SEND BACK

…er's failures chain nothing

Round 2 of the review of #781, finding 5.

Lookup counted the queries sent in a row and a server failure of the
newest query reset the count, so a report of each query to one resolver
and a failure from the other chained into ROUNDS queries to each with no
wait, and the lookup ended ServerFailed with the first resolver's answer
on its way.

The count is gone. Each server carries when it may next be asked:
WAIT_MS after its last query, WAIT_MS after the caller last knew it was
not reached, and at once when it has answered. Every path that asks, the
wait's end, a failure, a query known unreached and a report, is the one
`ask`: it sends where the next server may be asked, waits while any
answer is still read, until the next server's turn or the last query's
wait is over, and ends at once only with no query left.

The track names the slice that owns the node's two residuals where it
lists stage 5's slices, datagram senders that wait on the hop, and its
first residual says that an ICMP error can take the place of [ip]'s word
on a socket until the late read that residual is about.

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 3: mutation patches and the script that ran them, at 71db83dc1. Each m patch applies to the clean head with git apply; run.sh applies, builds, runs and reverses each (it was run once a prefix, m0 to m9, so that no run outlived its ceiling). The toyos-dns patches and m0 are made anew for this head; the node's, [udp]'s and [ip]'s are round 2's, which still apply. m21 to m25 are this round's rules. r0 applies to d9a5cf2e5, round 2's head: finding 5's three tests as they stand here, exit 101 there.

run.sh:

#!/bin/sh
# Each mutation: a checked patch applied to the clean committed tree, shown to
# build (or not), run, and reversed. Logs: $S/logs3/<mutation>-builds.log and
# $S/logs3/<mutation>.log, the command first and the exit code last.
W=<worktree>
S=<scratchpad>/orch/ownstack/resolver
P="-p toyos-dns -p toyos-net-node -p toyos-net-ip -p toyos-net-udp -p toyos-net-shard"
cd "$W" || exit 2
echo "HEAD=$(git rev-parse HEAD)"
clean() { [ -z "$(git status --porcelain --ignore-submodules=none)" ]; }
clean || { echo "the worktree is not clean; nothing run"; exit 2; }
for patch in "$S"/mutations3/${1:-m}*.patch; do
    name=$(basename "$patch" .patch)
    git apply --check "$patch" || { echo "$name does not apply"; exit 2; }
    git apply "$patch"
    # shellcheck disable=SC2086
    { echo "\$ cargo test --locked --no-run $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-run $P; echo "EXIT=$?"; } > "$S/logs3/$name-builds.log" 2>&1
    built=$(tail -1 "$S/logs3/$name-builds.log")
    if [ "$built" = "EXIT=0" ]; then
        # shellcheck disable=SC2086
        { echo "\$ cargo test --locked --no-fail-fast $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-fail-fast $P; echo "EXIT=$?"; } > "$S/logs3/$name.log" 2>&1
        ran=$(tail -1 "$S/logs3/$name.log")
    else
        ran="not run"
    fi
    git apply -R "$patch"
    clean || { echo "$name left the tree dirty"; exit 2; }
    echo "$name builds $built tests $ran"
done
echo "HEAD=$(git rev-parse HEAD) clean=$(clean && echo yes || echo no)"
echo "MUTATIONS DONE"

m0-whole-source-change-reverted.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..1f46cdaa9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -431,12 +431,8 @@ pub enum Failure {
     NoSuchName,
     /// The name exists and has no `A` record (RFC 2308 §2.2).
     NoAddress,
-    /// No server answered, and a query that may have reached one was given
-    /// its [`WAIT_MS`].
+    /// Every query went unanswered for [`WAIT_MS`].
     TimedOut,
-    /// No query reached a server ([`Lookup::on_unreached`]) and none is left
-    /// to send at once: there is no answer to wait for.
-    Unreachable,
     /// The answer did not fit a UDP reply (TC).
     Truncated,
     /// Every server that answered could not answer, the last with this RCODE.
@@ -482,46 +478,21 @@ struct Sent {
 /// any query this lookup sent for the name now asked is read, so an answer
 /// late past its query's wait still ends the lookup; it is read only on the
 /// query's own port, with the query's ID, from the server the query went to.
-///
-/// **A wait is for an answer that can come.** When the newest query is
-/// answered with a failure, or known or said not to have reached its server,
-/// the next server is asked at once: RFC 1035 §4.2.1's interval of two to
-/// five seconds is between repetitions of a query that may have been lost on
-/// its way, and the same section has the other servers tried first. **The
-/// interval is each server's**: one that has not answered is sent no second
-/// query inside [`WAIT_MS`] of its last, or of the caller's last knowledge
-/// that it was not reached, whatever is said of that query and whatever
-/// another server answers. Where the next server cannot be asked yet, the
-/// lookup waits until it can while any answer is still read, and ends at
-/// once only with none.
-///
-/// **What the caller knows and what it was told are two calls.** A query the
-/// caller itself knows never reached its server ([`Lookup::on_unreached`]) is
-/// let go, and a lookup with no query left whose answer is read ends with
-/// [`Failure::Unreachable`]. A report from the network
-/// ([`Lookup::on_report`]) is anyone's word (RFC 8085 §5.2): it lets no query
-/// go and ends nothing, so the answer to a query reported so is still read.
 #[derive(Debug)]
 pub struct Lookup {
     /// The name now asked: the one given, or the last alias followed.
     name: Name,
     /// Aliases followed so far, counted against [`MAX_ALIASES`].
     aliases: usize,
-    /// Each server, and when it may be asked again: [`WAIT_MS`] after its
-    /// last query or after it was last known not to be reached, and at once
-    /// when it has answered.
-    servers: Vec<([u8; 4], u64)>,
+    servers: Vec<[u8; 4]>,
     /// Queries sent for `name`, oldest first; one answered with a server
-    /// failure, or known not to have reached its server, is taken out.
-    /// [`Lookup::waiting`] is this list, which holds a query while the
-    /// lookup lasts.
+    /// failure is taken out. [`Lookup::waiting`] is this list.
     sent: Vec<Sent>,
     /// The [`Asked`] the next query gets: none is given twice in a lookup.
     next: u32,
     /// Queries sent for `name`, answered or not.
     asked: usize,
-    /// When the newest query is given up on, or the next server may be
-    /// asked if that is later.
+    /// When the newest query is given up on.
     due: u64,
     /// The RCODE of the last server failure, which is what a lookup that runs
     /// out of queries reports if any server answered at all.
@@ -538,7 +509,7 @@ impl Lookup {
         let mut lookup = Self {
             name,
             aliases: 0,
-            servers: servers.iter().map(|server| (*server, now)).collect(),
+            servers: servers.to_vec(),
             sent: Vec::new(),
             next: 0,
             asked: 0,
@@ -560,40 +531,22 @@ impl Lookup {
         self.sent.iter().map(|s| s.asked)
     }
 
-    /// The next server's query, if it may be asked at `now`; or the wait for
-    /// the answers still read, until it may or the last query's wait is
-    /// over; or the end.
+    /// The next query for `name`, or the end where every one has been sent.
     fn ask(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let turn = self.asked % self.servers.len();
-        let (to, free) = self.servers[turn];
-        let spent = self.asked == ROUNDS * self.servers.len();
-        if !spent && now >= free {
-            self.servers[turn].1 = now + WAIT_MS;
-            self.asked += 1;
-            let asked = Asked(self.next);
-            self.next += 1;
-            let id = id();
-            self.sent.push(Sent { asked, id, to });
-            self.due = now + WAIT_MS;
-            return Step::Ask { asked, to, query: query(id, &self.name) };
-        }
-        if self.sent.is_empty() || (spent && now >= self.due) {
-            // With no query left, each was answered with a failure or never
-            // reached its server.
-            let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
-            return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
-        }
-        if !spent {
-            self.due = self.due.max(free);
-        }
-        Step::Wait
-    }
-
-    /// `server` may next be asked at `at`.
-    fn free(&mut self, server: [u8; 4], at: u64) {
-        for (_, free) in self.servers.iter_mut().filter(|(addr, _)| *addr == server) {
-            *free = at;
+        if self.asked == ROUNDS * self.servers.len() {
+            return Step::Done(Err(match self.failed {
+                Some(rcode) => Failure::ServerFailed(rcode),
+                None => Failure::TimedOut,
+            }));
         }
+        let to = self.servers[self.asked % self.servers.len()];
+        self.asked += 1;
+        let asked = Asked(self.next);
+        self.next += 1;
+        let id = id();
+        self.sent.push(Sent { asked, id, to });
+        self.due = now + WAIT_MS;
+        Step::Ask { asked, to, query: query(id, &self.name) }
     }
 
     /// The time is `now`: the newest query has had its [`WAIT_MS`] where it
@@ -605,33 +558,6 @@ impl Lookup {
         self.ask(now, id)
     }
 
-    /// The caller knows at `now`, of its own knowledge, that the query
-    /// `asked` did not reach its server: it was never sent. Its answer is
-    /// read no more. `id` draws the ID of the query this sends.
-    pub fn on_unreached(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
-            return Step::Wait;
-        };
-        let unreached = self.sent.remove(which);
-        self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.ask(now, id)
-    }
-
-    /// The network said at `now` that the query `asked` did not reach its
-    /// server. Its answer is still read. `id` draws the ID of the query this
-    /// sends.
-    pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
-            return Step::Wait;
-        }
-        self.ask(now, id)
-    }
-
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was
     /// sent from. `id` draws the ID of the query this sends.
     pub fn on_datagram(
@@ -659,9 +585,6 @@ impl Lookup {
             Verdict::ServerFailed(rcode) => {
                 self.failed = Some(rcode);
                 self.sent.remove(which);
-                // It answered: its interval, which is for a query that may
-                // have been lost, is over.
-                self.free(from, now);
                 // The next server is asked now, unless a newer query is still
                 // waiting for its own answer.
                 if which == self.sent.len() {
@@ -682,9 +605,6 @@ impl Lookup {
                     self.sent.clear();
                     self.asked = 0;
                     self.failed = None;
-                    for (_, free) in &mut self.servers {
-                        *free = now;
-                    }
                     self.ask(now, id)
                 }
             },
diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..a5fa0f73a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -113,17 +113,11 @@ pub enum Binding {
     Connected { local: Ipv4Addr, peer: Ipv4Addr, peer_port: Port },
 }
 
-/// An error against a connected socket's datagrams: its next call returns it once. Two are what
-/// an ICMP message said, which anyone who knows the socket's addresses and ports can send; one
-/// is [ip]'s own.
+/// An error the network reported against a connected socket: its next call returns it once.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum SocketError {
-    /// An ICMP error said the peer refuses the protocol or the port.
     Refused,
-    /// An ICMP error said the way to the peer is administratively prohibited.
-    Prohibited,
-    /// [ip] found no link address for the next hop of a datagram it held: nothing left.
-    NextHopFailed,
+    Unreachable,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -665,7 +659,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::Unreachable,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);
@@ -675,7 +669,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Unreachable);
         }
     }
 
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d8a5cfc23 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -10,21 +10,9 @@
 //!   query other than the one whose socket it reached.
 //! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets
 //!   the query go, ends, or is let go itself, and its port is free from then.
-//! - **A query the node knows did not reach its resolver is let go**
-//!   (`toyos_dns::Lookup::on_unreached`): the lookup asks its next resolver at once, or ends
-//!   where no query's answer is read any more. The node knows it of a query [udp] refuses in the
-//!   call, which never left, is counted and holds no socket; and of one [ip] reports it found no
-//!   next hop for, which is counted and its socket closed.
-//! - **An ICMP error is a report and not knowledge** (`toyos_dns::Lookup::on_report`): [udp]
-//!   hands a query's socket one that quotes the socket's addresses and ports and says refused or
-//!   prohibited, which takes an off-path sender the query's port to forge and not its id (RFC
-//!   8085 §5.2). It is counted, and the next resolver is asked at once; the query's socket stays
-//!   open and its answer is read, and no number of them ends a lookup.
-//! - **Every lookup's first query can wait in [ip] for one next hop at once**, where no link
-//!   address is known yet: [ip] holds `PENDING_PER_NEIGHBOUR` datagrams for a next hop it is
-//!   resolving and keeps the newest (RFC 4861 §7.2.2), which is no fewer than the lookups held
-//!   here. A datagram another socket sends to that next hop meanwhile takes a place in the same
-//!   queue.
+//! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits
+//!   its wait out as for any query nobody answered. So does one the network reports back, its
+//!   resolver unreachable or its port refused: counted, and waited out.
 //! - **A lookup asks the resolvers of the lease it started under, and ends with that lease's
 //!   word**: when the held lease names other resolvers, or none is held.
 //! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame.
@@ -39,16 +27,13 @@ use alloc::vec::Vec;
 use core::net::Ipv4Addr;
 
 use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT};
-use toyos_net_ip::limits::nud::PENDING_PER_NEIGHBOUR;
-use toyos_net_udp::{Error, SocketError, SocketId};
+use toyos_net_udp::{Error, SocketId};
 use toyos_net_wire::Instant;
 
 use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
-
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct LookupId(u64);
@@ -120,51 +105,42 @@ fn close(stack: &mut Stack, now: Instant, socket: SocketId) {
     }
 }
 
-/// What waits at a query's socket.
-enum Heard {
-    /// A datagram of this length, now in the reply buffer.
-    Reply(usize),
-    /// [ip]'s word that the query never left.
-    Unreached,
-    /// An ICMP error's word that the query was refused.
-    Reported,
-}
-
-/// The first of `queries` with something at its socket, and what: a reply is now in `reply`.
-fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8]) -> Option<(Query, Heard)> {
+/// The length of the next reply waiting at one of `queries`' sockets, now in `reply`, and the
+/// query it is for. An error the network reported against a query is counted and read past.
+fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8], counters: &mut Counters) -> Option<(Query, usize)> {
     for query in queries {
-        match stack.recv_from(query.socket, reply) {
-            Ok(Some(received)) => return Some((*query, Heard::Reply(received.len))),
-            Ok(None) => {}
-            Err(Error::Failed(SocketError::NextHopFailed)) => return Some((*query, Heard::Unreached)),
-            Err(Error::Failed(SocketError::Refused | SocketError::Prohibited)) => return Some((*query, Heard::Reported)),
-            Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+        loop {
+            match stack.recv_from(query.socket, reply) {
+                Ok(Some(received)) => return Some((*query, received.len)),
+                Ok(None) => break,
+                Err(Error::Failed(_)) => counters.add(Counter::QueryFailed, 1),
+                Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+            }
         }
     }
     None
 }
 
-/// Carries out `step` for `asking`, and every step the lookup answers a query [udp] refused
-/// with, then closes the socket of every query the lookup no longer reads an answer for. Returns
-/// how the lookup ended, if it did.
-fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
-    let done = loop {
-        match step {
-            Step::Ask { asked, to, query } => {
-                // An any-address bind that names no port is refused only for want of a free one.
-                let Ok((socket, _)) = stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) else { break Some(Err(Ended::NoPort)) };
+/// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer
+/// reads an answer for. Returns how the lookup ended, if it did.
+fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
+    let done = match step {
+        // An any-address bind that names no port is refused only for want of a free one.
+        Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) {
+            Ok((socket, _)) => {
                 let resolver = Ipv4Addr::from(to);
                 if stack.connect(now, socket, resolver, PORT).is_ok() && stack.send_to(now, socket, resolver, PORT, &query).is_ok() {
                     asking.queries.push(Query { asked, socket, to });
-                    break None;
+                } else {
+                    counters.add(Counter::QueryUnsent, 1);
+                    close(stack, now, socket);
                 }
-                counters.add(Counter::QueryUnsent, 1);
-                close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                None
             }
-            Step::Wait => break None,
-            Step::Done(result) => break Some(result.map_err(Ended::Failed)),
-        }
+            Err(_) => Some(Err(Ended::NoPort)),
+        },
+        Step::Wait => None,
+        Step::Done(result) => Some(result.map_err(Ended::Failed)),
     };
     let lookup = &asking.lookup;
     asking.queries.retain(|query| {
@@ -198,19 +174,16 @@ impl Resolver {
         let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect();
         let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") };
         let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() };
-        let done = act(&mut asking, step, now, stack, counters, &mut *draw);
-        if done == Some(Err(Ended::NoPort)) {
-            return Err(NotStarted::ResourceExhausted);
-        }
-        self.next = self.next.wrapping_add(1);
-        let started = asking.id;
-        match done {
-            None => self.asking.push(asking),
-            // No resolver could be sent a query: the lookup has its id and its end at once, and
-            // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+        match act(&mut asking, step, now, stack, counters, &mut *draw) {
+            None => {
+                self.next = self.next.wrapping_add(1);
+                let started = asking.id;
+                self.asking.push(asking);
+                Ok(started)
+            }
+            Some(Err(Ended::NoPort)) => Err(NotStarted::ResourceExhausted),
+            Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"),
         }
-        Ok(started)
     }
 
     /// Ends every lookup whose lease went or names other resolvers, reads every reply that
@@ -223,20 +196,11 @@ impl Resolver {
             let named = stack.lease().is_some_and(|lease| lease.dns == asking.resolvers);
             let mut done = if named { None } else { Some(Err(Ended::LeaseChanged)) };
             while done.is_none() {
-                let Some((query, heard)) = waiting(&asking.queries, stack, reply.as_mut_slice()) else { break };
-                let step = match heard {
-                    // The socket is connected: [udp] delivered this from port 53 of the resolver
-                    // the query went to, or not at all.
-                    Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
-                    Heard::Unreached => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
-                    }
-                    Heard::Reported => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
-                    }
-                };
+                let Some((query, len)) = waiting(&asking.queries, stack, reply.as_mut_slice(), counters) else { break };
+                let message = reply.get(..len).unwrap_or_default();
+                // The socket is connected: [udp] delivered this from port 53 of the resolver the
+                // query went to, or not at all.
+                let step = asking.lookup.on_datagram(query.asked, query.to, PORT, message, ms, || id(&mut *draw));
                 done = act(asking, step, now, stack, counters, &mut *draw);
             }
             if done.is_none() {
@@ -267,11 +231,9 @@ impl Resolver {
 }
 
 impl Node {
-    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws a
-    /// query, its id and then its port: for the first query, and for each asked in its place
-    /// because [udp] refused the one before. A call refused for want of a port has spent them;
-    /// any other refused call spends none. A lookup no resolver could be sent a query of has
-    /// started and ended: its end is in the next [`Self::take_resolved`].
+    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws:
+    /// the first query's id, then its port. A call refused for want of a port has spent both;
+    /// any other refused call spends none.
     pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result<LookupId, NotStarted> {
         self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw)
     }
diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index 21c30f688..0bd5fcc9f 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1550,9 +1550,6 @@ impl Netstack {
                 // whether the name or only its address is missing.
                 Err(Ended::Failed(Failure::NoSuchName | Failure::NoAddress)) => answer_lookup(&client, &[]),
                 Err(Ended::Failed(Failure::TimedOut)) => client.error(ERR_TIMED_OUT),
-                Err(Ended::Failed(Failure::Unreachable)) => {
-                    unreachable!("netstack: no lookup here is told a query did not reach its server, and {name}'s ended so")
-                }
                 Err(Ended::Failed(why @ (Failure::Truncated | Failure::ServerFailed(_) | Failure::TooManyAliases))) => {
                     say!("netstack: a lookup of {name} ended without an answer: {why:?}");
                     client.error(ERR_OTHER);

m1-node-a-refused-query-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..be7c09da3 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -160,7 +160,7 @@ fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, cou
                 }
                 counters.add(Counter::QueryUnsent, 1);
                 close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                break None;
             }
             Step::Wait => break None,
             Step::Done(result) => break Some(result.map_err(Ended::Failed)),

m2-node-a-query-ip-reports-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d4b2e7efe 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -230,7 +230,7 @@ impl Resolver {
                     Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
                     Heard::Unreached => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
+                        Step::Wait
                     }
                     Heard::Reported => {
                         counters.add(Counter::QueryFailed, 1);

m3-dns-unreached-asks-nobody.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..8d38ef90b 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -615,10 +615,8 @@ impl Lookup {
         let unreached = self.sent.remove(which);
         self.free(unreached.to, now + WAIT_MS);
         // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.ask(now, id)
+        let _ = (now, id);
+        Step::Wait
     }
 
     /// The network said at `now` that the query `asked` did not reach its

m4-dns-a-server-is-asked-inside-its-interval.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..18d61b52f 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -567,7 +567,8 @@ impl Lookup {
         let turn = self.asked % self.servers.len();
         let (to, free) = self.servers[turn];
         let spent = self.asked == ROUNDS * self.servers.len();
-        if !spent && now >= free {
+        let _ = free;
+        if !spent {
             self.servers[turn].1 = now + WAIT_MS;
             self.asked += 1;
             let asked = Asked(self.next);

m5-dns-an-older-unreached-query-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..45613887e 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -614,10 +614,6 @@ impl Lookup {
         };
         let unreached = self.sent.remove(which);
         self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
         self.ask(now, id)
     }
 

m6-dns-a-query-does-not-start-its-servers-interval.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..d16a4cb9e 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -568,7 +568,6 @@ impl Lookup {
         let (to, free) = self.servers[turn];
         let spent = self.asked == ROUNDS * self.servers.len();
         if !spent && now >= free {
-            self.servers[turn].1 = now + WAIT_MS;
             self.asked += 1;
             let asked = Asked(self.next);
             self.next += 1;

m7-dns-an-unreached-query-is-still-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..861d17cab 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -612,10 +612,9 @@ impl Lookup {
         let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
             return Step::Wait;
         };
-        let unreached = self.sent.remove(which);
+        let unreached = self.sent[which];
         self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
+        if which + 1 < self.sent.len() {
             return Step::Wait;
         }
         self.ask(now, id)

m8-ip-queue-of-8.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }

m9-ip-queue-of-8-and-no-assertion.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..990bcdc37 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -47,7 +47,7 @@ use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
+const _: usize = PENDING_PER_NEIGHBOUR;
 
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]

m10-udp-a-hint-is-delivered.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8d8a26938 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -666,7 +666,8 @@ impl Udp {
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
             ErrorClass::Prohibited => SocketError::Prohibited,
-            ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::PathMtu => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::Soft => SocketError::Prohibited,
         };
         socket.pending = Some(pending);
         self.count(Counter::IcmpErrorDelivered);

m11-dns-unreachable-outranks-a-server-failure.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..8b6d0e153 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -581,7 +581,7 @@ impl Lookup {
             // With no query left, each was answered with a failure or never
             // reached its server.
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
-            return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
+            return Step::Done(Err(if self.sent.is_empty() { silence } else { self.failed.map_or(silence, Failure::ServerFailed) }));
         }
         if !spent {
             self.due = self.due.max(free);

m12-node-an-end-in-the-call-is-dropped.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..0fdade7bb 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -208,7 +208,7 @@ impl Resolver {
             None => self.asking.push(asking),
             // No resolver could be sent a query: the lookup has its id and its end at once, and
             // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+            Some(_) => {}
         }
         Ok(started)
     }

m13-udp-a-report-is-matched-by-the-port-alone.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8fe43316e 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -643,7 +643,8 @@ impl Udp {
     fn connected(&mut self, flow: &Flow) -> Option<&mut Socket> {
         let index = *self.ports.get(&flow.source_port)?;
         self.at(index).filter(|s| {
-            s.binding == Binding::Connected { local: flow.source, peer: flow.destination, peer_port: flow.destination_port }
+            let _ = flow;
+            matches!(s.binding, Binding::Connected { .. })
         })
     }
 

m14-node-an-icmp-error-is-taken-for-knowledge.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..99d3f8c5f 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -234,7 +234,7 @@ impl Resolver {
                     }
                     Heard::Reported => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
+                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
                     }
                 };
                 done = act(asking, step, now, stack, counters, &mut *draw);

m15-dns-a-reported-query-is-let-go.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..076c46777 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -629,6 +629,7 @@ impl Lookup {
         if self.sent.last().is_none_or(|s| s.asked != asked) {
             return Step::Wait;
         }
+        self.sent.pop();
         self.ask(now, id)
     }
 

m16-udp-a-prohibition-is-said-in-ips-word.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..855f0c92a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -665,7 +665,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::NextHopFailed,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);

m17-udp-ips-word-is-said-as-a-prohibition.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..798a9f404 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -675,7 +675,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Prohibited);
         }
     }
 

m18-dns-a-lookup-ends-while-a-query-is-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..b6a81f2ff 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -577,7 +577,7 @@ impl Lookup {
             self.due = now + WAIT_MS;
             return Step::Ask { asked, to, query: query(id, &self.name) };
         }
-        if self.sent.is_empty() || (spent && now >= self.due) {
+        if self.sent.is_empty() || spent || now < self.due {
             // With no query left, each was answered with a failure or never
             // reached its server.
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };

m19-dns-an-older-querys-report-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..17f643db1 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -626,7 +626,7 @@ impl Lookup {
     /// sends.
     pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
         // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
+        if !self.sent.iter().any(|s| s.asked == asked) {
             return Step::Wait;
         }
         self.ask(now, id)

m20-udp-a-refusal-is-said-in-ips-word.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8bb7cdd14 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -664,7 +664,7 @@ impl Udp {
             return self.count(counter);
         };
         let pending = match class {
-            ErrorClass::Refused => SocketError::Refused,
+            ErrorClass::Refused => SocketError::NextHopFailed,
             ErrorClass::Prohibited => SocketError::Prohibited,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };

m21-dns-a-server-that-answered-keeps-its-interval.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..30626950c 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -661,7 +661,6 @@ impl Lookup {
                 self.sent.remove(which);
                 // It answered: its interval, which is for a query that may
                 // have been lost, is over.
-                self.free(from, now);
                 // The next server is asked now, unless a newer query is still
                 // waiting for its own answer.
                 if which == self.sent.len() {

m22-dns-a-server-known-unreached-may-be-asked-at-once.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..2dad223d9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -613,7 +613,7 @@ impl Lookup {
             return Step::Wait;
         };
         let unreached = self.sent.remove(which);
-        self.free(unreached.to, now + WAIT_MS);
+        let _ = unreached;
         // A newer query still waits for its own answer.
         if which < self.sent.len() {
             return Step::Wait;

m23-dns-the-wait-does-not-reach-the-next-servers-turn.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..353ce59a6 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -583,9 +583,6 @@ impl Lookup {
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
             return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
         }
-        if !spent {
-            self.due = self.due.max(free);
-        }
         Step::Wait
     }
 

m24-dns-an-alias-keeps-the-old-names-intervals.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..97c899740 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -682,9 +682,6 @@ impl Lookup {
                     self.sent.clear();
                     self.asked = 0;
                     self.failed = None;
-                    for (_, free) in &mut self.servers {
-                        *free = now;
-                    }
                     self.ask(now, id)
                 }
             },

m25-dns-a-failure-ends-a-spent-lookup-under-its-wait.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..9ef8f06d3 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -577,7 +577,7 @@ impl Lookup {
             self.due = now + WAIT_MS;
             return Step::Ask { asked, to, query: query(id, &self.name) };
         }
-        if self.sent.is_empty() || (spent && now >= self.due) {
+        if self.sent.is_empty() || spent {
             // With no query left, each was answered with a failure or never
             // reached its server.
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };

r0-the-three-tests-of-finding-5-onto-d9a5cf2e5.patch:

diff --git a/toyos-dns/src/tests.rs b/toyos-dns/src/tests.rs
index 7bfc26d59..92540b0c2 100644
--- a/toyos-dns/src/tests.rs
+++ b/toyos-dns/src/tests.rs
@@ -1074,6 +1074,80 @@ fn a_reported_query_the_caller_then_knows_reached_nobody_is_let_go() {
     assert_eq!(lookup.on_unreached(q1, 3, undrawn), Step::Done(Err(Failure::Unreachable)), "no query is left to read an answer for");
 }
 
+// --- Whoever reports or fails what ---
+//
+// RFC 1035 §4.2.1's interval is a server's: one that has not answered is not
+// sent a second query inside it, whatever is said of its first and whatever
+// another server answers. And a lookup does not end on another server's
+// failure while a query's answer is read and its wait is not over.
+
+// S1's every query is reported the moment it is sent, and S2 answers each of
+// its own with a failure at once.
+#[test]
+fn reports_and_another_servers_failures_hurry_no_server_that_has_not_answered_and_end_no_lookup_early() {
+    let refused = reply(2, OK | 5, "www.example", &[]);
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
+    let (q1, ..) = asked(&first);
+    let mut sent = vec![(0, S1)];
+    let mut newest = q1;
+    let mut now = 0;
+    let ended = loop {
+        let step = lookup.on_report(newest, now, || 2);
+        assert_eq!(to(&step), (S2, 2), "the other server, at once");
+        sent.push((now, S2));
+        assert_eq!(lookup.on_datagram(asked(&step).0, S2, PORT, &refused, now, || 9), Step::Wait, "S1 has not answered and was asked this millisecond");
+        assert_eq!(lookup.on_report(newest, now, || 9), Step::Wait, "the same report again");
+        assert_eq!(lookup.on_time(now + WAIT_MS - 1, || 9), Step::Wait, "the wait for S1 was cut short");
+        now += WAIT_MS;
+        match lookup.on_time(now, || 1) {
+            Step::Done(result) => break result,
+            step => {
+                assert_eq!(to(&step), (S1, 1));
+                sent.push((now, S1));
+                newest = asked(&step).0;
+            }
+        }
+    };
+    let rounds: Vec<(u64, [u8; 4])> = (0..ROUNDS as u64).flat_map(|round| [(round * WAIT_MS, S1), (round * WAIT_MS, S2)]).collect();
+    assert_eq!(sent, rounds, "each server once a round, the rounds a wait apart");
+    assert_eq!((now, ended), (ROUNDS as u64 * WAIT_MS, Err(Failure::ServerFailed(5))));
+    assert_eq!(waiting(&lookup).len(), ROUNDS, "every query to S1 was read to the end");
+
+    // And S1's answer to its first query, a millisecond before that query's
+    // wait is over, is the lookup's.
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
+    let (q1, ..) = asked(&first);
+    let second = lookup.on_report(q1, 0, || 2);
+    assert_eq!(lookup.on_datagram(asked(&second).0, S2, PORT, &refused, 0, || 9), Step::Wait);
+    assert_eq!(waiting(&lookup), [q1]);
+    let ok = reply(1, OK, "www.example", &[a("www.example", [1, 2, 3, 4])]);
+    assert_eq!(lookup.on_datagram(q1, S1, PORT, &ok, WAIT_MS - 1, || 9), Step::Done(Ok(vec![[1, 2, 3, 4]])));
+}
+
+// The same of a query the caller knows did not leave: S1 is not asked again
+// inside its interval because S2 failed, and with no answer read the lookup
+// ends by S2's word.
+#[test]
+fn a_server_not_reached_is_not_asked_again_at_once_because_another_failed() {
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
+    let second = lookup.on_unreached(asked(&first).0, 0, || 2);
+    assert_eq!(to(&second), (S2, 2));
+    let refused = reply(2, OK | 5, "www.example", &[]);
+    assert_eq!(lookup.on_datagram(asked(&second).0, S2, PORT, &refused, 0, || 9), Step::Done(Err(Failure::ServerFailed(5))), "S1 was asked this millisecond, and no query is read");
+
+    // With a query to S1 still read, the lookup waits for it instead.
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
+    let (q1, ..) = asked(&first);
+    let second = lookup.on_time(WAIT_MS, || 2);
+    let third = lookup.on_datagram(asked(&second).0, S2, PORT, &refused, WAIT_MS, || 3);
+    assert_eq!(to(&third), (S1, 3), "S1 again, a wait after its first query");
+    let fourth = lookup.on_unreached(asked(&third).0, WAIT_MS, || 2);
+    assert_eq!(to(&fourth), (S2, 2), "S2 answered, and is asked again at once like any server that fails");
+    assert_eq!(lookup.on_datagram(asked(&fourth).0, S2, PORT, &refused, WAIT_MS, || 9), Step::Wait, "S1 was asked this millisecond");
+    assert_eq!(waiting(&lookup), [q1]);
+    assert_eq!(lookup.due(), 2 * WAIT_MS);
+}
+
 #[test]
 fn a_server_that_answered_with_a_failure_is_what_a_lookup_that_then_reached_nobody_reports() {
     let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
diff --git a/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs
index 0a922149b..d67ae6ddd 100644
--- a/userland/netstack/node/tests/resolve.rs
+++ b/userland/netstack/node/tests/resolve.rs
@@ -30,6 +30,9 @@ const SILENT: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 52);
 /// On the link and answering ARP, with nothing on its port 53: it reports every query back.
 const REFUSES: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 54);
 const MAC_F: [u8; 6] = [2, 0, 0, 0, 0, 0x54];
+/// On the link, answering ARP and every query with RCODE 5, refused (RFC 1035 §4.1.1).
+const FAILS: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 55);
+const MAC_X: [u8; 6] = [2, 0, 0, 0, 0, 0x55];
 /// Off the link, asked of a lease that names no router.
 const UNROUTED: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 53);
 const UNROUTED_TOO: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 54);
@@ -191,6 +194,8 @@ struct Net {
     queried: Vec<Query>,
     /// Every query that left for [`REFUSES`], in order.
     refused: Vec<Query>,
+    /// Every query that left for [`FAILS`], in order.
+    failed: Vec<Query>,
     ended: Vec<Resolved>,
     /// The last draw [`Self::resolve`] handed out: its ids are 0x8000 and up, and those the node
     /// draws for itself from `lan`'s count are below it.
@@ -219,6 +224,7 @@ impl Net {
             held: Vec::new(),
             queried: Vec::new(),
             refused: Vec::new(),
+            failed: Vec::new(),
             ended: Vec::new(),
             draws: 0x7c00_8000,
         }
@@ -263,6 +269,12 @@ impl Net {
         match seen {
             Seen::Arp { request: true, target, .. } if *target == ANSWERS => self.lan.deliver(&arp(MAC, false, MAC_S, ANSWERS, A)),
             Seen::Arp { request: true, target, .. } if *target == REFUSES => self.lan.deliver(&arp(MAC, false, MAC_F, REFUSES, A)),
+            Seen::Arp { request: true, target, .. } if *target == FAILS => self.lan.deliver(&arp(MAC, false, MAC_X, FAILS, A)),
+            Seen::Udp(udp) if udp.port == 53 && udp.destination == FAILS => {
+                assert_eq!(udp.to, MAC_X, "a query left for a server the wire cannot reach");
+                self.held.push((self.lan.now, lan::udp(MAC, MAC_X, (FAILS, 53), (A, udp.source_port), &reply(&udp.payload, RESPONSE | 5, &[]))));
+                self.failed.push(Query { at, udp: udp.clone(), id: u16::from_be_bytes([udp.payload[0], udp.payload[1]]), name: asked_name(&udp.payload) });
+            }
             Seen::Udp(udp) if udp.port == 53 && udp.destination == REFUSES => {
                 assert_eq!(udp.to, MAC_F, "a query left for a server the wire cannot reach");
                 self.held.push((self.lan.now, reports(PORT_UNREACHABLE, (REFUSES, MAC_F), udp)));
@@ -617,6 +629,32 @@ fn a_forged_report_of_the_querys_own_addresses_and_ports_ends_nothing_and_the_an
     assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Ok(vec![ADDRESS]) }]);
 }
 
+// And with a second resolver that answers every query with a failure, which a report of each
+// query to the first would chain into a round of queries with no wait between them: every query
+// that leaves for the answering resolver is reported in its name the moment it is on the wire,
+// until none follows. One has left for each resolver, the lookup has not ended, and the answer
+// the first resolver gives a millisecond before its query's wait is over ends it. RFC 1035
+// §4.2.1: the interval is the resolver's, whoever says what of its query.
+#[test]
+fn forged_reports_and_another_resolvers_failures_end_nothing_and_the_answer_behind_them_is_taken() {
+    let mut net = Net::leased(&[ANSWERS, FAILS], Some(R));
+    net.zone.push(("www.example", WAIT_MS - 1, Says::Address(ADDRESS)));
+    let id = net.resolve("www.example").unwrap();
+    let mut reported = 0;
+    for _ in 0..=2 * ROUNDS {
+        net.pass();
+        let Some(query) = net.queried.get(reported).cloned() else { break };
+        net.lan.deliver(&reports(PORT_UNREACHABLE, (ANSWERS, MAC_S), &query.udp));
+        reported += 1;
+    }
+    assert_eq!(net.lan.node.counters().get(Counter::QueryFailed), u64::try_from(reported).unwrap(), "the premise: every report reached its query's socket");
+    assert_eq!((net.queried.len(), net.failed.len(), net.ms()), (1, 1, 0), "one query to each resolver, the second's at the report of the first");
+    assert_eq!(net.lan.node.take_resolved(), NONE, "reports and another resolver's failure ended the lookup");
+    assert_eq!(net.run(id, 10 * WAIT_MS), Some(Ok(vec![ADDRESS])), "the resolvers heard {:?} and {:?}", net.queried, net.failed);
+    assert_eq!(net.ms(), WAIT_MS - 1);
+    assert_eq!((net.queried.len(), net.failed.len()), (1, 1));
+}
+
 // RFC 1034 §5.3.3: an alias with no address is asked again at its end, every query afresh. The
 // first resolver answers only after six queries have left, three of them to it, and with an alias
 // alone: the queries for the old name are let go, so the two other answers to them, arriving

@Japabu

Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review, round 3, of 71db83dc1 (git diff d9a5cf2e5 71db83dc1, and toyos_dns::Lookup whole at the head)

Read, nothing run. Evidence read: the implementer's round-3 step logs (each opens with its command and HEAD=71db83dc1…, closes with EXIT=), and the mutation set in the round-3 comment.

Round 2's BLOCKERs

  • 5, reports and another resolver's failures chain without a wait — CLOSED for distinct servers, by the implementer's measurement; the same defect is still reachable through a lease that names one address twice (BLOCKER 6 below). Step r0-red-first-at-d9a5cf2e5: cargo test --locked --no-fail-fast -p toyos-dns -p toyos-net-node -- <the three tests>, EXIT=101, the two toyos-dns tests and the node's red by assertion on round 2's source; at the head cargo test --locked -p toyos-dns 60 passed EXIT=0, -p toyos-net-node 70 passed EXIT=0. m4, m6, m18, m21, m22, m23, m25 red (101) on the tests the body names. Both halves read true of the code for servers with distinct addresses: every send is ask's, gated on now >= free of the turn's server; free is lowered only by that server's own matched failure or by an alias restart (a new question); the end is reached only with sent empty or spent && now >= due, and due is not moved once spent.
  • 4, evidence — OPEN. No cargo run -- --ci host and no guest run exists at this head; the body says so. toyos-dns ships in netstack, so both are owed.

BLOCKER

  • 6. toyos-dns/src/lib.rs:571 (self.servers[turn].1 = now + WAIT_MS in ask) — a send starts the interval of one slot, while free() and every other writer key the interval by address — a lookup whose server list names one address twice sends that server, which has not answered, a second query in the same millisecond on a report. Order, by reading: Lookup::start(name, &[S1, S1], 0, ..) sends q1 and sets slot 0 to WAIT_MS; on_report(q1, 0, ..) reaches ask with turn == 1, whose free is still the start's 0, and returns Step::Ask { to: S1 }. With three copies, three queries in the first millisecond and nine in 4 s. The list is the wire's: toyos-dhcp (lib.rs:824-831) pushes option 6's addresses up to MAX_DNS without removing a repeat, and a router that offers its own address twice and refuses port 53 by ICMP is an ordinary lease, no forgery needed. It makes false the head's own contract ("one that has not answered is sent no second query inside WAIT_MS of its last ... whatever is said of that query") and the body's sentence closing finding 5. No test has a repeated address and none of m0 to m25 reaches it. The rule that deletes the split rather than adding a case: the send calls self.free(to, now + WAIT_MS), so an interval has one key. Owed, red first at 71db83dc1: in toyos-dns, &[S1, S1], on_report(q1, 0, undrawn) is Step::Wait with due() == WAIT_MS and S1 is asked at 0, 2 s, 4 s ... and at no other time whatever is reported; the node's form with a lease naming REFUSES twice (one query on the wire per WAIT_MS).

NOTE

  • toyos-dns/src/lib.rs:587 (self.due = self.due.max(free)) — the contract says "where the next server cannot be asked yet, the lookup waits until it can"; the code waits until the later of that and the newest query's own wait, also when that query was just reported or failed. Order: [S1, S2], q1 to S1 at 0, reported at 1500 (q2 to S2 at 1500, due 3500), q2 reported at 1600: S1 may be asked at 2000 and the lookup wakes at 3500. A delay under WAIT_MS, never a loss. No test tells max from a plain self.due = free (every due() assertion at this head has the two equal): either the assignment is the rule, which deletes the max and is safe (no end is reached by time while not spent, and a send is gated on free, not on due), or the contract says "the later of the two"; a test at this order holds whichever it is.
  • The invariants, for the record of this round, each tried with three servers, one server, a failure then an unreached, reports of queries let go, and late wakes; apart from BLOCKER 6 none broke. (1) Interval: above. (2) No end while a query is read and under its wait: ask's one Done, m18 and m25. (3) Queries bounded: asked only grows to ROUNDS * servers.len() per name, reset only by an alias, itself bounded by MAX_ALIASES. (4) due: every return of ask leaves due > now (a send, max(due, free) with free > now, or spent with now < due); the early Step::Wait returns (an older query's unreached or failure, a report not of the newest, a stray) leave due as it was, which can be past if the caller is late, and both callers answer that themselves: the node's pass ends each lookup's turn with on_time, and netstack's calls neither new entry. (5) It ends: each wake at due sends, ends, or moves due to a free that only an on_unreached can push, once per query. Every server is asked once before a lookup can end with rounds unspent, since a slot never asked is free from the start.
  • "Unsure", a server that answers a failure is asked again at once in its turn — it is the base's behaviour exactly (origin/main's ServerFailed arm calls ask(now) with no interval; one resolver answering RCODE 2 or 5 gets ROUNDS queries back to back there too). RFC 1035 §4.2.1's interval is for loss and is not broken by it; RFC 2308 §7.1 only lets a resolver remember a server failure (MAY), so nothing is violated, though the repeat is work that section exists to spare. The wire's use of it is bounded: each repeat takes a reply matched on the query's port, id and question from the resolver's address, so the resolver itself or an on-path sender, and buys one query of at most ROUNDS per server per name. No change asked here. One thing this round added to it: free(from, now) on an older query's late failure also lifts the interval of a newer query to that server, so a report of the newer one then asks it again at once (one server: q1 at 0, q2 at 2 s, q1's failure at 2001, q2 reported at 2001, q3 at 2001). The server has answered, so the contract as written holds; say so in the field's doc or not at all.
  • "Unsure", the rotation does not skip — a delay only, bounded by WAIT_MS (free is never later than now + WAIT_MS), no liveness loss; and with no query read the lookup ends rather than skip, after every server has had a turn. Keeping the base's order is accepted.
  • The side effect on what ships — netstack calls only on_time and on_datagram, and in that subset ask never finds the turn's server unfree (a server that has not answered was waited out before the next send), so the one change is: the lookup's last query answered with a failure while an older query is still read now ends ServerFailed at that query's due, at most WAIT_MS later than the base, or sooner by any answer. A lookup every resolver refuses ends at once as before (sent is empty). That is right: it is the wait TimedOut always gave the last query. None of the shipped resolver's tests has a failing server or asserts that timing (netstack-tests, 27 passed, EXIT=0), and no guest test asserts a lookup's duration.
  • Simplicity — run, send and at_once are gone for one u64 a server and free; nothing in Lookup is left dead by the rule. BLOCKER 6's fix removes the one place the new state is written two ways.
  • Body, prose: "on origin/main at a4416fe6c" — main is at 6776714db (The host's toolchains live in one store outside every checkout, and every compiler is keyed #769); the branch merges into it without conflict. The sentence "no server that has not answered is sent a second query inside WAIT_MS, whoever reports or fails what" is false until BLOCKER 6 is fixed.

What closes this

Round 4 is the diff of BLOCKER 6's fix and its red-first log, nothing else re-read. BLOCKER 4 closes only on logs at the head that lands, after main is merged in (#779, #775, #777 are open today; the track's hand resolution as round 2 said, every hunk of #779's side accounted for):

If round 4 closes 6, the orchestrator may land on reading those two logs himself, provided the merge of main changes no line of toyos-dns/src/lib.rs or userland/netstack/node/src/resolve.rs; a merge that does comes back.

SEND BACK

Japabu and others added 2 commits October 9, 2026 00:36
…nd a lookup wakes at the next server's turn

Round 3 of the review of #781, finding 6.

A send started the interval of one place in the server list while every
other writer keyed it by address, so a lease that names one resolver
twice got a second query in the same millisecond on a report. The send
calls `free` like the rest: one key for an interval.

Where the newest query is not waited for and the next server may not be
asked yet, `due` is that server's turn, and no longer the later of it and
the newest query's own wait.

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 4: mutation patches and the script that ran them, at b2d903719. Each m patch applies to the clean head with git apply; run.sh applies, builds, runs and reverses each (run once a prefix). m0, m4, m6, m11 and m23 are made anew for this head, m26 and m27 are this round's, and the rest are round 3's, which still apply. r0 applies to b10a788df, the merge of origin/main into round 3's head 71db83dc1, where this stage's source is round 3's: finding 6's two tests and the test of the wake as they stand here, exit 101 there.

run.sh:

#!/bin/sh
# Each mutation: a checked patch applied to the clean committed tree, shown to
# build (or not), run, and reversed. Logs: $S/logs4/<mutation>-builds.log and
# $S/logs4/<mutation>.log, the command first and the exit code last.
W=<worktree>
S=<scratchpad>/orch/ownstack/resolver
P="-p toyos-dns -p toyos-net-node -p toyos-net-ip -p toyos-net-udp -p toyos-net-shard"
cd "$W" || exit 2
echo "HEAD=$(git rev-parse HEAD)"
clean() { [ -z "$(git status --porcelain --ignore-submodules=none)" ]; }
clean || { echo "the worktree is not clean; nothing run"; exit 2; }
for patch in "$S"/mutations4/${1:-m}*.patch; do
    name=$(basename "$patch" .patch)
    git apply --check "$patch" || { echo "$name does not apply"; exit 2; }
    git apply "$patch"
    # shellcheck disable=SC2086
    { echo "\$ cargo test --locked --no-run $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-run $P; echo "EXIT=$?"; } > "$S/logs4/$name-builds.log" 2>&1
    built=$(tail -1 "$S/logs4/$name-builds.log")
    if [ "$built" = "EXIT=0" ]; then
        # shellcheck disable=SC2086
        { echo "\$ cargo test --locked --no-fail-fast $P"; echo "HEAD=$(git rev-parse HEAD) + $name"; cargo test --locked --no-fail-fast $P; echo "EXIT=$?"; } > "$S/logs4/$name.log" 2>&1
        ran=$(tail -1 "$S/logs4/$name.log")
    else
        ran="not run"
    fi
    git apply -R "$patch"
    clean || { echo "$name left the tree dirty"; exit 2; }
    echo "$name builds $built tests $ran"
done
echo "HEAD=$(git rev-parse HEAD) clean=$(clean && echo yes || echo no)"
echo "MUTATIONS DONE"

m0-whole-source-change-reverted.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..1f46cdaa9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -431,12 +431,8 @@ pub enum Failure {
     NoSuchName,
     /// The name exists and has no `A` record (RFC 2308 §2.2).
     NoAddress,
-    /// No server answered, and a query that may have reached one was given
-    /// its [`WAIT_MS`].
+    /// Every query went unanswered for [`WAIT_MS`].
     TimedOut,
-    /// No query reached a server ([`Lookup::on_unreached`]) and none is left
-    /// to send at once: there is no answer to wait for.
-    Unreachable,
     /// The answer did not fit a UDP reply (TC).
     Truncated,
     /// Every server that answered could not answer, the last with this RCODE.
@@ -482,49 +478,21 @@ struct Sent {
 /// any query this lookup sent for the name now asked is read, so an answer
 /// late past its query's wait still ends the lookup; it is read only on the
 /// query's own port, with the query's ID, from the server the query went to.
-///
-/// **A wait is for an answer that can come.** When the newest query is
-/// answered with a failure, or known or said not to have reached its server,
-/// the next server is asked at once: RFC 1035 §4.2.1's interval of two to
-/// five seconds is between repetitions of a query that may have been lost on
-/// its way, and the same section has the other servers tried first. **The
-/// interval is each server's**: one that has not answered is sent no second
-/// query inside [`WAIT_MS`] of its last, or of the caller's last knowledge
-/// that it was not reached, whatever is said of that query and whatever
-/// another server answers; a server is its address, however often the list
-/// names it. Where the next server cannot be asked yet, the lookup waits
-/// until it can while any answer is still read, and ends at once only with
-/// none.
-///
-/// **What the caller knows and what it was told are two calls.** A query the
-/// caller itself knows never reached its server ([`Lookup::on_unreached`]) is
-/// let go, and a lookup with no query left whose answer is read ends with
-/// [`Failure::Unreachable`]. A report from the network
-/// ([`Lookup::on_report`]) is anyone's word (RFC 8085 §5.2): it lets no query
-/// go and ends nothing, so the answer to a query reported so is still read.
 #[derive(Debug)]
 pub struct Lookup {
     /// The name now asked: the one given, or the last alias followed.
     name: Name,
     /// Aliases followed so far, counted against [`MAX_ALIASES`].
     aliases: usize,
-    /// Each server, and when it may be asked again: [`WAIT_MS`] after its
-    /// last query or after it was last known not to be reached, and at once
-    /// when it has answered any query, an older one's late failure included.
-    /// An address named twice is one server: every place it has carries the
-    /// same time.
-    servers: Vec<([u8; 4], u64)>,
+    servers: Vec<[u8; 4]>,
     /// Queries sent for `name`, oldest first; one answered with a server
-    /// failure, or known not to have reached its server, is taken out.
-    /// [`Lookup::waiting`] is this list, which holds a query while the
-    /// lookup lasts.
+    /// failure is taken out. [`Lookup::waiting`] is this list.
     sent: Vec<Sent>,
     /// The [`Asked`] the next query gets: none is given twice in a lookup.
     next: u32,
     /// Queries sent for `name`, answered or not.
     asked: usize,
-    /// When the newest query is given up on; or, where it is not waited for
-    /// and the next server may not be asked yet, that server's turn.
+    /// When the newest query is given up on.
     due: u64,
     /// The RCODE of the last server failure, which is what a lookup that runs
     /// out of queries reports if any server answered at all.
@@ -541,7 +509,7 @@ impl Lookup {
         let mut lookup = Self {
             name,
             aliases: 0,
-            servers: servers.iter().map(|server| (*server, now)).collect(),
+            servers: servers.to_vec(),
             sent: Vec::new(),
             next: 0,
             asked: 0,
@@ -563,41 +531,22 @@ impl Lookup {
         self.sent.iter().map(|s| s.asked)
     }
 
-    /// The next server's query, if it may be asked at `now`; or the wait for
-    /// the answers still read, until it may or the last query's wait is
-    /// over; or the end.
+    /// The next query for `name`, or the end where every one has been sent.
     fn ask(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let turn = self.asked % self.servers.len();
-        let (to, free) = self.servers[turn];
-        let spent = self.asked == ROUNDS * self.servers.len();
-        if !spent && now >= free {
-            self.free(to, now + WAIT_MS);
-            self.asked += 1;
-            let asked = Asked(self.next);
-            self.next += 1;
-            let id = id();
-            self.sent.push(Sent { asked, id, to });
-            self.due = now + WAIT_MS;
-            return Step::Ask { asked, to, query: query(id, &self.name) };
-        }
-        if self.sent.is_empty() || (spent && now >= self.due) {
-            // With no query left, each was answered with a failure or never
-            // reached its server.
-            let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
-            return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
-        }
-        if !spent {
-            // The newest query is not waited for: the next server's turn is.
-            self.due = free;
-        }
-        Step::Wait
-    }
-
-    /// `server` may next be asked at `at`.
-    fn free(&mut self, server: [u8; 4], at: u64) {
-        for (_, free) in self.servers.iter_mut().filter(|(addr, _)| *addr == server) {
-            *free = at;
+        if self.asked == ROUNDS * self.servers.len() {
+            return Step::Done(Err(match self.failed {
+                Some(rcode) => Failure::ServerFailed(rcode),
+                None => Failure::TimedOut,
+            }));
         }
+        let to = self.servers[self.asked % self.servers.len()];
+        self.asked += 1;
+        let asked = Asked(self.next);
+        self.next += 1;
+        let id = id();
+        self.sent.push(Sent { asked, id, to });
+        self.due = now + WAIT_MS;
+        Step::Ask { asked, to, query: query(id, &self.name) }
     }
 
     /// The time is `now`: the newest query has had its [`WAIT_MS`] where it
@@ -609,33 +558,6 @@ impl Lookup {
         self.ask(now, id)
     }
 
-    /// The caller knows at `now`, of its own knowledge, that the query
-    /// `asked` did not reach its server: it was never sent. Its answer is
-    /// read no more. `id` draws the ID of the query this sends.
-    pub fn on_unreached(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
-            return Step::Wait;
-        };
-        let unreached = self.sent.remove(which);
-        self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.ask(now, id)
-    }
-
-    /// The network said at `now` that the query `asked` did not reach its
-    /// server. Its answer is still read. `id` draws the ID of the query this
-    /// sends.
-    pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
-            return Step::Wait;
-        }
-        self.ask(now, id)
-    }
-
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was
     /// sent from. `id` draws the ID of the query this sends.
     pub fn on_datagram(
@@ -663,9 +585,6 @@ impl Lookup {
             Verdict::ServerFailed(rcode) => {
                 self.failed = Some(rcode);
                 self.sent.remove(which);
-                // It answered: its interval, which is for a query that may
-                // have been lost, is over.
-                self.free(from, now);
                 // The next server is asked now, unless a newer query is still
                 // waiting for its own answer.
                 if which == self.sent.len() {
@@ -686,9 +605,6 @@ impl Lookup {
                     self.sent.clear();
                     self.asked = 0;
                     self.failed = None;
-                    for (_, free) in &mut self.servers {
-                        *free = now;
-                    }
                     self.ask(now, id)
                 }
             },
diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..a5fa0f73a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -113,17 +113,11 @@ pub enum Binding {
     Connected { local: Ipv4Addr, peer: Ipv4Addr, peer_port: Port },
 }
 
-/// An error against a connected socket's datagrams: its next call returns it once. Two are what
-/// an ICMP message said, which anyone who knows the socket's addresses and ports can send; one
-/// is [ip]'s own.
+/// An error the network reported against a connected socket: its next call returns it once.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum SocketError {
-    /// An ICMP error said the peer refuses the protocol or the port.
     Refused,
-    /// An ICMP error said the way to the peer is administratively prohibited.
-    Prohibited,
-    /// [ip] found no link address for the next hop of a datagram it held: nothing left.
-    NextHopFailed,
+    Unreachable,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -665,7 +659,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::Unreachable,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);
@@ -675,7 +669,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Unreachable);
         }
     }
 
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d8a5cfc23 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -10,21 +10,9 @@
 //!   query other than the one whose socket it reached.
 //! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets
 //!   the query go, ends, or is let go itself, and its port is free from then.
-//! - **A query the node knows did not reach its resolver is let go**
-//!   (`toyos_dns::Lookup::on_unreached`): the lookup asks its next resolver at once, or ends
-//!   where no query's answer is read any more. The node knows it of a query [udp] refuses in the
-//!   call, which never left, is counted and holds no socket; and of one [ip] reports it found no
-//!   next hop for, which is counted and its socket closed.
-//! - **An ICMP error is a report and not knowledge** (`toyos_dns::Lookup::on_report`): [udp]
-//!   hands a query's socket one that quotes the socket's addresses and ports and says refused or
-//!   prohibited, which takes an off-path sender the query's port to forge and not its id (RFC
-//!   8085 §5.2). It is counted, and the next resolver is asked at once; the query's socket stays
-//!   open and its answer is read, and no number of them ends a lookup.
-//! - **Every lookup's first query can wait in [ip] for one next hop at once**, where no link
-//!   address is known yet: [ip] holds `PENDING_PER_NEIGHBOUR` datagrams for a next hop it is
-//!   resolving and keeps the newest (RFC 4861 §7.2.2), which is no fewer than the lookups held
-//!   here. A datagram another socket sends to that next hop meanwhile takes a place in the same
-//!   queue.
+//! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits
+//!   its wait out as for any query nobody answered. So does one the network reports back, its
+//!   resolver unreachable or its port refused: counted, and waited out.
 //! - **A lookup asks the resolvers of the lease it started under, and ends with that lease's
 //!   word**: when the held lease names other resolvers, or none is held.
 //! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame.
@@ -39,16 +27,13 @@ use alloc::vec::Vec;
 use core::net::Ipv4Addr;
 
 use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT};
-use toyos_net_ip::limits::nud::PENDING_PER_NEIGHBOUR;
-use toyos_net_udp::{Error, SocketError, SocketId};
+use toyos_net_udp::{Error, SocketId};
 use toyos_net_wire::Instant;
 
 use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
-
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct LookupId(u64);
@@ -120,51 +105,42 @@ fn close(stack: &mut Stack, now: Instant, socket: SocketId) {
     }
 }
 
-/// What waits at a query's socket.
-enum Heard {
-    /// A datagram of this length, now in the reply buffer.
-    Reply(usize),
-    /// [ip]'s word that the query never left.
-    Unreached,
-    /// An ICMP error's word that the query was refused.
-    Reported,
-}
-
-/// The first of `queries` with something at its socket, and what: a reply is now in `reply`.
-fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8]) -> Option<(Query, Heard)> {
+/// The length of the next reply waiting at one of `queries`' sockets, now in `reply`, and the
+/// query it is for. An error the network reported against a query is counted and read past.
+fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8], counters: &mut Counters) -> Option<(Query, usize)> {
     for query in queries {
-        match stack.recv_from(query.socket, reply) {
-            Ok(Some(received)) => return Some((*query, Heard::Reply(received.len))),
-            Ok(None) => {}
-            Err(Error::Failed(SocketError::NextHopFailed)) => return Some((*query, Heard::Unreached)),
-            Err(Error::Failed(SocketError::Refused | SocketError::Prohibited)) => return Some((*query, Heard::Reported)),
-            Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+        loop {
+            match stack.recv_from(query.socket, reply) {
+                Ok(Some(received)) => return Some((*query, received.len)),
+                Ok(None) => break,
+                Err(Error::Failed(_)) => counters.add(Counter::QueryFailed, 1),
+                Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+            }
         }
     }
     None
 }
 
-/// Carries out `step` for `asking`, and every step the lookup answers a query [udp] refused
-/// with, then closes the socket of every query the lookup no longer reads an answer for. Returns
-/// how the lookup ended, if it did.
-fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
-    let done = loop {
-        match step {
-            Step::Ask { asked, to, query } => {
-                // An any-address bind that names no port is refused only for want of a free one.
-                let Ok((socket, _)) = stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) else { break Some(Err(Ended::NoPort)) };
+/// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer
+/// reads an answer for. Returns how the lookup ended, if it did.
+fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
+    let done = match step {
+        // An any-address bind that names no port is refused only for want of a free one.
+        Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) {
+            Ok((socket, _)) => {
                 let resolver = Ipv4Addr::from(to);
                 if stack.connect(now, socket, resolver, PORT).is_ok() && stack.send_to(now, socket, resolver, PORT, &query).is_ok() {
                     asking.queries.push(Query { asked, socket, to });
-                    break None;
+                } else {
+                    counters.add(Counter::QueryUnsent, 1);
+                    close(stack, now, socket);
                 }
-                counters.add(Counter::QueryUnsent, 1);
-                close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                None
             }
-            Step::Wait => break None,
-            Step::Done(result) => break Some(result.map_err(Ended::Failed)),
-        }
+            Err(_) => Some(Err(Ended::NoPort)),
+        },
+        Step::Wait => None,
+        Step::Done(result) => Some(result.map_err(Ended::Failed)),
     };
     let lookup = &asking.lookup;
     asking.queries.retain(|query| {
@@ -198,19 +174,16 @@ impl Resolver {
         let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect();
         let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") };
         let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() };
-        let done = act(&mut asking, step, now, stack, counters, &mut *draw);
-        if done == Some(Err(Ended::NoPort)) {
-            return Err(NotStarted::ResourceExhausted);
-        }
-        self.next = self.next.wrapping_add(1);
-        let started = asking.id;
-        match done {
-            None => self.asking.push(asking),
-            // No resolver could be sent a query: the lookup has its id and its end at once, and
-            // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+        match act(&mut asking, step, now, stack, counters, &mut *draw) {
+            None => {
+                self.next = self.next.wrapping_add(1);
+                let started = asking.id;
+                self.asking.push(asking);
+                Ok(started)
+            }
+            Some(Err(Ended::NoPort)) => Err(NotStarted::ResourceExhausted),
+            Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"),
         }
-        Ok(started)
     }
 
     /// Ends every lookup whose lease went or names other resolvers, reads every reply that
@@ -223,20 +196,11 @@ impl Resolver {
             let named = stack.lease().is_some_and(|lease| lease.dns == asking.resolvers);
             let mut done = if named { None } else { Some(Err(Ended::LeaseChanged)) };
             while done.is_none() {
-                let Some((query, heard)) = waiting(&asking.queries, stack, reply.as_mut_slice()) else { break };
-                let step = match heard {
-                    // The socket is connected: [udp] delivered this from port 53 of the resolver
-                    // the query went to, or not at all.
-                    Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
-                    Heard::Unreached => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
-                    }
-                    Heard::Reported => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
-                    }
-                };
+                let Some((query, len)) = waiting(&asking.queries, stack, reply.as_mut_slice(), counters) else { break };
+                let message = reply.get(..len).unwrap_or_default();
+                // The socket is connected: [udp] delivered this from port 53 of the resolver the
+                // query went to, or not at all.
+                let step = asking.lookup.on_datagram(query.asked, query.to, PORT, message, ms, || id(&mut *draw));
                 done = act(asking, step, now, stack, counters, &mut *draw);
             }
             if done.is_none() {
@@ -267,11 +231,9 @@ impl Resolver {
 }
 
 impl Node {
-    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws a
-    /// query, its id and then its port: for the first query, and for each asked in its place
-    /// because [udp] refused the one before. A call refused for want of a port has spent them;
-    /// any other refused call spends none. A lookup no resolver could be sent a query of has
-    /// started and ended: its end is in the next [`Self::take_resolved`].
+    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws:
+    /// the first query's id, then its port. A call refused for want of a port has spent both;
+    /// any other refused call spends none.
     pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result<LookupId, NotStarted> {
         self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw)
     }
diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index 21c30f688..0bd5fcc9f 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1550,9 +1550,6 @@ impl Netstack {
                 // whether the name or only its address is missing.
                 Err(Ended::Failed(Failure::NoSuchName | Failure::NoAddress)) => answer_lookup(&client, &[]),
                 Err(Ended::Failed(Failure::TimedOut)) => client.error(ERR_TIMED_OUT),
-                Err(Ended::Failed(Failure::Unreachable)) => {
-                    unreachable!("netstack: no lookup here is told a query did not reach its server, and {name}'s ended so")
-                }
                 Err(Ended::Failed(why @ (Failure::Truncated | Failure::ServerFailed(_) | Failure::TooManyAliases))) => {
                     say!("netstack: a lookup of {name} ended without an answer: {why:?}");
                     client.error(ERR_OTHER);

m1-node-a-refused-query-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..be7c09da3 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -160,7 +160,7 @@ fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, cou
                 }
                 counters.add(Counter::QueryUnsent, 1);
                 close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                break None;
             }
             Step::Wait => break None,
             Step::Done(result) => break Some(result.map_err(Ended::Failed)),

m2-node-a-query-ip-reports-is-waited-out.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d4b2e7efe 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -230,7 +230,7 @@ impl Resolver {
                     Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
                     Heard::Unreached => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
+                        Step::Wait
                     }
                     Heard::Reported => {
                         counters.add(Counter::QueryFailed, 1);

m3-dns-unreached-asks-nobody.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..8d38ef90b 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -615,10 +615,8 @@ impl Lookup {
         let unreached = self.sent.remove(which);
         self.free(unreached.to, now + WAIT_MS);
         // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.ask(now, id)
+        let _ = (now, id);
+        Step::Wait
     }
 
     /// The network said at `now` that the query `asked` did not reach its

m4-dns-a-server-is-asked-inside-its-interval.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..394ae25e1 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -570,7 +570,7 @@ impl Lookup {
         let turn = self.asked % self.servers.len();
         let (to, free) = self.servers[turn];
         let spent = self.asked == ROUNDS * self.servers.len();
-        if !spent && now >= free {
+        if !spent && free < u64::MAX {
             self.free(to, now + WAIT_MS);
             self.asked += 1;
             let asked = Asked(self.next);

m5-dns-an-older-unreached-query-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..45613887e 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -614,10 +614,6 @@ impl Lookup {
         };
         let unreached = self.sent.remove(which);
         self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
         self.ask(now, id)
     }
 

m6-dns-a-query-does-not-start-its-servers-interval.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..57d195f25 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -571,7 +571,6 @@ impl Lookup {
         let (to, free) = self.servers[turn];
         let spent = self.asked == ROUNDS * self.servers.len();
         if !spent && now >= free {
-            self.free(to, now + WAIT_MS);
             self.asked += 1;
             let asked = Asked(self.next);
             self.next += 1;

m7-dns-an-unreached-query-is-still-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..861d17cab 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -612,10 +612,9 @@ impl Lookup {
         let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
             return Step::Wait;
         };
-        let unreached = self.sent.remove(which);
+        let unreached = self.sent[which];
         self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
+        if which + 1 < self.sent.len() {
             return Step::Wait;
         }
         self.ask(now, id)

m8-ip-queue-of-8.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }

m9-ip-queue-of-8-and-no-assertion.patch:

diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..990bcdc37 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -47,7 +47,7 @@ use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
+const _: usize = PENDING_PER_NEIGHBOUR;
 
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]

m10-udp-a-hint-is-delivered.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8d8a26938 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -666,7 +666,8 @@ impl Udp {
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
             ErrorClass::Prohibited => SocketError::Prohibited,
-            ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::PathMtu => return self.count(Counter::IcmpErrorSoft),
+            ErrorClass::Soft => SocketError::Prohibited,
         };
         socket.pending = Some(pending);
         self.count(Counter::IcmpErrorDelivered);

m11-dns-unreachable-outranks-a-server-failure.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..0d2a1fbf6 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -584,7 +584,7 @@ impl Lookup {
             // With no query left, each was answered with a failure or never
             // reached its server.
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
-            return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
+            return Step::Done(Err(if self.sent.is_empty() { silence } else { self.failed.map_or(silence, Failure::ServerFailed) }));
         }
         if !spent {
             // The newest query is not waited for: the next server's turn is.

m12-node-an-end-in-the-call-is-dropped.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..0fdade7bb 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -208,7 +208,7 @@ impl Resolver {
             None => self.asking.push(asking),
             // No resolver could be sent a query: the lookup has its id and its end at once, and
             // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+            Some(_) => {}
         }
         Ok(started)
     }

m13-udp-a-report-is-matched-by-the-port-alone.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8fe43316e 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -643,7 +643,8 @@ impl Udp {
     fn connected(&mut self, flow: &Flow) -> Option<&mut Socket> {
         let index = *self.ports.get(&flow.source_port)?;
         self.at(index).filter(|s| {
-            s.binding == Binding::Connected { local: flow.source, peer: flow.destination, peer_port: flow.destination_port }
+            let _ = flow;
+            matches!(s.binding, Binding::Connected { .. })
         })
     }
 

m14-node-an-icmp-error-is-taken-for-knowledge.patch:

diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..99d3f8c5f 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -234,7 +234,7 @@ impl Resolver {
                     }
                     Heard::Reported => {
                         counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
+                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
                     }
                 };
                 done = act(asking, step, now, stack, counters, &mut *draw);

m15-dns-a-reported-query-is-let-go.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..076c46777 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -629,6 +629,7 @@ impl Lookup {
         if self.sent.last().is_none_or(|s| s.asked != asked) {
             return Step::Wait;
         }
+        self.sent.pop();
         self.ask(now, id)
     }
 

m16-udp-a-prohibition-is-said-in-ips-word.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..855f0c92a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -665,7 +665,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::NextHopFailed,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);

m17-udp-ips-word-is-said-as-a-prohibition.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..798a9f404 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -675,7 +675,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Prohibited);
         }
     }
 

m18-dns-a-lookup-ends-while-a-query-is-read.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..b6a81f2ff 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -577,7 +577,7 @@ impl Lookup {
             self.due = now + WAIT_MS;
             return Step::Ask { asked, to, query: query(id, &self.name) };
         }
-        if self.sent.is_empty() || (spent && now >= self.due) {
+        if self.sent.is_empty() || spent || now < self.due {
             // With no query left, each was answered with a failure or never
             // reached its server.
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };

m19-dns-an-older-querys-report-asks-too.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..17f643db1 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -626,7 +626,7 @@ impl Lookup {
     /// sends.
     pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
         // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
+        if !self.sent.iter().any(|s| s.asked == asked) {
             return Step::Wait;
         }
         self.ask(now, id)

m20-udp-a-refusal-is-said-in-ips-word.patch:

diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..8bb7cdd14 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -664,7 +664,7 @@ impl Udp {
             return self.count(counter);
         };
         let pending = match class {
-            ErrorClass::Refused => SocketError::Refused,
+            ErrorClass::Refused => SocketError::NextHopFailed,
             ErrorClass::Prohibited => SocketError::Prohibited,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };

m21-dns-a-server-that-answered-keeps-its-interval.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..30626950c 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -661,7 +661,6 @@ impl Lookup {
                 self.sent.remove(which);
                 // It answered: its interval, which is for a query that may
                 // have been lost, is over.
-                self.free(from, now);
                 // The next server is asked now, unless a newer query is still
                 // waiting for its own answer.
                 if which == self.sent.len() {

m22-dns-a-server-known-unreached-may-be-asked-at-once.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..2dad223d9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -613,7 +613,7 @@ impl Lookup {
             return Step::Wait;
         };
         let unreached = self.sent.remove(which);
-        self.free(unreached.to, now + WAIT_MS);
+        let _ = unreached;
         // A newer query still waits for its own answer.
         if which < self.sent.len() {
             return Step::Wait;

m23-dns-the-wait-does-not-reach-the-next-servers-turn.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..2ae54e43a 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -586,10 +586,6 @@ impl Lookup {
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
             return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
         }
-        if !spent {
-            // The newest query is not waited for: the next server's turn is.
-            self.due = free;
-        }
         Step::Wait
     }
 

m24-dns-an-alias-keeps-the-old-names-intervals.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..97c899740 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -682,9 +682,6 @@ impl Lookup {
                     self.sent.clear();
                     self.asked = 0;
                     self.failed = None;
-                    for (_, free) in &mut self.servers {
-                        *free = now;
-                    }
                     self.ask(now, id)
                 }
             },

m25-dns-a-failure-ends-a-spent-lookup-under-its-wait.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 4d8406a43..9ef8f06d3 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -577,7 +577,7 @@ impl Lookup {
             self.due = now + WAIT_MS;
             return Step::Ask { asked, to, query: query(id, &self.name) };
         }
-        if self.sent.is_empty() || (spent && now >= self.due) {
+        if self.sent.is_empty() || spent {
             // With no query left, each was answered with a failure or never
             // reached its server.
             let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };

m26-dns-a-query-starts-the-interval-of-its-place-in-the-list.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..43918beb7 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -571,7 +571,7 @@ impl Lookup {
         let (to, free) = self.servers[turn];
         let spent = self.asked == ROUNDS * self.servers.len();
         if !spent && now >= free {
-            self.free(to, now + WAIT_MS);
+            self.servers[turn].1 = now + WAIT_MS;
             self.asked += 1;
             let asked = Asked(self.next);
             self.next += 1;

m27-dns-the-wake-is-the-later-of-the-turn-and-the-newest-wait.patch:

diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..f8bd88ac2 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -588,7 +588,7 @@ impl Lookup {
         }
         if !spent {
             // The newest query is not waited for: the next server's turn is.
-            self.due = free;
+            self.due = self.due.max(free);
         }
         Step::Wait
     }

r0-the-tests-of-finding-6-onto-the-merge-of-71db83dc1.patch:

diff --git a/toyos-dns/src/tests.rs b/toyos-dns/src/tests.rs
index 48078eef0..630ac141f 100644
--- a/toyos-dns/src/tests.rs
+++ b/toyos-dns/src/tests.rs
@@ -1148,6 +1148,51 @@ fn a_server_not_reached_is_not_asked_again_at_once_because_another_failed() {
     assert_eq!(lookup.due(), 2 * WAIT_MS);
 }
 
+// The interval is a server's and not its place in the list: a list that names
+// one address twice, as a lease may, asks it once a wait, whatever is
+// reported of each query, twice over, the moment it is sent.
+#[test]
+fn a_server_named_twice_is_one_server_and_is_asked_once_a_wait_whatever_is_reported() {
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S1], 0, || 1).unwrap();
+    let mut newest = asked(&first).0;
+    let mut sent = vec![0];
+    let mut now = 0;
+    let ended = loop {
+        assert_eq!(lookup.on_report(newest, now, || 9), Step::Wait, "S1 has not answered and was asked this millisecond");
+        assert_eq!(lookup.due(), now + WAIT_MS);
+        assert_eq!(lookup.on_report(newest, now, || 9), Step::Wait, "the same report again");
+        assert_eq!(lookup.on_time(now + WAIT_MS - 1, || 9), Step::Wait);
+        now += WAIT_MS;
+        match lookup.on_time(now, || 1) {
+            Step::Done(result) => break result,
+            step => {
+                assert_eq!(to(&step), (S1, 1));
+                sent.push(now);
+                newest = asked(&step).0;
+            }
+        }
+    };
+    let turns = 2 * ROUNDS as u64;
+    assert_eq!(sent, (0..turns).map(|turn| turn * WAIT_MS).collect::<Vec<_>>(), "ROUNDS queries a place in the list, a wait apart");
+    assert_eq!((now, ended), (turns * WAIT_MS, Err(Failure::TimedOut)));
+}
+
+// Where the newest query is not waited for and the next server may not be
+// asked yet, the lookup wakes at that server's turn, not at the end of a
+// wait nobody is keeping: S1 asked at 0 and reported at 1.5 s, S2 asked then
+// and reported at 1.6 s, and S1 is asked again at 2 s.
+#[test]
+fn a_lookup_whose_newest_query_is_not_waited_for_wakes_at_the_next_servers_turn() {
+    let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap();
+    let second = lookup.on_report(asked(&first).0, 1_500, || 2);
+    assert_eq!((to(&second), lookup.due()), ((S2, 2), 1_500 + WAIT_MS));
+    assert_eq!(lookup.on_report(asked(&second).0, 1_600, || 9), Step::Wait, "S1 was asked 1.6 s ago");
+    assert_eq!(lookup.due(), WAIT_MS, "S1's turn");
+    assert_eq!(lookup.on_time(WAIT_MS - 1, || 9), Step::Wait);
+    assert_eq!(to(&lookup.on_time(WAIT_MS, || 3)), (S1, 3));
+    assert_eq!(waiting(&lookup).len(), 3, "every query's answer is still read");
+}
+
 // What the caller knows of a server holds for an interval too: S1's query is
 // known not to have left a second after S2 was asked, so when S2's wait ends
 // the lookup waits on, its answer still read, until S1 may be asked.
diff --git a/userland/netstack/node/tests/resolve.rs b/userland/netstack/node/tests/resolve.rs
index d67ae6ddd..7d930f464 100644
--- a/userland/netstack/node/tests/resolve.rs
+++ b/userland/netstack/node/tests/resolve.rs
@@ -569,6 +569,21 @@ fn reports_alone_end_no_lookup_and_ask_no_resolver_twice_inside_a_wait() {
     assert_eq!(net.lan.node.counters().get(Counter::QueryFailed), rounds);
 }
 
+// And a lease may name one resolver twice (RFC 2132 §3.8 lists addresses and forbids no repeat):
+// it is one resolver, with one interval. Every query is reported back, and one is on the wire a
+// wait, `ROUNDS` for each time the lease names it.
+#[test]
+fn a_resolver_the_lease_names_twice_is_asked_once_a_wait_whatever_it_reports() {
+    let mut net = Net::leased(&[REFUSES, REFUSES], Some(R));
+    let id = net.resolve("www.example").unwrap();
+    assert_eq!(net.run(id, 20 * WAIT_MS), Some(Err(Ended::Failed(Failure::TimedOut))));
+    let turns = 2 * u64::try_from(ROUNDS).unwrap();
+    let left: Vec<u64> = net.refused.iter().map(|query| net.ms_of(query.at)).collect();
+    assert_eq!(left, (0..turns).map(|turn| turn * WAIT_MS).collect::<Vec<_>>());
+    assert_eq!(net.ms(), turns * WAIT_MS);
+    assert_eq!(net.lan.node.counters().get(Counter::QueryFailed), turns, "the premise: every report reached its query's socket");
+}
+
 // A report has crossed a trust boundary: anyone can send an ICMP message. RFC 1122 §4.1.3.3 has
 // the application "demultiplex these messages when they arrive", which [udp] does by the quoted
 // datagram's addresses and ports against the connected socket's, and §3.2.2.1 has a destination

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 4, of b2d903719 (git diff 71db83dc1 b2d903719, less the merge of origin/main)

Read, nothing run. Evidence read: the implementer's round-4 step logs (each opens with its command and HEAD=b2d903719… or its tree, closes with EXIT=), and the mutation set in the round-4 comment.

What was judged. b10a788df is the merge of origin/main at 6776714db (#769) into 71db83dc1; git diff 71db83dc1 b10a788df -- toyos-dns userland/netstack toyos-net-ip toyos-net-udp toyos-net-shard issues/toyos-has-its-own-network-stack.md is empty. b2d903719 is its one child and touches three files: toyos-dns/src/lib.rs +12 -8, toyos-dns/src/tests.rs +45, userland/netstack/node/tests/resolve.rs +15. That diff is the round.

Round 3's BLOCKERs

  • 6, the interval keyed by slot at the send and by address elsewhere — CLOSED, by the implementer's measurement.
    • The fix is the rule asked: toyos-dns/src/lib.rs:574 is self.free(to, now + WAIT_MS); ask writes no slot, and every writer of a server's time is now free or the alias restart, which writes all.
    • Red first: step r0-red-first-at-71db83dc1, cargo test --locked --no-fail-fast -p toyos-dns -p toyos-net-node -- <the three tests>, EXIT=101. toyos-dns: a_server_named_twice_… left: Ask { asked: Asked(1), to: [10, 0, 2, 3], .. }, right: Wait. The node: a_resolver_the_lease_names_twice_… left: [0, 0, 2000, 2000, 4000, 4000], right: [0, 2000, 4000, 6000, 8000, 10000].
    • The base of that control is accepted. Its tree is b10a788df plus a test-only patch, not 71db83dc1 itself. The stage's source is byte-identical between the two (the empty diff above), the patch adds only the three tests, and its blobs are the head's (tests.rs 630ac141f, resolve.rs 7d930f464). The log's second line says what the tree is. The step's name says 71db83dc1 where the tree is the merge; the body says so beside it.
    • At the head: cargo test --locked -p toyos-dns 62 passed EXIT=0, -p toyos-net-node 71 passed (5, 18, 13, 32, 3) EXIT=0, both new tests ok.
    • The control on the head: m26 (self.servers[turn].1 = now + WAIT_MS back) builds, EXIT=101, red on exactly a_server_named_twice_is_one_server_and_is_asked_once_a_wait_whatever_is_reported and a_resolver_the_lease_names_twice_is_asked_once_a_wait_whatever_it_reports.
    • What ships is not moved by it: netstack calls only on_time and on_datagram, where a repeated address was already waited out before its second slot's turn.
  • 4, evidence — OPEN. At b2d903719 the checks host, toolchain and guest are SKIPPED (a draft): no cargo run -- --ci host and no guest run exists at this head, and the body says so. toyos-dns ships in netstack, so both are owed. What closes it is below.

Round 3's NOTE with a choice: self.due = free

The assignment is taken and the max is deleted (lib.rs:591). Test a_lookup_whose_newest_query_is_not_waited_for_wakes_at_the_next_servers_turn: red in the same r0 (left: 3500, right: 2000), ok at the head; m27 (the max back) EXIT=101, red on it alone. The three things asked, by reading Lookup whole at the head:

  • It cannot leave due at or before now. Line 591 is reached only past both returns above it: not spent, so the send's guard failed on now < free. due = free > now.
  • It cannot end a lookup early. The one Done by time needs spent && now >= due. Line 591 runs only while not spent, and a lookup becomes spent only by a send, which sets due = now + WAIT_MS after it. Once spent, due is the last query's wait and nothing moves it. The other Done needs sent empty and reads no time.
  • It cannot make a wake that does nothing forever. A wake at due reaches ask, which sends, ends, or sets due to a free strictly past now. A server's free is raised only by a send to it or by on_unreached, each bounded: sends by ROUNDS * servers.len() a name and names by MAX_ALIASES, on_unreached once a query. With no further event the next wake finds now >= free and sends.
  • due is lowered below the newest query's wait only where that query is not waited for: ask is entered from a report, an unreached or a failure of the newest query, from an alias restart (which always sends), or from on_time with now >= due, where free > now >= due and the assignment equals the max.
  • One direction is left stale, as it was with the max and by the early Step::Wait returns round 3 named: an older query's late failure lowers the turn server's free under a due already set to it. The lookup then wakes at due, at most WAIT_MS after it could have asked, and sends. A delay, no loss, no early end; no change asked.

BLOCKER

None new.

NOTE

  • The field docs and the body are as round 3 asked. m0 to m27 each read EXIT=101, m0 and m8 by build, and the body's table agrees with the logs on the tests each turns red. Counts read from the logs at this head: toyos-net-ip 262, toyos-net-udp 59, toyos-net-shard 47, netstack 27, the four neighbouring crates 464, gates 45, clippy --all-targets EXIT=0.
  • Growth: git diff --shortstat origin/main...b2d903719 is 12 files, +881 -112. Production +201 -70 (toyos-dns/src/lib.rs +103 -19, node/src/resolve.rs +84 -46, toyos-net-udp/src/lib.rs +10 -4, toyos-net-ip/src/lib.rs +1 -1, netstack/src/main.rs +3). Tests +676 -39. The track +4 -3. This round's two lines of code add no state and delete one key and one max; accepted.

What closes BLOCKER 4, at the head that lands

Order: #779, #775 and #777 land first. This branch then merges main; the only hand resolution is in issues/toyos-has-its-own-network-stack.md, every hunk of #779's side accounted for. The pull request leaves draft so the checks run, once main's post-merge build has saved its cache.

The orchestrator may land on reading those two logs himself, with no further round, provided that merge of main changes no line of toyos-dns/src/lib.rs or userland/netstack/node/src/resolve.rs against b2d903719. A merge that does, a count below those above, or a named test absent comes back.

SEND BACK

…ers (#777), the drivers' room and wake (#782), the SMI_CMD call (#780) and the guest waits (#786), into the resolver rules

One conflict, in issues/toyos-has-its-own-network-stack.md, resolved by hand
with every line of both sides accounted for:

- The stage 5 paragraph: main's "In the tree", which now names streams,
  listeners and the one bound, and this branch's "Still to build on it",
  less the streams and listeners that landed: datagram senders that wait on
  the hop, then the move.
- "What the node does not yet meet": this branch's two lines stand in place
  of the two lines they rewrote, which main had left as they were; the line
  on an answer to a 169.254/16 asker, which #779 deleted with the defect, is
  gone, as is the line on a silent next hop, which #779 deleted with its
  test (no conflict).
- "What stage 3 departs from its specifications": both sides added a line at
  the list's end; both stand, this branch's on NUD-04 and #779's on the
  scenarios the readers' specification lacks.

Every other file merged without a conflict. toyos-dns/src/lib.rs and
userland/netstack/node/src/resolve.rs are byte-identical to b2d9037.

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Merge round: origin/main at 3fd6a2ac7 merged into the reviewed b2d903719; the head is c1dc7b176. Nothing of the reviewed work changed.

The two files. git diff b2d903719 c1dc7b176 -- toyos-dns/src/lib.rs userland/netstack/node/src/resolve.rs is empty.

The stage's twelve files, git diff b2d903719 c1dc7b176 --stat over them. Four differ, each by main alone:

 issues/toyos-has-its-own-network-stack.md |  18 +-
 toyos-net-ip/tests/nud.rs                 |  44 +++++
 userland/netstack/node/tests/resolve.rs   |   5 +-
 userland/netstack/src/main.rs             | 309 ++++--------------------------
 4 files changed, 95 insertions(+), 281 deletions(-)

git diff --stat origin/main c1dc7b176 is the stage's diff as reviewed, file for file: 12 files, +881 -112.

Measured at c1dc7b176, each the command's own exit:

count exit
cargo test --locked -p toyos-dns 62 0
cargo test --locked -p toyos-net-node 135: datagram 5, host 1, lease 19, listeners 28, name 13, resolve 32, slirp 3, streams 34 0
cargo test --locked -p toyos-net-ip 271 0
cargo test --locked -p toyos-net-udp 59 0
cargo test --locked -p toyos-net-shard 47 0
cargo test --locked -p toyos-net-testnet -p toyos-net-tcp -p toyos-mdns -p toyos-dhcp 475 0
cargo test --locked --manifest-path userland/netstack/Cargo.toml 29 0
cargo clippy on the five crates, --all-targets, ADOPTED, -D warnings 0
cargo run -- --ci host 78 steps, all green 0
cargo run -- --build-only 0

Zero failed and zero ignored in every suite above. What moved, by counting #[test] in each landing's diff:

The eight tests round 4's review names are each ok in the host log.

Mutations. Of m0 to m27 only m0's patch names a file the merge touched (userland/netstack/src/main.rs), so it is the one run again: the stage's five source files as on main, tests as here, cargo test --locked --no-run of the five crates, exit 101 by build (41 errors in toyos-dns' tests: on_unreached, on_report, Failure::Unreachable), reversed, tree clean. Its patch, made by git diff c1dc7b176 3fd6a2ac7 over those five files:

m0-whole-source-change-reverted-onto-main.patch
diff --git a/toyos-dns/src/lib.rs b/toyos-dns/src/lib.rs
index 1712abefd..1f46cdaa9 100644
--- a/toyos-dns/src/lib.rs
+++ b/toyos-dns/src/lib.rs
@@ -431,12 +431,8 @@ pub enum Failure {
     NoSuchName,
     /// The name exists and has no `A` record (RFC 2308 §2.2).
     NoAddress,
-    /// No server answered, and a query that may have reached one was given
-    /// its [`WAIT_MS`].
+    /// Every query went unanswered for [`WAIT_MS`].
     TimedOut,
-    /// No query reached a server ([`Lookup::on_unreached`]) and none is left
-    /// to send at once: there is no answer to wait for.
-    Unreachable,
     /// The answer did not fit a UDP reply (TC).
     Truncated,
     /// Every server that answered could not answer, the last with this RCODE.
@@ -482,49 +478,21 @@ struct Sent {
 /// any query this lookup sent for the name now asked is read, so an answer
 /// late past its query's wait still ends the lookup; it is read only on the
 /// query's own port, with the query's ID, from the server the query went to.
-///
-/// **A wait is for an answer that can come.** When the newest query is
-/// answered with a failure, or known or said not to have reached its server,
-/// the next server is asked at once: RFC 1035 §4.2.1's interval of two to
-/// five seconds is between repetitions of a query that may have been lost on
-/// its way, and the same section has the other servers tried first. **The
-/// interval is each server's**: one that has not answered is sent no second
-/// query inside [`WAIT_MS`] of its last, or of the caller's last knowledge
-/// that it was not reached, whatever is said of that query and whatever
-/// another server answers; a server is its address, however often the list
-/// names it. Where the next server cannot be asked yet, the lookup waits
-/// until it can while any answer is still read, and ends at once only with
-/// none.
-///
-/// **What the caller knows and what it was told are two calls.** A query the
-/// caller itself knows never reached its server ([`Lookup::on_unreached`]) is
-/// let go, and a lookup with no query left whose answer is read ends with
-/// [`Failure::Unreachable`]. A report from the network
-/// ([`Lookup::on_report`]) is anyone's word (RFC 8085 §5.2): it lets no query
-/// go and ends nothing, so the answer to a query reported so is still read.
 #[derive(Debug)]
 pub struct Lookup {
     /// The name now asked: the one given, or the last alias followed.
     name: Name,
     /// Aliases followed so far, counted against [`MAX_ALIASES`].
     aliases: usize,
-    /// Each server, and when it may be asked again: [`WAIT_MS`] after its
-    /// last query or after it was last known not to be reached, and at once
-    /// when it has answered any query, an older one's late failure included.
-    /// An address named twice is one server: every place it has carries the
-    /// same time.
-    servers: Vec<([u8; 4], u64)>,
+    servers: Vec<[u8; 4]>,
     /// Queries sent for `name`, oldest first; one answered with a server
-    /// failure, or known not to have reached its server, is taken out.
-    /// [`Lookup::waiting`] is this list, which holds a query while the
-    /// lookup lasts.
+    /// failure is taken out. [`Lookup::waiting`] is this list.
     sent: Vec<Sent>,
     /// The [`Asked`] the next query gets: none is given twice in a lookup.
     next: u32,
     /// Queries sent for `name`, answered or not.
     asked: usize,
-    /// When the newest query is given up on; or, where it is not waited for
-    /// and the next server may not be asked yet, that server's turn.
+    /// When the newest query is given up on.
     due: u64,
     /// The RCODE of the last server failure, which is what a lookup that runs
     /// out of queries reports if any server answered at all.
@@ -541,7 +509,7 @@ impl Lookup {
         let mut lookup = Self {
             name,
             aliases: 0,
-            servers: servers.iter().map(|server| (*server, now)).collect(),
+            servers: servers.to_vec(),
             sent: Vec::new(),
             next: 0,
             asked: 0,
@@ -563,41 +531,22 @@ impl Lookup {
         self.sent.iter().map(|s| s.asked)
     }
 
-    /// The next server's query, if it may be asked at `now`; or the wait for
-    /// the answers still read, until it may or the last query's wait is
-    /// over; or the end.
+    /// The next query for `name`, or the end where every one has been sent.
     fn ask(&mut self, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let turn = self.asked % self.servers.len();
-        let (to, free) = self.servers[turn];
-        let spent = self.asked == ROUNDS * self.servers.len();
-        if !spent && now >= free {
-            self.free(to, now + WAIT_MS);
-            self.asked += 1;
-            let asked = Asked(self.next);
-            self.next += 1;
-            let id = id();
-            self.sent.push(Sent { asked, id, to });
-            self.due = now + WAIT_MS;
-            return Step::Ask { asked, to, query: query(id, &self.name) };
-        }
-        if self.sent.is_empty() || (spent && now >= self.due) {
-            // With no query left, each was answered with a failure or never
-            // reached its server.
-            let silence = if self.sent.is_empty() { Failure::Unreachable } else { Failure::TimedOut };
-            return Step::Done(Err(self.failed.map_or(silence, Failure::ServerFailed)));
-        }
-        if !spent {
-            // The newest query is not waited for: the next server's turn is.
-            self.due = free;
-        }
-        Step::Wait
-    }
-
-    /// `server` may next be asked at `at`.
-    fn free(&mut self, server: [u8; 4], at: u64) {
-        for (_, free) in self.servers.iter_mut().filter(|(addr, _)| *addr == server) {
-            *free = at;
+        if self.asked == ROUNDS * self.servers.len() {
+            return Step::Done(Err(match self.failed {
+                Some(rcode) => Failure::ServerFailed(rcode),
+                None => Failure::TimedOut,
+            }));
         }
+        let to = self.servers[self.asked % self.servers.len()];
+        self.asked += 1;
+        let asked = Asked(self.next);
+        self.next += 1;
+        let id = id();
+        self.sent.push(Sent { asked, id, to });
+        self.due = now + WAIT_MS;
+        Step::Ask { asked, to, query: query(id, &self.name) }
     }
 
     /// The time is `now`: the newest query has had its [`WAIT_MS`] where it
@@ -609,33 +558,6 @@ impl Lookup {
         self.ask(now, id)
     }
 
-    /// The caller knows at `now`, of its own knowledge, that the query
-    /// `asked` did not reach its server: it was never sent. Its answer is
-    /// read no more. `id` draws the ID of the query this sends.
-    pub fn on_unreached(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        let Some(which) = self.sent.iter().position(|s| s.asked == asked) else {
-            return Step::Wait;
-        };
-        let unreached = self.sent.remove(which);
-        self.free(unreached.to, now + WAIT_MS);
-        // A newer query still waits for its own answer.
-        if which < self.sent.len() {
-            return Step::Wait;
-        }
-        self.ask(now, id)
-    }
-
-    /// The network said at `now` that the query `asked` did not reach its
-    /// server. Its answer is still read. `id` draws the ID of the query this
-    /// sends.
-    pub fn on_report(&mut self, asked: Asked, now: u64, id: impl FnOnce() -> u16) -> Step {
-        // A newer query still waits for its own answer.
-        if self.sent.last().is_none_or(|s| s.asked != asked) {
-            return Step::Wait;
-        }
-        self.ask(now, id)
-    }
-
     /// `msg` arrived at `now` from `port` of `from`, on the port `asked` was
     /// sent from. `id` draws the ID of the query this sends.
     pub fn on_datagram(
@@ -663,9 +585,6 @@ impl Lookup {
             Verdict::ServerFailed(rcode) => {
                 self.failed = Some(rcode);
                 self.sent.remove(which);
-                // It answered: its interval, which is for a query that may
-                // have been lost, is over.
-                self.free(from, now);
                 // The next server is asked now, unless a newer query is still
                 // waiting for its own answer.
                 if which == self.sent.len() {
@@ -686,9 +605,6 @@ impl Lookup {
                     self.sent.clear();
                     self.asked = 0;
                     self.failed = None;
-                    for (_, free) in &mut self.servers {
-                        *free = now;
-                    }
                     self.ask(now, id)
                 }
             },
diff --git a/toyos-net-ip/src/lib.rs b/toyos-net-ip/src/lib.rs
index 59be88ccf..9cbee3f78 100644
--- a/toyos-net-ip/src/lib.rs
+++ b/toyos-net-ip/src/lib.rs
@@ -108,7 +108,7 @@ pub mod limits {
         pub const FAILED_HOLD: Duration = Duration::from_secs(20);
         pub const LOCKTIME: Duration = Duration::from_secs(1);
         pub const IDLE_LIFETIME: Duration = Duration::from_secs(600);
-        pub const PENDING_PER_NEIGHBOUR: usize = 16;
+        pub const PENDING_PER_NEIGHBOUR: usize = 8;
         pub const PENDING_TOTAL: usize = 64;
         pub const TABLE_MAX: usize = 512;
     }
diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs
index f94fecbc8..a5fa0f73a 100644
--- a/toyos-net-udp/src/lib.rs
+++ b/toyos-net-udp/src/lib.rs
@@ -113,17 +113,11 @@ pub enum Binding {
     Connected { local: Ipv4Addr, peer: Ipv4Addr, peer_port: Port },
 }
 
-/// An error against a connected socket's datagrams: its next call returns it once. Two are what
-/// an ICMP message said, which anyone who knows the socket's addresses and ports can send; one
-/// is [ip]'s own.
+/// An error the network reported against a connected socket: its next call returns it once.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum SocketError {
-    /// An ICMP error said the peer refuses the protocol or the port.
     Refused,
-    /// An ICMP error said the way to the peer is administratively prohibited.
-    Prohibited,
-    /// [ip] found no link address for the next hop of a datagram it held: nothing left.
-    NextHopFailed,
+    Unreachable,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -665,7 +659,7 @@ impl Udp {
         };
         let pending = match class {
             ErrorClass::Refused => SocketError::Refused,
-            ErrorClass::Prohibited => SocketError::Prohibited,
+            ErrorClass::Prohibited => SocketError::Unreachable,
             ErrorClass::PathMtu | ErrorClass::Soft => return self.count(Counter::IcmpErrorSoft),
         };
         socket.pending = Some(pending);
@@ -675,7 +669,7 @@ impl Udp {
     /// [ip] could not reach the next hop of a datagram this crate handed it.
     pub fn unreachable(&mut self, flow: &Flow) {
         if let Some(socket) = self.connected(flow) {
-            socket.pending = Some(SocketError::NextHopFailed);
+            socket.pending = Some(SocketError::Unreachable);
         }
     }
 
diff --git a/userland/netstack/node/src/resolve.rs b/userland/netstack/node/src/resolve.rs
index 1dcef864a..d8a5cfc23 100644
--- a/userland/netstack/node/src/resolve.rs
+++ b/userland/netstack/node/src/resolve.rs
@@ -10,21 +10,9 @@
 //!   query other than the one whose socket it reached.
 //! - **A socket lives as long as its query's answer is read**: it is closed when the lookup lets
 //!   the query go, ends, or is let go itself, and its port is free from then.
-//! - **A query the node knows did not reach its resolver is let go**
-//!   (`toyos_dns::Lookup::on_unreached`): the lookup asks its next resolver at once, or ends
-//!   where no query's answer is read any more. The node knows it of a query [udp] refuses in the
-//!   call, which never left, is counted and holds no socket; and of one [ip] reports it found no
-//!   next hop for, which is counted and its socket closed.
-//! - **An ICMP error is a report and not knowledge** (`toyos_dns::Lookup::on_report`): [udp]
-//!   hands a query's socket one that quotes the socket's addresses and ports and says refused or
-//!   prohibited, which takes an off-path sender the query's port to forge and not its id (RFC
-//!   8085 §5.2). It is counted, and the next resolver is asked at once; the query's socket stays
-//!   open and its answer is read, and no number of them ends a lookup.
-//! - **Every lookup's first query can wait in [ip] for one next hop at once**, where no link
-//!   address is known yet: [ip] holds `PENDING_PER_NEIGHBOUR` datagrams for a next hop it is
-//!   resolving and keeps the newest (RFC 4861 §7.2.2), which is no fewer than the lookups held
-//!   here. A datagram another socket sends to that next hop meanwhile takes a place in the same
-//!   queue.
+//! - **A query [udp] refuses never left**: it is counted, holds no socket, and the lookup waits
+//!   its wait out as for any query nobody answered. So does one the network reports back, its
+//!   resolver unreachable or its port refused: counted, and waited out.
 //! - **A lookup asks the resolvers of the lease it started under, and ends with that lease's
 //!   word**: when the held lease names other resolvers, or none is held.
 //! - **A wait is a deadline of the node's** ([`Resolver::next_deadline`]); a reply is a frame.
@@ -39,16 +27,13 @@ use alloc::vec::Vec;
 use core::net::Ipv4Addr;
 
 use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT};
-use toyos_net_ip::limits::nud::PENDING_PER_NEIGHBOUR;
-use toyos_net_udp::{Error, SocketError, SocketId};
+use toyos_net_udp::{Error, SocketId};
 use toyos_net_wire::Instant;
 
 use crate::lease::Stack;
 use crate::name::millis;
 use crate::{Counter, Counters, Node};
 
-const _: () = assert!(PENDING_PER_NEIGHBOUR >= MAX_LOOKUPS);
-
 /// One lookup, from [`Node::resolve`] until its answer is taken or it is let go.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct LookupId(u64);
@@ -120,51 +105,42 @@ fn close(stack: &mut Stack, now: Instant, socket: SocketId) {
     }
 }
 
-/// What waits at a query's socket.
-enum Heard {
-    /// A datagram of this length, now in the reply buffer.
-    Reply(usize),
-    /// [ip]'s word that the query never left.
-    Unreached,
-    /// An ICMP error's word that the query was refused.
-    Reported,
-}
-
-/// The first of `queries` with something at its socket, and what: a reply is now in `reply`.
-fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8]) -> Option<(Query, Heard)> {
+/// The length of the next reply waiting at one of `queries`' sockets, now in `reply`, and the
+/// query it is for. An error the network reported against a query is counted and read past.
+fn waiting(queries: &[Query], stack: &mut Stack, reply: &mut [u8], counters: &mut Counters) -> Option<(Query, usize)> {
     for query in queries {
-        match stack.recv_from(query.socket, reply) {
-            Ok(Some(received)) => return Some((*query, Heard::Reply(received.len))),
-            Ok(None) => {}
-            Err(Error::Failed(SocketError::NextHopFailed)) => return Some((*query, Heard::Unreached)),
-            Err(Error::Failed(SocketError::Refused | SocketError::Prohibited)) => return Some((*query, Heard::Reported)),
-            Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+        loop {
+            match stack.recv_from(query.socket, reply) {
+                Ok(Some(received)) => return Some((*query, received.len)),
+                Ok(None) => break,
+                Err(Error::Failed(_)) => counters.add(Counter::QueryFailed, 1),
+                Err(Error::NoSuchSocket | Error::Refused(_)) => unreachable!("a query's socket is open while it is listed, and no rule refuses a receive"),
+            }
         }
     }
     None
 }
 
-/// Carries out `step` for `asking`, and every step the lookup answers a query [udp] refused
-/// with, then closes the socket of every query the lookup no longer reads an answer for. Returns
-/// how the lookup ended, if it did.
-fn act(asking: &mut Asking, mut step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
-    let done = loop {
-        match step {
-            Step::Ask { asked, to, query } => {
-                // An any-address bind that names no port is refused only for want of a free one.
-                let Ok((socket, _)) = stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) else { break Some(Err(Ended::NoPort)) };
+/// Carries out `step` for `asking`, then closes the socket of every query the lookup no longer
+/// reads an answer for. Returns how the lookup ended, if it did.
+fn act(asking: &mut Asking, step: Step, now: Instant, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Option<Result<Vec<[u8; 4]>, Ended>> {
+    let done = match step {
+        // An any-address bind that names no port is refused only for want of a free one.
+        Step::Ask { asked, to, query } => match stack.bind(Ipv4Addr::UNSPECIFIED, None, &mut *draw) {
+            Ok((socket, _)) => {
                 let resolver = Ipv4Addr::from(to);
                 if stack.connect(now, socket, resolver, PORT).is_ok() && stack.send_to(now, socket, resolver, PORT, &query).is_ok() {
                     asking.queries.push(Query { asked, socket, to });
-                    break None;
+                } else {
+                    counters.add(Counter::QueryUnsent, 1);
+                    close(stack, now, socket);
                 }
-                counters.add(Counter::QueryUnsent, 1);
-                close(stack, now, socket);
-                step = asking.lookup.on_unreached(asked, millis(now), || id(&mut *draw));
+                None
             }
-            Step::Wait => break None,
-            Step::Done(result) => break Some(result.map_err(Ended::Failed)),
-        }
+            Err(_) => Some(Err(Ended::NoPort)),
+        },
+        Step::Wait => None,
+        Step::Done(result) => Some(result.map_err(Ended::Failed)),
     };
     let lookup = &asking.lookup;
     asking.queries.retain(|query| {
@@ -198,19 +174,16 @@ impl Resolver {
         let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect();
         let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") };
         let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() };
-        let done = act(&mut asking, step, now, stack, counters, &mut *draw);
-        if done == Some(Err(Ended::NoPort)) {
-            return Err(NotStarted::ResourceExhausted);
-        }
-        self.next = self.next.wrapping_add(1);
-        let started = asking.id;
-        match done {
-            None => self.asking.push(asking),
-            // No resolver could be sent a query: the lookup has its id and its end at once, and
-            // holds no socket.
-            Some(result) => self.ended.push(Resolved { id: started, result }),
+        match act(&mut asking, step, now, stack, counters, &mut *draw) {
+            None => {
+                self.next = self.next.wrapping_add(1);
+                let started = asking.id;
+                self.asking.push(asking);
+                Ok(started)
+            }
+            Some(Err(Ended::NoPort)) => Err(NotStarted::ResourceExhausted),
+            Some(other) => unreachable!("a lookup's first step is a query, not {other:?}"),
         }
-        Ok(started)
     }
 
     /// Ends every lookup whose lease went or names other resolvers, reads every reply that
@@ -223,20 +196,11 @@ impl Resolver {
             let named = stack.lease().is_some_and(|lease| lease.dns == asking.resolvers);
             let mut done = if named { None } else { Some(Err(Ended::LeaseChanged)) };
             while done.is_none() {
-                let Some((query, heard)) = waiting(&asking.queries, stack, reply.as_mut_slice()) else { break };
-                let step = match heard {
-                    // The socket is connected: [udp] delivered this from port 53 of the resolver
-                    // the query went to, or not at all.
-                    Heard::Reply(len) => asking.lookup.on_datagram(query.asked, query.to, PORT, reply.get(..len).unwrap_or_default(), ms, || id(&mut *draw)),
-                    Heard::Unreached => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_unreached(query.asked, ms, || id(&mut *draw))
-                    }
-                    Heard::Reported => {
-                        counters.add(Counter::QueryFailed, 1);
-                        asking.lookup.on_report(query.asked, ms, || id(&mut *draw))
-                    }
-                };
+                let Some((query, len)) = waiting(&asking.queries, stack, reply.as_mut_slice(), counters) else { break };
+                let message = reply.get(..len).unwrap_or_default();
+                // The socket is connected: [udp] delivered this from port 53 of the resolver the
+                // query went to, or not at all.
+                let step = asking.lookup.on_datagram(query.asked, query.to, PORT, message, ms, || id(&mut *draw));
                 done = act(asking, step, now, stack, counters, &mut *draw);
             }
             if done.is_none() {
@@ -267,11 +231,9 @@ impl Resolver {
 }
 
 impl Node {
-    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws a
-    /// query, its id and then its port: for the first query, and for each asked in its place
-    /// because [udp] refused the one before. A call refused for want of a port has spent them;
-    /// any other refused call spends none. A lookup no resolver could be sent a query of has
-    /// started and ended: its end is in the next [`Self::take_resolved`].
+    /// Starts looking `name` up at the resolvers the held lease names, which spends two draws:
+    /// the first query's id, then its port. A call refused for want of a port has spent both;
+    /// any other refused call spends none.
     pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result<LookupId, NotStarted> {
         self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw)
     }
diff --git a/userland/netstack/src/main.rs b/userland/netstack/src/main.rs
index 3125aa5c1..76e8c7fcc 100644
--- a/userland/netstack/src/main.rs
+++ b/userland/netstack/src/main.rs
@@ -1295,9 +1295,6 @@ impl Netstack {
                 // whether the name or only its address is missing.
                 Err(Ended::Failed(Failure::NoSuchName | Failure::NoAddress)) => answer_lookup(&client, &[]),
                 Err(Ended::Failed(Failure::TimedOut)) => client.error(ERR_TIMED_OUT),
-                Err(Ended::Failed(Failure::Unreachable)) => {
-                    unreachable!("netstack: no lookup here is told a query did not reach its server, and {name}'s ended so")
-                }
                 Err(Ended::Failed(why @ (Failure::Truncated | Failure::ServerFailed(_) | Failure::TooManyAliases))) => {
                     say!("netstack: a lookup of {name} ended without an answer: {why:?}");
                     client.error(ERR_OTHER);

No guest test was run: the merge changed no line this stage ships. The pull request stays a draft, so guest / suite at this head is still owed, as round 4's review says.

One conflict, the track's stage-5 paragraph: this branch named datagram
senders that wait on the hop as still to build, #783 named the datagram
sockets' broadcast permission. Both stand, in that order, before the
move. The clause that ordered the first after #777 is met and is
restated as where the work lies.

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

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Merge round 2: origin/main at 0d6cd9a60 (#778, #785, #783) merged into c1dc7b176, head 8330a5c75.

Conflict hunks: one, in issues/toyos-has-its-own-network-stack.md, the last sentence of the stage-5 paragraph. Everything before "Still to build on it:" was the same on both sides.

Both slices stand, since neither is in the merged tree: the node has no call that takes the permission (#783's own line says so), and the two residual lines of this branch still name the hop slice as their owner. "after #777, which opens …" is met, #777 having landed, and is restated as where the work lies; no #777 is left in the track.

Every other hunk of the track merged without a conflict: main's two lines (the stream its peer reset, and the broadcast line #783 rewrote) and this branch's four (the two resolver residuals and the NUD-04 departure) are each byte for byte their side's.

Nothing reviewed moved. git diff b2d903719 8330a5c75 -- toyos-dns/src/lib.rs userland/netstack/node/src/resolve.rs is empty. Of the stage's twelve files, git diff --name-only c1dc7b176 8330a5c75 names the track and userland/netstack/src/main.rs; the latter's changed lines are exactly those of git diff 3fd6a2ac7 0d6cd9a60 on that file (#783, +6 -8).

Gates at 8330a5c75: cargo run -- --build-only exit 0; cargo run -- --ci host exit 0 (Host: 78 step(s), all green); cargo test --locked -p toyos-dns exit 0, 62 passed; cargo test --locked -p toyos-net-node --test resolve exit 0, 32 passed. No guest test, nothing on metal. The body's other gates are of c1dc7b176 and are labelled so.

@Japabu
Japabu marked this pull request as ready for review October 9, 2026 03:52
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 8330a5c75, read from each job's own log (the orchestrator). host: [ci] Host: 79 step(s), all green; toyos-dns's 62 and the node's resolve 32 each 0 failed (the [udp] and shard totals of 59 and 47 are sums over several test binaries and are not single lines in the log; every binary's result is ok). guest / suite: test result: ok. 36 passed, 36 total, [ci] Guest: 5 step(s), all green, no FAIL line. The two merges of main left toyos-dns/src/lib.rs and the node's resolve.rs byte-identical to the reviewed b2d903719; git merge-tree against main at 0d6cd9a60 exits 0 with no conflict.

@Japabu
Japabu added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 44871be Oct 9, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-move-resolver branch October 9, 2026 04:32
Japabu added a commit that referenced this pull request Oct 9, 2026
…stage

One conflict, in the track. Both sides rewrote the paragraph that says
what is still to build on the node: #781's sentence stands less the
broadcast permission, which this branch builds, so what is left is the
datagram senders that wait on the hop, and then the move. Both sides
rewrote the lines after the name's: this branch's line on the name
stands, and #781's two lines replace the two on a query that never
reached its resolver and on a burst at an unresolved resolver, whose
exits #781 met. #781's departure line on NUD-04 and this branch's on the
carried permission both stand.

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