From ec9f8660e6a1dc0eaeb804334cd6d605b7bd98fb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 11 Aug 2026 00:35:28 -0500 Subject: [PATCH] Fix HTTP server timeout property lowering --- .../ambient/scriptc-node-fallback.d.ts | 4 +- .../src/backend/emission/emit-exprs.ts | 2 + packages/compiler/src/backend/llvm/emitter.ts | 1 + .../src/frontend/lowering/lower-server.ts | 101 +++++++++++++----- .../src/frontend/lowering/surfaces.ts | 7 +- packages/compiler/src/ir/nodes.ts | 2 + packages/compiler/src/ir/validate.ts | 1 + packages/runtime/src/scr_net.c | 59 +++++++++- packages/runtime/src/scr_runtime.h | 3 + .../2689-http-server-timeout-properties.ts | 28 ++++- 10 files changed, 170 insertions(+), 38 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 84fd6038d..7fc01d668 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2138,11 +2138,13 @@ declare module "http" { }; /* The options-record stance (the RequestOptions precedent): every * documented key typechecks; the lowering's option walk decides per - * key — requireHostHeader: false and joinDuplicateHeaders lower, + * key — requireHostHeader: false, joinDuplicateHeaders, and + * keepAliveTimeoutBuffer lower, * documented-but-unlowered keys fence by name, unknown keys drop. */ export interface ServerOptions { requireHostHeader?: boolean; joinDuplicateHeaders?: boolean; + keepAliveTimeoutBuffer?: number; [option: string]: unknown; } /* The outgoing-header shape, @types/node's matrix: numbers format via diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index 6f0a74d81..769289168 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -4469,6 +4469,8 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { case "http.serverTimeoutSet": E.line(`scr_net_server_timeout_set(${arg(0)}, ${arg(1)}, ${arg(2)});${E.srcComment(e.loc)}`); return { name: "", type: e.type }; + case "http.serverTimeoutOptionSet": + return finish(`scr_net_server_timeout_option_set(${arg(0)}, ${arg(1)}, ${arg(2)})`); case "net.serverOnListening": { const cb = args[1]!; E.moveTemp(cb); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 89352b915..133dc89c5 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -688,6 +688,7 @@ const LIB_FN_SYMS: Record = { "http.serverJoinDupHeaders": "scr_http_server_join_duplicate_headers", "http.serverTimeoutGet": "scr_net_server_timeout_get", "http.serverTimeoutSet": "scr_net_server_timeout_set", + "http.serverTimeoutOptionSet": "scr_net_server_timeout_option_set", "http.clientWrite": "scr_http_client_write_str", "http.clientWriteBytes": "scr_http_client_write_bytes", "http.clientEnd": "scr_http_client_end", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 30b006189..86548af65 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2001,7 +2001,7 @@ function lowerH2StreamMethodCall(L: Lowerer, call: ts.CallExpression, * the two reads never materializes; the runtime answers the bound port * directly. Null for every other property shape. */ export function lowerServerProperty(L: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { - if (expr.questionDotToken) return null; + if (expr.questionDotToken && !L.chainHandled.has(expr)) return null; const loc = locOf(expr); // req.url / req.method — always-present strings on server requests; // req.headers.NAME — the `string | undefined` union (envGet's type). @@ -2277,13 +2277,15 @@ function lowerRequestHandlerArg(L: Lowerer, node: ts.Expression): IrExpr { } /** The http.createServer / http.Server options object — { requireHostHeader?, - * joinDuplicateHeaders?, ... }: the two lowered keys are exactly the - * parser behaviors this runtime has (requireHostHeader: false IS the - * parser's stance — it never answers 400 for a missing Host; - * joinDuplicateHeaders: true joins repeated request-header reads with - * ", "). Other documented keys fence by name; unknown keys drop like - * Node drops them. Answers whether joinDuplicateHeaders was requested. */ -function lowerHttpServerOptions(L: Lowerer, node: ts.Expression, what: string): { joinDup: boolean } { + * joinDuplicateHeaders?, keepAliveTimeoutBuffer?, ... }. The first two + * select parser behavior; keepAliveTimeoutBuffer initializes the matching + * writable server field after Node's non-negative-safe-integer validation. + * Other documented keys fence by name; unknown keys drop like Node drops + * them. */ +function lowerHttpServerOptions(L: Lowerer, node: ts.Expression, what: string): { + joinDup: boolean; + keepAliveTimeoutBuffer: IrExpr | null; +} { if (!ts.isObjectLiteralExpression(node)) { L.noLowering( `${what} with a non-literal options argument`, @@ -2292,6 +2294,7 @@ function lowerHttpServerOptions(L: Lowerer, node: ts.Expression, what: string): ); } let joinDup = false; + let keepAliveTimeoutBuffer: IrExpr | null = null; for (const prop of node.properties) { let initializer: ts.Expression | null; if (ts.isPropertyAssignment(prop) && @@ -2337,20 +2340,34 @@ function lowerHttpServerOptions(L: Lowerer, node: ts.Expression, what: string): "the lowered forms are the literals joinDuplicateHeaders: true (repeats join \", \") and false (the keep-first default)", ); } + if (key === "keepAliveTimeoutBuffer") { + if (keepAliveTimeoutBuffer !== null) { + L.noLowering( + `${what} with duplicate keepAliveTimeoutBuffer options`, + prop, + "write keepAliveTimeoutBuffer once in the options literal", + ); + } + const value = initializer !== null + ? L.lowerExpr(initializer) + : L.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); + keepAliveTimeoutBuffer = L.coerceToExpected(value, F64); + continue; + } fenceOrDropOptionKey( L, prop, key, what, HTTP_SERVER_DOCUMENTED_OPTIONS, - "requireHostHeader: false and joinDuplicateHeaders are the supported options", + "requireHostHeader: false, joinDuplicateHeaders, and keepAliveTimeoutBuffer are the supported options", ); // An undocumented key, dropped like Node drops it. } - return { joinDup }; + return { joinDup, keepAliveTimeoutBuffer }; } /** The shared createServer([options][, handler]) shapes behind * http.createServer, http.Server, and `new http.Server`: no arguments * (the on("request") route), a lone handler, a lone options object, and - * (options, handler). joinDuplicateHeaders wraps the construction in the - * flag-setting helper. */ + * (options, handler). Modeled option values wrap the construction in an + * interned option-setting helper. */ function lowerHttpCreateServerForms(L: Lowerer, expr: ts.CallExpression | ts.NewExpression, what: string, loc: SrcLoc,): IrExpr { const args = expr.arguments ?? ([] as unknown as ts.NodeArray); @@ -2370,36 +2387,62 @@ function lowerHttpCreateServerForms(L: Lowerer, expr: ts.CallExpression | ts.New if (ts.isObjectLiteralExpression(args[0]!)) optsNode = args[0]!; else handlerNode = args[0]!; } - const joinDup = optsNode !== null && lowerHttpServerOptions(L, optsNode, what).joinDup; + const opts = optsNode !== null + ? lowerHttpServerOptions(L, optsNode, what) + : { joinDup: false, keepAliveTimeoutBuffer: null }; const server: IrExpr = handlerNode !== null ? { kind: "libCall", fn: "http.createServer", args: [lowerRequestHandlerArg(L, handlerNode)], type: NETSERVER_T, loc } : { kind: "libCall", fn: "http.createServerEmpty", args: [], type: NETSERVER_T, loc }; - if (!joinDup) return server; - // The flag-setting composition: an interned helper takes the fresh - // server, sets the parser flag, and answers it — one expression. - const key = `server.joindup`; + if (!opts.joinDup && opts.keepAliveTimeoutBuffer === null) return server; + // The option-setting composition: an interned helper takes the option + // values first (preserving Node's options-before-handler evaluation + // order), then the fresh server, applies the modeled fields, and answers + // the server as the constructor/factory result. + const hasBuffer = opts.keepAliveTimeoutBuffer !== null; + const key = `server.opts:${opts.joinDup ? 1 : 0}:${hasBuffer ? 1 : 0}`; const existing = L.arrHofHelpers.get(key); - const name = existing ?? `%server.joindup.${L.arrHofHelpers.size}`; + const name = existing ?? `%server.opts.${L.arrHofHelpers.size}`; if (!existing) { L.arrHofHelpers.set(key, name); + const params = [ + ...(hasBuffer ? [{ localId: "b.0", name: "buffer", type: F64 }] : []), + { localId: "s.0", name: "s", type: NETSERVER_T }, + ]; const ref: IrExpr = { kind: "varRef", localId: "s.0", type: NETSERVER_T, loc }; + const body: IrStmt[] = []; + if (hasBuffer) { + const selector: IrExpr = { kind: "numLit", value: 4, type: F64, loc }; + const value: IrExpr = { kind: "varRef", localId: "b.0", type: F64, loc }; + body.push({ + kind: "exprStmt", + expr: { kind: "libCall", fn: "http.serverTimeoutOptionSet", args: [ref, selector, value], type: VOID, loc }, + loc, + }); + } + if (opts.joinDup) { + body.push({ + kind: "exprStmt", + expr: { kind: "libCall", fn: "http.serverJoinDupHeaders", args: [ref], type: VOID, loc }, + loc, + }); + } + body.push({ kind: "return", value: ref, loc }); L.liftedFns.push({ name, - params: [{ localId: "s.0", name: "s", type: NETSERVER_T }], + params, returnType: NETSERVER_T, - locals: [{ id: "s.0", name: "s", type: NETSERVER_T, mutable: false }], - body: [ - { - kind: "exprStmt", - expr: { kind: "libCall", fn: "http.serverJoinDupHeaders", args: [ref], type: VOID, loc }, - loc, - }, - { kind: "return", value: ref, loc }, - ], + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body, loc, }); } - return { kind: "call", callee: name, args: [server], type: NETSERVER_T, loc }; + return { + kind: "call", + callee: name, + args: [...(opts.keepAliveTimeoutBuffer !== null ? [opts.keepAliveTimeoutBuffer] : []), server], + type: NETSERVER_T, + loc, + }; } /** `new http.Server([options][, handler])` — the constructor spelling of diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4b69a4062..4ccf0c2e5 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -195,12 +195,13 @@ export const HTTP_CLIENT_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ /** http.createServer / http.Server's documented option keys (Node v24 — * http.createServer options + the net.Server knobs it forwards). The - * lowered pair is requireHostHeader/joinDuplicateHeaders; the rest fence - * by name here, and unknown keys drop like Node drops them. */ + * lowered set is requireHostHeader/joinDuplicateHeaders plus + * keepAliveTimeoutBuffer storage; the rest fence by name here, and + * unknown keys drop like Node drops them. */ export const HTTP_SERVER_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ "IncomingMessage", "ServerResponse", "allowHalfOpen", "connectionsCheckingInterval", "headersTimeout", "highWaterMark", "insecureHTTPParser", "joinDuplicateHeaders", - "keepAlive", "keepAliveInitialDelay", "keepAliveTimeout", "maxHeaderSize", + "keepAlive", "keepAliveInitialDelay", "keepAliveTimeout", "keepAliveTimeoutBuffer", "maxHeaderSize", "noDelay", "pauseOnConnect", "rejectNonStandardBodyWrites", "requestTimeout", "requireHostHeader", "uniqueHeaders", ]); diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index a845a14a7..8b69787fe 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -2341,6 +2341,7 @@ export type IrLibFn = * the property values; timer enforcement remains outside this surface. */ | "http.serverTimeoutGet" | "http.serverTimeoutSet" + | "http.serverTimeoutOptionSet" /** server.on("listening", cb) — the deferred listen-callback list. */ | "net.serverOnListening" /** The ServerResponse member surface: statusCode/statusMessage reads @@ -6758,6 +6759,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dc.chanBindStore", "dc.chanRunStores", "http.resWriteHeadDyn", + "http.serverTimeoutOptionSet", "net.sockSetEncoding", "http.reqSetEncoding", "fs.readFileSyncBuf", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index b768bd96e..8e34c8548 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -427,6 +427,7 @@ export const LIB_FN_SIGS: Recordhttp_timeouts[i] = value; } +/* Constructor-option validation is intentionally separate from the plain + * writable-property setter above. Node validates these option values as + * non-negative safe integers, while later property assignments store the + * number directly. */ +void scr_net_server_timeout_option_set(ScrNetServer *s, double field, double value) { + static const char *const names[] = { + "timeout", "keepAliveTimeout", "headersTimeout", "requestTimeout", + "keepAliveTimeoutBuffer", + }; + int i = (int)field; + if (i < 0 || i >= 5) abort(); /* compiler/runtime ABI violation */ + char recv[48], msg[224]; + if (!(isfinite(value) && trunc(value) == value)) { + scr_num_received(value, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be an integer. Received %s", + names[i], recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return; + } + if (value < 0 || value > 9007199254740991.0) { + scr_num_received(value, recv); + int len = snprintf(msg, sizeof msg, + "The value of \"%s\" is out of range. It must be >= 0 && <= 9007199254740991. Received %s", + names[i], recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return; + } + s->http_timeouts[i] = value; +} + /* address()'s other two fields — the bound host ('::'/'0.0.0.0' for the * host-less any, the normalized explicit host otherwise) and the family * string. Answer the any-form defaults before listen (Node answers null @@ -3321,13 +3352,25 @@ static ScrDyn *scr_net_dynh_srv_invoke(void *h, ScrDyn *self, const char *method return NULL; } +static int scr_net_dynh_srv_timeout_field(const char *key) { + if (strcmp(key, "timeout") == 0) return 0; + if (strcmp(key, "keepAliveTimeout") == 0) return 1; + if (strcmp(key, "headersTimeout") == 0) return 2; + if (strcmp(key, "requestTimeout") == 0) return 3; + if (strcmp(key, "keepAliveTimeoutBuffer") == 0) return 4; + return -1; +} + static ScrDyn *scr_net_dynh_srv_get(void *h, const char *key, size_t key_len) { ScrNetServer *s = (ScrNetServer *)h; (void)key_len; if (strcmp(key, "listening") == 0) return scr_dyn_new_bool(s->listening); + int timeout_field = scr_net_dynh_srv_timeout_field(key); + if (timeout_field >= 0) { + return scr_dyn_new_num(scr_net_server_timeout_get(s, (double)timeout_field)); + } { - static const char *const known[] = { "maxConnections", "connections", "maxHeadersCount", - "timeout", "keepAliveTimeout", "headersTimeout", "requestTimeout", NULL }; + static const char *const known[] = { "maxConnections", "connections", "maxHeadersCount", NULL }; for (size_t i = 0; known[i]; i++) { if (strcmp(key, known[i]) == 0) { scr_net_dynh_srv_unsupported(key, NULL); @@ -3339,7 +3382,17 @@ static ScrDyn *scr_net_dynh_srv_get(void *h, const char *key, size_t key_len) { } static bool scr_net_dynh_srv_set(void *h, const char *key, size_t key_len, const ScrDyn *value) { - (void)h; (void)key; (void)key_len; (void)value; + ScrNetServer *s = (ScrNetServer *)h; + (void)key_len; + int timeout_field = scr_net_dynh_srv_timeout_field(key); + if (timeout_field >= 0) { + if (value->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail(key, "of type number", value); + return true; /* handled: the exception is pending */ + } + scr_net_server_timeout_set(s, (double)timeout_field, value->v.num); + return true; + } return false; } diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 6edd2304e..a7fa4ff24 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -5118,6 +5118,9 @@ double scr_net_server_port(ScrNetServer *s); /* address().port */ * keepAliveTimeoutBuffer. */ double scr_net_server_timeout_get(ScrNetServer *s, double field); void scr_net_server_timeout_set(ScrNetServer *s, double field, double value); +/* Constructor-option twin: validates a non-negative safe integer before + * storing, matching Node's option ladder (plain property writes do not). */ +void scr_net_server_timeout_option_set(ScrNetServer *s, double field, double value); ScrStr *scr_net_server_addr_ip(ScrNetServer *s); /* +1 — address().address */ ScrStr *scr_net_server_addr_family(ScrNetServer *s); /* +1 — address().family */ void scr_net_server_close(ScrNetServer *s, ScrClosure *cb /*moves, nullable*/); diff --git a/tests/corpus/2689-http-server-timeout-properties.ts b/tests/corpus/2689-http-server-timeout-properties.ts index 8badad314..3cf0d71f4 100644 --- a/tests/corpus/2689-http-server-timeout-properties.ts +++ b/tests/corpus/2689-http-server-timeout-properties.ts @@ -1,6 +1,7 @@ // The five writable numeric http.Server timeout fields: Node 24 defaults, -// independent per-server storage, and static reads/writes through a typed -// helper. Timer enforcement is a separate server behavior surface. +// independent per-server storage, constructor initialization, optional and +// dynamic access, and static reads/writes through a typed helper. Timer +// enforcement is a separate server behavior surface. import { createServer, Server } from "node:http"; function configure(server: Server): void { @@ -13,6 +14,7 @@ function configure(server: Server): void { const configured = createServer(); const untouched = new Server(); +const fromOption = createServer({ keepAliveTimeoutBuffer: 4321 }); console.log( configured.timeout, @@ -37,3 +39,25 @@ console.log( untouched.headersTimeout, untouched.requestTimeout, ); + +console.log("option", fromOption.keepAliveTimeoutBuffer); + +function logOptional(server: Server | undefined): void { + console.log("optional", server?.keepAliveTimeoutBuffer); +} +logOptional(fromOption); +logOptional(undefined); + +const dynamic: any = fromOption; +console.log("dynamic", dynamic.keepAliveTimeoutBuffer); +dynamic.keepAliveTimeoutBuffer = 8765; +console.log("dynamic-set", dynamic.keepAliveTimeoutBuffer, fromOption.keepAliveTimeoutBuffer); + +try { + createServer({ keepAliveTimeoutBuffer: -1 }); +} catch (err) { + if (err instanceof Error) { + const code = (err as NodeJS.ErrnoException).code; + console.log(err.name, code, err.message); + } +}