From 07ce602de5dccabac1c353705b940540853be687 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 02:26:31 -0500 Subject: [PATCH] Close Node 24 gaps in util.types keys, HTTP header limits and unread requests - Object.keys(util.types) lists Node's 43 keys in Node's order, and Object.keys over the node:util/types namespace lists them sorted with "default". Object.values and Object.entries over util.types are refused, because some members do not compile yet. - The HTTP server answers 431 Request Header Fields Too Large when a head or trailer block exceeds 1000 fields or the header byte bound, and the trailer block counts its fields on its own, like Node. Parser errors send Node's fixed responses without a Content-Length header. - Server requests start paused like Node's IncomingMessage: body bytes and 'end' wait for a 'data' listener, pipe() or resume(). When the response finishes, an unread request is dumped and emits 'end', before the response's 'close' for an empty body and after it otherwise. A body that is still arriving when the response finishes keeps reaching its listeners. req.complete reads true once the body has arrived. - Document that small Buffers own exact-size ArrayBuffers instead of sharing Node's 64 KiB pool. Corpus 5405-5409 cover each case. --- docs/content/docs/limitations.mdx | 2 + .../src/frontend/lowering/lower-calls.ts | 39 +++- .../src/frontend/lowering/surfaces.ts | 49 +++++ .../5405-buffer-arraybuffer-window.ts.json | 6 + .../tests/corpus/5406-util-types-keys.ts.json | 6 + .../5407-http-header-field-limit.ts.json | 6 + .../5408-http-unread-request-end.ts.json | 6 + .../5409-http-held-request-body.ts.json | 6 + packages/runtime/src/scr_http.c | 208 +++++++++++++----- .../corpus/5405-buffer-arraybuffer-window.ts | 17 ++ tests/corpus/5406-util-types-keys.ts | 12 + tests/corpus/5407-http-header-field-limit.ts | 55 +++++ tests/corpus/5408-http-unread-request-end.ts | 58 +++++ tests/corpus/5409-http-held-request-body.ts | 57 +++++ tests/harness/server.test.ts | 6 +- 15 files changed, 481 insertions(+), 52 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5405-buffer-arraybuffer-window.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5406-util-types-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5407-http-header-field-limit.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5408-http-unread-request-end.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5409-http-held-request-body.ts.json create mode 100644 tests/corpus/5405-buffer-arraybuffer-window.ts create mode 100644 tests/corpus/5406-util-types-keys.ts create mode 100644 tests/corpus/5407-http-header-field-limit.ts create mode 100644 tests/corpus/5408-http-unread-request-end.ts create mode 100644 tests/corpus/5409-http-held-request-body.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index f12c09aff..e058af988 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -181,6 +181,8 @@ Optional fields and properties added through a record's `unknown` view can be de `Buffer.from` accepts untyped strings, byte arrays, arrays, and plain array-like or Buffer JSON objects. Encoding arguments must be supported literals. Custom input `valueOf` hooks and opaque class or native-handle inputs throw an unsupported-operation error. +Every Buffer owns an ArrayBuffer of its own length. Node allocates small Buffers from `Buffer.from`, `Buffer.allocUnsafe`, and `Buffer.concat` out of a shared 64 KiB pool, so for those Buffers `buf.buffer.byteLength` is the pool size in Node and the Buffer's length here, and `buf.byteOffset` is `0` here. Reading `buf.buffer` through `buf.byteOffset` and `buf.length` returns the same bytes in both. `Buffer.poolSize` is unsupported. + ### JSON callbacks Function replacers and two-argument revivers support nested replacements, object-property deletion, and thrown exceptions. Omitting the root through a replacer returns `undefined`. Array replacers list the object keys to serialize, as in Node.js. Stored `JSON.stringify` functions accept numeric and string indentation; direct calls require literal indentation. diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 959192c83..d1aa20934 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -52,7 +52,13 @@ import { lowerProtocolMethodCall } from "./class-protocol-dispatch.js"; import { isJsSourceFile, locOf, requireSpecOf } from "../program.js"; import { isGenericCallableMemberType, typeKey } from "../type-mapper.js"; import { dynFallbackType, jsFuncNameOf, newFnCtx, staticImportNamespaceType } from "./lowerer.js"; -import { NARROW_FIRST, STRING_INDEX_METHODS, STR_METHODS, stdlibGlobalNameOf } from "./surfaces.js"; +import { + NARROW_FIRST, + STRING_INDEX_METHODS, + STR_METHODS, + UTIL_TYPES_KEYS, + stdlibGlobalNameOf, +} from "./surfaces.js"; import { dynStringReceiver } from "./containers/dynamic-receivers.js"; import { lowerArrayConstructor, @@ -7327,6 +7333,37 @@ function lowerObjectStaticCall( if (member !== "keys" && member !== "values" && member !== "entries") return null; if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) return null; const argNode = call.arguments[0]!; + // util.types and node:util/types list Node's pinned key census + // (UTIL_TYPES_KEYS): the object's own keys in Node's order, or the module + // namespace's export names sorted with "default". values/entries would + // include members that do not compile yet. + const probedModule = tryLowerExpression(lowerer, argNode); + if ( + lowerer.builtinNamespaceModuleOf(argNode) === "util/types" || + (probedModule?.type.kind === "moduleNs" && probedModule.type.moduleId === "builtin:util/types") + ) { + if (member !== "keys") { + lowerer.noLowering( + `Object.${member} over util.types`, + call, + "call the util.types predicates by name", + ); + } + const symbol = ts.isIdentifier(argNode) + ? lowerer.checker.getSymbolAtLocation(argNode) + : undefined; + const namespace = symbol + ? lowerer.checker.declarationsOf(symbol).some((decl) => ts.isNamespaceImport(decl)) + : false; + const names = namespace ? [...UTIL_TYPES_KEYS, "default"].sort() : UTIL_TYPES_KEYS; + const loc = locOf(call); + return { + kind: "arrayLit", + elems: names.map((name) => ({ kind: "strLit", value: name, type: STRING, loc })), + type: arrayOf(STRING), + loc, + }; + } // A compiled PROGRAM module namespace is a nominal token whose members // remain live bindings, but its enumerable key set is static and Node // sorts it in code-unit order. Materialize exactly those value-export diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index aa7e95e21..ed82da1d1 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1015,6 +1015,55 @@ const PATH_WIN32_MODULE_FNS: Record = { format: { fn: "path.win32Format", params: [], result: STRING }, }; +/** util.types's own keys in Node's order (Node 24.22.0). The table is the + * key census for Object.keys over util.types and node:util/types, including + * the members that do not compile yet; the corpus checks it against Node. */ +export const UTIL_TYPES_KEYS: readonly string[] = [ + "isArgumentsObject", + "isArrayBuffer", + "isAsyncFunction", + "isBigIntObject", + "isBooleanObject", + "isDate", + "isExternal", + "isGeneratorFunction", + "isGeneratorObject", + "isMap", + "isMapIterator", + "isModuleNamespaceObject", + "isNativeError", + "isNumberObject", + "isPromise", + "isProxy", + "isRegExp", + "isSet", + "isSetIterator", + "isSharedArrayBuffer", + "isStringObject", + "isSymbolObject", + "isWeakMap", + "isWeakSet", + "isAnyArrayBuffer", + "isBoxedPrimitive", + "isArrayBufferView", + "isDataView", + "isTypedArray", + "isUint8Array", + "isUint8ClampedArray", + "isUint16Array", + "isUint32Array", + "isInt8Array", + "isInt16Array", + "isInt32Array", + "isFloat16Array", + "isFloat32Array", + "isFloat64Array", + "isBigInt64Array", + "isBigUint64Array", + "isKeyObject", + "isCryptoKey", +]; + export const BUILTIN_MODULE_FNS: Record< string, Record | undefined diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5405-buffer-arraybuffer-window.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5405-buffer-arraybuffer-window.ts.json new file mode 100644 index 000000000..da6bb4efb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5405-buffer-arraybuffer-window.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5405-buffer-arraybuffer-window.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5406-util-types-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5406-util-types-keys.ts.json new file mode 100644 index 000000000..50e35f4d4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5406-util-types-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5406-util-types-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5407-http-header-field-limit.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5407-http-header-field-limit.ts.json new file mode 100644 index 000000000..515805095 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5407-http-header-field-limit.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5407-http-header-field-limit.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5408-http-unread-request-end.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5408-http-unread-request-end.ts.json new file mode 100644 index 000000000..39bcd6174 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5408-http-unread-request-end.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5408-http-unread-request-end.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5409-http-held-request-body.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5409-http-held-request-body.ts.json new file mode 100644 index 000000000..ef9fd73f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5409-http-held-request-body.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5409-http-held-request-body.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index d9c2f75ee..1ba814bdb 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -57,7 +57,9 @@ /* The HTTP/1 parser has no configurable maxHeaderSize surface yet. Keep its * head and trailer block within the existing 64 KiB parser bound, and cap - * fields too: each stored field owns three strings and three growing arrays. */ + * fields per block at Node's default of 1000 (server.maxHeadersCount unset): + * each stored field owns three strings and three growing arrays. A server + * answers 431 past either bound, like Node. */ #define SCR_HTTP_MAX_HEADER_BYTES 65536u #define SCR_HTTP_MAX_HEADER_FIELDS 1000u @@ -243,6 +245,14 @@ struct ScrHttpReq { bool drain_queued; char *pend; size_t pend_len, pend_cap; + /* HTTP/1 server requests start paused like Node's IncomingMessage: body + * bytes and the completed body's 'end' are held in pend/end_pending until + * a consumer starts the flow (a 'data' listener, pipe() or resume()), or + * until the response finishes and the unread request is dumped. */ + bool gated; /* holds until flowing */ + bool flowing; /* a consumer or the dump started the flow */ + bool received; /* the whole body arrived (req.complete) */ + bool body_seen; /* body bytes arrived */ bool destroyed; /* req.destroy()/the teardown ran — req.destroyed */ ScrNetLs aborted_ls; /* req.on('aborted') — the h2 compat event */ }; @@ -521,6 +531,8 @@ ScrStr *scr_http_req_header(ScrHttpReq *r, ScrStr *name) { * backpressure — divergence 54's stream model); the body's natural end * ends the destination, Node's pipe default. A body that already ended * ends the destination NOW (nothing more will flow). */ +static void scr_http_req_consume(ScrHttpReq *r); + void scr_http_req_pipe_res(ScrHttpReq *r, ScrHttpRes *dst /*borrowed*/) { if (r->ended) { scr_http_res_end(dst); @@ -528,6 +540,7 @@ void scr_http_req_pipe_res(ScrHttpReq *r, ScrHttpRes *dst /*borrowed*/) { } if (r->pipe_res) scr_http_res_release(r->pipe_res); r->pipe_res = scr_http_res_retain(dst); + scr_http_req_consume(r); } /* socket.pipe(res) — the extended-CONNECT bridge leg: a native reader on @@ -576,6 +589,7 @@ void scr_http_req_pipe_client(ScrHttpReq *r, ScrHttpClientReq *dst /*borrowed*/) } if (r->pipe_client) scr_http_client_release(r->pipe_client); r->pipe_client = scr_http_client_retain(dst); + scr_http_req_consume(r); } void scr_http_req_pipe_sock(ScrHttpReq *r, ScrNetSocket *dst /*borrowed*/) { @@ -585,6 +599,7 @@ void scr_http_req_pipe_sock(ScrHttpReq *r, ScrNetSocket *dst /*borrowed*/) { } if (r->pipe_sock) scr_net_sock_release(r->pipe_sock); r->pipe_sock = scr_net_sock_retain(dst); + scr_http_req_consume(r); } void scr_http_req_on_data(ScrHttpReq *r, ScrClosure *cb /*moves*/, ScrNetDataFn fn, bool once) { @@ -593,6 +608,7 @@ void scr_http_req_on_data(ScrHttpReq *r, ScrClosure *cb /*moves*/, ScrNetDataFn return; } scr_net_ls_add(&r->data_ls, cb, (void *)fn, once); + scr_http_req_consume(r); } void scr_http_req_on_end(ScrHttpReq *r, ScrClosure *cb /*moves*/, bool once) { @@ -619,17 +635,48 @@ static void scr_http_req_deliver(ScrHttpReq *r, const char *data, size_t n); static void scr_http_res_cork_flush(ScrHttpRes *r); #define SCR_HTTP_EMIT_REQ_DRAIN_K 6 -/* resume(): a flow-control no-op unless pause() held delivery — then the - * buffered bytes (and a deferred 'end') drain through the emit queue, - * never the resuming stack. This parser always consumes body bytes - * (SEMANTICS.md; Node requires the stream to flow). */ -void scr_http_req_resume(ScrHttpReq *r) { - if (!r->paused) return; +static bool scr_http_req_holding(const ScrHttpReq *r) { + return r->paused || (r->gated && !r->flowing); +} + +static void scr_http_req_queue_drain(ScrHttpReq *r) { + if (r->drain_queued) return; + r->drain_queued = true; + scr_http_emit_push(SCR_HTTP_EMIT_REQ_DRAIN_K, scr_http_req_retain(r)); +} + +/* Starts (or restarts) the flow: held bytes and a deferred 'end' drain + * through the emit queue, never the resuming stack. */ +static void scr_http_req_flow(ScrHttpReq *r) { r->paused = false; - if ((r->pend_len > 0 || r->end_pending) && !r->drain_queued) { - r->drain_queued = true; - scr_http_emit_push(SCR_HTTP_EMIT_REQ_DRAIN_K, scr_http_req_retain(r)); - } + r->flowing = true; + if (r->pend_len > 0 || r->end_pending) scr_http_req_queue_drain(r); +} + +/* A 'data' listener or pipe() starts the flow unless pause() stopped it, + * like Node's `if (state.flowing !== false) this.resume()`. */ +static void scr_http_req_consume(ScrHttpReq *r) { + if (r->gated && !r->paused && !r->flowing) scr_http_req_flow(r); +} + +/* resume(): releases what pause() or the initial paused state held. This + * parser always consumes body bytes from the socket (SEMANTICS.md); held + * bytes buffer in memory. */ +void scr_http_req_resume(ScrHttpReq *r) { + if (!scr_http_req_holding(r)) return; + scr_http_req_flow(r); +} + +/* Node's resOnFinish: a request nothing consumed is dumped. Its 'data' + * listeners are removed, held bytes are discarded and the stream flows, + * so 'end' fires once the body is complete. */ +static void scr_http_req_dump(ScrHttpReq *r) { + scr_net_ls_drop(&r->data_ls); + free(r->pend); + r->pend = NULL; + r->pend_len = r->pend_cap = 0; + r->paused = false; + r->flowing = true; } /* pause(): delivery holds until resume() (scr_http_req_deliver buffers; @@ -707,8 +754,8 @@ void scr_http_req_on_aborted(ScrHttpReq *r, ScrClosure *cb /*moves*/, bool once) * req cannot cycle past the body). */ static void scr_http_req_finish(ScrHttpReq *r, bool fire) { if (r->ended) return; - if (fire && r->paused) { - /* pause() holds 'end' too — resume()'s drain finishes the body */ + if (fire && scr_http_req_holding(r)) { + /* a held body holds 'end' too — the drain finishes the body */ r->end_pending = true; return; } @@ -2201,12 +2248,15 @@ typedef struct ScrHttpConn { size_t len, cap; ScrHttpParseState state; size_t body_remaining; - size_t head_bytes, head_fields; + size_t head_bytes; size_t trailer_bytes, trailer_fields; ScrHttpReq *req; /* the in-flight request (server) or response (client), * +1; NULL between requests / before the head */ ScrHttpRes *res; /* +1; server mode only */ bool close_after; /* the response said Connection: close */ + bool pumping; /* inside scr_http_conn_pump */ + bool res_close_deferred; /* the response finished inside the pump while + * the request body was still arriving */ bool client_mode; /* RESPONSE parsing: status line, EOF bodies */ bool client_resp_started; /* the status line parsed (conn-level twin of * the client's response_started — the pump @@ -2219,8 +2269,9 @@ typedef struct ScrHttpConn { static bool scr_http_client_parse_head(ScrHttpConn *conn, size_t head_len); static void scr_http_client_head_overflow(ScrHttpConn *conn); -/* The trailer budget includes the request/response head and every trailer - * line on the wire, including the terminating blank line. Counters live on +/* The trailer byte budget includes the request/response head and every + * trailer line on the wire, including the terminating blank line; the field + * count covers the trailer block alone, like Node's. Counters live on * the connection because an early response can release conn->req while the * parser must continue consuming and validating its incoming chunk stream. */ static bool scr_http_conn_trailer_fits(const ScrHttpConn *conn, size_t bytes, bool field) { @@ -2228,10 +2279,7 @@ static bool scr_http_conn_trailer_fits(const ScrHttpConn *conn, size_t bytes, bo conn->trailer_bytes > SCR_HTTP_MAX_HEADER_BYTES - conn->head_bytes) return false; size_t remaining = SCR_HTTP_MAX_HEADER_BYTES - conn->head_bytes - conn->trailer_bytes; if (bytes > remaining) return false; - if (!field) return true; - if (conn->head_fields > SCR_HTTP_MAX_HEADER_FIELDS || - conn->trailer_fields >= SCR_HTTP_MAX_HEADER_FIELDS - conn->head_fields) return false; - return true; + return !field || conn->trailer_fields < SCR_HTTP_MAX_HEADER_FIELDS; } /* The server-side ctx: the 'request' listener list, shared by every @@ -2343,17 +2391,53 @@ static void scr_http_conn_drop_request(ScrHttpConn *conn, bool fire_end) { } } +/* Ends a request whose response already finished and detaches the + * exchange; the queued emits hold their own references. Node emits the + * request's 'end' after the response's 'close' when body bytes arrived, + * and before it otherwise. */ +static void scr_http_conn_end_after_response(ScrHttpConn *conn) { + ScrHttpReq *r = conn->req; + bool before_close = !r->body_seen; + conn->res_close_deferred = false; + r->received = true; + r->end_pending = true; + if (before_close) scr_http_req_queue_drain(r); + if (conn->res) scr_http_queue_res_close(conn->res); + if (!before_close) scr_http_req_queue_drain(r); + scr_http_queue_req_close(r); + conn->req = NULL; + scr_http_req_release(r); + if (conn->res) { + conn->res->conn = NULL; + scr_http_res_release(conn->res); + conn->res = NULL; + } +} + /* The response finished: with keep-alive the parser waits for the next * request (pipelined bytes may already sit in the buffer — the read path - * keeps parsing); without it the socket half-closes, Node's teardown. */ + * keeps parsing); without it the socket half-closes, Node's teardown. A + * request nothing consumed is dumped first. While its body is still + * arriving the exchange stays attached, and scr_http_conn_body_done ends + * the request when the body completes. */ static void scr_http_conn_response_finished(ScrHttpConn *conn, bool keep_alive) { - bool fire_end = conn->req != NULL && conn->state == SCR_HTTP_HEAD; - /* the handler answered before consuming the whole body: the request - * settles quietly (Node destroys unconsumed bodies on 'close') */ - scr_http_conn_drop_request(conn, fire_end && false); - if (!keep_alive || conn->close_after) { - scr_net_sock_end(conn->sock); + ScrHttpReq *r = conn->req; + bool close = !keep_alive || conn->close_after; + if (r != NULL && !r->ended && r->gated && !r->flowing) scr_http_req_dump(r); + if (r != NULL && !r->ended && conn->state != SCR_HTTP_HEAD) { + /* The rest of the body may already sit in the buffer: inside the pump + * the response's 'close' waits until the pump stops, so a body that + * completes there orders its 'end' first. */ + if (conn->pumping) conn->res_close_deferred = true; + else scr_http_queue_res_close(conn->res); + } else if (r != NULL && !r->ended) { + /* the body is complete (a request without one completes after the + * 'request' listeners return) */ + scr_http_conn_end_after_response(conn); + } else { + scr_http_conn_drop_request(conn, false); } + if (close) scr_net_sock_end(conn->sock); } /* One parsed header line (name lowercased into the req). */ @@ -2406,18 +2490,28 @@ static void scr_http_req_add_trailer(ScrHttpReq *r, const char *name, size_t nle free(lower); } -/* Malformed input: answer 400 and close. Node's lenient spots (bare LF - * line endings, obsolete folding) are NOT accepted; SEMANTICS.md states - * the bound. */ -static void scr_http_conn_bad_request(ScrHttpConn *conn) { - static const char bad[] = - "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"; - scr_net_sock_write_native(conn->sock, bad, sizeof bad - 1); +/* Parser errors answer Node's fixed responses and close: 400 for + * malformed input, 431 when a head or trailer block exceeds the field or + * byte bound. Node's lenient spots (bare LF line endings, obsolete + * folding) are NOT accepted; SEMANTICS.md states the bound. */ +static void scr_http_conn_reject(ScrHttpConn *conn, const char *response, size_t len) { + scr_net_sock_write_native(conn->sock, response, len); scr_net_sock_end(conn->sock); conn->len = 0; scr_http_conn_drop_request(conn, false); } +static void scr_http_conn_bad_request(ScrHttpConn *conn) { + static const char bad[] = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"; + scr_http_conn_reject(conn, bad, sizeof bad - 1); +} + +static void scr_http_conn_header_overflow(ScrHttpConn *conn) { + static const char overflow[] = + "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n"; + scr_http_conn_reject(conn, overflow, sizeof overflow - 1); +} + /* Node answers a missing HTTP/1.1 Host through ServerResponse rather than * the parser-error path: the 400 is chunked and includes Date. */ static void scr_http_conn_missing_host(ScrHttpConn *conn) { @@ -2436,9 +2530,10 @@ static void scr_http_conn_missing_host(ScrHttpConn *conn) { * feed, and the h2 compat DATA-frame feed). */ static void scr_http_req_deliver(ScrHttpReq *r, const char *data, size_t n) { if (!r || n == 0 || r->ended) return; - if (r->paused) { - /* req.pause(): the parser keeps consuming (memory is the buffer — - * a documented bound), delivery waits for resume()'s drain */ + r->body_seen = true; + if (scr_http_req_holding(r)) { + /* paused: the parser keeps consuming (memory is the buffer — a + * documented bound), delivery waits for the drain */ if (r->pend_len + n > r->pend_cap) { size_t cap = r->pend_cap ? r->pend_cap : 4096; while (cap < r->pend_len + n) cap *= 2; @@ -2493,7 +2588,13 @@ static void scr_http_conn_body_done(ScrHttpConn *conn) { return; } ScrHttpReq *r = conn->req; + if (r && !r->ended && conn->res && conn->res->finished) { + /* the response finished while the body was arriving */ + scr_http_conn_end_after_response(conn); + return; + } if (r) { + r->received = true; scr_http_req_retain(r); scr_http_req_finish(r, true); scr_http_req_release(r); @@ -2506,8 +2607,9 @@ static void scr_http_conn_body_done(ScrHttpConn *conn) { } /* Parses one complete request HEAD sitting at buf[0..head_len). Returns - * false on malformed input (already answered). */ -static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len) { + * false on malformed input, with *overflow set when the head carries more + * fields than the parser accepts. */ +static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len, bool *overflow) { const char *buf = conn->buf; const char *line_end = memchr(buf, '\r', head_len); if (!line_end || line_end + 1 >= buf + head_len || line_end[1] != '\n') return false; @@ -2527,6 +2629,7 @@ static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len) { if (!req) scr_http_oom(); req->rc = 1; req->status = -1; /* Node: statusCode is undefined on server requests */ + req->gated = true; req->join_dup = conn->srv != NULL && conn->srv->join_dup; req->sock = scr_net_sock_retain(conn->sock); #ifdef SCR_RC_AUDIT @@ -2563,11 +2666,11 @@ static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len) { p = eol + 2; } if (!ok || req->nheaders > SCR_HTTP_MAX_HEADER_FIELDS) { + *overflow = ok; scr_http_req_release(req); return false; } conn->head_bytes = head_len; - conn->head_fields = req->nheaders; conn->trailer_bytes = 0; conn->trailer_fields = 0; @@ -2763,14 +2866,14 @@ static void scr_http_conn_pump(ScrHttpConn *conn) { if (!hit) { if (conn->len > SCR_HTTP_MAX_HEADER_BYTES) { if (conn->client_mode) scr_http_client_head_overflow(conn); - else scr_http_conn_bad_request(conn); /* header cap */ + else scr_http_conn_header_overflow(conn); } return; } size_t head_len = (size_t)(hit - conn->buf) + 4; if (head_len > SCR_HTTP_MAX_HEADER_BYTES) { if (conn->client_mode) scr_http_client_head_overflow(conn); - else scr_http_conn_bad_request(conn); + else scr_http_conn_header_overflow(conn); return; } if (conn->client_mode) { @@ -2784,8 +2887,10 @@ static void scr_http_conn_pump(ScrHttpConn *conn) { * response may still be streaming (keep-alive pipelining waits) */ if (conn->res != NULL && !conn->res->finished) return; if (conn->res != NULL) scr_http_conn_drop_request(conn, false); - if (!scr_http_conn_parse_head(conn, head_len)) { - scr_http_conn_bad_request(conn); + bool overflow = false; + if (!scr_http_conn_parse_head(conn, head_len, &overflow)) { + if (overflow) scr_http_conn_header_overflow(conn); + else scr_http_conn_bad_request(conn); return; } continue; @@ -2878,7 +2983,7 @@ static void scr_http_conn_pump(ScrHttpConn *conn) { if (conn->buf[0] == '\r' && conn->buf[1] == '\n') { if (!scr_http_conn_trailer_fits(conn, 2, false)) { if (conn->client_mode) scr_http_client_head_overflow(conn); - else scr_http_conn_bad_request(conn); + else scr_http_conn_header_overflow(conn); return; } memmove(conn->buf, conn->buf + 2, conn->len - 2); @@ -2897,7 +3002,7 @@ static void scr_http_conn_pump(ScrHttpConn *conn) { if (!eol) { if (!scr_http_conn_trailer_fits(conn, conn->len, false)) { if (conn->client_mode) scr_http_client_head_overflow(conn); - else scr_http_conn_bad_request(conn); + else scr_http_conn_header_overflow(conn); } return; } @@ -2925,7 +3030,7 @@ static void scr_http_conn_pump(ScrHttpConn *conn) { size_t consumed = (size_t)(eol - conn->buf) + 2; if (!scr_http_conn_trailer_fits(conn, consumed, true)) { if (conn->client_mode) scr_http_client_head_overflow(conn); - else scr_http_conn_bad_request(conn); + else scr_http_conn_header_overflow(conn); return; } /* An early server response releases the request but deliberately leaves @@ -2957,7 +3062,13 @@ static void scr_http_conn_data(void *ctx, const char *buf, size_t n) { } memcpy(conn->buf + conn->len, buf, n); conn->len += n; + conn->pumping = true; scr_http_conn_pump(conn); + conn->pumping = false; + if (conn->res_close_deferred) { + conn->res_close_deferred = false; + if (conn->res) scr_http_queue_res_close(conn->res); + } } static void scr_http_client_eof(ScrHttpConn *conn); @@ -3200,7 +3311,7 @@ ScrStr *scr_http_req_http_version(ScrHttpReq *r) { double scr_http_req_http_version_major(ScrHttpReq *r) { return r->http2 ? 2 : 1; } double scr_http_req_http_version_minor(ScrHttpReq *r) { return r->http2 || r->http10 ? 0 : 1; } bool scr_http_req_aborted_flag(ScrHttpReq *r) { return r->aborted; } -bool scr_http_req_complete(ScrHttpReq *r) { return r->ended && !r->aborted; } +bool scr_http_req_complete(ScrHttpReq *r) { return (r->ended || r->received) && !r->aborted; } /* The stream died with the response side open: 'aborted' fires NOW (the * teardown macrotask, Node's position), then the close rides the sweep. */ @@ -4051,7 +4162,6 @@ static bool scr_http_client_parse_head(ScrHttpConn *conn, size_t head_len) { return false; } conn->head_bytes = head_len; - conn->head_fields = res->nheaders; conn->trailer_bytes = 0; conn->trailer_fields = 0; diff --git a/tests/corpus/5405-buffer-arraybuffer-window.ts b/tests/corpus/5405-buffer-arraybuffer-window.ts new file mode 100644 index 000000000..ac3680fbd --- /dev/null +++ b/tests/corpus/5405-buffer-arraybuffer-window.ts @@ -0,0 +1,17 @@ +// Small Buffers own exact-size ArrayBuffers here, while Node allocates them +// from a shared 64 KiB pool, so buf.buffer.byteLength and buf.byteOffset +// differ. Reading a Buffer's window of its ArrayBuffer through byteOffset +// and length gives the same bytes either way, and Buffer.alloc is never +// pooled. +const small = Buffer.from("hello"); +const copy = small.buffer.slice(small.byteOffset, small.byteOffset + small.length); +console.log(copy.byteLength, new TextDecoder().decode(new Uint8Array(copy))); +const view = new Uint8Array(small.buffer, small.byteOffset, small.length); +console.log(Array.from(view).join(",")); +const joined = Buffer.concat([Buffer.from("ab"), Buffer.from("cd")]); +const data = new DataView(joined.buffer, joined.byteOffset, joined.length); +console.log(data.getUint16(0), data.byteLength); +const unsafe = Buffer.allocUnsafe(4).fill(7); +console.log(Array.from(new Uint8Array(unsafe.buffer, unsafe.byteOffset, 4)).join(",")); +const zeroed = Buffer.alloc(16, 1); +console.log(zeroed.buffer.byteLength, zeroed.byteOffset); diff --git a/tests/corpus/5406-util-types-keys.ts b/tests/corpus/5406-util-types-keys.ts new file mode 100644 index 000000000..172c4fb6b --- /dev/null +++ b/tests/corpus/5406-util-types-keys.ts @@ -0,0 +1,12 @@ +// Object.keys over util.types lists the object's own keys in Node's order; +// over the node:util/types module namespace it lists the export names +// sorted, with "default". +import * as util from "node:util"; +import { types } from "node:util"; +import * as typesNamespace from "node:util/types"; + +console.log(Object.keys(util.types)); +const keys = Object.keys(types); +console.log(keys.length, keys[0], keys[keys.length - 1]); +console.log(Object.keys(typesNamespace).join(",")); +console.log(keys.every((key) => key.startsWith("is")), keys.includes("isMap")); diff --git a/tests/corpus/5407-http-header-field-limit.ts b/tests/corpus/5407-http-header-field-limit.ts new file mode 100644 index 000000000..cd1f0a872 --- /dev/null +++ b/tests/corpus/5407-http-header-field-limit.ts @@ -0,0 +1,55 @@ +// The HTTP server accepts up to 1000 header fields per block (Node's +// default with server.maxHeadersCount unset) and answers 431 past that, +// for the request head and for a chunked request's trailer block. A +// malformed request line answers 400. Both error responses carry only +// Connection: close. +import { createServer } from "node:http"; +import { connect } from "node:net"; + +function fields(name: string, count: number): string { + let out = ""; + for (let i = 0; i < count; i++) out += `${name}${i}: v\r\n`; + return out; +} + +const server = createServer((req, res) => { + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + }); + req.on("end", () => { + res.end(`fields=${req.rawHeaders.length / 2} trailers=${req.rawTrailers.length / 2} body=${size}`); + }); +}); + +function exchange(port: number, wire: string): Promise { + return new Promise((resolve) => { + let response = ""; + const socket = connect({ port, host: "127.0.0.1" }, () => { + socket.write(wire); + }); + socket.on("data", (chunk: Buffer) => { + response += chunk.toString("latin1"); + }); + socket.on("close", () => resolve(response)); + }); +} + +function show(label: string, response: string): void { + const status = response.slice(0, response.indexOf("\r\n")); + if (status.includes(" 200 ")) console.log(label, status, response.slice(response.indexOf("\r\n\r\n") + 4)); + else console.log(label, JSON.stringify(response)); +} + +server.listen(0, "127.0.0.1", async () => { + const { port } = server.address() as { port: number }; + const head = "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n"; + show("head 1000", await exchange(port, head + fields("x-h", 998) + "\r\n")); + show("head 1001", await exchange(port, head + fields("x-h", 999) + "\r\n")); + show("head 1500", await exchange(port, head + fields("x-h", 1498) + "\r\n")); + const post = "POST / HTTP/1.1\r\nHost: x\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n"; + show("trailers 1000", await exchange(port, post + fields("x-h", 500) + "\r\n3\r\nabc\r\n0\r\n" + fields("x-t", 1000) + "\r\n")); + show("trailers 1001", await exchange(port, post + "\r\n3\r\nabc\r\n0\r\n" + fields("x-t", 1001) + "\r\n")); + show("malformed", await exchange(port, "GARBAGE\r\n\r\n")); + server.close(); +}); diff --git a/tests/corpus/5408-http-unread-request-end.ts b/tests/corpus/5408-http-unread-request-end.ts new file mode 100644 index 000000000..edcc71e04 --- /dev/null +++ b/tests/corpus/5408-http-unread-request-end.ts @@ -0,0 +1,58 @@ +// A request the handler never reads still emits 'end': when the response +// finishes, the server dumps the unread body and the request ends, before +// the response's 'close' for an empty body and after it otherwise. A body +// that arrives with the head reaches 'data' listeners even when the +// handler responds first, and resume() alone also ends the request. +import { createServer } from "node:http"; +import { connect } from "node:net"; + +const server = createServer((req, res) => { + const tag = `${req.method} ${req.url}`; + req.on("end", () => console.log("req end", tag, req.complete)); + req.on("close", () => console.log("req close", tag)); + res.on("finish", () => console.log("res finish", tag)); + res.on("close", () => console.log("res close", tag)); + if (req.url === "/data") { + req.on("data", (chunk: Buffer) => console.log("data", tag, chunk.toString())); + } + if (req.url === "/resume") req.resume(); + if (req.url === "/late") { + setTimeout(() => res.end("late"), 20); + return; + } + res.end("ok"); +}); + +const wires = [ + "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + "POST /cl0 HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + "POST /chunk0 HTTP/1.1\r\nHost: x\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + "POST /chunk HTTP/1.1\r\nHost: x\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", + "POST /body HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 3\r\n\r\nabc", + "POST /data HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 3\r\n\r\nabc", + "POST /resume HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 3\r\n\r\nabc", + "GET /resume HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + "POST /late HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 3\r\n\r\nabc", +]; + +function exchange(port: number, wire: string): Promise { + return new Promise((resolve) => { + const socket = connect({ port, host: "127.0.0.1" }, () => { + socket.write(wire); + }); + socket.on("data", () => {}); + socket.on("close", () => resolve()); + }); +} + +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +server.listen(0, "127.0.0.1", async () => { + const { port } = server.address() as { port: number }; + for (const wire of wires) { + await exchange(port, wire); + await pause(20); + console.log("--"); + } + server.close(); +}); diff --git a/tests/corpus/5409-http-held-request-body.ts b/tests/corpus/5409-http-held-request-body.ts new file mode 100644 index 000000000..a10b63054 --- /dev/null +++ b/tests/corpus/5409-http-held-request-body.ts @@ -0,0 +1,57 @@ +// Server requests start paused: body bytes wait for a consumer, so a 'data' +// listener attached after a timer still receives the whole body before +// 'end'. A request the handler never reads ends once the response +// finishes, on keep-alive connections too. +import { createServer, request } from "node:http"; + +const server = createServer((req, res) => { + const tag = `${req.method} ${req.url}`; + req.on("end", () => console.log("req end", tag)); + req.on("close", () => console.log("req close", tag)); + res.on("finish", () => console.log("res finish", tag)); + res.on("close", () => console.log("res close", tag)); + if (req.url === "/delayed") { + setTimeout(() => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => res.end(`got ${body}`)); + }, 30); + return; + } + res.end("ok"); +}); + +const cases: [string, string, string][] = [ + ["GET", "/", ""], + ["POST", "/", "body"], + ["POST", "/delayed", "held body"], + ["GET", "/keep", ""], + ["POST", "/keep", "body"], +]; + +server.listen(0, "127.0.0.1", async () => { + const { port } = server.address() as { port: number }; + for (const [method, path, body] of cases) { + await new Promise((resolve) => { + const headers: Record = path === "/keep" ? {} : { connection: "close" }; + const req = request({ host: "127.0.0.1", port, method, path, headers: headers }, (res) => { + let text = ""; + res.on("data", (chunk: Buffer) => { + text += chunk.toString(); + }); + res.on("end", () => { + console.log("client", method, path, res.statusCode, text); + resolve(); + }); + }); + if (body) req.end(body); + else req.end(); + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + console.log("--"); + } + server.close(); + server.closeAllConnections(); +}); diff --git a/tests/harness/server.test.ts b/tests/harness/server.test.ts index 230085ada..6126f0850 100644 --- a/tests/harness/server.test.ts +++ b/tests/harness/server.test.ts @@ -154,13 +154,15 @@ describe(`server differential (${cases.length} programs${sanitize ? ", sanitized 120_000, ); - test("http-trailers rejects an aggregate trailer overflow", async () => { + test("http-trailers answers 431 for a trailer block over the field limit", async () => { const entry = join(fixturesRoot, "cases/http-trailers/main.ts"); const driver = join(fixturesRoot, "cases/http-trailers/limit-driver.mjs"); const binary = await build(entry); const nativeRes = await runLane(binary, [], driver); expect(nativeRes.exitCode).toBe(0); - expect(nativeRes.driverStdout).toBe("limit HTTP/1.1 400 Bad Request\nlimit driver done\n"); + expect(nativeRes.driverStdout).toBe( + "limit HTTP/1.1 431 Request Header Fields Too Large\nlimit driver done\n", + ); }, 120_000); test("net-reuse-port emits the additive LLVM ABI", async () => {