Skip to content

sockets: format every kernel address from its sockaddr, so a link-local address keeps its zone - #47403

Draft
robobun wants to merge 2 commits into
claude/http-consolidatedfrom
robobun/70745f0e/dgram-link-local-zone
Draft

robobun wants to merge 2 commits into
claude/http-consolidatedfrom
robobun/70745f0e/dgram-link-local-zone

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, stacked on #46528.

Problem

  • An address getter prints a link-local address without its zone: fe80::1, where Node prints fe80::1%eth0. The printed address does not connect again (Failed to connect socket).
  • Seven usockets getters (for example us_udp_socket_bound_ip, packages/bun-usockets/src/udp.c:87) return only the IP bytes of a kernel address. No formatter sees sin6_scope_id.

Fix

  • Six getters return the whole sockaddr_storage. One formatter (src/uws_sys/SocketAddress.rs) prints it by Node's rule: a zone only for fe80::/10 with a scope id.
  • Commit 1: the node:dgram, node:net, node:http, node:http2 and ws getters print the zone. No Bun-native string changes.
  • Commit 2: the Bun.* getters too. A maintainer can drop it.
  • Verified: test/js/node/net/node-address-zone.test.ts (passes in Node v26.3.0 too) and new tests in eight files. Self-reviewed: 23 concerns raised, 18 addressed, 2 in part.

Background

  • A link-local address is unique only on one interface. The zone (%eth0, an index on Windows) names it. The kernel keeps it in sin6_scope_id.
  • A comparison with Node found this. No user reported it.
  • Considered a scope id out-parameter beside the IP bytes: the lossy getters stay, and 9 call sites join the two by hand.

Downsides

  • Commit 2 changes Bun-native strings: a link-local address from a Bun.* getter gains %zone, which a URL cannot hold.
  • node:dgram remoteAddress() and node:http server.address() ask the kernel on each call, as Node does: +1,600 instructions and 2 heap cells.
  • A link-local address costs an interface lookup per print: requestIP() takes 2,358 ns for such a peer, 345 ns before. Not measured on macOS.
Notes

Order

Reproduction (a host with a link-local address on eth0)

const dgram = require("node:dgram");
const server = dgram.createSocket("udp6");
server.bind(0, "fe80::400:acff:fe1e:2%eth0", () => {
  const { address, port } = server.address();
  console.log(address);
  const client = dgram.createSocket("udp6");
  client.connect(port, address, err => {
    console.log(err?.message ?? client.remoteAddress().address);
    client.close();
    server.close();
  });
});
line 1 line 2
claude/http-consolidated (edb9ce8), and main fe80::400:acff:fe1e:2 Failed to connect socket
this PR fe80::400:acff:fe1e:2%eth0 fe80::400:acff:fe1e:2%eth0
Node v26.3.0 fe80::400:acff:fe1e:2%eth0 fe80::400:acff:fe1e:2%eth0

The rule is Node's AddressToJS (tcp_wrap.cc L520-L596): the inet_ntop() text, then % and the interface only when the address is link-local and sin6_scope_id > 0. A multicast, loopback or site-local address gets no zone. net.SocketAddress#address and os.networkInterfaces() print no zone in Node, and this change does not touch them.

Where Bun is not as Node, on purpose

  • An interface with no name is printed by its index (fe80::1%7). Node throws uv_if_indextoiid there (tcp_wrap.cc L545-L550). The lookup opens a descriptor, so it also fails at the descriptor limit, and net.Server reads the address of a peer before any code of the program runs. No getter throws after this change.
  • The name of an interface is read as UTF-8, as os.networkInterfaces() reads it. Node reads Latin-1 (tcp_wrap.cc L555), and such a zone names no interface when a socket parses it again.
  • A blockList of a net.Server checks a peer without its zone. net.isIP() accepts only [0-9a-zA-Z-.:] in a zone, so Node v26 does not check a peer on an interface such as docker_gwbridge (net.js L2338-L2346).
  • session.originSet of an HTTP/2 session has the address without its zone. Node throws ERR_INVALID_URL for such a peer (core.js L700-L712).

What takes the printed address

  • Bun.connect(), Bun.udpSocket(), node:net, node:dgram, the host option of node:http, and the Postgres cancel() dial.
  • dns.reverse() and dns.lookupService() ask for the address alone. Before this change they answered ENOTIMP and threw ERR_INVALID_ARG_VALUE for an address with a zone. Node answers ENOTFOUND.
  • fetch() and WebSocket do not: a URL cannot hold a zone. new URL("http://[" + ip.address + "]") throws for a link-local requestIP() after commit 2.

