diff --git a/ChangeLog.md b/ChangeLog.md index 3420041cbf3b1..68b62107b58e0 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -161,6 +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 + 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. (#27693) - 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..928777d174877 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1007,8 +1007,21 @@ addToLibrary({ return inetPton4(DNS.lookup_name(nameString)); }, - getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl'], + getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', +#if NODERAWSOCKETS + '$nodeSockHelpers', +#endif +#if NODERAWSOCKETS && ASYNCIFY + '$Asyncify', +#endif + ], 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 @@ -1055,6 +1068,27 @@ addToLibrary({ return ai; } +#if NODERAWSOCKETS + // 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); + if (prev) { + {{{ makeSetValue('prev', C_STRUCTS.addrinfo.ai_next, 'ai', '*') }}}; + } else { + head = ai; + } + prev = ai; + } + {{{ makeSetValue('out', '0', 'head', '*') }}}; + return 0; + } +#endif + if (hint) { flags = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_flags, 'i32') }}}; family = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_family, 'i32') }}}; @@ -1167,6 +1201,25 @@ addToLibrary({ // // try as a hostname // +#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, + // 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 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 +#else // resolve the hostname to a temporary fake address node = DNS.lookup_name(node); addr = inetPton4(node); @@ -1178,6 +1231,7 @@ addToLibrary({ ai = allocaddrinfo(family, type, proto, null, addr, port); {{{ makeSetValue('out', '0', 'ai', '*') }}}; return 0; +#endif }, getnameinfo__deps: ['$DNS', '$readSockaddr', '$stringToUTF8'], 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/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index b5428ffae9add..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', + $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$inetPton4', '$inetPton6', #if ASSERTIONS '$warnOnce', #endif @@ -73,6 +73,37 @@ var NodeSockFSLibrary = { getDgram() { return nodeSockHelpers.dgramModule ??= (process.getBuiltinModule || require)('dgram'); }, + getDns() { + return nodeSockHelpers.dnsModule ??= (process.getBuiltinModule || require)('dns'); + }, + // 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/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index b6d1cd83f9864..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": 270520, - "a.out.nodebug.wasm": 588266, - "total": 858786, + "a.out.nodebug.wasm": 588289, + "total": 858809, "sent": [ "IMG_Init", "IMG_Load", 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)", diff --git a/test/sockets/test_dns.c b/test/sockets/test_dns.c new file mode 100644 index 0000000000000..4be513aef1e0a --- /dev/null +++ b/test/sockets/test_dns.c @@ -0,0 +1,102 @@ +/* + * 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 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). + */ + +#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 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); + freeaddrinfo(res); + + // A hostname is a real node:dns lookup. +#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); + } + freeaddrinfo(res); + + // 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) { + if (ai->ai_family == AF_INET6) { + struct sockaddr_in6* sin6 = (struct sockaddr_in6*)ai->ai_addr; + assert(ai->ai_addrlen == sizeof(*sin6)); + assert(ntohs(sin6->sin6_port) == 80); + assert(IN6_IS_ADDR_LOOPBACK(&sin6->sin6_addr)); + } else { + assert(ai->ai_family == AF_INET); + } + } + 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..414d27cd37d63 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -212,6 +212,27 @@ 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 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 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']) + + @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_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