From 545c4829ab434d41a0f6acbd7af9d46c40a506af Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 9 Sep 2026 15:04:09 -0700 Subject: [PATCH 1/9] Real DNS resolution for getaddrinfo under -sNODERAWSOCKETS /etc/hosts entries (read through the emscripten FS) resolve synchronously, other hostnames resolve via node:dns, blocking where the calling stack can wait (a proxied pthread via PROXY_SYNC_ASYNC, ASYNCIFY/JSPI by suspending) and returning EAI_AGAIN otherwise. Results may be a linked list, which freeaddrinfo now frees in full. --- ChangeLog.md | 6 + src/lib/libcore.js | 81 ++++++++++++- src/lib/libsockfs_node.js | 58 ++++++++- src/struct_info.json | 3 +- src/struct_info_generated.json | 1 + src/struct_info_generated_wasm64.json | 1 + .../lib/libc/musl/src/network/freeaddrinfo.c | 13 +- test/sockets/test_dns.c | 112 ++++++++++++++++++ test/test_sockets_node.py | 17 +++ 9 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 test/sockets/test_dns.c diff --git a/ChangeLog.md b/ChangeLog.md index 3420041cbf3b1..d8d8c48a12e97 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -161,6 +161,12 @@ See docs/process.md for more on how version tagging works. process, or pthreads required. Supports incoming and outgoing TCP, UDP, IPv6, and `-pthread` with `PROXY_TO_PTHREAD`. Uses the public node APIs where available, falling back to `tcp_wrap`/`udp_wrap` on older Node.js. (#27080) +- Under `-sNODERAWSOCKETS`, `getaddrinfo` now performs real name resolution: + `/etc/hosts` entries (read through the emscripten FS) resolve synchronously, + and other hostnames resolve via `node:dns`, blocking the caller where its + stack can wait (a proxied pthread, `ASYNCIFY`/`JSPI`) and returning + `EAI_AGAIN` otherwise. Results may now be a linked list, which `freeaddrinfo` + frees in full. - The following symbols are no longer included in `INCOMING_MODULE_JS_API` by default: - GL_MAX_TEXTURE_IMAGE_UNITS diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 5247fbfd06284..4fd4c61a28534 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1007,9 +1007,17 @@ addToLibrary({ return inetPton4(DNS.lookup_name(nameString)); }, - getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl'], - getaddrinfo__proxy: 'sync', - getaddrinfo: (node, service, hint, out) => { + // Returns an EAI_* code (0 on success, having written the addrinfo list to + // *out), or - under NODERAWSOCKETS, for a hostname needing a real DNS lookup - + // a thunk producing a Promise of that code, for getaddrinfo to wait on where + // the calling stack can suspend. + $doGetAddrInfo__internal: true, + $doGetAddrInfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', +#if NODERAWSOCKETS + '$nodeSockHelpers', +#endif + ], + $doGetAddrInfo: (node, service, hint, out) => { // Note getaddrinfo currently only returns a single addrinfo with ai_next defaulting to NULL. When NULL // hints are specified or ai_family set to AF_UNSPEC or ai_socktype or ai_protocol set to 0 then we // really should provide a linked list of suitable addrinfo values. @@ -1055,6 +1063,23 @@ addToLibrary({ return ai; } +#if NODERAWSOCKETS + // Chain one addrinfo per {family, addr} entry, returning the head. + function allocaddrinfos(entries) { + var head = 0, prev = 0; + for (var entry of entries) { + var ai = allocaddrinfo(entry.family, type, proto, null, entry.addr, port); + if (prev) { + {{{ makeSetValue('prev', C_STRUCTS.addrinfo.ai_next, 'ai', '*') }}}; + } else { + head = ai; + } + prev = ai; + } + return head; + } +#endif + if (hint) { flags = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_flags, 'i32') }}}; family = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_family, 'i32') }}}; @@ -1167,6 +1192,21 @@ addToLibrary({ // // try as a hostname // +#if NODERAWSOCKETS + // /etc/hosts first (read through emscripten's FS), then a real node:dns + // lookup, which is asynchronous: hand the caller a thunk to wait on. + var hosts = nodeSockHelpers.readHosts(node).filter((e) => + family === {{{ cDefs.AF_UNSPEC }}} || e.family === family); + if (hosts.length) { + {{{ makeSetValue('out', '0', 'allocaddrinfos(hosts)', '*') }}}; + return 0; + } + return () => nodeSockHelpers.lookupHost(node, family).then((entries) => { + if (typeof entries == 'number') return entries; + {{{ makeSetValue('out', '0', 'allocaddrinfos(entries)', '*') }}}; + return 0; + }); +#else // resolve the hostname to a temporary fake address node = DNS.lookup_name(node); addr = inetPton4(node); @@ -1178,6 +1218,41 @@ addToLibrary({ ai = allocaddrinfo(family, type, proto, null, addr, port); {{{ makeSetValue('out', '0', 'ai', '*') }}}; return 0; +#endif + }, + + getaddrinfo__deps: ['$doGetAddrInfo', +#if NODERAWSOCKETS && ASYNCIFY + '$Asyncify', +#endif + ], + getaddrinfo__proxy: 'sync', +#if NODERAWSOCKETS && (PTHREADS || ASYNCIFY) + // A hostname needing a real DNS lookup blocks by returning a Promise, which + // a proxied pthread awaits (PROXY_SYNC_ASYNC) and ASYNCIFY/JSPI suspends on. + // Every other outcome still returns synchronously. + getaddrinfo__async: true, +#endif + getaddrinfo: (node, service, hint, out) => { + var ret = doGetAddrInfo(node, service, hint, out); +#if NODERAWSOCKETS + if (typeof ret == 'function') { +#if PTHREADS + if (PThread.currentProxiedOperationCallerThread) return ret(); +#endif +#if ASYNCIFY + return Asyncify.handleAsync(ret); +#else + // No stack that can wait on the lookup (the event-loop thread itself). + return {{{ cDefs.EAI_AGAIN }}}; +#endif + } +#if PTHREADS + // A sync-proxied caller awaits a thenable even for an immediate result. + if (PThread.currentProxiedOperationCallerThread) return Promise.resolve(ret); +#endif +#endif + return ret; }, getnameinfo__deps: ['$DNS', '$readSockaddr', '$stringToUTF8'], diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index b5428ffae9add..3b64e5f8eca93 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -56,7 +56,7 @@ null; var NodeSockFSLibrary = { // Node plumbing shared by the interface methods below. - $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', + $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$FS', '$inetPton4', '$inetPton6', #if ASSERTIONS '$warnOnce', #endif @@ -73,6 +73,62 @@ var NodeSockFSLibrary = { getDgram() { return nodeSockHelpers.dgramModule ??= (process.getBuiltinModule || require)('dgram'); }, + getDns() { + return nodeSockHelpers.dnsModule ??= (process.getBuiltinModule || require)('dns'); + }, + // Address entries for `name` in /etc/hosts, read fresh through emscripten's + // FS on each call so a MEMFS or mounted file is honored as written. A + // missing file is simply empty. + readHosts(name) { + var out = []; + var text; + try { + text = FS.readFile('/etc/hosts', { encoding: 'utf8' }); + } catch (e) { + return out; + } + for (var line of text.split('\n')) { + var hash = line.indexOf('#'); + if (hash !== -1) line = line.slice(0, hash); + var parts = line.split(/\s+/).filter((p) => p.length); + if (parts.length < 2 || !parts.slice(1).includes(name)) continue; + var addr = inetPton4(parts[0]); + if (addr !== null) { + out.push({ family: {{{ cDefs.AF_INET }}}, addr }); + } else if ((addr = inetPton6(parts[0])) !== null) { + out.push({ family: {{{ cDefs.AF_INET6 }}}, addr }); + } + } + return out; + }, + // Resolve a hostname via node:dns for `family` (AF_UNSPEC for both). + // Resolves to a list of {family, addr} entries, or an EAI_* code: node:dns + // surfaces either getaddrinfo EAI_* names or libuv codes, of which the + // transient ones map to EAI_AGAIN and the rest to "name not found". + lookupHost(name, family) { + var opts = { all: true }; + if (family === {{{ cDefs.AF_INET }}}) opts.family = 4; + else if (family === {{{ cDefs.AF_INET6 }}}) opts.family = 6; + return new Promise((resolve) => { + nodeSockHelpers.getDns().lookup(name, opts, (err, addresses) => { + if (err) { + switch (err.code) { + case 'EAI_AGAIN': + case 'ETIMEDOUT': + case 'ESERVFAIL': + case 'EREFUSED': + return resolve({{{ cDefs.EAI_AGAIN }}}); + default: + return resolve({{{ cDefs.EAI_NONAME }}}); + } + } + if (!addresses.length) return resolve({{{ cDefs.EAI_NONAME }}}); + resolve(addresses.map((a) => a.family === 6 ? + { family: {{{ cDefs.AF_INET6 }}}, addr: inetPton6(a.address) } : + { family: {{{ cDefs.AF_INET }}}, addr: inetPton4(a.address) })); + }); + }); + }, // True when node:dgram exposes both synchronous bindSync and connectSync // (a recent addition), letting UDP run entirely on the public API. A runtime // missing either falls back to the private udp_wrap handle, which provides diff --git a/src/struct_info.json b/src/struct_info.json index be92ff18a8d9c..9d68f3a0f7659 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -206,7 +206,8 @@ "NI_NAMEREQD", "EAI_NONAME", "EAI_SOCKTYPE", - "EAI_BADFLAGS" + "EAI_BADFLAGS", + "EAI_AGAIN" ], "structs": { "addrinfo": [ diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index e266b9eb7d2d8..e8cad551d543c 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -64,6 +64,7 @@ "EADV": 122, "EAFNOSUPPORT": 5, "EAGAIN": 6, + "EAI_AGAIN": -3, "EAI_BADFLAGS": -1, "EAI_FAMILY": -6, "EAI_NONAME": -2, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 115caf29cd902..c08719f390c1f 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -64,6 +64,7 @@ "EADV": 122, "EAFNOSUPPORT": 5, "EAGAIN": 6, + "EAI_AGAIN": -3, "EAI_BADFLAGS": -1, "EAI_FAMILY": -6, "EAI_NONAME": -2, diff --git a/system/lib/libc/musl/src/network/freeaddrinfo.c b/system/lib/libc/musl/src/network/freeaddrinfo.c index c4016d9f7c246..25d5c8f668530 100644 --- a/system/lib/libc/musl/src/network/freeaddrinfo.c +++ b/system/lib/libc/musl/src/network/freeaddrinfo.c @@ -7,11 +7,14 @@ void freeaddrinfo(struct addrinfo *p) { #if __EMSCRIPTEN__ - // Emscripten's usage of this structure is very simple: we always allocate - // ai_addr, and do not use the linked list aspect at all. There is also no - // aliasing with aibuf. - free(p->ai_addr); - free(p); + // Emscripten allocates each node and its ai_addr separately (no aibuf + // block, no aliasing), so walk the list freeing both. + while (p) { + struct addrinfo *next = p->ai_next; + free(p->ai_addr); + free(p); + p = next; + } #else size_t cnt; for (cnt=1; p->ai_next; cnt++, p=p->ai_next); diff --git a/test/sockets/test_dns.c b/test/sockets/test_dns.c new file mode 100644 index 0000000000000..ba5bf6514c1f3 --- /dev/null +++ b/test/sockets/test_dns.c @@ -0,0 +1,112 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * getaddrinfo() under -sNODERAWSOCKETS: numeric addresses and /etc/hosts + * entries (read through emscripten's FS) resolve synchronously, a name with + * several addresses comes back as a linked list, and any other hostname goes + * to node:dns. That lookup is asynchronous, so it blocks where the calling + * stack can wait (a proxied pthread, JSPI) and is EAI_AGAIN where it cannot + * (built with -DNO_WAIT). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static struct addrinfo* lookup(const char* name, int family, int expect) { + struct addrinfo hints = {0}; + hints.ai_family = family; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = NULL; + int err = getaddrinfo(name, "80", &hints, &res); + if (err != expect) { + printf("getaddrinfo(%s) = %d, expected %d\n", name, err, expect); + exit(1); + } + return res; +} + +static int count_v4(struct addrinfo* res, const char* addr) { + int n = 0; + for (struct addrinfo* ai = res; ai; ai = ai->ai_next) { + assert(ai->ai_socktype == SOCK_STREAM); + assert(ai->ai_protocol == IPPROTO_TCP); + if (ai->ai_family != AF_INET) continue; + struct sockaddr_in* sin = (struct sockaddr_in*)ai->ai_addr; + assert(ai->ai_addrlen == sizeof(*sin)); + assert(ntohs(sin->sin_port) == 80); + if (sin->sin_addr.s_addr == inet_addr(addr)) n++; + } + return n; +} + +int main(void) { + mkdir("/etc", 0777); + FILE* f = fopen("/etc/hosts", "w"); + assert(f); + fputs("# test hosts\n" + "10.1.2.3 statichost.test alias.test\n" + "192.0.2.1 multi.test\n" + "fe80::1 multi.test\n" + "192.0.2.2 multi.test # trailing comment\n", + f); + fclose(f); + + struct addrinfo* res = lookup("10.9.8.7", AF_UNSPEC, 0); + assert(count_v4(res, "10.9.8.7") == 1 && !res->ai_next); + freeaddrinfo(res); + + res = lookup("alias.test", AF_INET, 0); + assert(count_v4(res, "10.1.2.3") == 1 && !res->ai_next); + freeaddrinfo(res); + + // Only the matching family, still as a list. + res = lookup("multi.test", AF_INET, 0); + assert(count_v4(res, "192.0.2.1") == 1); + assert(count_v4(res, "192.0.2.2") == 1); + assert(res->ai_next && !res->ai_next->ai_next); + freeaddrinfo(res); + + // AF_UNSPEC includes the IPv6 entry. + res = lookup("multi.test", AF_UNSPEC, 0); + int n = 0, v6 = 0; + for (struct addrinfo* ai = res; ai; ai = ai->ai_next) { + n++; + if (ai->ai_family == AF_INET6) { + struct sockaddr_in6* sin6 = (struct sockaddr_in6*)ai->ai_addr; + assert(ai->ai_addrlen == sizeof(*sin6)); + assert(sin6->sin6_addr.s6_addr[0] == 0xfe && sin6->sin6_addr.s6_addr[15] == 1); + v6++; + } + } + assert(n == 3 && v6 == 1); + freeaddrinfo(res); + + // Not in /etc/hosts: a real node:dns lookup. +#ifdef NO_WAIT + lookup("localhost", AF_INET, EAI_AGAIN); +#else + res = lookup("localhost", AF_INET, 0); + assert(count_v4(res, "127.0.0.1") == 1); + freeaddrinfo(res); + + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + res = NULL; + int err = getaddrinfo("nonexistent.invalid", NULL, &hints, &res); + assert(err == EAI_NONAME || err == EAI_AGAIN); + assert(!res); +#endif + + printf("done\n"); + return 0; +} diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index ca645e06ecca3..13117f0cd25bc 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -212,6 +212,23 @@ def test_noderawsockets_udp_ipv6(self): self.skipTest('no IPv6 loopback available') self.do_runf('sockets/test_udp_ipv6.c', 'done\n', cflags=['-sNODERAWSOCKETS']) + def test_noderawsockets_dns(self): + # getaddrinfo() resolves numeric addresses and /etc/hosts entries (read via + # emscripten's FS) synchronously, as a linked list. A real hostname needs a + # node:dns lookup, and with no stack able to wait on it is EAI_AGAIN. + self.do_runf('sockets/test_dns.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-DNO_WAIT']) + + def test_noderawsockets_dns_blocking(self): + # A real hostname blocks on the node:dns lookup: main() is proxied to a + # worker, which awaits the resolution through the sync proxy. + self.do_runf('sockets/test_dns.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + @requires_jspi_node + def test_noderawsockets_dns_blocking_jspi(self): + # Same, but getaddrinfo() suspends the wasm stack under JSPI. + self.do_runf('sockets/test_dns.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_epoll_socket_blocking(self): # A blocking epoll_wait() on a socket is woken by an incoming datagram # through the unified readiness wait-queue (the SOCKFS.emit bridge), with From 16c58a0203d1217bcb11197b822376289f28b49c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 14 Sep 2026 12:00:22 -0700 Subject: [PATCH 2/9] Drop virtual-FS /etc/hosts lookup; rely on node:dns resolver --- ChangeLog.md | 10 +++---- src/lib/libcore.js | 10 ++----- src/lib/libsockfs_node.js | 27 +----------------- test/sockets/test_dns.c | 59 +++++++++++++-------------------------- test/test_sockets_node.py | 10 +++---- 5 files changed, 31 insertions(+), 85 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index d8d8c48a12e97..bfaaea35f5432 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -161,12 +161,10 @@ See docs/process.md for more on how version tagging works. process, or pthreads required. Supports incoming and outgoing TCP, UDP, IPv6, and `-pthread` with `PROXY_TO_PTHREAD`. Uses the public node APIs where available, falling back to `tcp_wrap`/`udp_wrap` on older Node.js. (#27080) -- Under `-sNODERAWSOCKETS`, `getaddrinfo` now performs real name resolution: - `/etc/hosts` entries (read through the emscripten FS) resolve synchronously, - and other hostnames resolve via `node:dns`, blocking the caller where its - stack can wait (a proxied pthread, `ASYNCIFY`/`JSPI`) and returning - `EAI_AGAIN` otherwise. Results may now be a linked list, which `freeaddrinfo` - frees in full. +- Under `-sNODERAWSOCKETS`, `getaddrinfo` now performs real name resolution + via `node:dns`, blocking the caller where its stack can wait (a proxied + pthread, `ASYNCIFY`/`JSPI`) and returning `EAI_AGAIN` otherwise. Results may + now be a linked list, which `freeaddrinfo` frees in full. - The following symbols are no longer included in `INCOMING_MODULE_JS_API` by default: - GL_MAX_TEXTURE_IMAGE_UNITS diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 4fd4c61a28534..9ea50b5e98f15 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1193,14 +1193,8 @@ addToLibrary({ // try as a hostname // #if NODERAWSOCKETS - // /etc/hosts first (read through emscripten's FS), then a real node:dns - // lookup, which is asynchronous: hand the caller a thunk to wait on. - var hosts = nodeSockHelpers.readHosts(node).filter((e) => - family === {{{ cDefs.AF_UNSPEC }}} || e.family === family); - if (hosts.length) { - {{{ makeSetValue('out', '0', 'allocaddrinfos(hosts)', '*') }}}; - return 0; - } + // A real node:dns lookup (which honors the host's /etc/hosts). It is + // asynchronous: hand the caller a thunk to wait on. return () => nodeSockHelpers.lookupHost(node, family).then((entries) => { if (typeof entries == 'number') return entries; {{{ makeSetValue('out', '0', 'allocaddrinfos(entries)', '*') }}}; diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index 3b64e5f8eca93..481a9c0c396bd 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -56,7 +56,7 @@ null; var NodeSockFSLibrary = { // Node plumbing shared by the interface methods below. - $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$FS', '$inetPton4', '$inetPton6', + $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$inetPton4', '$inetPton6', #if ASSERTIONS '$warnOnce', #endif @@ -76,31 +76,6 @@ var NodeSockFSLibrary = { getDns() { return nodeSockHelpers.dnsModule ??= (process.getBuiltinModule || require)('dns'); }, - // Address entries for `name` in /etc/hosts, read fresh through emscripten's - // FS on each call so a MEMFS or mounted file is honored as written. A - // missing file is simply empty. - readHosts(name) { - var out = []; - var text; - try { - text = FS.readFile('/etc/hosts', { encoding: 'utf8' }); - } catch (e) { - return out; - } - for (var line of text.split('\n')) { - var hash = line.indexOf('#'); - if (hash !== -1) line = line.slice(0, hash); - var parts = line.split(/\s+/).filter((p) => p.length); - if (parts.length < 2 || !parts.slice(1).includes(name)) continue; - var addr = inetPton4(parts[0]); - if (addr !== null) { - out.push({ family: {{{ cDefs.AF_INET }}}, addr }); - } else if ((addr = inetPton6(parts[0])) !== null) { - out.push({ family: {{{ cDefs.AF_INET6 }}}, addr }); - } - } - return out; - }, // Resolve a hostname via node:dns for `family` (AF_UNSPEC for both). // Resolves to a list of {family, addr} entries, or an EAI_* code: node:dns // surfaces either getaddrinfo EAI_* names or libuv codes, of which the diff --git a/test/sockets/test_dns.c b/test/sockets/test_dns.c index ba5bf6514c1f3..aaa1fdec82fc2 100644 --- a/test/sockets/test_dns.c +++ b/test/sockets/test_dns.c @@ -4,10 +4,9 @@ * University of Illinois/NCSA Open Source License. Both these licenses can be * found in the LICENSE file. * - * getaddrinfo() under -sNODERAWSOCKETS: numeric addresses and /etc/hosts - * entries (read through emscripten's FS) resolve synchronously, a name with - * several addresses comes back as a linked list, and any other hostname goes - * to node:dns. That lookup is asynchronous, so it blocks where the calling + * getaddrinfo() under -sNODERAWSOCKETS: numeric addresses resolve + * synchronously, and any hostname goes to node:dns, returning every address as + * a linked list. That lookup is asynchronous, so it blocks where the calling * stack can wait (a proxied pthread, JSPI) and is EAI_AGAIN where it cannot * (built with -DNO_WAIT). */ @@ -20,7 +19,6 @@ #include #include #include -#include static struct addrinfo* lookup(const char* name, int family, int expect) { struct addrinfo hints = {0}; @@ -50,53 +48,34 @@ static int count_v4(struct addrinfo* res, const char* addr) { } int main(void) { - mkdir("/etc", 0777); - FILE* f = fopen("/etc/hosts", "w"); - assert(f); - fputs("# test hosts\n" - "10.1.2.3 statichost.test alias.test\n" - "192.0.2.1 multi.test\n" - "fe80::1 multi.test\n" - "192.0.2.2 multi.test # trailing comment\n", - f); - fclose(f); - struct addrinfo* res = lookup("10.9.8.7", AF_UNSPEC, 0); assert(count_v4(res, "10.9.8.7") == 1 && !res->ai_next); freeaddrinfo(res); - res = lookup("alias.test", AF_INET, 0); - assert(count_v4(res, "10.1.2.3") == 1 && !res->ai_next); - freeaddrinfo(res); - - // Only the matching family, still as a list. - res = lookup("multi.test", AF_INET, 0); - assert(count_v4(res, "192.0.2.1") == 1); - assert(count_v4(res, "192.0.2.2") == 1); - assert(res->ai_next && !res->ai_next->ai_next); + // A hostname is a real node:dns lookup. +#ifdef NO_WAIT + lookup("localhost", AF_INET, EAI_AGAIN); +#else + res = lookup("localhost", AF_INET, 0); + assert(count_v4(res, "127.0.0.1") == 1); + for (struct addrinfo* ai = res; ai; ai = ai->ai_next) { + assert(ai->ai_family == AF_INET); + } freeaddrinfo(res); - // AF_UNSPEC includes the IPv6 entry. - res = lookup("multi.test", AF_UNSPEC, 0); - int n = 0, v6 = 0; + // AF_UNSPEC returns every address the resolver has, each in its own family. + res = lookup("localhost", AF_UNSPEC, 0); + assert(count_v4(res, "127.0.0.1") == 1); for (struct addrinfo* ai = res; ai; ai = ai->ai_next) { - n++; if (ai->ai_family == AF_INET6) { struct sockaddr_in6* sin6 = (struct sockaddr_in6*)ai->ai_addr; assert(ai->ai_addrlen == sizeof(*sin6)); - assert(sin6->sin6_addr.s6_addr[0] == 0xfe && sin6->sin6_addr.s6_addr[15] == 1); - v6++; + assert(ntohs(sin6->sin6_port) == 80); + assert(IN6_IS_ADDR_LOOPBACK(&sin6->sin6_addr)); + } else { + assert(ai->ai_family == AF_INET); } } - assert(n == 3 && v6 == 1); - freeaddrinfo(res); - - // Not in /etc/hosts: a real node:dns lookup. -#ifdef NO_WAIT - lookup("localhost", AF_INET, EAI_AGAIN); -#else - res = lookup("localhost", AF_INET, 0); - assert(count_v4(res, "127.0.0.1") == 1); freeaddrinfo(res); struct addrinfo hints = {0}; diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index 13117f0cd25bc..f9b28512d4b44 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -213,14 +213,14 @@ def test_noderawsockets_udp_ipv6(self): self.do_runf('sockets/test_udp_ipv6.c', 'done\n', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_dns(self): - # getaddrinfo() resolves numeric addresses and /etc/hosts entries (read via - # emscripten's FS) synchronously, as a linked list. A real hostname needs a - # node:dns lookup, and with no stack able to wait on it is EAI_AGAIN. + # getaddrinfo() resolves numeric addresses synchronously. A hostname needs + # a node:dns lookup, and with no stack able to wait on it is EAI_AGAIN. self.do_runf('sockets/test_dns.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-DNO_WAIT']) def test_noderawsockets_dns_blocking(self): - # A real hostname blocks on the node:dns lookup: main() is proxied to a - # worker, which awaits the resolution through the sync proxy. + # A hostname blocks on the node:dns lookup, returning every address as a + # linked list: main() is proxied to a worker, which awaits the resolution + # through the sync proxy. self.do_runf('sockets/test_dns.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) From 596909f3696be0e97fb05917cf6d9cfef9e42a76 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 14 Sep 2026 12:59:39 -0700 Subject: [PATCH 3/9] rebaseline --- test/codesize/test_codesize_hello_dylink_all.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index b6d1cd83f9864..7189aa50210dc 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 270520, - "a.out.nodebug.wasm": 588266, - "total": 858786, + "a.out.js": 270546, + "a.out.nodebug.wasm": 588289, + "total": 858835, "sent": [ "IMG_Init", "IMG_Load", From 2dd36269ce011f968505355f88c45c48549be364 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:02:12 -0700 Subject: [PATCH 4/9] getaddrinfo: async thunk instead of .then, document why a thunk --- src/lib/libcore.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 9ea50b5e98f15..f9185f15e19b4 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1194,12 +1194,15 @@ addToLibrary({ // #if NODERAWSOCKETS // A real node:dns lookup (which honors the host's /etc/hosts). It is - // asynchronous: hand the caller a thunk to wait on. - return () => nodeSockHelpers.lookupHost(node, family).then((entries) => { + // asynchronous, and returned as a thunk rather than a started Promise + // because ASYNCIFY re-runs this import body on rewind: Asyncify.handleAsync + // only invokes the thunk on the initial unwind, so the lookup runs once. + return async () => { + var entries = await nodeSockHelpers.lookupHost(node, family); if (typeof entries == 'number') return entries; {{{ makeSetValue('out', '0', 'allocaddrinfos(entries)', '*') }}}; return 0; - }); + }; #else // resolve the hostname to a temporary fake address node = DNS.lookup_name(node); From b0aa96ea11d89a49d3fe17e9c1de487acfa2c1a2 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:23:16 -0700 Subject: [PATCH 5/9] getaddrinfo: return the lookup Promise directly, JSPI only --- src/lib/libcore.js | 61 +++++++++++++++++----------------------------- 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index f9185f15e19b4..1decd216c610a 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1009,8 +1009,7 @@ addToLibrary({ // Returns an EAI_* code (0 on success, having written the addrinfo list to // *out), or - under NODERAWSOCKETS, for a hostname needing a real DNS lookup - - // a thunk producing a Promise of that code, for getaddrinfo to wait on where - // the calling stack can suspend. + // a Promise of one. $doGetAddrInfo__internal: true, $doGetAddrInfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', #if NODERAWSOCKETS @@ -1064,8 +1063,11 @@ addToLibrary({ } #if NODERAWSOCKETS - // Chain one addrinfo per {family, addr} entry, returning the head. - function allocaddrinfos(entries) { + // Resolve via node:dns (which honors the host's /etc/hosts), chaining one + // addrinfo per {family, addr} result into *out. + async function lookupHostname() { + var entries = await nodeSockHelpers.lookupHost(node, family); + if (typeof entries == 'number') return entries; var head = 0, prev = 0; for (var entry of entries) { var ai = allocaddrinfo(entry.family, type, proto, null, entry.addr, port); @@ -1076,7 +1078,8 @@ addToLibrary({ } prev = ai; } - return head; + {{{ makeSetValue('out', '0', 'head', '*') }}}; + return 0; } #endif @@ -1193,16 +1196,18 @@ addToLibrary({ // try as a hostname // #if NODERAWSOCKETS - // A real node:dns lookup (which honors the host's /etc/hosts). It is - // asynchronous, and returned as a thunk rather than a started Promise - // because ASYNCIFY re-runs this import body on rewind: Asyncify.handleAsync - // only invokes the thunk on the initial unwind, so the lookup runs once. - return async () => { - var entries = await nodeSockHelpers.lookupHost(node, family); - if (typeof entries == 'number') return entries; - {{{ makeSetValue('out', '0', 'allocaddrinfos(entries)', '*') }}}; - return 0; - }; + // The lookup is asynchronous, so only start it where the calling stack can + // wait on the Promise: a sync-proxied pthread (PROXY_SYNC_ASYNC) awaits it, + // JSPI suspends on it. Otherwise (the event-loop thread itself) it must not + // start at all, since it would write to *out after we have returned. +#if PTHREADS + if (PThread.currentProxiedOperationCallerThread) return lookupHostname(); +#endif +#if JSPI + return lookupHostname(); +#else + return {{{ cDefs.EAI_AGAIN }}}; +#endif #else // resolve the hostname to a temporary fake address node = DNS.lookup_name(node); @@ -1218,36 +1223,16 @@ addToLibrary({ #endif }, - getaddrinfo__deps: ['$doGetAddrInfo', -#if NODERAWSOCKETS && ASYNCIFY - '$Asyncify', -#endif - ], + getaddrinfo__deps: ['$doGetAddrInfo'], getaddrinfo__proxy: 'sync', -#if NODERAWSOCKETS && (PTHREADS || ASYNCIFY) - // A hostname needing a real DNS lookup blocks by returning a Promise, which - // a proxied pthread awaits (PROXY_SYNC_ASYNC) and ASYNCIFY/JSPI suspends on. - // Every other outcome still returns synchronously. +#if NODERAWSOCKETS && (PTHREADS || JSPI) getaddrinfo__async: true, #endif getaddrinfo: (node, service, hint, out) => { var ret = doGetAddrInfo(node, service, hint, out); -#if NODERAWSOCKETS - if (typeof ret == 'function') { -#if PTHREADS - if (PThread.currentProxiedOperationCallerThread) return ret(); -#endif -#if ASYNCIFY - return Asyncify.handleAsync(ret); -#else - // No stack that can wait on the lookup (the event-loop thread itself). - return {{{ cDefs.EAI_AGAIN }}}; -#endif - } -#if PTHREADS +#if NODERAWSOCKETS && PTHREADS // A sync-proxied caller awaits a thenable even for an immediate result. if (PThread.currentProxiedOperationCallerThread) return Promise.resolve(ret); -#endif #endif return ret; }, From dc436907496ae37b3e066a1ec1e70456bc774217 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 13:04:04 -0700 Subject: [PATCH 6/9] getaddrinfo: hold a keepalive across the suspension via Asyncify.handleAsync, support ASYNCIFY --- src/lib/libcore.js | 18 +++++++++++++----- test/sockets/test_dns.c | 11 +++++++++++ test/test_sockets_node.py | 4 ++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 1decd216c610a..07531374a279c 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1014,6 +1014,9 @@ addToLibrary({ $doGetAddrInfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', #if NODERAWSOCKETS '$nodeSockHelpers', +#endif +#if NODERAWSOCKETS && ASYNCIFY + '$Asyncify', #endif ], $doGetAddrInfo: (node, service, hint, out) => { @@ -1198,13 +1201,18 @@ addToLibrary({ #if NODERAWSOCKETS // The lookup is asynchronous, so only start it where the calling stack can // wait on the Promise: a sync-proxied pthread (PROXY_SYNC_ASYNC) awaits it, - // JSPI suspends on it. Otherwise (the event-loop thread itself) it must not - // start at all, since it would write to *out after we have returned. + // ASYNCIFY/JSPI suspend on it. Otherwise (the event-loop thread itself) it + // must not start at all, since it would write to *out after we have + // returned. #if PTHREADS if (PThread.currentProxiedOperationCallerThread) return lookupHostname(); #endif -#if JSPI - return lookupHostname(); +#if ASYNCIFY + // handleAsync holds a runtime keepalive across the suspension, so a user + // callback completing meanwhile does not exit the runtime under main(). + // Everything above this point is pure, so the ASYNCIFY rewind re-running + // this body reaches handleAsync again and takes the stored result. + return Asyncify.handleAsync(lookupHostname); #else return {{{ cDefs.EAI_AGAIN }}}; #endif @@ -1225,7 +1233,7 @@ addToLibrary({ getaddrinfo__deps: ['$doGetAddrInfo'], getaddrinfo__proxy: 'sync', -#if NODERAWSOCKETS && (PTHREADS || JSPI) +#if NODERAWSOCKETS && (PTHREADS || ASYNCIFY) getaddrinfo__async: true, #endif getaddrinfo: (node, service, hint, out) => { diff --git a/test/sockets/test_dns.c b/test/sockets/test_dns.c index aaa1fdec82fc2..4be513aef1e0a 100644 --- a/test/sockets/test_dns.c +++ b/test/sockets/test_dns.c @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -47,6 +48,9 @@ static int count_v4(struct addrinfo* res, const char* addr) { return n; } +int ticked = 0; +void tick(void* arg) { ticked = 1; } + int main(void) { struct addrinfo* res = lookup("10.9.8.7", AF_UNSPEC, 0); assert(count_v4(res, "10.9.8.7") == 1 && !res->ai_next); @@ -56,7 +60,14 @@ int main(void) { #ifdef NO_WAIT lookup("localhost", AF_INET, EAI_AGAIN); #else + // A user callback completing while main() is suspended in the lookup must + // not exit the runtime (EXIT_RUNTIME). Under PROXY_TO_PTHREAD the calling + // thread is parked, so the timer only runs once the lookup has returned. + emscripten_set_timeout(tick, 0, NULL); res = lookup("localhost", AF_INET, 0); +#ifndef __EMSCRIPTEN_PTHREADS__ + assert(ticked); +#endif assert(count_v4(res, "127.0.0.1") == 1); for (struct addrinfo* ai = res; ai; ai = ai->ai_next) { assert(ai->ai_family == AF_INET); diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index f9b28512d4b44..414d27cd37d63 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -229,6 +229,10 @@ def test_noderawsockets_dns_blocking_jspi(self): # Same, but getaddrinfo() suspends the wasm stack under JSPI. self.do_runf('sockets/test_dns.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_dns_blocking_asyncify(self): + # Same, unwinding the wasm stack under ASYNCIFY. + self.do_runf('sockets/test_dns.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sASYNCIFY', '-sEXIT_RUNTIME']) + def test_noderawsockets_epoll_socket_blocking(self): # A blocking epoll_wait() on a socket is woken by an incoming datagram # through the unified readiness wait-queue (the SOCKFS.emit bridge), with From d963cd6c087b04380a6bda612a9a3d4d19558ace Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 13:14:17 -0700 Subject: [PATCH 7/9] getaddrinfo: drop the doGetAddrInfo split; let PROXY_SYNC_ASYNC accept a sync value --- src/lib/libcore.js | 29 ++++++------------- src/lib/libpthread.js | 3 +- .../test_codesize_minimal_pthreads.json | 8 ++--- ...t_codesize_minimal_pthreads_memgrowth.json | 8 ++--- 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 07531374a279c..928777d174877 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1007,11 +1007,7 @@ addToLibrary({ return inetPton4(DNS.lookup_name(nameString)); }, - // Returns an EAI_* code (0 on success, having written the addrinfo list to - // *out), or - under NODERAWSOCKETS, for a hostname needing a real DNS lookup - - // a Promise of one. - $doGetAddrInfo__internal: true, - $doGetAddrInfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', + getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', #if NODERAWSOCKETS '$nodeSockHelpers', #endif @@ -1019,7 +1015,14 @@ addToLibrary({ '$Asyncify', #endif ], - $doGetAddrInfo: (node, service, hint, out) => { + getaddrinfo__proxy: 'sync', +#if NODERAWSOCKETS && (PTHREADS || ASYNCIFY) + // Returns an EAI_* code synchronously, or - for a hostname needing a real + // DNS lookup - a Promise of one, which a sync-proxied pthread awaits and + // ASYNCIFY/JSPI suspend on. + getaddrinfo__async: true, +#endif + getaddrinfo: (node, service, hint, out) => { // Note getaddrinfo currently only returns a single addrinfo with ai_next defaulting to NULL. When NULL // hints are specified or ai_family set to AF_UNSPEC or ai_socktype or ai_protocol set to 0 then we // really should provide a linked list of suitable addrinfo values. @@ -1231,20 +1234,6 @@ addToLibrary({ #endif }, - getaddrinfo__deps: ['$doGetAddrInfo'], - getaddrinfo__proxy: 'sync', -#if NODERAWSOCKETS && (PTHREADS || ASYNCIFY) - getaddrinfo__async: true, -#endif - getaddrinfo: (node, service, hint, out) => { - var ret = doGetAddrInfo(node, service, hint, out); -#if NODERAWSOCKETS && PTHREADS - // A sync-proxied caller awaits a thenable even for an immediate result. - if (PThread.currentProxiedOperationCallerThread) return Promise.resolve(ret); -#endif - return ret; - }, - getnameinfo__deps: ['$DNS', '$readSockaddr', '$stringToUTF8'], getnameinfo: (sa, salen, node, nodelen, serv, servlen, flags) => { var info = readSockaddr(sa, salen); diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index e474545e911d0..7e68b6e14d7d8 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -1086,7 +1086,8 @@ var LibraryPThread = { var rtn = func(...proxiedJSCallArgs); PThread.currentProxiedOperationCallerThread = 0; if (ctx) { - rtn.then((rtn) => __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, rtn)); + // A PROXY_SYNC_ASYNC function may complete synchronously with a plain value. + Promise.resolve(rtn).then((rtn) => __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, rtn)); return; } diff --git a/test/codesize/test_codesize_minimal_pthreads.json b/test/codesize/test_codesize_minimal_pthreads.json index a41c0e3de5b46..f897ee3214106 100644 --- a/test/codesize/test_codesize_minimal_pthreads.json +++ b/test/codesize/test_codesize_minimal_pthreads.json @@ -1,10 +1,10 @@ { - "a.out.js": 6883, - "a.out.js.gz": 3422, + "a.out.js": 6900, + "a.out.js.gz": 3432, "a.out.nodebug.wasm": 19147, "a.out.nodebug.wasm.gz": 8834, - "total": 26030, - "total_gz": 12256, + "total": 26047, + "total_gz": 12266, "sent": [ "a (memory)", "b (exit)", diff --git a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json index 5d890fa247891..5124575dc3ca5 100644 --- a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json +++ b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json @@ -1,10 +1,10 @@ { - "a.out.js": 7341, - "a.out.js.gz": 3639, + "a.out.js": 7358, + "a.out.js.gz": 3648, "a.out.nodebug.wasm": 19148, "a.out.nodebug.wasm.gz": 8835, - "total": 26489, - "total_gz": 12474, + "total": 26506, + "total_gz": 12483, "sent": [ "a (memory)", "b (exit)", From 8f885035798600144fba5114126344ef868fe823 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 13:40:11 -0700 Subject: [PATCH 8/9] rebaseline --- test/codesize/test_codesize_hello_dylink_all.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 7189aa50210dc..19e642f329f77 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 270546, + "a.out.js": 270520, "a.out.nodebug.wasm": 588289, - "total": 858835, + "total": 858809, "sent": [ "IMG_Init", "IMG_Load", From f5b1482c816e31b8575ddd0140bfec892301ba57 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 15:07:33 -0700 Subject: [PATCH 9/9] changelog --- ChangeLog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChangeLog.md b/ChangeLog.md index bfaaea35f5432..68b62107b58e0 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -164,7 +164,7 @@ See docs/process.md for more on how version tagging works. - Under `-sNODERAWSOCKETS`, `getaddrinfo` now performs real name resolution via `node:dns`, blocking the caller where its stack can wait (a proxied pthread, `ASYNCIFY`/`JSPI`) and returning `EAI_AGAIN` otherwise. Results may - now be a linked list, which `freeaddrinfo` frees in full. + now be a linked list, which `freeaddrinfo` frees in full. (#27693) - The following symbols are no longer included in `INCOMING_MODULE_JS_API` by default: - GL_MAX_TEXTURE_IMAGE_UNITS