Design

  • src/ and packages/: 671 lines added, 945 removed. Cargo.lock does not change.
  • Removed C exports (7): us_udp_socket_bound_ip, us_udp_socket_remote_ip, us_socket_remote_address, us_socket_local_address, us_get_remote_address_info, us_quic_listen_socket_local_address, us_quic_socket_remote_address.
  • Added C exports (6): us_udp_socket_local_sockaddr, us_udp_socket_remote_sockaddr, us_socket_local_sockaddr, us_socket_remote_sockaddr, us_quic_listen_socket_local_sockaddr, us_quic_socket_remote_sockaddr. The two TCP ones return 0 for a closed socket, whose descriptor number the kernel can give to another connection.
  • src/uws_sys/SocketAddress.rs is the formatter. It prints with core::net, and adds the one form that libuv's inet_ntop() prints in another way (::1.2.3.4). A test compares 2,000 addresses with what Node prints.
  • src/runtime/socket/SocketAddress.rs is the JS glue. In commit 1 its Zone argument says whether a getter prints the zone. Commit 2 deletes the argument.
  • node:* reads go through natives of their own, with one system call: getSockName / getPeerName for UDP and for TCP, getServerAddress for http.Server#address().
  • NewSocketHandler::{local,remote}_sockaddr use on_socket!, so a new kind of InternalSocket does not compile until it has an arm.
  • The zone of rinfo.address follows the same rule. Before, any IPv6 sender with a non-zero scope id got a zone.
  • An IPv4-compatible address (::1.2.3.4) is printed as Node prints it. The TCP getters printed ::102:304.
  • server.hostname of Bun.serve called getpeername() on the listener on its first read. That call always failed, and it is gone.
  • bun:internal-for-testing gains formatSocketAddress(host, port), and dnsCacheSeed keeps the scope id of an address with a zone.

Measurements (Linux x64, release builds of claude/http-consolidated 001958b and of this branch, Node v26.3.0)

name system calls per call base this PR Node
node:dgram address() 1 1 1
node:dgram remoteAddress() 0.01 (cached) 1 1
node:net server.address() 2 1 1
node:net socket, three remote fields 3 1 1
node:net socket, three local fields 3 1 1
node:http server.address() 0.02 (cached) 1 1
node:http req.socket.remoteAddress 3 1
Bun.serve address, first read 2 1
Bun.serve hostname, first read 1 0
requestIP(), Bun.udpSocket, Bun.connect, ServerWebSocket getters 1 1
if_indextoname per printed fe80::/10 address with a scope id 0 1 1
if_indextoname per any other address 0 0 0
instructions from entry to return of the getter (gdb single-step) base this PR
socket remoteAddress, 127.0.0.1 1,942 1,438
socket remoteAddress, ::1 1,405 885
socket remoteAddress, ::ffff:127.0.0.1 2,208 1,648
socket remoteAddress, fe80::400:acff:fe1e:2 (1 to 4 system calls) 2,616 2,242
requestIP(), 127.0.0.1 1,987 1,608
requestIP(), ::1 1,229 1,055
Bun.udpSocket().address, first read 1,917 1,549
datagram callback, per datagram with its JS handler 12,735 12,382
  • instructions:u per call in a loop of 1e6 calls (5 interleaved runs, medians): node:dgram address() 2,114 to 1,736 (spread 8), node:net server.address() 3,434 to 2,929 (spread 33), node:dgram remoteAddress() 196 to 1,745, node:http server.address() 118 to 1,757.
  • Wall clock of requestIP() (median of 5 runs of 2e5 calls): 289 to 246 ns for 127.0.0.1, 345 to 2,358 ns for a link-local peer. if_indextoname() is a socket, an ioctl and a close on glibc. On macOS and FreeBSD it reads the list of interfaces (getifaddrs()). I have no such machine, so that cost has no number here.
  • JS heap cells per call: node:dgram remoteAddress() 0 to 2, node:http server.address() 0 to 2, node:dgram address(), node:net server.address() and requestIP() 2 to 2.
  • Binary: size text 138,054,273 to 138,059,812 (+5,539 B). .tbss 14,696 to 14,520 (-176 B per thread). File size +4,056 B.
  • Symbols: C exports -7 +6. Rust exports to C++ +1 (Bun__sockaddrToJS), -2 (Bun__WebSocketClient__remoteAddress, Bun__WebSocketClientTLS__remoteAddress). Dynamic imports 391 to 390 (ntohs).
  • Host functions: +6 (the five natives above and the testing hook).
  • bun build --compile --aot: every build numbers 16 more identifiers (5,920 to 5,936 for a program of one class at edb9ce8), so the ids of a program shift. One shape of test/aot/build-scaling moves with the ids, in either direction: "classes with a field each that extend one another in a row" grows 3.39 times in instructions without this change and 3.29 with it (bound 3.36). At 001958b it was 3.39 without and 3.58 with, and a profile put 326 M of the 333 M more instructions at 500 classes in JSC::AOT::Typed::ValueSets::InternedRuns::intern and ValueSetMaker::combine. At 500 classes two counters of the compiler differ (FlowEdgesLogged 53,488 to 52,012, FlowReaders 28,844 to 26,434). The other counters that are not times or sizes are equal. The other 131 tests of test/aot/build-scaling pass or fail alike with and without this change (release builds at edb9ce8, Linux x64).
  • Address strings: 19 of 79 cells (end by address kind) change against the base, all of them link-local: 10 after commit 1, 9 more after commit 2. On the node:* rows, 0 of 42 differ from Node after commit 1 (base: 10).
  • The branch moved from 001958b to edb9ce8 on 3 November with the same code. Every number of this section is from 001958b, but for the --aot line.

