diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 2dd867bb7..70b6233a1 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2036,8 +2036,9 @@ declare module "net" { * spelling). */ listen(port: number, host: string, callback?: () => void): Server; /* The explicit-interface bind: host is an IP literal (absent = the - * host-less dual-stack any); ipv6Only sets IPV6_V6ONLY. */ - listen(options: { port: number; host?: string; ipv6Only?: boolean }, callback?: () => void): Server; + * host-less dual-stack any); ipv6Only sets IPV6_V6ONLY and reusePort + * requests kernel connection distribution where Node supports it. */ + listen(options: { port: number; host?: string; ipv6Only?: boolean; reusePort?: boolean }, callback?: () => void): Server; close(callback?: () => void): void; /* The bound AddressInfo (Node answers null before listen — this * surface answers the record with port 0 there, the serverPort diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index b8dc5510d..9bcd9bc0a 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -5335,6 +5335,10 @@ function emitNetworkLibCall(state: LibCallState): Temp { emitter.usesTimers = true; emitter.line(`scr_net_listen_opts(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, NULL);${emitter.srcComment(e.loc)}`); return { name: "", type: e.type }; + case "net.listenOptsReusePort": + emitter.usesTimers = true; + emitter.line(`scr_net_listen_opts_reuse_port(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)}, NULL);${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; case "net.listenOptsCb": { emitter.usesTimers = true; // The callback slot may be the `(() => void) | undefined` @@ -5362,6 +5366,32 @@ function emitNetworkLibCall(state: LibCallState): Temp { emitter.line(`scr_net_listen_opts(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${cbExpr});${emitter.srcComment(e.loc)}`); return { name: "", type: e.type }; } + case "net.listenOptsReusePortCb": { + emitter.usesTimers = true; + // The callback slot follows reusePort in the additive ABI and + // may be the `(() => void) | undefined` optional-binding union. + const cbT = e.args[5]!.type; + let cbExpr: string; + if (cbT.kind === "func") { + const cb = args[5]!; + emitter.moveTemp(cb); + cbExpr = cb.name; + } else { + if (cbT.kind !== "union") throw new InternalCompilerError("emitter bug: net.listenOptsReusePortCb callback shape"); + const def = emitter.unionsById.get(cbT.unionId); + const funcTag = def ? def.arms.findIndex((a) => a.kind === "func") : -1; + if (funcTag < 0) throw new InternalCompilerError("emitter bug: net.listenOptsReusePortCb union lacks its func arm"); + const u = args[5]!; + const t = emitter.newTemp( + def!.arms[funcTag]!, + `${u.name}->tag == ${funcTag} ? scr_closure_retain((ScrClosure *)scr_union_peek(${u.name})) : NULL`, + ); + emitter.moveTemp(t); + cbExpr = t.name; + } + emitter.line(`scr_net_listen_opts_reuse_port(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)}, ${cbExpr});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + } case "net.serverPort": return finish(`scr_net_server_port(${arg(0)})`); case "net.serverAddress": { diff --git a/packages/compiler/src/backend/llvm/lib-network.ts b/packages/compiler/src/backend/llvm/lib-network.ts index e2cbd37a9..b50843641 100644 --- a/packages/compiler/src/backend/llvm/lib-network.ts +++ b/packages/compiler/src/backend/llvm/lib-network.ts @@ -40,28 +40,34 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) B.line(`call void @scr_net_listen(ptr ${args[0]!.name}, double ${args[1]!.name}, ptr ${cb})`); return { name: "", type: e.type }; } - if (e.fn === "net.listenOpts" || e.fn === "net.listenOptsCb") { + if (e.fn === "net.listenOpts" || e.fn === "net.listenOptsCb" || + e.fn === "net.listenOptsReusePort" || e.fn === "net.listenOptsReusePortCb") { // The callback slot may be the `(() => void) | undefined` optional- // binding union: unwrap to a nullable closure. const args = e.args.map((a) => host.emitExpr(a)); let cb = "null"; - if (e.fn === "net.listenOptsCb") { - const cbT = e.args[4]!.type; + const reusePort = e.fn === "net.listenOptsReusePort" || e.fn === "net.listenOptsReusePortCb"; + const withCallback = e.fn === "net.listenOptsCb" || e.fn === "net.listenOptsReusePortCb"; + const cbIndex = reusePort ? 5 : 4; + if (withCallback) { + const cbT = e.args[cbIndex]!.type; if (cbT.kind === "func") { - host.moveTemp(args[4]!); - cb = args[4]!.name; + host.moveTemp(args[cbIndex]!); + cb = args[cbIndex]!.name; } else { - if (cbT.kind !== "union") throw new InternalCompilerError("llvm emitter bug: net.listenOptsCb callback shape"); + if (cbT.kind !== "union") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} callback shape`); const def = host.unionsById.get(cbT.unionId); const funcTag = def ? def.arms.findIndex((a) => a.kind === "func") : -1; - if (funcTag < 0) throw new InternalCompilerError("llvm emitter bug: net.listenOptsCb union lacks its func arm"); - cb = host.unwrapNullableClosure(args[4]!.name, funcTag); + if (funcTag < 0) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its func arm`); + cb = host.unwrapNullableClosure(args[cbIndex]!.name, funcTag); } } - const decls = e.args.slice(0, 4).map((a) => (host.llType(a.type) === "i1" ? "i1 zeroext" : host.llType(a.type))); - host.declare(`declare void @scr_net_listen_opts(${decls.join(", ")}, ptr)`); + const valueCount = reusePort ? 5 : 4; + const decls = e.args.slice(0, valueCount).map((a) => (host.llType(a.type) === "i1" ? "i1 zeroext" : host.llType(a.type))); + const runtimeFn = reusePort ? "scr_net_listen_opts_reuse_port" : "scr_net_listen_opts"; + host.declare(`declare void @${runtimeFn}(${decls.join(", ")}, ptr)`); B.line( - `call void @scr_net_listen_opts(${args.slice(0, 4).map((a) => `${host.llType(a.type)} ${a.name}`).join(", ")}, ptr ${cb})`, + `call void @${runtimeFn}(${args.slice(0, valueCount).map((a) => `${host.llType(a.type)} ${a.name}`).join(", ")}, ptr ${cb})`, ); return { name: "", type: e.type }; } diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index ead60b602..a654f9e69 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -677,6 +677,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "sp.finished", "sp.pipeline", "sc.text", "sc.json", "sc.buffer", "net.listen", "net.listenCb", "net.listenOpts", "net.listenOptsCb", + "net.listenOptsReusePort", "net.listenOptsReusePortCb", "net.connect", "net.connectCb", "net.connectLookup", "net.connectAttempt", "fs.existsChk", "http.createServer", "http.createServerEmpty", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 7373ad8f2..27fdacf2a 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -954,16 +954,19 @@ function lowerNetServerMethodCall(lowerer: Lowerer, call: ts.CallExpression, return receiverReturningCall(lowerer, fn, callArgs, NETSERVER_T, loc); }; const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); - // The options-object form — listen({ port, host?, ipv6Only? }[, cb]), + // The options-object form — listen({ port, host?, ipv6Only?, reusePort? }[, cb]), // the portless listenOnProxyInterface shape: host binds that ONE // address (an IP literal — the runtime has no resolver here; absent // = Node's host-less dual-stack any), ipv6Only sets IPV6_V6ONLY // (truthiness, like Node's option handling — `boolean | undefined` - // flows). Every other key fences by name. + // flows). reusePort follows the same truthiness path, but its presence + // selects the additive reuse-port ABI even when the value is false. + // Every other key fences by name. if (ts.isObjectLiteralExpression(args[0]!)) { let port: IrExpr | null = null; let host: IrExpr | null = null; let v6only: IrExpr | null = null; + let reusePort: IrExpr | null = null; for (const prop of (args[0] as ts.ObjectLiteralExpression).properties) { let initializer: ts.Expression | null; if (ts.isPropertyAssignment(prop) && @@ -1007,6 +1010,20 @@ function lowerNetServerMethodCall(lowerer: Lowerer, call: ts.CallExpression, } v6only = v; } + } else if (key === "reusePort") { + if (initializer !== null) { + reusePort = lowerer.lowerCondition(initializer); /* truthiness: `boolean | undefined` flows */ + } else { + const v = lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); + if (v.type.kind !== "bool") { + lowerer.noLowering( + `a listen 'reusePort' option of '${lowerer.fmt(v.type)}' values`, + prop, + "spell the option out (reusePort: value) so non-boolean values can narrow", + ); + } + reusePort = v; + } } else if (key === "signal" && ts.isPropertyAssignment(prop) && isJsSourceFile(call.getSourceFile())) { // A provably-non-AbortSignal signal (the invalid-input probes: @@ -1032,13 +1049,13 @@ function lowerNetServerMethodCall(lowerer: Lowerer, call: ts.CallExpression, lowerer.noLowering( `listen option 'signal'`, prop, - "abort-driven close has no lowering yet — port, host, and ipv6Only are the supported listen options", + "abort-driven close has no lowering yet — port, host, ipv6Only, and reusePort are the supported listen options", ); } else { lowerer.noLowering( `listen option '${key}'`, prop, - "port, host, and ipv6Only are the supported listen options", + "port, host, ipv6Only, and reusePort are the supported listen options", ); } } @@ -1046,13 +1063,17 @@ function lowerNetServerMethodCall(lowerer: Lowerer, call: ts.CallExpression, lowerer.noLowering( "listen options without a port", args[0]!, - "the supported options object is { port, host?, ipv6Only? } — port 0 binds an ephemeral port", + "the supported options object is { port, host?, ipv6Only?, reusePort? } — port 0 binds an ephemeral port", ); } host ??= { kind: "strLit", value: "", type: STRING, loc }; /* "" = the dual-stack any default */ v6only ??= boolLit(false, loc); + const listenOptsFn: IrLibFn = reusePort === null ? "net.listenOpts" : "net.listenOptsReusePort"; + const listenOptsCbFn: IrLibFn = reusePort === null ? "net.listenOptsCb" : "net.listenOptsReusePortCb"; if (args.length === 1) { - return listenResult("net.listenOpts", [receiver, port, host, v6only]); + return listenResult(listenOptsFn, reusePort === null + ? [receiver, port, host, v6only] + : [receiver, port, host, v6only, reusePort]); } // The callback may be an OPTIONAL binding — `(() => void) | // undefined`, portless's listenOnProxyInterface pass-through: the @@ -1085,7 +1106,9 @@ function lowerNetServerMethodCall(lowerer: Lowerer, call: ts.CallExpression, `listen callbacks of type '${lowerer.fmt(cbV.type)}' (use () — an optional \`(() => void) | undefined\` binding also flows)`, ); } - return listenResult("net.listenOptsCb", [receiver, port, host, v6only, cbV]); + return listenResult(listenOptsCbFn, reusePort === null + ? [receiver, port, host, v6only, cbV] + : [receiver, port, host, v6only, reusePort, cbV]); } const port = lowerer.lowerExprExpecting(args[0]!, F64); // The optional middle host — listen(port, '127.0.0.1'[, cb]): a diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index aed30d105..0d89fa2a0 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2384,10 +2384,18 @@ export type IrLibFn = * (portless's listenOnProxyInterface): args [server, port, host, * ipv6Only]. host is an IP literal string ("" = the host-less * dual-stack any default); ipv6Only sets IPV6_V6ONLY before the bind. - * Failures are the async 'error', message in Node's listen shape with - * the requested host. Never throws. */ + * These are the old-shaped calls, retained for omitted reusePort so + * serialized IR remains compatible. Failures are the async 'error', + * message in Node's listen shape with the requested host. Never throws. */ | "net.listenOpts" | "net.listenOptsCb" + /** Additive ABI for listen({ ..., reusePort }) — args + * [server, port, host, ipv6Only, reusePort], with the callback following + * those values in the callback form. The final BOOL is the requested + * kernel connection-distribution option; old spellings remain + * four/five-argument calls so serialized IR stays compatible. */ + | "net.listenOptsReusePort" + | "net.listenOptsReusePortCb" | "net.serverPort" /** server.address() as the full AddressInfo record (the dgram.address * materialization pattern: the emitter builds the record from the three diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 86553251e..561565a79 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -348,6 +348,10 @@ export const LIB_FN_SIGS: Record void) | undefined` optional-binding union (checked specially). "net.listenOptsCb": { argTypes: [NETSERVER_T, F64, STRING, BOOL, null], result: VOID }, + "net.listenOptsReusePort": { argTypes: [NETSERVER_T, F64, STRING, BOOL, BOOL], result: VOID }, + // The callback is a zero-param void closure OR its optional-binding union; + // the callback follows the reusePort BOOL in this additive ABI. + "net.listenOptsReusePortCb": { argTypes: [NETSERVER_T, F64, STRING, BOOL, BOOL, null], result: VOID }, "net.serverPort": { argTypes: [NETSERVER_T], result: F64 }, // net.serverAddress's record result is shape-checked in the libCall case // (the dgram.address sentinel pattern). @@ -3957,8 +3961,9 @@ function validateFunction( } break; } - if (e.fn === "net.listenOptsCb") { - const t = e.args[4]?.type; + if (e.fn === "net.listenOptsCb" || e.fn === "net.listenOptsReusePortCb") { + const cbIndex = e.fn === "net.listenOptsReusePortCb" ? 5 : 4; + const t = e.args[cbIndex]?.type; const funcOk = (x: IrType | undefined): boolean => x?.kind === "func" && x.params.length === 0 && x.ret.kind === "void"; let ok = funcOk(t); @@ -3968,7 +3973,7 @@ function validateFunction( def.arms.some((a) => a.kind === "undefinedT"); } if (!ok) { - err(`libCall net.listenOptsCb callback shape (frontend must fence)`, e.loc); + err(`libCall ${e.fn} callback shape (frontend must fence)`, e.loc); } break; } diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 9f95d86d2..0701f0c23 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -107,6 +107,20 @@ #include #include #include +#if defined(__FreeBSD__) || defined(__DragonFly__) || defined(_AIX) || defined(__sun) +#include +#endif +#endif + +/* MinGW's errno headers do not expose ENOTSUP on every supported toolchain. + * Keep a stable internal value for the Node/libuv unsupported reuse-port + * result, while preserving the platform's EOPNOTSUPP spelling when present. */ +#ifndef ENOTSUP +#ifdef EOPNOTSUPP +#define ENOTSUP EOPNOTSUPP +#else +#define ENOTSUP 4095 +#endif #endif static void scr_net_oom(void) { @@ -275,6 +289,10 @@ static const char *scr_net_errname(int err) { case EHOSTDOWN: return "EHOSTDOWN"; /* absent from the win32 CRT */ #endif case EINVAL: return "EINVAL"; + case ENOTSUP: return "ENOTSUP"; +#if defined(EOPNOTSUPP) && (!defined(ENOTSUP) || EOPNOTSUPP != ENOTSUP) + case EOPNOTSUPP: return "EOPNOTSUPP"; +#endif default: return "EUNKNOWN"; } } @@ -710,6 +728,62 @@ static void scr_net_listen_reuseaddr(int fd) { #endif } +/* Node/libuv's TCP reuse-port matrix. SO_REUSEPORT is deliberately NOT + * selected merely because a platform header happens to expose it: macOS's + * meaning is not the required TCP load-balancing contract, and Windows' + * SO_REUSEADDR is port hijacking rather than reuse-port support. FreeBSD's + * ordinary SO_REUSEPORT does not distribute connections, so FreeBSD 12+ + * uses the load-balancing spelling instead. A setsockopt failure is returned + * to the caller so an unsupported kernel cannot silently become a singleton. */ +static int scr_net_listen_reuseport(int fd) { + int rc; +#if defined(__FreeBSD__) && defined(__FreeBSD_version) && __FreeBSD_version >= 1200000 && defined(SO_REUSEPORT_LB) + int one = 1; + rc = setsockopt(fd, SOL_SOCKET, SO_REUSEPORT_LB, &one, sizeof one); +#elif (defined(__linux__) || defined(_AIX73) || \ + (defined(__DragonFly__) && defined(__DragonFly_version) && __DragonFly_version >= 300600) || \ + (defined(__sun) && !defined(__illumos__) && defined(SO_FLOW_NAME))) && \ + defined(SO_REUSEPORT) + int one = 1; + rc = setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); +#else + (void)fd; + errno = ENOTSUP; + return -1; +#endif +#if defined(__linux__) && defined(ENOPROTOOPT) + /* Linux kernels predating 3.9 expose no reuse-port TCP support. Keep + * Node's stable unsupported result instead of leaking ENOPROTOOPT. */ + if (rc != 0 && errno == ENOPROTOOPT) errno = ENOTSUP; +#endif + return rc; +} + +/* Shared bind setup for both host-less and explicit-interface listeners. + * SO_REUSEADDR remains first, reuse-port is requested next, and both are + * complete before bind/listen. The returned errno is the native failure. */ +static int scr_net_bind_listen(int fd, const struct sockaddr *sa, socklen_t salen, + bool reuse_port) { + scr_net_listen_reuseaddr(fd); + if (reuse_port && scr_net_listen_reuseport(fd) != 0) return -1; + scr_net_nonblock(fd); + int rc = bind(fd, sa, salen); + if (rc == 0) rc = listen(fd, 511); /* Node's default backlog */ + return rc; +} + +static const char *scr_net_listen_reason(int err, bool explicit_host) { + if (err == EADDRINUSE) return "address already in use"; +#ifdef EADDRNOTAVAIL + if (explicit_host && err == EADDRNOTAVAIL) return "address not available"; +#endif + if (err == ENOTSUP) return "operation not supported"; +#if defined(EOPNOTSUPP) && (!defined(ENOTSUP) || EOPNOTSUPP != ENOTSUP) + if (err == EOPNOTSUPP) return "operation not supported"; +#endif + return "permission denied"; +} + /* write(2) to a socket with SIGPIPE suppressed. macOS sets SO_NOSIGPIPE * per fd above (send() path unchanged: write, exactly the historical * call); Linux has no SO_NOSIGPIPE — MSG_NOSIGNAL on each send is the @@ -1280,7 +1354,8 @@ ScrNetServer *scr_net_create_server(ScrClosure *handler /*moves, nullable*/, Scr * 'error'); success defers the listen callback ('listening') to the next * dispatch pass, Node's next-tick emit. Binds like Node's host-less * listen: IPv6 any with dual-stack when the kernel allows, IPv4 fallback. */ -void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullable*/) { +static void scr_net_listen_any(ScrNetServer *s, double port, bool reuse_port, + ScrClosure *cb /*moves, nullable*/) { if (cb) scr_net_ls_add(&s->listening_cbs, cb, NULL, true); if (s->listening || s->close_emitted) return; if (!scr_net_poller_init()) { @@ -1300,8 +1375,6 @@ void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullab fputs("scriptc: socket() failed\n", stderr); abort(); } - scr_net_listen_reuseaddr(fd); - scr_net_nonblock(fd); int rc; if (v6) { struct sockaddr_in6 addr; @@ -1309,23 +1382,22 @@ void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullab addr.sin6_family = AF_INET6; addr.sin6_addr = in6addr_any; addr.sin6_port = htons((uint16_t)p); - rc = bind(fd, (struct sockaddr *)&addr, sizeof addr); + rc = scr_net_bind_listen(fd, (struct sockaddr *)&addr, sizeof addr, reuse_port); } else { struct sockaddr_in addr; memset(&addr, 0, sizeof addr); addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons((uint16_t)p); - rc = bind(fd, (struct sockaddr *)&addr, sizeof addr); + rc = scr_net_bind_listen(fd, (struct sockaddr *)&addr, sizeof addr, reuse_port); } - if (rc == 0) rc = listen(fd, 511); /* Node's default backlog */ if (rc != 0) { int err = errno; close(fd); char msg[128]; /* Node: "listen EADDRINUSE: address already in use :::4000" */ snprintf(msg, sizeof msg, "listen %s: %s %s:%d", scr_net_errname(err), - err == EADDRINUSE ? "address already in use" : "permission denied", + scr_net_listen_reason(err, false), v6 ? "::" : "0.0.0.0", p); if (!s->pending_err) s->pending_err = scr_str_new(msg, strlen(msg)); scr_net_server_register(s); /* the sweep delivers the failure */ @@ -1347,7 +1419,11 @@ void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullab scr_net_server_register(s); } -/* listen({ port, host, ipv6Only }) — the explicit-interface bind +void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullable*/) { + scr_net_listen_any(s, port, false, cb); +} + +/* listen({ port, host, ipv6Only, reusePort }) — the explicit-interface bind * (portless's listenOnProxyInterface): host is an IP literal ("" = the * host-less dual-stack default above; "localhost" pins to 127.0.0.1, the * connect-side divergence); ipv6Only sets IPV6_V6ONLY before the bind @@ -1355,10 +1431,11 @@ void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullab * listener, exactly the portless pair). Failures are the async 'error' * with Node's listen message naming the requested host; a non-IP host is * the async getaddrinfo ENOTFOUND (no resolver in this slice). */ -void scr_net_listen_opts(ScrNetServer *s, double port, ScrStr *host /*borrowed*/, - bool ipv6_only, ScrClosure *cb /*moves, nullable*/) { +static void scr_net_listen_opts_impl(ScrNetServer *s, double port, ScrStr *host /*borrowed*/, + bool ipv6_only, bool reuse_port, + ScrClosure *cb /*moves, nullable*/) { if (host == NULL || host->len == 0) { - scr_net_listen(s, port, cb); + scr_net_listen_any(s, port, reuse_port, cb); return; } if (cb) scr_net_ls_add(&s->listening_cbs, cb, NULL, true); @@ -1406,18 +1483,13 @@ void scr_net_listen_opts(ScrNetServer *s, double port, ScrStr *host /*borrowed*/ int only = ipv6_only ? 1 : 0; setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &only, sizeof only); } - scr_net_listen_reuseaddr(fd); - scr_net_nonblock(fd); - int rc = bind(fd, sa, salen); - if (rc == 0) rc = listen(fd, 511); /* Node's default backlog */ + int rc = scr_net_bind_listen(fd, sa, salen, reuse_port); if (rc != 0) { int err = errno; close(fd); char msg[160]; snprintf(msg, sizeof msg, "listen %s: %s %s:%d", scr_net_errname(err), - err == EADDRINUSE ? "address already in use" - : err == EADDRNOTAVAIL ? "address not available" - : "permission denied", + scr_net_listen_reason(err, true), h, p); if (!s->pending_err) s->pending_err = scr_str_new(msg, strlen(msg)); scr_net_server_register(s); @@ -1440,6 +1512,17 @@ void scr_net_listen_opts(ScrNetServer *s, double port, ScrStr *host /*borrowed*/ scr_net_server_register(s); } +void scr_net_listen_opts(ScrNetServer *s, double port, ScrStr *host /*borrowed*/, + bool ipv6_only, ScrClosure *cb /*moves, nullable*/) { + scr_net_listen_opts_impl(s, port, host, ipv6_only, false, cb); +} + +void scr_net_listen_opts_reuse_port(ScrNetServer *s, double port, ScrStr *host /*borrowed*/, + bool ipv6_only, bool reuse_port, + ScrClosure *cb /*moves, nullable*/) { + scr_net_listen_opts_impl(s, port, host, ipv6_only, reuse_port, cb); +} + double scr_net_server_port(ScrNetServer *s) { return (double)s->port; } double scr_net_server_timeout_get(ScrNetServer *s, double field) { diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 740942cce..7885adbb0 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -5317,10 +5317,18 @@ void scr_net_sock_release_v(void *p); ScrNetServer *scr_net_create_server(ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn); /* +1 */ void scr_net_listen(ScrNetServer *s, double port, ScrClosure *cb /*moves, nullable*/); -/* listen({ port, host, ipv6Only }): host is an IP literal ("" = the - * dual-stack any default); ipv6Only sets IPV6_V6ONLY before the bind. */ +/* listen({ port, host, ipv6Only, reusePort }): host is an IP literal ("" = + * the dual-stack any default); ipv6Only sets IPV6_V6ONLY before the bind. + * The old entry point ignores reusePort and is retained for old IR. */ void scr_net_listen_opts(ScrNetServer *s, double port, ScrStr *host /*borrowed*/, bool ipv6_only, ScrClosure *cb /*moves, nullable*/); +/* Additive ABI for listen({ port, host, ipv6Only, reusePort }): reuse_port + * requests the platform matrix documented in scr_net.c. Unsupported + * platforms and kernels fail asynchronously; they never fall back to an + * ordinary single listener. */ +void scr_net_listen_opts_reuse_port(ScrNetServer *s, double port, ScrStr *host /*borrowed*/, + bool ipv6_only, bool reuse_port, + ScrClosure *cb /*moves, nullable*/); double scr_net_server_port(ScrNetServer *s); /* address().port */ /* Writable http.Server timeout property storage. `field` is the compiler * ABI selector: timeout, keepAliveTimeout, headersTimeout, requestTimeout, diff --git a/tests/fixtures/server/cases/http-reuse-port/main.ts b/tests/fixtures/server/cases/http-reuse-port/main.ts new file mode 100644 index 000000000..412ee3857 --- /dev/null +++ b/tests/fixtures/server/cases/http-reuse-port/main.ts @@ -0,0 +1,76 @@ +/* http.Server inherits net.Server.listen({ reusePort: true }) through the + * shared server handle. Fresh requests must reach both HTTP listeners on + * supported platforms; unsupported platforms report the same stable error + * code shape as Node. */ +import { createServer } from "node:http"; +import { get } from "node:http"; + +const host = "127.0.0.1"; +const wanted = 64; +let receivedA = 0; +let receivedB = 0; +let completed = 0; +let finished = false; + +const first = createServer((_req, res) => { + receivedA++; + res.end("a"); +}); +const second = createServer((_req, res) => { + receivedB++; + res.end("b"); +}); + +function isUnsupported(error: Error): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOTSUP" || code === "EOPNOTSUPP"; +} + +function closeBoth(done: () => void): void { + first.close(() => second.close(done)); +} + +function finish(): void { + if (finished) return; + finished = true; + closeBoth(() => console.log(receivedA > 0 && receivedB > 0 ? "distributed" : "not distributed")); +} + +function requestOne(port: number): void { + const request = get({ hostname: host, port, path: "/" }, (response) => { + response.on("data", () => {}); + response.on("end", () => { + completed++; + if (completed === wanted) finish(); + }); + }); + request.on("error", () => {}); +} + +function requestMany(port: number): void { + for (let i = 0; i < wanted; i++) requestOne(port); +} + +first.on("error", (error) => { + if (finished) return; + finished = true; + if (isUnsupported(error)) console.log("unsupported true"); + else console.log("unexpected true error"); +}); + +second.on("error", (error) => { + if (finished) return; + finished = true; + first.close(() => console.log(isUnsupported(error) ? "unsupported true" : "unexpected true error")); +}); + +first.listen({ port: 0, host, reusePort: true }, () => { + const address = first.address(); + if (address === null || typeof address === "string") { + console.log("unexpected address"); + return; + } + second.listen({ port: address.port, host, reusePort: true }, () => { + requestMany(address.port); + }); +}); diff --git a/tests/fixtures/server/cases/net-listen-opts/main.ts b/tests/fixtures/server/cases/net-listen-opts/main.ts index f1156a248..2d56111da 100644 --- a/tests/fixtures/server/cases/net-listen-opts/main.ts +++ b/tests/fixtures/server/cases/net-listen-opts/main.ts @@ -1,5 +1,5 @@ /* The portless listenOnProxyInterface shape: listen({ port, host, - * ipv6Only }[, cb]) — the explicit v4/v6 listener PAIR on one port + * ipv6Only, reusePort }[, cb]) — the explicit v4/v6 listener PAIR on one port * (getProxyBindTargets' loopback targets), each family answered by its * own server, plus the EADDRINUSE arm (same host, same port) and the * host that exists on no interface. Ephemeral ports never print; the @@ -9,10 +9,10 @@ import * as net from "node:net"; const v4 = net.createServer((socket) => socket.end("v4\n")); const v6 = net.createServer((socket) => socket.end("v6\n")); -type ProxyBindTarget = { host: string; ipv6Only?: boolean }; +type ProxyBindTarget = { host: string; ipv6Only?: boolean; reusePort?: boolean }; /* portless's listenOnProxyInterface, verbatim shapes: the target record - * (ipv6Only optional — `boolean | undefined` flows into the option) and + * (ipv6Only and reusePort optional — `boolean | undefined` flows into the option) and * the OPTIONAL listener binding (`(() => void) | undefined`). */ function listenOnProxyInterface( server: net.Server, @@ -20,7 +20,12 @@ function listenOnProxyInterface( target: ProxyBindTarget, listener?: () => void ): void { - server.listen({ port, host: target.host, ipv6Only: target.ipv6Only }, listener); + server.listen({ + port, + host: target.host, + ipv6Only: target.ipv6Only, + reusePort: target.reusePort, + }, listener); } function readFrom(host: string, port: number): Promise { @@ -52,7 +57,7 @@ async function main(port: number): Promise { }); // The OMITTED-listener call — the undefined arm of the optional // callback flows through listenOnProxyInterface's pass-through. - listenOnProxyInterface(clash, port, { host: "127.0.0.1" }); + listenOnProxyInterface(clash, port, { host: "127.0.0.1", reusePort: false }); } listenOnProxyInterface(v4, 0, { host: "127.0.0.1" }, () => { diff --git a/tests/fixtures/server/cases/net-port-check/main.ts b/tests/fixtures/server/cases/net-port-check/main.ts index 88228c3e0..3ad550f40 100644 --- a/tests/fixtures/server/cases/net-port-check/main.ts +++ b/tests/fixtures/server/cases/net-port-check/main.ts @@ -16,7 +16,9 @@ first.listen(0, () => { } first.close(() => console.log("released")); }); - second.listen(port, () => { + // Explicit false follows the new options ABI but must retain the ordinary + // single-listener EADDRINUSE behavior. + second.listen({ port, host: "127.0.0.1", reusePort: false }, () => { console.log("bound twice?!"); second.close(); }); diff --git a/tests/fixtures/server/cases/net-reuse-port/main.ts b/tests/fixtures/server/cases/net-reuse-port/main.ts new file mode 100644 index 000000000..13432598b --- /dev/null +++ b/tests/fixtures/server/cases/net-reuse-port/main.ts @@ -0,0 +1,87 @@ +/* Node's net.Server.listen({ reusePort: true }) contract. Linux and the + * other supported Unix targets bind two listeners to one IPv4 endpoint and + * distribute fresh connections between them. Unsupported targets report the + * stable Node error-code shape instead; no platform silently degrades to a + * single listener. */ +import { connect, createServer } from "node:net"; + +const host = "127.0.0.1"; +const wanted = 128; +let receivedA = 0; +let receivedB = 0; +let completed = 0; +let finished = false; + +const first = createServer((socket) => { + receivedA++; + socket.end(); +}); +const second = createServer((socket) => { + receivedB++; + socket.end(); +}); + +function isUnsupported(error: Error): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOTSUP" || code === "EOPNOTSUPP"; +} + +function closeBoth(done: () => void): void { + first.close(() => second.close(done)); +} + +function finish(): void { + if (finished) return; + finished = true; + closeBoth(() => console.log(receivedA > 0 && receivedB > 0 ? "distributed" : "not distributed")); +} + +function openOne(port: number): void { + const socket = connect(port, host); + // Consume the server's FIN so the native socket arms its read side just + // as Node does for a socket with no application payload. + socket.on("data", () => {}); + socket.on("error", () => { + }); + socket.on("close", () => { + completed++; + if (completed === wanted) finish(); + }); +} + +function openMany(port: number): void { + for (let i = 0; i < wanted; i++) openOne(port); +} + +first.on("error", (error) => { + if (finished) return; + if (isUnsupported(error)) { + finished = true; + console.log("unsupported true"); + } else { + console.log("unexpected true error"); + finished = true; + } +}); + +second.on("error", (error) => { + if (finished) return; + if (isUnsupported(error)) { + finished = true; + first.close(() => console.log("unsupported true")); + } else { + finished = true; + first.close(() => console.log("unexpected true error")); + } +}); + +first.listen({ port: 0, host, reusePort: true }, () => { + const address = first.address(); + if (address === null || typeof address === "string") { + console.log("unexpected address"); + return; + } + second.listen({ port: address.port, host, reusePort: true }, () => { + openMany(address.port); + }); +}); diff --git a/tests/harness/server.test.ts b/tests/harness/server.test.ts index 41a1b23a7..076d27413 100644 --- a/tests/harness/server.test.ts +++ b/tests/harness/server.test.ts @@ -143,4 +143,29 @@ describe(`server differential (${cases.length} programs${sanitize ? ", sanitized expect(nativeRes.exitCode).toBe(nodeRes.exitCode); expect(nativeRes.driverStdout).toBe(nodeRes.driverStdout); }, 120_000); + + test("net-reuse-port emits the additive LLVM ABI", async () => { + const entry = join(fixturesRoot, "cases/net-reuse-port/main.ts"); + const outDir = join(cacheDir, `server-llvm-reuse-port${sanitize ? "-san" : ""}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { + outPath: join(outDir, "program"), + outDir, + sanitize, + backend: "llvm", + }); + if (!result.ok) { + throw new Error( + "net-reuse-port LLVM fixture failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const llvm = readFileSync(result.cPath, "utf8"); + expect(llvm).toContain( + "declare void @scr_net_listen_opts_reuse_port(ptr, double, ptr, i1 zeroext, i1 zeroext, ptr)", + ); + expect(llvm).toMatch( + /call void @scr_net_listen_opts_reuse_port\(ptr [^,]+, double [^,]+, ptr [^,]+, i1 [^,]+, i1 [^,]+, ptr [^)]+\)/, + ); + }); });