Repository navigation
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
Draft
sockets: format every kernel address from its sockaddr, so a link-local address keeps its zone#47403robobun wants to merge 2 commits into
robobun wants to merge 2 commits into
Conversation
Collaborator
Author
|
Status Reproduced on 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
});
The branch is on edb9ce8 since 3 November (head b860034). The rebase had two conflicts, and the code of this change is the same.
|
Collaborator
Author
|
Updated 11:45 PM PT - Nov 2nd, 2026
❌ @robobun, your commit b860034 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 47403That installs a local version of the PR into your bun-47403 --bun |
robobun
force-pushed
the
robobun/70745f0e/dgram-link-local-zone
branch
from
October 30, 2026 06:39
50d4660 to
7e478db
Compare
robobun
force-pushed
the
robobun/70745f0e/dgram-link-local-zone
branch
from
October 30, 2026 07:02
7e478db to
a077438
Compare
…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.
robobun
force-pushed
the
robobun/70745f0e/dgram-link-local-zone
branch
from
November 3, 2026 07:44
a077438 to
b860034
Compare
This was referenced Nov 7, 2026
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft, stacked on #46528.
Problem
fe80::1, where Node printsfe80::1%eth0. The printed address does not connect again (Failed to connect socket).us_udp_socket_bound_ip,packages/bun-usockets/src/udp.c:87) return only the IP bytes of a kernel address. No formatter seessin6_scope_id.Fix
sockaddr_storage. One formatter (src/uws_sys/SocketAddress.rs) prints it by Node's rule: a zone only forfe80::/10with a scope id.node:dgram,node:net,node:http,node:http2andwsgetters print the zone. No Bun-native string changes.Bun.*getters too. A maintainer can drop it.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
%eth0, an index on Windows) names it. The kernel keeps it insin6_scope_id.Downsides
Bun.*getter gains%zone, which a URL cannot hold.node:dgramremoteAddress()andnode:httpserver.address()ask the kernel on each call, as Node does: +1,600 instructions and 2 heap cells.requestIP()takes 2,358 ns for such a peer, 345 ns before. Not measured on macOS.Notes
Order
claude/http-consolidated(http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528, which contains net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522). It must not merge before that stack. After the stack lands I cut it again onmain.claude/http-consolidated, 0 after this change. They move the zone-less formatting intoip_address_to_jsandcreate_sock_addr, which this change deletes.Reproduction (a host with a link-local address on
eth0)claude/http-consolidated(edb9ce8), and mainfe80::400:acff:fe1e:2Failed to connect socketfe80::400:acff:fe1e:2%eth0fe80::400:acff:fe1e:2%eth0fe80::400:acff:fe1e:2%eth0fe80::400:acff:fe1e:2%eth0The rule is Node's
AddressToJS(tcp_wrap.cc L520-L596): theinet_ntop()text, then%and the interface only when the address is link-local andsin6_scope_id > 0. A multicast, loopback or site-local address gets no zone.net.SocketAddress#addressandos.networkInterfaces()print no zone in Node, and this change does not touch them.Where Bun is not as Node, on purpose
fe80::1%7). Node throwsuv_if_indextoiidthere (tcp_wrap.cc L545-L550). The lookup opens a descriptor, so it also fails at the descriptor limit, andnet.Serverreads the address of a peer before any code of the program runs. No getter throws after this change.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.blockListof anet.Serverchecks 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 asdocker_gwbridge(net.js L2338-L2346).session.originSetof an HTTP/2 session has the address without its zone. Node throwsERR_INVALID_URLfor such a peer (core.js L700-L712).What takes the printed address
Bun.connect(),Bun.udpSocket(),node:net,node:dgram, thehostoption ofnode:http, and the Postgrescancel()dial.dns.reverse()anddns.lookupService()ask for the address alone. Before this change they answeredENOTIMPand threwERR_INVALID_ARG_VALUEfor an address with a zone. Node answersENOTFOUND.fetch()andWebSocketdo not: a URL cannot hold a zone.new URL("http://[" + ip.address + "]")throws for a link-localrequestIP()after commit 2.Design
src/andpackages/: 671 lines added, 945 removed.Cargo.lockdoes not change.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.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.rsis the formatter. It prints withcore::net, and adds the one form that libuv'sinet_ntop()prints in another way (::1.2.3.4). A test compares 2,000 addresses with what Node prints.src/runtime/socket/SocketAddress.rsis the JS glue. In commit 1 itsZoneargument 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/getPeerNamefor UDP and for TCP,getServerAddressforhttp.Server#address().NewSocketHandler::{local,remote}_sockaddruseon_socket!, so a new kind ofInternalSocketdoes not compile until it has an arm.rinfo.addressfollows the same rule. Before, any IPv6 sender with a non-zero scope id got a zone.::1.2.3.4) is printed as Node prints it. The TCP getters printed::102:304.server.hostnameofBun.servecalledgetpeername()on the listener on its first read. That call always failed, and it is gone.bun:internal-for-testinggainsformatSocketAddress(host, port), anddnsCacheSeedkeeps the scope id of an address with a zone.Measurements (Linux x64, release builds of
claude/http-consolidated001958b and of this branch, Node v26.3.0)node:dgramaddress()node:dgramremoteAddress()node:netserver.address()node:netsocket, three remote fieldsnode:netsocket, three local fieldsnode:httpserver.address()node:httpreq.socket.remoteAddressBun.serveaddress, first readBun.servehostname, first readrequestIP(),Bun.udpSocket,Bun.connect,ServerWebSocketgettersif_indextonameper printedfe80::/10address with a scope idif_indextonameper any other addressremoteAddress,127.0.0.1remoteAddress,::1remoteAddress,::ffff:127.0.0.1remoteAddress,fe80::400:acff:fe1e:2(1 to 4 system calls)requestIP(),127.0.0.1requestIP(),::1Bun.udpSocket().address, first readinstructions:uper call in a loop of 1e6 calls (5 interleaved runs, medians):node:dgramaddress()2,114 to 1,736 (spread 8),node:netserver.address()3,434 to 2,929 (spread 33),node:dgramremoteAddress()196 to 1,745,node:httpserver.address()118 to 1,757.requestIP()(median of 5 runs of 2e5 calls): 289 to 246 ns for127.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.node:dgramremoteAddress()0 to 2,node:httpserver.address()0 to 2,node:dgramaddress(),node:netserver.address()andrequestIP()2 to 2.sizetext 138,054,273 to 138,059,812 (+5,539 B)..tbss14,696 to 14,520 (-176 B per thread). File size +4,056 B.Bun__sockaddrToJS), -2 (Bun__WebSocketClient__remoteAddress,Bun__WebSocketClientTLS__remoteAddress). Dynamic imports 391 to 390 (ntohs).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 oftest/aot/build-scalingmoves 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 inJSC::AOT::Typed::ValueSets::InternedRuns::internandValueSetMaker::combine. At 500 classes two counters of the compiler differ (FlowEdgesLogged53,488 to 52,012,FlowReaders28,844 to 26,434). The other counters that are not times or sizes are equal. The other 131 tests oftest/aot/build-scalingpass or fail alike with and without this change (release builds at edb9ce8, Linux x64).node:*rows, 0 of 42 differ from Node after commit 1 (base: 10).--aotline.Not in this change
Bun.dns.lookupprints 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.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.accept()to its open callback without the scope id. No receiver reads them, before or after this change.error(udp_wrap.cc L788-L805).addressandremoteAddressgetters ofBun.udpSocketkeep their first answer.server.urlof aBun.serveon an address with a zone throwsERR_INVALID_URL, as before.Self-review
blockListcheck,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.node-address-zone.test.tspass there and the others skip. The formatter tests need no such address.ipparameter of the usockets open callback. Nothing read it before this change either.@matter/nodejspasses a printed address toconnect()).requestIP()of a link-local peer over HTTP/2 and HTTP/3, andwss. No client of Bun dials a URL with a zone.Tests
test/js/node/net/node-address-zone.test.tsis anode:testfile. Bun runs it, and one of its tests runs the same file in Node.js. On the base 8 of its 17 tests fail.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.tsandpostgres-connection.test.ts. Each file fails on the base.docker_gwbridge,vé0) and skip whereunsharecannot.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 owntest-dgram-*,test-net-*,test-cluster-dgram-*andtest-http2-*origin*files.cargo checkof 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