Not in this change

  • Bun.dns.lookup prints a numeric %<id> for any non-zero scope id (src/dns/lib.rs:381). That stays: a test pins it, and a connect to such a result needs the scope.
  • Four functions read a zone, each by its own rule (scope_index, UDPSocket::parse_addr, getaddrinfo, SocketAddress.parse). udp: connect by sockaddr, read node:dgram connect() addresses as Node's handle does #47315 and udp, dgram: read multicast addresses as full IP literals #47350 change two of them. This change only prints.
  • usockets passes the peer's IP bytes from accept() to its open callback without the scope id. No receiver reads them, before or after this change.
  • A datagram from a sender whose interface has no name is delivered with the numeric index. Node emits error (udp_wrap.cc L788-L805).
  • The address and remoteAddress getters of Bun.udpSocket keep their first answer.
  • server.url of a Bun.serve on an address with a zone throws ERR_INVALID_URL, as before.

Self-review

  • Done: the new base and the two commits, a formatter that cannot fail, the blockList check, originSet, the closed-socket check, the dns functions, no c-ares dependency, on_socket!, the two red checks (cargo clippy, Lint JavaScript), and tests that tell the local address from the remote one.
  • Not done: a fault-injection rule that fakes a link-local address. The link-local tests run in CI on Linux, macOS and Windows: 17, 14 and 14 of the 17 tests of node-address-zone.test.ts pass there and the others skip. The formatter tests need no such address.
  • Not done: the removal of the ip parameter of the usockets open callback. Nothing read it before this change either.
  • Not done: timing on macOS and FreeBSD, and a run of a program that depends on the zone (@matter/nodejs passes a printed address to connect()).
  • Not covered by a test: requestIP() of a link-local peer over HTTP/2 and HTTP/3, and wss. No client of Bun dials a URL with a zone.

Tests

  • test/js/node/net/node-address-zone.test.ts is a node:test file. Bun runs it, and one of its tests runs the same file in Node.js. On the base 8 of its 17 tests fail.
  • New tests in udp_socket.test.ts, tcp-server.test.ts, bun-server.test.ts, websocket-server.test.ts, ws-compat-shape.test.ts, node-http2.test.js, dns-resolver-class.test.ts and postgres-connection.test.ts. Each file fails on the base.
  • The tests that need a real link-local address skip on a host that has none. Two tests make an interface in a network namespace (docker_gwbridge, vé0) and skip where unshare cannot.
  • Each of the nine test files passes on the debug build at edb9ce8. So do test/js/bun/udp, test/js/node/dgram, test/js/bun/net, test/js/node/net, test/js/bun/websocket, serve-http2, serve-http3, node-http, node-https-server, test/js/first_party/ws, test/js/node/quic, and 241 of Node's own test-dgram-*, test-net-*, test-cluster-dgram-* and test-http2-*origin* files.
  • Commit 1 alone passed its tests on a release build at 001958b. Both commits pass the nine files on a release build at edb9ce8, where the base fails each file.
  • cargo check of the workspace passes for 8 of the 12 CI targets: Linux gnu (x64, arm64), Linux musl x64, Android arm64, macOS arm64, Windows (x64, arm64) and FreeBSD x64.

