diff --git a/ChangeLog.md b/ChangeLog.md index e09084933ccd9..54449f3da96e8 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -154,6 +154,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 890f3cea6a807..c5422ca021427 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'], $nodeSockHelpers: { // node builtins, resolved once each. getBuiltinModule works in both // CommonJS and ESM output, with require as the fallback. @@ -69,6 +69,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/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index b193194f47af9..daf1199bab653 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": 270584, + "a.out.js": 270610, "a.out.nodebug.wasm": 588233, - "total": 858817, + "total": 858843, "sent": [ "IMG_Init", "IMG_Load", 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 f549d996258e9..c669b7db949ca 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -209,6 +209,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