diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 2d229bd84..320530d23 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -158,6 +158,8 @@ title: "Limitations" ../limitations/buffer-construction.mdx +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 ../limitations/json-callbacks.mdx 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 5042e2efd..8aba8d283 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 () => {