Fix HTTP server timeout property lowering

This commit is contained in:
Chris Tate
2026-08-11 00:35:28 -05:00
parent 77e78ed76d
commit ec9f8660e6
10 changed files with 170 additions and 38 deletions
+3 -1
View File
@@ -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
@@ -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);
@@ -688,6 +688,7 @@ const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -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<ts.Expression>);
@@ -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
@@ -195,12 +195,13 @@ export const HTTP_CLIENT_DOCUMENTED_OPTIONS: ReadonlySet<string> = 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<string> = 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",
]);
+2
View File
@@ -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<IrLibFn> = new Set([
"dc.chanBindStore",
"dc.chanRunStores",
"http.resWriteHeadDyn",
"http.serverTimeoutOptionSet",
"net.sockSetEncoding",
"http.reqSetEncoding",
"fs.readFileSyncBuf",
+1
View File
@@ -427,6 +427,7 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"http.serverJoinDupHeaders": { argTypes: [NETSERVER_T], result: VOID },
"http.serverTimeoutGet": { argTypes: [NETSERVER_T, F64], result: F64 },
"http.serverTimeoutSet": { argTypes: [NETSERVER_T, F64, F64], result: VOID },
"http.serverTimeoutOptionSet": { argTypes: [NETSERVER_T, F64, F64], result: VOID },
"net.serverOnListening": { argTypes: [NETSERVER_T, { kind: "func", params: [], ret: VOID }, BOOL], result: VOID },
"http.resStatusGet": { argTypes: [HTTPRES_T], result: F64 },
"http.resStatusSet": { argTypes: [HTTPRES_T, F64], result: VOID },
+56 -3
View File
@@ -1400,6 +1400,37 @@ void scr_net_server_timeout_set(ScrNetServer *s, double field, double value) {
s->http_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;
}
+3
View File
@@ -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*/);
@@ -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);
}
}