no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/sql/postgres-connection.test.ts, test/js/node/net/node-address-zone.test.ts, test/js/node/http2/node-http2.test.js, test/js/bun/websocket/websocket-server.test.ts, test/js/bun/udp/udp_socket.test.ts, test/js/bun/net/tcp-server.test.ts, test/js/bun/http/bun-server.test.ts

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on claude/http-consolidated (edb9ce8) and on main, on a Linux host with a link-local address on eth0:

const dgram = require("node:dgram");
const server = dgram.createSocket("udp6");
server.bind(0, "fe80::400:acff:fe1e:2%eth0", () => {
  const { address, port } = server.address();
  console.log(address); // before: fe80::400:acff:fe1e:2, Node v26.3.0: fe80::400:acff:fe1e:2%eth0
  const client = dgram.createSocket("udp6");
  client.connect(port, address, err => console.log(err?.message)); // before: Failed to connect socket
});

test/js/node/net/node-address-zone.test.ts fails 8 of its 17 tests on claude/http-consolidated. It passes with this change, and in Node v26.3.0.

The branch is on edb9ce8 since 3 November (head b860034). The rebase had two conflicts, and the code of this change is the same.

  • Buildkite does not build this head. Build 137197 stops in its first step: "This branch is too far behind main to build. Merge main into it." The step asks for a4c3a5e (ci: look up and bake Windows images with Azure federated identities #47779), and claude/http-consolidated does not have it yet. I rebase again when that branch merges main.
  • The other checks of this head are green.
  • Run here on this head: the nine test files of this change pass on the debug build (1,405 tests) and on a release build. Each of them fails on a release build of edb9ce8.
  • test/aot/build-scaling (release builds, Linux x64): this change adds 16 identifiers to the table that every bun build --compile --aot numbers, and one shape moves with the ids. "classes with a field each that extend one another in a row" grows 3.39 times in instructions on edb9ce8, which is over its bound of 3.36, and 3.29 times with this change. On the previous base the same shape went from 3.39 to 3.58. The other 131 tests pass or fail alike with and without this change.

Pull request: #47403 (draft, stacked on #46528)

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:45 PM PT - Nov 2nd, 2026

❌ @robobun, your commit b860034 has 1 failures in Build #137197 (All Failures):

  • step failed outside runner - exit 1 on ⚙️

🧪   To try this PR locally:

bunx bun-pr 47403

That installs a local version of the PR into your bun-47403 executable, so you can run:

bun-47403 --bun

Comment thread src/js/internal-for-testing.ts Resolved
Comment thread src/js/node/dgram.ts Resolved
@robobun
robobun changed the base branch from main to claude/http-consolidated October 30, 2026 06:40
Comment thread src/jsc/bindings/JSSocketAddressDTO.h Resolved
Comment thread src/runtime/dns_jsc/dns.rs Resolved
Comment thread src/runtime/node/node_http_binding.rs Resolved
Comment thread src/runtime/socket/SocketAddress.rs Resolved
Comment thread src/runtime/socket/SocketAddress.rs Resolved
Comment thread src/runtime/socket/SocketAddress.rs Resolved
Comment thread src/runtime/socket/SocketAddress.rs Resolved
Comment thread src/uws_sys/SocketAddress.rs Resolved
Comment thread src/uws_sys/SocketAddress.rs Resolved
Comment thread src/uws_sys/SocketAddress.rs Resolved
Comment thread src/uws_sys/SocketAddress.rs Resolved
…ne place

Six usockets getters return the whole sockaddr of getsockname() and
getpeername(). They replace the seven that returned only the IP bytes, so
a formatter can see sin6_scope_id. bun_uws_sys::SocketAddress prints an
address as Node's AddressToJS does: a link-local address with a scope id
has its zone.

node:dgram, node:net, node:http, node:http2 and the ws client print the
zone. Each reads an address through a native of its own, with one system
call. The address getters of Bun.udpSocket, Bun.listen, Bun.connect,
Bun.serve and ServerWebSocket print what they printed before.

What reads the new text inside Bun takes it: a blockList checks a peer
without its zone, the origin set of an HTTP/2 session has none, and
dns.reverse() and dns.lookupService() ask for the address alone.
…rint the zone of a link-local address

The address getters of the Bun-native sockets and servers print a
link-local address as the node:* ones do: with its zone. The printed
address of a peer then connects again through Bun.connect() and
Bun.udpSocket(). Before, these getters dropped the zone, and the data
callback of Bun.udpSocket printed it.

This commit changes Bun-native strings. The commit before it changes none.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant