Skip to content

net: a stream's local address is netstack's, not QEMU's 10.0.2.15 - #1147

Merged
Japabu merged 3 commits into
mainfrom
local-addr
Nov 2, 2026
Merged

Japabu merged 3 commits into
mainfrom
local-addr

Conversation

@Japabu

@Japabu Japabu commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Closes issues/getsockname-answers-an-address-nobody-asked-netstack-for.md and issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md.

Problem

std's TcpStream::local_addr and libc's getsockname returned QEMU's user-network address, 10.0.2.15, with the socket's port, whatever the machine leased. A connected datagram socket returned its bind's 0.0.0.0. Since #1122, osbench names a connection's link by its local address, so on the T14 osbench_lan refused its whole group (no netstack link holds 10.0.2.15; recorded in #1143), and the ramp issue's parity session could not measure.

Change

Streams

  • netstack's own wire types carry the address the stream left from, beside its port: TcpConnectResponse, TcpAcceptResponse and the stream Adopt.
  • netstack fills it from the connection's tuple. The node's StreamEvent::Connected, Accepted and export give a local Endpoint.
  • The SDK accepts a stream end only if it is a unicast address and a port (net::unicast: not unspecified, multicast or broadcast, by Ipv4Addr's own predicates).
    • A connect or accept answer naming another is refused as NetError::Protocol.
    • An ADOPT naming another is treated as malformed.
  • stream::endpoints returns (peer, local) as stream::Endpoints. Connected and Accepted drop their unread local_port.

Connected datagram sockets

  • [udp] already fixes the source address at connect. The shard's udp_peer now returns it in a UdpPeer, and netstack's PEER answer (socket::Peer) carries it.
  • socket::local asks PEER for a datagram socket. So every holder sees the address the route sends from, as on Linux, whichever holder connected. An answer naming no host address is refused.
  • Why PEER and not CONNECT: a socket's connection is shared by all its holders, but only the connecting holder reads a CONNECT answer.

std and libc

  • std's TcpStream::local_addr and UdpSocket::local_addr, and libc's getsockname, return these addresses.
  • An unconnected socket or a listener returns its bound address. A socket neither bound nor connected answers 0.0.0.0:0.

Elsewhere

  • The guest jobs that play netstack answer with a TEST-NET-1 local address.
  • The literal is gone from sdk/. The copies left are in rust/ (upstream tooling) and in tests and log samples that describe QEMU's network.

Tests

  • Node host tests: connect, accept and export assert the lease's address. A connected datagram socket's udp_peer asserts it too.

  • SDK host tests:

    • a_streams_own_end_is_a_unicast_address_and_a_port;
    • a_peers_answer_ends_in_the_address_it_is_sent_from;
    • the accept answer's layout test.
  • stream_handles (guest, the test plays netstack):

    • a connect answered as from 192.0.2.1:49152 reads that through std's local_addr;
    • a connect answered with 0.0.0.0 is refused.

    This covers the check at the call site, not just the predicate.

  • link_failover (guest, QEMU x86-64, two user networks): a stream and a connected datagram socket to the host on each network each report that link's lease as their local address.

    • The two leases differ, so one is not 10.0.2.15, and the value is netstack's whichever order the links came up in.
    • It extends traffic_leaves_by_the_second_link_while_the_first_is_down, which already boots two NICs, so it costs no extra boot.

Checks

  • Rebased onto main 058bea2 (carrying buildlock: one polled wait with a ceiling, and a would-be maker tries #1145's sysroot-lock fix); head a7c1b31.
  • cargo run -- --ci host at a7c1b31: 109 steps green, exit 0. The first attempt died of a full host disk and was rerun, not judged.
  • Guest tests at a7c1b31, the four together in one run, exit 0: stream_holders, traffic_leaves_by_the_second_link_while_the_first_is_down (local addresses 10.0.2.15 and 10.0.3.15 for each kind), netstack_holders_exported_to_children, libc_children. The earlier hang of this combination was the sysroot-lock defect buildlock: one polled wait with a ceiling, and a would-be maker tries #1145 fixed.
  • Negative controls, each a patch applied, run and reverted in the same run:
    • Round 0 (at 9f4d00502 plus the edits that became 9070b61):

      • std's stream literal restored: the guest test fails;
      • the node answering the literal: the node tests fail (exit 101);
      • the SDK accepting 0.0.0.0: the SDK test fails (exit 101).

      The test rename, the registration comment and the harness's extra accept that followed don't affect them.

    • Review round 1 (at 7a601f4):

      • the shard answering an unspecified UDP local address: the node test fails (exit 101);
      • socket::local ignoring the connect: the failover test fails ("sends from 0.0.0.0");
      • open discarding its local-end check: stream_holders fails ("connected from 0.0.0.0").

The T14 parity session (closes the ramp issue)

  • The session ran on 9070b61, whose TCP upload path is unchanged since: Linux (L1), ToyOS (t1), Linux (L2), all rc 0. It was judged by judge.py under the rule set before the run.
  • p50: ToyOS 114 MB/s against Linux's 113 and 113.
  • Loss: nothing resent (no RTO, recovery or loss probe).
  • 100 ms: ToyOS's rounds 6.31–9.93 MB against Linux's 6.24–6.68 MB; the one-sided rank test gives p = 0.22.
  • 300 ms: 29.3–33.2 MB against 28.3–30.0 MB; p = 0.78.
  • Linux's two runs agree: p = 0.55 and 1.00.
  • Verdict: parity.
  • One outlier: ToyOS's first round read 9.93 MB by 100 ms. Its cause is unknown. Without it the 100 ms test gives p = 0.053, still a pass but close to the line.

Evidence: r1, r2.

Risks

  • A stream's local address is fixed when it is established, and a lease change ends it (ADDRESS_GONE, netstack runs every link a driver brings: ranked, with failover and return (P5) #1122). A connected datagram socket's address is fixed at its connect, so after a lease change it reports the address it was connected from until it connects again.
  • A local_addr on a datagram socket is now an IPC round trip to netstack, as peer_addr already is.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1147 at 9070b61

Architecture

The ownership is right. [tcp]'s tuple owns a connection's local address. netstack puts it in its own wire types (TcpConnectResponse, TcpAcceptResponse, stream::Adopt), all in sdk/toyos/src/net*, so no kernel ABI is touched. The SDK checks it once when it builds the End. std and libc read it from there. No second mechanism is added and the literal is deleted, not hidden behind a fallback. Net change: about +60 production lines (most in stream.rs), +40 test lines, and the issue removed. That size fits the change.

What I checked and found sound:

  • Wire format. ipc_payload! refuses implicit padding at compile time. Each new layout (8, 16 and 16 bytes) is padding-free and fits MAX_ANSWER (64). decode_payload requires the exact length, so a short or long answer from an old or hostile netstack is Malformed (hangup). Every constructor and consumer of the three structs is updated; the two guest fakes are the only off-netstack constructors.
  • Hostile netstack. No new expect, unwrap or panic. A bad local end on connect or accept becomes NetError::Protocol. On ADOPT the end becomes the Aborted stream that adopted already documents. On a refusal in open, the region, bells and connection drop and the slot is unreserved, so nothing leaks and netstack sees a hangup.
  • IPv6. None on this path. Endpoint is IPv4-only, so octets() into [u8; 4] checks at compile time; std's V6 refusal is unchanged.
  • POSIX and Linux behaviour.
    • A connected or accepted stream returns its specific address. A stream accepted from a listener on 0.0.0.0 gets the SYN's destination, as on Linux.
    • A listener or an unconnected UDP socket returns its bound address (0.0.0.0:port for a wildcard), as on Linux.
    • Sock::Entry really is "neither bound nor connected": libc's TCP bind makes a listener at once, and its connect blocks. So 0.0.0.0:0 matches Linux.
    • Failed connect: libc leaves the entry, so the answer is 0.0.0.0:0. POSIX leaves this unspecified, so it is acceptable.
    • Connected UDP is the exception; see B1.
  • Literal. git grep outside rust/ finds 10.0.2.15 only in the slirp model and its sample log lines (node/tests/slirp.rs, build/sourcegate.rs, lib/logstream), in issue records and in one test comment. No code path answers it. The PR's account is accurate.
  • Guest test design.
    • Both links are now checked, and assert_ne!(networks[0], networks[1]) guards the premise. So the std literal fails one of the two in either link order, and a netstack that answered one lease for both links fails too.
    • The extra stream rides an existing boot.
    • The harness's extra accept is ordered correctly: connect returns only after slirp's host-side connect.
    • The control log (controls-guest-2.txt) shows the fix passing with 10.0.3.15 and 10.0.2.15, and the literal failing on link 0 (exit 1).
  • Evidence revisions. The controls and the failover run were at 9f4d00502 plus the uncommitted edits. From there to head, only a test rename, the registration comment, the harness accept and the guest fakes changed. The fakes were then run again (guest-fakes.log) and the ADOPT path covered (guest-export.log). Reuse is justified.

Findings

BLOCKER 1 — issues/getsockname-answers-an-address-nobody-asked-netstack-for.md (deleted), sdk/libc/src/socket.rs:833, sdk/std/sys/net/connection.rs:458: the issue is closed with a part of its defect unfixed and unrecorded.

  • The issue said every program that asks what address it is bound to is told the wrong one. After this PR, a connected UDP socket still answers its bind's address, 0.0.0.0:port, from socket::local.
  • Linux answers the route's source address there: ip4_datagram_connect sets the socket's saddr from the route.
  • So connect(udp, 8.8.8.8:53) followed by getsockname (or std's UdpSocket::local_addr) gives 0.0.0.0 on ToyOS. That is the usual way a program finds its own address.
  • In QEMU, libc's literal happened to give the right answer for this case. Now it gives none.
  • Stream scope was the agreed fix, so implementing UDP is not required here. Either have netstack's datagram CONNECT answer carry the source, or keep the issue narrowed to connected datagram sockets (std and libc), rather than deleting it. The cheap option is a few lines in the issue.

BLOCKER 2 — issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md:355: "std's TcpStream::local_addr answers QEMU's user-network address whatever the lease is" is false in the present tense as of this PR, in the paragraph the PR edits. Use "answered".

BLOCKER 3 — system/netstack/node/tests/streams.rs:420: delete the comment "The address the stream left from is the node's lease, as the SYN carried it." Far and Segment record no source IP (Segment has to, not from), so nothing checks "as the SYN carried it". The rest repeats the next line.

SUGGESTION 1 — sdk/toyos/src/net/stream.rs:181-183: two optional cleanups.

  • addr[0] < 224 also refuses 240/4, which the doc doesn't mention. let ip = core::net::Ipv4Addr::from(addr); !ip.is_unspecified() && !ip.is_multicast() && !ip.is_broadcast() would match the doc word for word. Alternatively, say "below 224".
  • EndAddr is used in two signatures, while End::local, local_end, Answered::local and Held::end still spell ([u8; 4], u16). Use it throughout or drop it. EndAddr beside the struct End also reads ambiguously; Endpoint would be clearer.

SUGGESTION 2 — tests/guest/rust-tests/src/bin/link_failover.rs:115: assert_ne!(local.port(), 0, …) checks something the SDK already guarantees: local_end refuses port 0 before an end exists. It can go.

SUGGESTION 3 — No test fails if the local_end call in open or adoption is removed. The unit test covers the predicate only. If the refusal is worth holding, the cheapest check is a stream_handles fake case that answers local_addr: [0; 4] and expects the connect refused. As it stands it is a fence that inspection covers. Optional.

SUGGESTION 4 (PR description, orchestrator's) — "Checks (head 9070b61)" covers controls and a failover run made at 9f4d00502 plus uncommitted edits. State that revision and why the later changes don't invalidate it, as the handoff does for host CI.

SEND BACK

Japabu added a commit that referenced this pull request Nov 2, 2026
…tstack's too

A connected datagram socket answered its bind's address, 0.0.0.0 for a
wildcard, where Linux answers the address its connect's route sends from.
[udp] already fixes that address at connect (Binding::Connected's local).
The shard's udp_peer now gives it as UdpPeer, and netstack's PEER answer
(socket::Peer) carries it beside the peer. The SDK's socket::local asks
PEER for a datagram socket, so every holder sees the address whichever
holder connected; an answer naming no host address is refused. std's
UdpSocket::local_addr and libc's getsockname return it.

The PEER answer carries it rather than CONNECT's: a socket's connection
is every holder's, and only the connecting holder reads a CONNECT answer.

- The stream end's check and the datagram one share net::unicast, which
  says what its doc says through Ipv4Addr's own predicates.
- stream::Endpoint names an address and port throughout the stream end.
- stream_handles answers a connect as from 192.0.2.1 and asserts std's
  local_addr reads it, and answers one with the unspecified address and
  asserts the connect is refused, so the check at open is held.
- link_failover also connects a datagram socket over each link and
  asserts its local address is that link's lease. The stream's port-0
  assert is gone: the SDK refuses port 0 before an end exists.
- The ramp issue's account of std is in the past tense, and a node test
  comment that claimed what nothing checks is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 3 commits November 2, 2026 01:24
std's TcpStream::local_addr and libc's getsockname answered QEMU's user
network address with the stream's port, whatever the machine leased. Since
#1122 osbench names a connection's link by that address, so on the T14
osbench_lan refused its group: "no netstack link holds 10.0.2.15".

netstack's connect and accept answers and its ADOPT now carry the address
the stream left from with its port: the node's StreamEvent::Connected,
Accepted and export give the connection's local Endpoint, the tuple [tcp]
holds. The SDK takes it only as a stream's end can be, a unicast address
and a port; a connect or accept answer naming another is refused as a
protocol error, and an ADOPT naming one as malformed. stream::endpoints
returns it, std's local_addr and libc's getsockname answer it, and
getsockname answers a listener's or datagram socket's bound address rather
than the literal. Connected and Accepted lose their unread local_port. The
guest jobs that play netstack (stream_handles, libc_recv_beside_close) name
a TEST-NET-1 local address in their answers.

The node's host tests assert the lease's address on connect, accept and
export. link_failover opens a stream to the host on each of its two QEMU
user networks and asserts each one's local address is its link's lease.
The two leases differ, so one is not 10.0.2.15 and the value is netstack's:
with std's literal restored the job fails, whichever order the links came
up in.

issues/getsockname-answers-an-address-nobody-asked-netstack-for.md is
closed, and its references go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tstack's too

A connected datagram socket answered its bind's address, 0.0.0.0 for a
wildcard, where Linux answers the address its connect's route sends from.
[udp] already fixes that address at connect (Binding::Connected's local).
The shard's udp_peer now gives it as UdpPeer, and netstack's PEER answer
(socket::Peer) carries it beside the peer. The SDK's socket::local asks
PEER for a datagram socket, so every holder sees the address whichever
holder connected; an answer naming no host address is refused. std's
UdpSocket::local_addr and libc's getsockname return it.

The PEER answer carries it rather than CONNECT's: a socket's connection
is every holder's, and only the connecting holder reads a CONNECT answer.

- The stream end's check and the datagram one share net::unicast, which
  says what its doc says through Ipv4Addr's own predicates.
- stream::Endpoint names an address and port throughout the stream end.
- stream_handles answers a connect as from 192.0.2.1 and asserts std's
  local_addr reads it, and answers one with the unspecified address and
  asserts the connect is refused, so the check at open is held.
- link_failover also connects a datagram socket over each link and
  asserts its local address is that link's lease. The stream's port-0
  assert is gone: the SDK refuses port 0 before an end exists.
- The ramp issue's account of std is in the past tense, and a node test
  comment that claimed what nothing checks is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… closes

The session the issue's exit names ran on 9070b61 (this PR's local
address fix, which let osbench_lan measure on the T14's lease): Linux,
ToyOS, Linux, all rc 0. judge.py applied the rule set before the run:

- p50: ToyOS 114 MB/s, Linux 113 and 113.
- Nothing resent: no RTO, recovery or loss probe.
- 100 ms: ToyOS's five rounds 6.31-9.93 MB (mean 7.11) against Linux's
  ten 6.24-6.68 MB; the one-sided rank test that ToyOS ranks low gives
  p = 0.22.
- 300 ms: 29.3-33.2 MB against 28.3-30.0 MB; p = 0.78.
- Linux's two runs agree: p = 0.55 and 1.00, so the rounds compare.

Verdict: parity. ToyOS's first round read 9.93 MB by 100 ms, far above
every other round on either system; its cause is not known. Without it
the 100 ms test gives p = 0.053, still a pass, so the verdict does not
rest on it.

Evidence: https://github.com/ToyOSOrg/evidence/tree/1c9f4c83c362a1b30746f1e096ad412a2d5f25f9/r2/session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu marked this pull request as ready for review November 2, 2026 02:13
@Japabu
Japabu added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit aca2a3c Nov 2, 2026
10 checks passed
@Japabu
Japabu deleted the local-addr branch November 2, 2026 02:50
Japabu added a commit that referenced this pull request Nov 3, 2026
…ade it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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