From 7c90c8e3a18a3abddc03beee61717f9b1b7b8a44 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 18:23:08 -0500 Subject: [PATCH 01/53] Pool keep-alive sockets in http Agents and use globalAgent for default requests --- .../ambient/scriptc-node-fallback.d.ts | 12 +- .../compiler/src/backend/llvm/lib-shared.ts | 4 +- packages/compiler/src/backend/llvm/lib-tls.ts | 9 +- .../src/frontend/lowering/lower-exprs.ts | 9 + .../src/frontend/lowering/lower-server.ts | 88 ++-- .../compiler/src/frontend/lowering/lowerer.ts | 9 + .../src/frontend/lowering/surfaces.ts | 4 +- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 14 +- .../corpus/5596-http-agent-keep-alive.ts.json | 6 + .../5597-http-agent-idle-sockets.ts.json | 6 + .../corpus/5598-http-global-agent.ts.json | 6 + packages/runtime/src/scr_http.c | 404 ++++++++++++++++-- packages/runtime/src/scr_http.h | 12 +- packages/runtime/src/scr_http2.h | 6 +- packages/runtime/src/scr_net.c | 8 + packages/runtime/src/scr_net.h | 1 + packages/runtime/src/scr_tls.c | 86 +++- tests/corpus/5564-http-request-signal.ts | 11 +- tests/corpus/5596-http-agent-keep-alive.ts | 109 +++++ tests/corpus/5597-http-agent-idle-sockets.ts | 79 ++++ tests/corpus/5598-http-global-agent.ts | 30 ++ 22 files changed, 800 insertions(+), 114 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5596-http-agent-keep-alive.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5597-http-agent-idle-sockets.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5598-http-global-agent.ts.json create mode 100644 tests/corpus/5596-http-agent-keep-alive.ts create mode 100644 tests/corpus/5597-http-agent-idle-sockets.ts create mode 100644 tests/corpus/5598-http-global-agent.ts diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index d87172f61..3b033027a 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -4716,17 +4716,16 @@ declare module "http" { /* The Agent lowers to a checked-dynamic HANDLE: option parsing at the * literal construction site, getName, destroy, the sockets/requests/ * freeSockets snapshots, and REAL maxSockets accounting (over-limit - * requests defer their dial and queue). keepAlive: true throws the - * named pooling fence — this client dials one connection per request - * and closes it with the response, so freeSockets is always - * empty. */ + * requests defer their dial and queue). keepAlive: true keeps finished + * connections in freeSockets for the next request to the same name, + * taken in `scheduling` order. */ export interface AgentOptions { keepAlive?: boolean; keepAliveMsecs?: number; maxSockets?: number; maxFreeSockets?: number; maxTotalSockets?: number; - scheduling?: string; + scheduling?: "fifo" | "lifo"; timeout?: number; port?: number; [option: string]: unknown; @@ -4756,6 +4755,7 @@ declare module "http" { readonly freeSockets: { [key: string]: unknown[] }; readonly requests: { [key: string]: unknown[] }; } + /* Node's keepAlive global agent for requests without an agent option. */ export const globalAgent: Agent; export interface ClientRequest { readonly destroyed: boolean; @@ -4959,6 +4959,8 @@ declare module "https" { import { Server } from "http"; import { Agent, ClientRequest, IncomingMessage, ServerResponse } from "http"; export { Agent, Server }; + /* Node's keepAlive global agent for requests without an agent option. */ + export const globalAgent: Agent; export interface ServerOptions { cert: string | Uint8Array; key: string | Uint8Array; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 4e1ba54ac..0be05ef5f 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1210,9 +1210,11 @@ export const LIB_FN_SYMS: Record = { "http.reqSetTimeout": "scr_http_req_set_timeout_plain", "http.clientDestroy": "scr_http_client_destroy", "http.clientDestroyError": "scr_http_client_destroy_error", + "http.globalAgent": "scr_http_global_agent_value", "http.serverMaxHeaderSize": "scr_http_server_max_header_size", "http.clientMaxHeaderSize": "scr_http_client_max_header_size", - "https.clientServername": "scr_https_client_servername", + "http.clientSignalCheck": "scr_http_client_signal_check", + "https.nextServername": "scr_https_set_next_servername", "http.clientAbort": "scr_http_client_abort", "http.clientAborted": "scr_http_client_aborted", "http.clientDestroyed": "scr_http_client_destroyed", diff --git a/packages/compiler/src/backend/llvm/lib-tls.ts b/packages/compiler/src/backend/llvm/lib-tls.ts index f174248c0..a993ec49a 100644 --- a/packages/compiler/src/backend/llvm/lib-tls.ts +++ b/packages/compiler/src/backend/llvm/lib-tls.ts @@ -164,13 +164,8 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu ]); B.br(done); B.startBlock(plain); - const insecure = callRuntime(host, "scr_http_request_ex", "ptr", [ - ...a.slice(1, 8), - ...handler, - { type: "i32", name: "80" }, - ptr(), - ptr(), - ]); + // The plain arm is http.request: Node's global agent serves it. + const insecure = callRuntime(host, "scr_http_request", "ptr", [...a.slice(1, 8), ...handler]); B.br(done); B.startBlock(done); const result = B.tmp(); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 08e12518b..1292a4ddb 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1158,6 +1158,15 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (bi.module === "http" && bi.member === "METHODS") { return { kind: "libCall", fn: "http.methods", args: [], type: arrayOf(STRING), loc }; } + if ((bi.module === "http" || bi.module === "https") && bi.member === "globalAgent") { + return { + kind: "libCall", + fn: "http.globalAgent", + args: [{ kind: "boolLit", value: bi.module === "https", type: BOOL, loc }], + type: DYN, + loc, + }; + } if (bi.module === "http" && bi.member === "STATUS_CODES") { return { kind: "libCall", fn: "http.statusCodes", args: [], type: DYN, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 8c8639d43..8f4793153 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -4228,12 +4228,12 @@ function lowerHttpCreateServerForms( * getName/destroy and the sockets/requests/freeSockets counters dispatch * through the dyn handle ops, and the request path threads it (the * requestAgent rows). Options parse at the LITERAL construction site: - * keepAlive (a boolean value — TRUE throws the runtime's named pooling - * fence: this client dials one connection per request), keepAliveMsecs / - * maxSockets / maxFreeSockets / timeout / port as numbers, scheduling - * DROPS (no free pool exists, so selection order cannot observe), other - * documented keys fence by name, and undocumented keys drop like Node - * drops them. Null when the callee isn't the http/https Agent. */ + * keepAlive (a boolean value: finished connections return to the free + * list), keepAliveMsecs / maxSockets / maxFreeSockets / timeout / port as + * numbers, scheduling as a value the runtime validates ('fifo' or + * 'lifo'), other documented keys fence by name, and undocumented keys + * drop like Node drops them. Null when the callee isn't the http/https + * Agent. */ export function lowerHttpAgentNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { const callee = expr.expression; const bi = @@ -4252,6 +4252,7 @@ export function lowerHttpAgentNew(lowerer: Lowerer, expr: ts.NewExpression): IrE let maxFree: IrExpr = numLit(-1, loc); let timeout: IrExpr = numLit(-1, loc); let port: IrExpr = numLit(-1, loc); + let scheduling: IrExpr = lowerer.coerceToExpected(strLit("lifo", loc), DYN); const args = expr.arguments ?? []; if (args.length > 1) { lowerer.noLowering( @@ -4334,11 +4335,15 @@ export function lowerHttpAgentNew(lowerer: Lowerer, expr: ts.NewExpression): IrE // the settable defaultPort carries this one. port = lowerVal("f64"); break; - case "scheduling": - // Free-socket selection order: no free pool exists here (no - // keep-alive reuse), so the choice cannot observe — dropped. - if (initializer !== null) lowerer.lowerExpr(initializer); // evaluate, like Node + case "scheduling": { + // 'fifo' | 'lifo' (falsy = 'lifo'), validated by the runtime + const v = + initializer !== null + ? lowerer.lowerExpr(initializer) + : lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); + scheduling = lowerer.coerceToExpected(v, DYN); break; + } default: fenceOrDropOptionKey( lowerer, @@ -4364,6 +4369,7 @@ export function lowerHttpAgentNew(lowerer: Lowerer, expr: ts.NewExpression): IrE maxFree, timeout, port, + scheduling, ], type: DYN, loc, @@ -5960,6 +5966,13 @@ function lowerHttpClientCall( maxHeaderSize = lowerVal("f64"); break; case "servername": + if (binding !== null) { + lowerer.noLowering( + `${member} option 'servername' through a module-function binding`, + prop, + "call https.request directly when passing servername", + ); + } if (!secureish) { lowerer.noLowering( `${member} option 'servername'`, @@ -6196,14 +6209,48 @@ function lowerHttpClientCall( timeout ??= numLit(0, loc); headers ??= { kind: "arrayLit", elems: [], type: arrayOf(STRING), loc }; const autoEnd = boolLit(member === "get", loc); - // Options applied to the built request: servername before the TLS - // handshake starts, then options.signal (an already aborted signal - // destroys the request at once, like Node's constructor). + // options.signal is checked first: Node's constructor rejects a value + // that is not an AbortSignal before it dials. servername is stored just + // before construction (it selects the TLS name and which pooled sockets + // the request may reuse); maxHeaderSize and the signal then apply to the + // built request (an already aborted signal destroys it at once, like + // Node's constructor). const withSignal = (request: IrExpr): IrExpr => { if (signal === null && servername === null && maxHeaderSize === null) return request; const local = lowerer.declareHiddenLocal("%requestOptions", HTTPCLIENTREQ_T); const ref = (): IrExpr => varRef(local.id, HTTPCLIENTREQ_T, loc); - const steps: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: request, loc }]; + const steps: IrStmt[] = []; + let signalRef: (() => IrExpr) | null = null; + if (signal !== null) { + const signalLocal = lowerer.declareHiddenLocal("%requestSignal", DYN); + signalRef = () => varRef(signalLocal.id, DYN, loc); + steps.push({ kind: "varDecl", localId: signalLocal.id, init: signal, loc }); + steps.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "http.clientSignalCheck", + args: [signalRef()], + type: VOID, + loc, + }, + loc, + }); + } + if (servername !== null) { + steps.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "https.nextServername", + args: [servername], + type: VOID, + loc, + }, + loc, + }); + } + steps.push({ kind: "varDecl", localId: local.id, init: request, loc }); if (maxHeaderSize !== null) { steps.push({ kind: "exprStmt", @@ -6217,26 +6264,19 @@ function lowerHttpClientCall( loc, }); } - if (servername !== null) { + if (signalRef !== null) { steps.push({ kind: "exprStmt", expr: { kind: "libCall", - fn: "https.clientServername", - args: [ref(), servername], + fn: "http.clientSignal", + args: [ref(), signalRef()], type: VOID, loc, }, loc, }); } - if (signal !== null) { - steps.push({ - kind: "exprStmt", - expr: { kind: "libCall", fn: "http.clientSignal", args: [ref(), signal], type: VOID, loc }, - loc, - }); - } return { kind: "seqExpr", stmts: steps, result: ref(), type: HTTPCLIENTREQ_T, loc }; }; if (connCb !== null) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d74cec0ae..9d2e8228f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11338,6 +11338,15 @@ export class Lowerer { if (bi.module === "http" && bi.member === "METHODS") { return { kind: "libCall", fn: "http.methods", args: [], type: arrayOf(STRING), loc }; } + if ((bi.module === "http" || bi.module === "https") && bi.member === "globalAgent") { + return { + kind: "libCall", + fn: "http.globalAgent", + args: [{ kind: "boolLit", value: bi.module === "https", type: BOOL, loc }], + type: DYN, + loc, + }; + } if (bi.module === "http" && bi.member === "STATUS_CODES") { return { kind: "libCall", fn: "http.statusCodes", args: [], type: DYN, loc }; } diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4c74e8b6a..7fbf2258d 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -276,8 +276,8 @@ export const HTTP_SERVER_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ /** new http.Agent(options)'s documented keys (Node v24 — the Agent * constructor table plus the socket.connect() options it forwards into * each dial). The lowered set is keepAlive/keepAliveMsecs/maxSockets/ - * maxFreeSockets/timeout/port (+ scheduling, dropped: no free pool - * exists); the rest fence by name and unknown keys drop like Node. */ + * maxFreeSockets/timeout/port/scheduling; the rest fence by name and + * unknown keys drop like Node. */ export const AGENT_DOCUMENTED_OPTIONS: ReadonlySet = new Set([ "family", "hints", diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index bd87a470d..f9b092e90 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -143,6 +143,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "http.resWriteHeadDyn", "http.resSetHeaderDyn", "http.clientSignal", + "http.clientSignalCheck", "http.serverMaxHeaderSize", "http.clientMaxHeaderSize", "http.resAppendHeader", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 4a7833053..0a2ecbaa3 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1719,15 +1719,21 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * socket POOLING is not modeled (one dial per request); maxSockets * accounting is real: over-limit requests defer their dial and queue. * MAY THROW. */ - "http.agentNew": { argTypes: [BOOL, BOOL, F64, F64, F64, F64, F64], result: DYN }, + "http.agentNew": { argTypes: [BOOL, BOOL, F64, F64, F64, F64, F64, DYN], result: DYN }, + /** http.globalAgent / https.globalAgent (true): Node's keepAlive global + * agent, the one requests without an agent option use. */ + "http.globalAgent": { argTypes: [BOOL], result: DYN }, /** http.request's `signal` option over the fresh request: an aborted * signal destroys it with Node's AbortError now, a later abort event * does the same. A falsy value is ignored; a non-signal throws * ERR_INVALID_ARG_TYPE. */ "http.clientSignal": { argTypes: [HTTPCLIENTREQ_T, DYN], result: VOID }, - /** https.request's servername option: the TLS name sent and verified. - * The "https." prefix keeps the TLS unit linked. */ - "https.clientServername": { argTypes: [HTTPCLIENTREQ_T, STRING], result: VOID }, + /** options.signal checked before the request is built (ERR_INVALID_ARG_TYPE). */ + "http.clientSignalCheck": { argTypes: [DYN], result: VOID }, + /** https.request's servername option, stored for the construction call + * that follows: the TLS name sent and verified, and part of the pooled + * socket key. The "https." prefix keeps the TLS unit linked. */ + "https.nextServername": { argTypes: [STRING], result: VOID }, /** The maxHeaderSize option of createServer / http.request: Node's * validateInteger (ERR_OUT_OF_RANGE) then the header size bound. * MAY THROW. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5596-http-agent-keep-alive.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5596-http-agent-keep-alive.ts.json new file mode 100644 index 000000000..f09fa3087 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5596-http-agent-keep-alive.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5596-http-agent-keep-alive.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5597-http-agent-idle-sockets.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5597-http-agent-idle-sockets.ts.json new file mode 100644 index 000000000..91656c30a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5597-http-agent-idle-sockets.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5597-http-agent-idle-sockets.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5598-http-global-agent.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5598-http-global-agent.ts.json new file mode 100644 index 000000000..fdb1969f9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5598-http-global-agent.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5598-http-global-agent.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 7a8359032..af73d8e3f 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -2408,6 +2408,8 @@ typedef struct ScrHttpConn { bool pumping; /* inside scr_http_conn_pump */ bool keep_alive_timer; /* the socket's idle timer is the keep-alive timeout */ size_t max_header_size; /* maxHeaderSize option; 0 = SCR_HTTP_MAX_HEADER_SIZE */ + struct ScrHttpAgent *pool_agent; /* client mode: the agent whose free list holds + * this idle socket (borrowed), or NULL */ bool header_overflow; /* client mode: the response head exceeded a bound */ bool res_close_deferred; /* the response finished inside the pump while * the request body was still arriving */ @@ -3757,6 +3759,10 @@ struct ScrHttpClientReq { ScrError *destroy_error; /* destroy(err) or the signal's AbortError (+1): * fired in place of the teardown's own error */ ScrDyn *abort_dispose; /* options.signal: removes the abort listener (+1) */ + double timeout_ms; /* the timeout option (0 = none), reapplied on a pooled socket */ + bool reused; /* req.reusedSocket: the socket came from an agent's free list */ + bool sock_pooled; /* the socket went back to the agent after the response */ + ScrStr *pool_key; /* https: TLS options a pooled socket must match (+1), or NULL */ ScrClosure *abort_listener; /* its closure (borrowed); caps[0] points back here */ bool in_registry; struct ScrHttpClientReq *next; @@ -3796,6 +3802,7 @@ void scr_http_client_release(ScrHttpClientReq *c) { if (c->res) scr_http_req_release(c->res); if (c->sock) scr_net_sock_release(c->sock); if (c->destroy_error) scr_error_release(c->destroy_error); + scr_str_release(c->pool_key); if (c->abort_listener) scr_box_set_ref(c->abort_listener->caps[0], NULL); scr_dyn_release(c->abort_dispose); #ifdef SCR_RC_AUDIT @@ -3836,6 +3843,9 @@ static void scr_http_client_unregister(ScrHttpClientReq *c) { /* Agent bookkeeping (implementation below, with the Agent unit): the * settling client leaves its agent's lists and frees a slot. */ static void scr_http_agent_client_done(struct ScrHttpAgent *ag, struct ScrHttpClientReq *c); +static void scr_http_agent_free_remove(struct ScrHttpAgent *a, struct ScrHttpConn *conn); +static bool scr_http_client_pool(struct ScrHttpConn *conn, struct ScrHttpClientReq *c, double hint); +static double scr_http_response_keep_alive(ScrHttpReq *res, const struct ScrHttpClientReq *c); static struct ScrHttpAgent *scr_http_agent_release_p(struct ScrHttpAgent *ag); static void scr_http_client_agent_detach(struct ScrHttpClientReq *c); @@ -3994,7 +4004,7 @@ bool scr_http_client_headers_sent(ScrHttpClientReq *c) { return c->head_sent; } bool scr_http_client_writable_ended(ScrHttpClientReq *c) { return c->ended; } bool scr_http_client_writable_finished(ScrHttpClientReq *c) { return c->writable_finished; } ScrNetSocket *scr_http_client_socket(ScrHttpClientReq *c) { return scr_net_sock_retain(c->sock); } -bool scr_http_client_reused_socket(ScrHttpClientReq *c) { (void)c; return false; } +bool scr_http_client_reused_socket(ScrHttpClientReq *c) { return c->reused; } void scr_http_client_set_nodelay(ScrHttpClientReq *c, bool enable) { if (!c->sock) return; scr_net_sock_release(scr_net_sock_set_nodelay(c->sock, enable)); @@ -4295,7 +4305,7 @@ void scr_http_client_add_trailers(ScrHttpClientReq *c, ScrArr *pairs /*borrowed* void scr_http_client_set_timeout(ScrHttpClientReq *c, double ms) { if (!scr_http_timeout_valid(ms)) return; - if (c->sock) scr_net_sock_set_timeout(c->sock, ms); + if (c->sock && !c->sock_pooled) scr_net_sock_set_timeout(c->sock, ms); } void scr_http_client_set_timeout_cb(ScrHttpClientReq *c, double ms, ScrClosure *cb /*moves*/) { @@ -4314,7 +4324,8 @@ void scr_http_client_set_timeout_cb(ScrHttpClientReq *c, double ms, ScrClosure * void scr_http_client_destroy(ScrHttpClientReq *c) { if (c->destroyed) return; c->destroyed = true; - if (c->sock) scr_net_sock_destroy(c->sock); + /* a socket already back in the agent's pool belongs to the agent */ + if (c->sock && !c->sock_pooled) scr_net_sock_destroy(c->sock); } static void scr_http_client_emit_destroy_error(ScrHttpClientReq *c) { @@ -4387,14 +4398,10 @@ static ScrDyn *scr_http_client_abort_thunk(ScrClosure *clo, ScrDyn *const *args, static void *scr_http_dyn_retain_v(void *p) { return p ? scr_dyn_retain(p) : NULL; } static void scr_http_dyn_release_v(void *p) { scr_dyn_release(p); } -/* http.request's `signal` option: an aborted signal destroys the request - * with Node's AbortError now; otherwise the abort event does, until the - * request closes. A falsy signal is ignored; anything else that is not an - * AbortSignal throws ERR_INVALID_ARG_TYPE. Borrows the signal. */ -void scr_http_client_add_abort_signal(ScrHttpClientReq *c, ScrDyn *signal, - ScrDyn *(*add)(ScrDyn *signal, ScrDyn *listener)) { - if (signal == NULL || !scr_dyn_truthy(signal)) return; - /* Node's validateAbortSignal: an object with an `aborted` property */ +/* The signal's `aborted` value (+1), or NULL with Node's + * ERR_INVALID_ARG_TYPE pending when the value is not an AbortSignal + * (validateAbortSignal: an object with an `aborted` property). */ +static ScrDyn *scr_http_signal_aborted(const ScrDyn *signal) { ScrDyn *aborted_value = NULL; if (signal->kind == SCR_DYN_OBJ) { ScrDyn *v = scr_dyn_obj_get(signal, "aborted", 7); @@ -4408,10 +4415,29 @@ void scr_http_client_add_abort_signal(ScrHttpClientReq *c, ScrDyn *signal, aborted_value = NULL; } } - if (aborted_value == NULL) { - scr_dyn_arg_type_fail("options.signal", "an instance of AbortSignal", signal); - return; - } + if (aborted_value == NULL) scr_dyn_arg_type_fail("signal", "an instance of AbortSignal", signal); + return aborted_value; +} + +/* http.request's `signal` option before the request is built: Node's + * ClientRequest constructor validates a truthy signal before it dials, so + * a value that is not an AbortSignal throws ERR_INVALID_ARG_TYPE and no + * request exists. Borrows the signal. */ +void scr_http_client_signal_check(const ScrDyn *signal) { + if (signal == NULL || !scr_dyn_truthy(signal)) return; + ScrDyn *aborted_value = scr_http_signal_aborted(signal); + if (aborted_value != NULL) scr_dyn_release(aborted_value); +} + +/* http.request's `signal` option: an aborted signal destroys the request + * with Node's AbortError now; otherwise the abort event does, until the + * request closes. A falsy signal is ignored; anything else that is not an + * AbortSignal throws ERR_INVALID_ARG_TYPE. Borrows the signal. */ +void scr_http_client_add_abort_signal(ScrHttpClientReq *c, ScrDyn *signal, + ScrDyn *(*add)(ScrDyn *signal, ScrDyn *listener)) { + if (signal == NULL || !scr_dyn_truthy(signal)) return; + ScrDyn *aborted_value = scr_http_signal_aborted(signal); + if (aborted_value == NULL) return; bool aborted = scr_dyn_truthy(aborted_value); scr_dyn_release(aborted_value); if (aborted) { @@ -4733,6 +4759,10 @@ static void scr_http_client_response_done(ScrHttpConn *conn) { ScrHttpReq *res = conn->req; if (!c || c->response_done) return; c->response_done = true; + /* the response's reuse verdict, read before the response detaches: + * -2 = the connection closes, -1 = reusable with no Keep-Alive timeout + * hint, otherwise the hint in milliseconds */ + double keep_alive_hint = res != NULL ? scr_http_response_keep_alive(res, c) : -2; if (res) { scr_http_req_retain(res); scr_http_req_finish(res, true); /* fires 'end' */ @@ -4748,7 +4778,8 @@ static void scr_http_client_response_done(ScrHttpConn *conn) { conn->req = NULL; scr_http_req_release(res); } - if (c->sock) scr_net_sock_destroy(c->sock); /* quiet close, no pooling */ + /* a keepAlive agent takes the socket back; otherwise it closes quietly */ + if (c->sock && !scr_http_client_pool(conn, c, keep_alive_hint)) scr_net_sock_destroy(c->sock); } /* Premature teardown classification, shared by eof/closed/error: @@ -4792,6 +4823,11 @@ static void scr_http_client_premature(ScrHttpConn *conn) { static void scr_http_client_eof(ScrHttpConn *conn) { ScrHttpClientReq *c = conn->client; + if (conn->pool_agent != NULL) { + /* the server closed an idle pooled connection */ + scr_net_sock_destroy(conn->sock); + return; + } if (!c) return; if (conn->state == SCR_HTTP_BODY_EOF && c->response_started && !c->response_done) { /* EOF-delimited body: this IS completion */ @@ -4814,6 +4850,7 @@ static void scr_http_client_agent_detach(ScrHttpClientReq *c) { } static void scr_http_client_closed(ScrHttpConn *conn) { + if (conn->pool_agent != NULL) scr_http_agent_free_remove(conn->pool_agent, conn); ScrHttpClientReq *c = conn->client; if (!c) return; scr_http_client_agent_detach(c); @@ -4868,6 +4905,11 @@ static void scr_http_client_head_overflow(ScrHttpConn *conn) { * (never destroys anything — Node; the caller destroys). */ static void scr_http_client_sock_timeout(void *ctx) { ScrHttpConn *conn = (ScrHttpConn *)ctx; + if (conn->pool_agent != NULL) { + /* an idle pooled socket timed out: the agent destroys it */ + scr_net_sock_destroy(conn->sock); + return; + } ScrHttpClientReq *c = conn->client; if (!c || c->response_done || c->close_emitted) return; scr_net_fire0(&c->timeout_ls); @@ -4882,6 +4924,7 @@ static void scr_http_client_sock_established(void *ctx) { } static void scr_http_client_conn_free(ScrHttpConn *conn) { + if (conn->pool_agent != NULL) scr_http_agent_free_remove(conn->pool_agent, conn); if (conn->req) { scr_http_req_finish(conn->req, false); scr_http_req_release(conn->req); @@ -4998,6 +5041,7 @@ static ScrHttpClientReq *scr_http_request_impl(ScrStr *host /*borrowed*/, double if (wrap) wrap(c->sock, wrap_ctx); scr_net_sock_set_native_established(c->sock, &scr_http_client_sock_established); c->sock_ready = scr_net_sock_established(c->sock); + c->timeout_ms = timeout_ms > 0 ? timeout_ms : 0; if (timeout_ms > 0) scr_net_sock_set_timeout(c->sock, timeout_ms); scr_http_client_register(c); scr_http_emit_push(SCR_HTTP_EMIT_CLIENT_SOCKET, scr_http_client_retain(c)); @@ -5041,13 +5085,15 @@ ScrHttpClientReq *scr_http_request_conn(ScrClosure *conn_cb /*moves*/, ScrStr *p return c; } +/* http.request without an agent option: the request goes through + * http.globalAgent, as in Node. */ ScrHttpClientReq *scr_http_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { - return scr_http_request_ex(host, port, path, method, timeout_ms, header_pairs, auto_end, cb, - fn, 80, NULL, NULL); + return scr_http_request_agent_ex(host, port, path, method, timeout_ms, header_pairs, auto_end, + NULL, cb, fn, 80, NULL, NULL, NULL); } /* ── the http Agent ────────────────────────────────────────────────────── @@ -5075,7 +5121,8 @@ typedef struct ScrHttpAgentEnt { typedef struct ScrHttpAgent { size_t rc; bool secure; /* https.Agent: protocol/defaultPort answers */ - bool keep_alive; /* always false here (true fences at construction) */ + bool keep_alive; /* keepAlive: finished connections return to free_socks */ + bool fifo; /* scheduling: 'fifo' takes the oldest free socket, 'lifo' the newest */ double ka_msecs; double max_sockets; /* INFINITY = Node's default */ double max_free; @@ -5083,10 +5130,20 @@ typedef struct ScrHttpAgent { double default_port; /* settable (agent.defaultPort = p) */ bool destroyed; ScrHttpAgentEnt *ents; /* append order — Node's FIFO queue */ + struct ScrHttpAgentFree *free_socks; /* keepAlive: idle sockets, most recent first */ bool in_registry; struct ScrHttpAgent *next; } ScrHttpAgent; +/* One idle socket in a keepAlive agent's free list, with its parser. */ +typedef struct ScrHttpAgentFree { + ScrStr *name; /* getName's key */ + ScrStr *pool_key; /* https: the TLS options the socket was verified with (+1), or NULL */ + ScrNetSocket *sock; /* +1 */ + struct ScrHttpConn *conn; /* the socket's parser (owned by the socket) */ + struct ScrHttpAgentFree *next; +} ScrHttpAgentFree; + static SCR_TL ScrHttpAgent *scr_http_agents = NULL; /* registry: +1 each, atexit-swept */ /* Node's ClientRequest: an agent without keepAlive and without a finite @@ -5102,6 +5159,15 @@ static ScrHttpAgent *scr_http_agent_retain(ScrHttpAgent *a) { static void scr_http_agent_release(ScrHttpAgent *a) { if (!a || --a->rc > 0) return; + while (a->free_socks) { + ScrHttpAgentFree *f = a->free_socks; + a->free_socks = f->next; + f->conn->pool_agent = NULL; + scr_str_release(f->name); + scr_str_release(f->pool_key); + scr_net_sock_release(f->sock); + free(f); + } ScrHttpAgentEnt *e = a->ents; while (e) { ScrHttpAgentEnt *next = e->next; @@ -5131,6 +5197,190 @@ static size_t scr_http_agent_active(const ScrHttpAgent *a, const ScrStr *name) { return n; } +static bool scr_http_name_same(const ScrStr *a, const ScrStr *b) { + return a->len == b->len && memcmp(a->data, b->data, a->len) == 0; +} + +static size_t scr_http_agent_free_count(const ScrHttpAgent *a, const ScrStr *name) { + size_t n = 0; + for (const ScrHttpAgentFree *f = a->free_socks; f; f = f->next) n += scr_http_name_same(f->name, name); + return n; +} + +static bool scr_http_key_same(const ScrStr *a, const ScrStr *b) { + return a == NULL ? b == NULL : b != NULL && scr_http_name_same(a, b); +} + +/* Takes a free socket for `name` off the list, or NULL: the most recently + * freed one under 'lifo' scheduling, the oldest under 'fifo'. */ +static ScrHttpAgentFree *scr_http_agent_free_take(ScrHttpAgent *a, const ScrStr *name, + const ScrStr *pool_key) { + ScrHttpAgentFree **found = NULL; + for (ScrHttpAgentFree **link = &a->free_socks; *link; link = &(*link)->next) { + if (!scr_http_name_same((*link)->name, name) || !scr_http_key_same((*link)->pool_key, pool_key)) + continue; + found = link; + if (!a->fifo) break; + } + if (found == NULL) return NULL; + ScrHttpAgentFree *f = *found; + *found = f->next; + return f; +} + +/* Puts a socket taken by scr_http_agent_free_take back where it was + * relative to the other sockets for its name. */ +static void scr_http_agent_free_untake(ScrHttpAgent *a, ScrHttpAgentFree *f) { + ScrHttpAgentFree **link = &a->free_socks; + if (a->fifo) { + while (*link) link = &(*link)->next; + } + f->next = *link; + *link = f; +} + +/* The idle socket closed (or its parser is going away): it leaves the + * free list. */ +static void scr_http_agent_free_remove(struct ScrHttpAgent *a, struct ScrHttpConn *conn) { + for (ScrHttpAgentFree **link = &a->free_socks; *link; link = &(*link)->next) { + if ((*link)->conn != conn) continue; + ScrHttpAgentFree *f = *link; + *link = f->next; + conn->pool_agent = NULL; + scr_str_release(f->name); + scr_str_release(f->pool_key); + scr_net_sock_release(f->sock); + free(f); + return; + } + conn->pool_agent = NULL; +} + +/* Binds request `c` to a pooled socket in place of its deferred, + * never-dialed one: the deferred socket's parser is dropped, its buffered + * bytes move to the pooled socket, and the pooled parser serves `c`. + * `reused` is req.reusedSocket: Node sets it when a new request takes a + * free socket, not when a queued request receives a freed one. */ +static void scr_http_client_rebind(ScrHttpClientReq *c, ScrHttpAgentFree *f, bool reused) { + ScrNetSocket *old = c->sock; + ScrHttpConn *oldconn = c->conn; + scr_net_sock_set_native_reader(old, NULL, NULL, NULL, NULL, NULL); + scr_net_sock_set_native_events(old, NULL, NULL); + scr_net_sock_set_native_established(old, NULL); + scr_net_sock_move_writes(old, f->sock); + if (oldconn != NULL) { + if (oldconn->client) scr_http_client_release(oldconn->client); + free(oldconn->buf); + free(oldconn); + } + scr_net_sock_destroy(old); + scr_net_sock_release(old); + ScrHttpConn *conn = f->conn; + c->sock = f->sock; /* the free entry's +1 moves */ + c->conn = conn; + conn->client = scr_http_client_retain(c); + conn->pool_agent = NULL; + conn->state = SCR_HTTP_HEAD; + conn->len = 0; + conn->body_remaining = 0; + conn->client_resp_started = false; + conn->header_overflow = false; + conn->head_bytes = conn->trailer_bytes = conn->trailer_fields = 0; + scr_str_release(f->name); + scr_str_release(f->pool_key); + free(f); + c->reused = reused; + c->sock_ready = true; + scr_net_sock_set_ref(c->sock, true); + scr_net_sock_set_timeout(c->sock, c->timeout_ms); + scr_http_client_queue_finish(c); +} + +/* Whether the request's own header `name` carries `token`. */ +static bool scr_http_client_has_header_value(const ScrHttpClientReq *c, const char *name, + const char *token) { + for (size_t i = 0; i < c->nheaders; i++) { + if (scr_http_header_name_is(c->hnames[i], name) && scr_http_header_has_token(c->hvalues[i], token)) + return true; + } + return false; +} + +/* A client response's keep-alive verdict: -2 when the connection closes + * after it (Connection: close, HTTP/1.0 without keep-alive, or a body + * delimited by the connection closing), otherwise the Keep-Alive header's + * /^timeout=(\d+)/ hint in milliseconds, or -1 without one. */ +static double scr_http_response_keep_alive(ScrHttpReq *res, const struct ScrHttpClientReq *c) { + bool keep = !res->http10, framed = false; + double hint = -1; + for (size_t i = 0; i < res->nheaders; i++) { + const ScrStr *n = res->hnames[i]; + const ScrStr *v = res->hvalues[i]; + if (scr_http_header_name_is((ScrStr *)n, "connection")) { + if (scr_http_header_has_token(v, "close")) keep = false; + else if (scr_http_header_has_token(v, "keep-alive")) keep = true; + } else if (scr_http_header_name_is((ScrStr *)n, "content-length") || + scr_http_header_name_is((ScrStr *)n, "transfer-encoding")) { + framed = true; + } else if (scr_http_header_name_is((ScrStr *)n, "keep-alive") && v->len > 8 && + memcmp(v->data, "timeout=", 8) == 0 && isdigit((unsigned char)v->data[8])) { + double seconds = 0; + for (size_t k = 8; k < v->len && isdigit((unsigned char)v->data[k]); k++) seconds = seconds * 10 + (v->data[k] - '0'); + hint = seconds * 1000; + } + } + bool head = c->method->len == 4 && memcmp(c->method->data, "HEAD", 4) == 0; + bool bodiless = head || res->status == 204 || res->status == 304 || (res->status >= 100 && res->status < 200); + if (!keep || (!framed && !bodiless)) return -2; + return hint; +} + +/* Node's agent.keepSocketAlive after a response: the socket returns to the + * agent's free list (unref'd, with the agent's timeout lowered to the + * server's Keep-Alive hint less one second) unless the response or request + * asked to close, the request did not finish, or the free list for the + * name is full. Answers whether the socket was kept. */ +static bool scr_http_client_pool(struct ScrHttpConn *conn, struct ScrHttpClientReq *c, double hint) { + ScrHttpAgent *ag = c->agent; + if (ag == NULL || !ag->keep_alive || ag->destroyed || c->destroyed || c->aborted) return false; + if (hint == -2 || !c->ended || conn->len != 0 || scr_http_client_has_header_value(c, "connection", "close")) + return false; + ScrStr *name = NULL; + for (ScrHttpAgentEnt *e = ag->ents; e && name == NULL; e = e->next) + if (e->client == c) name = scr_str_retain(e->name); + if (name == NULL) return false; + if ((double)scr_http_agent_free_count(ag, name) >= ag->max_free) { + scr_str_release(name); + return false; + } + double timeout = ag->timeout_ms > 0 ? ag->timeout_ms : 0; + if (hint >= 0) { + double server = hint - 1000; + if (server < 0) { + scr_str_release(name); + return false; + } + if (server < timeout) timeout = server; + } + ScrHttpAgentFree *f = calloc(1, sizeof *f); + if (!f) scr_http_oom(); + f->name = name; + f->pool_key = c->pool_key ? scr_str_retain(c->pool_key) : NULL; + f->sock = scr_net_sock_retain(c->sock); + f->conn = conn; + /* the parser no longer serves this request */ + conn->client = NULL; + conn->pool_agent = ag; + f->next = ag->free_socks; + ag->free_socks = f; + c->sock_pooled = true; + scr_net_sock_set_ref(f->sock, false); + scr_net_sock_set_timeout(f->sock, timeout); + scr_http_client_agent_detach(c); /* frees the slot: a queued request takes the socket */ + scr_http_client_release(c); /* the parser's reference */ + return true; +} + /* The settling/dying client leaves the lists; a freed slot starts the * first queued dial for the same name (Node's FIFO). */ static void scr_http_agent_client_done(struct ScrHttpAgent *ag, struct ScrHttpClientReq *c) { @@ -5148,7 +5398,11 @@ static void scr_http_agent_client_done(struct ScrHttpAgent *ag, struct ScrHttpCl for (ScrHttpAgentEnt *e = ag->ents; e; e = e->next) { if (e->queued && e->name->len == name->len && memcmp(e->name->data, name->data, name->len) == 0) { - if (scr_http_agent_active(ag, name) < ag->max_sockets) { + ScrHttpAgentFree *f = scr_http_agent_free_take(ag, name, e->client->pool_key); + if (f != NULL) { + e->queued = false; + scr_http_client_rebind(e->client, f, false); + } else if (scr_http_agent_active(ag, name) < ag->max_sockets) { e->queued = false; if (e->client->sock) scr_net_sock_dial_start(e->client->sock); } @@ -5185,21 +5439,24 @@ static ScrStr *scr_http_agent_name(const char *host, size_t host_len, const char ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, double max_sockets, double max_free, double timeout_ms, - double port /* < 0 = unset */) { - if (keep_alive) { - static const char msg[] = - "an http Agent with keepAlive: true (socket pooling and reuse — compiled clients " - "dial one connection per request and close it with the response) is not supported " - "yet — construct the Agent without keepAlive, or drop the agent option"; - scr_throw_error_msg(SCR_ERR_ERROR, msg, sizeof msg - 1); - return NULL; - } + double port /* < 0 = unset */, const ScrDyn *scheduling) { scr_http_install(); + /* Node: options.scheduling || 'lifo', then validateOneOf */ + bool fifo = false; + if (scheduling != NULL && scr_dyn_truthy(scheduling)) { + const ScrStr *s = scheduling->kind == SCR_DYN_STR ? scheduling->v.str : NULL; + if (s != NULL && s->len == 4 && memcmp(s->data, "fifo", 4) == 0) fifo = true; + else if (s == NULL || s->len != 4 || memcmp(s->data, "lifo", 4) != 0) { + scr_dyn_arg_value_fail("scheduling", "must be one of: 'fifo', 'lifo'", scheduling); + return NULL; + } + } ScrHttpAgent *a = calloc(1, sizeof *a); if (!a) scr_http_oom(); a->rc = 1; a->secure = secure; - a->keep_alive = false; + a->keep_alive = keep_alive; + a->fifo = fifo; a->ka_msecs = ka_msecs >= 0 ? ka_msecs : 1000; a->max_sockets = max_sockets >= 0 ? max_sockets : (double)INFINITY; a->max_free = max_free >= 0 ? max_free : 256; @@ -5218,11 +5475,35 @@ ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, return d; } +/* http.globalAgent / https.globalAgent: Node's + * new Agent({ keepAlive: true, scheduling: 'lifo', timeout: 5000 }), + * created on first use. Borrowed (the thread keeps it until exit). */ +static SCR_TL ScrDyn *scr_http_global_agents[2]; + +const ScrDyn *scr_http_global_agent(bool secure) { + ScrDyn **slot = &scr_http_global_agents[secure ? 1 : 0]; + if (*slot == NULL) *slot = scr_http_agent_new(secure, true, -1, -1, -1, 5000, -1, NULL); + return *slot; +} + +/* http.globalAgent as a value (+1). */ +ScrDyn *scr_http_global_agent_value(bool secure) { + return scr_dyn_retain((ScrDyn *)scr_http_global_agent(secure)); +} + /* agent.destroy(): tears down every listed connection (actives destroy * their sockets, queued dials never start) — Node destroys in-use * sockets too; there is no free pool here. */ static void scr_http_agent_destroy(ScrHttpAgent *a) { a->destroyed = true; + /* idle pooled sockets close; their 'close' removes each from the list */ + while (a->free_socks) { + ScrNetSocket *sock = scr_net_sock_retain(a->free_socks->sock); + struct ScrHttpConn *conn = a->free_socks->conn; + scr_http_agent_free_remove(a, conn); + scr_net_sock_destroy(sock); + scr_net_sock_release(sock); + } /* destroying sockets detaches entries re-entrantly — walk a snapshot */ for (;;) { ScrHttpClientReq *victim = NULL; @@ -5244,13 +5525,11 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po bool auto_end, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, int default_port, - void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx) { - /* the null/undefined arms: the default path (port < 0 = no port option) */ - if (agent == NULL || agent->kind == SCR_DYN_UNDEF || agent->kind == SCR_DYN_NULL) { - double p = port >= 0 ? port : (double)default_port; - return scr_http_request_impl(host, p, path, method, timeout_ms, header_pairs, auto_end, - cb, fn, default_port, wrap, wrap_ctx, NULL); - } + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrStr *pool_key /*borrowed, nullable*/) { + /* the null/undefined arms: the scheme's global agent, like Node */ + if (agent == NULL || agent->kind == SCR_DYN_UNDEF || agent->kind == SCR_DYN_NULL) + agent = scr_http_global_agent(default_port == 443); /* agent: false — Node's one-shot Agent: exactly this client's model, * plus its Connection: close request header (unless the caller set one) */ if (agent->kind == SCR_DYN_BOOL && !agent->v.b) { @@ -5295,16 +5574,24 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po int portn = snprintf(portbuf, sizeof portbuf, "%d", (int)p); ScrStr *name = scr_http_agent_name(host->data, host->len, portbuf, (size_t)portn, NULL, 0, 0, NULL, 0); - bool queue = scr_http_agent_active(ag, name) >= ag->max_sockets; - ScrNetSocket *presock = queue ? scr_net_connect_deferred(p, host) : NULL; + /* a free pooled socket for the name serves the request right away */ + ScrHttpAgentFree *reuse = ag->keep_alive ? scr_http_agent_free_take(ag, name, pool_key) : NULL; + bool queue = reuse == NULL && scr_http_agent_active(ag, name) >= ag->max_sockets; + ScrNetSocket *presock = queue || reuse ? scr_net_connect_deferred(p, host) : NULL; ScrHttpClientReq *c = scr_http_request_impl(host, p, path, method, timeout_ms, header_pairs, auto_end, cb, fn, default_port, wrap, wrap_ctx, presock); if (c == NULL) { /* the method-token throw: nothing registered */ + if (reuse != NULL) scr_http_agent_free_untake(ag, reuse); /* back to the list it came from */ scr_str_release(name); return NULL; } - if (ag->timeout_ms >= 0 && timeout_ms <= 0) scr_net_sock_set_timeout(c->sock, ag->timeout_ms); + c->pool_key = pool_key ? scr_str_retain(pool_key) : NULL; + if (ag->timeout_ms >= 0 && timeout_ms <= 0) { + c->timeout_ms = ag->timeout_ms; + scr_net_sock_set_timeout(c->sock, ag->timeout_ms); + } + if (reuse != NULL) scr_http_client_rebind(c, reuse, true); c->agent = scr_http_agent_retain(ag); ScrHttpAgentEnt *e = calloc(1, sizeof *e); if (!e) scr_http_oom(); @@ -5323,12 +5610,16 @@ ScrHttpClientReq *scr_http_request_agent(ScrStr *host /*borrowed*/, double port, bool auto_end, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { return scr_http_request_agent_ex(host, port, path, method, timeout_ms, header_pairs, auto_end, - agent, cb, fn, 80, NULL, NULL); + agent, cb, fn, 80, NULL, NULL, NULL); } /* The atexit sweep: break agent↔client edges a program leaves in flight * (queued dials that never ran) so the RC audit sees a clean heap. */ static void scr_http_agents_cleanup(void) { + for (size_t i = 0; i < 2; i++) { + scr_dyn_release(scr_http_global_agents[i]); + scr_http_global_agents[i] = NULL; + } while (scr_http_agents) { ScrHttpAgent *a = scr_http_agents; scr_http_agents = a->next; @@ -6434,6 +6725,12 @@ static ScrDyn *scr_http_dynh_agent_get(void *h, const char *key, size_t key_len) if (strcmp(key, "maxFreeSockets") == 0) return scr_dyn_new_num(a->max_free); if (strcmp(key, "keepAlive") == 0) return scr_dyn_new_bool(a->keep_alive); if (strcmp(key, "keepAliveMsecs") == 0) return scr_dyn_new_num(a->ka_msecs); + if (strcmp(key, "scheduling") == 0) { + ScrStr *s = scr_str_new(a->fifo ? "fifo" : "lifo", 4); + ScrDyn *d = scr_dyn_new_str(s); + scr_str_release(s); + return d; + } if (strcmp(key, "defaultPort") == 0) return scr_dyn_new_num(a->default_port); if (strcmp(key, "protocol") == 0) { ScrStr *s = scr_str_new(a->secure ? "https:" : "http:", a->secure ? 6 : 5); @@ -6444,8 +6741,27 @@ static ScrDyn *scr_http_dynh_agent_get(void *h, const char *key, size_t key_len) if (strcmp(key, "sockets") == 0) return scr_http_dynh_agent_table(a, false); if (strcmp(key, "requests") == 0) return scr_http_dynh_agent_table(a, true); if (strcmp(key, "freeSockets") == 0) { - /* Always empty: the runtime pools nothing (keepAlive fences). */ - return scr_dyn_new_obj(); + /* { name: [socket, ...] }, oldest first like Node's arrays */ + ScrDyn *obj = scr_dyn_new_obj(); + for (ScrHttpAgentFree *f = a->free_socks; f; f = f->next) { + ScrDyn *arr = scr_dyn_obj_get(obj, f->name->data, f->name->len); + if (arr == NULL) { + arr = scr_dyn_new_arr(); + scr_dyn_obj_set(obj, f->name->data, f->name->len, arr); + } + ScrDyn *item = scr_dyn_new_handle(f->sock, SCR_DYNH_NET_SOCKET); + scr_dyn_arr_push(arr, item); + } + /* the list is most recent first: reverse each array into Node's order */ + for (size_t i = 0; i < obj->v.obj.len; i++) { + ScrDyn *arr = obj->v.obj.entries[i].value; + for (size_t l = 0, r = arr->v.arr.len ? arr->v.arr.len - 1 : 0; l < r; l++, r--) { + ScrDyn *t = arr->v.arr.items[l]; + arr->v.arr.items[l] = arr->v.arr.items[r]; + arr->v.arr.items[r] = t; + } + } + return obj; } if (strcmp(key, "totalSocketCount") == 0) { size_t n = 0; @@ -6455,7 +6771,7 @@ static ScrDyn *scr_http_dynh_agent_get(void *h, const char *key, size_t key_len) return scr_dyn_new_num((double)n); } { - static const char *const known[] = { "options", "maxTotalSockets", "scheduling", NULL }; + static const char *const known[] = { "options", "maxTotalSockets", NULL }; if (scr_http_dynh_in(key, known)) { scr_http_dynh_unsupported("Agent", key, NULL); return NULL; diff --git a/packages/runtime/src/scr_http.h b/packages/runtime/src/scr_http.h index 33c24a0d8..a4dcc44ce 100644 --- a/packages/runtime/src/scr_http.h +++ b/packages/runtime/src/scr_http.h @@ -295,6 +295,9 @@ void scr_http_client_destroy(ScrHttpClientReq *c); /* req.destroy(err): err (borrowed) becomes the request's 'error'. */ void scr_http_client_destroy_error(ScrHttpClientReq *c, ScrError *err); void scr_http_client_max_header_size(ScrHttpClientReq *c, double value); /* see the server form */ +/* http.request's `signal` option (borrowed), checked before the request + * is built. May throw ERR_INVALID_ARG_TYPE. */ +void scr_http_client_signal_check(const ScrDyn *signal); /* http.request's `signal` option (borrowed); `add` is * events.addAbortListener. May throw ERR_INVALID_ARG_TYPE. */ void scr_http_client_add_abort_signal(ScrHttpClientReq *c, ScrDyn *signal, @@ -350,14 +353,19 @@ bool scr_http_url_parts(ScrStr *url /*borrowed*/, bool secure, ScrStr **host_out * option" — the agent's (settable) defaultPort, then the scheme's. */ ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, double max_sockets, double max_free, double timeout_ms, - double port /* < 0 = unset: the option-merge default */); /* +1 */ + double port /* < 0 = unset: the option-merge default */, + const ScrDyn *scheduling /* borrowed; NULL = 'lifo' */); /* +1 */ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, int default_port, - void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx); /* +1 */ + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrStr *pool_key /*borrowed, nullable: https TLS options*/); /* +1 */ +/* http.globalAgent / https.globalAgent: borrowed, and as a +1 value. */ +const ScrDyn *scr_http_global_agent(bool secure); +ScrDyn *scr_http_global_agent_value(bool secure); ScrHttpClientReq *scr_http_request_agent(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, diff --git a/packages/runtime/src/scr_http2.h b/packages/runtime/src/scr_http2.h index d5b1cae2d..839a7d98c 100644 --- a/packages/runtime/src/scr_http2.h +++ b/packages/runtime/src/scr_http2.h @@ -166,9 +166,9 @@ long scr_http2_live_count(void); ScrNetServer *scr_https_create_server(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves*/, ScrHttpReqFn fn); /* +1 */ /* The runtime-options-record twin (the tls block's divergence-66 walk). */ ScrNetServer *scr_https_create_server_dyn(const struct ScrDyn *opts /*borrowed*/, ScrClosure *handler /*moves, nullable*/, ScrHttpReqFn fn); /* +1 or NULL+pending */ -/* https.request's servername option (borrowed), applied before the - * handshake starts. */ -void scr_https_client_servername(ScrHttpClientReq *c, ScrStr *name); +/* https.request's servername option (borrowed), stored for the next + * https request construction call. */ +void scr_https_set_next_servername(ScrStr *name); ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 64f5dee02..53387f301 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -2395,6 +2395,14 @@ ScrNetSocket *scr_net_connect_deferred(double port, ScrStr *host /*borrowed, nul return s; } +/* Moves a deferred socket's buffered (never sent) bytes onto `to`, which + * sends them in order after its own: an http agent request that waited + * for a slot takes a pooled socket instead of dialing. */ +void scr_net_sock_move_writes(ScrNetSocket *from, ScrNetSocket *to) { + if (from->wlen > from->whead) scr_net_sock_write_native(to, from->wbuf + from->whead, from->wlen - from->whead); + from->whead = from->wlen = 0; +} + void scr_net_sock_dial_start(ScrNetSocket *s) { if (!s->dial_deferred || s->close_emitted || s->emit_close || s->fd >= 0) return; s->dial_deferred = false; diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 539c14cdb..05e969002 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -138,6 +138,7 @@ void scr_net_listen_opts_reuse_port(ScrNetServer *s, double port, ScrStr *host / double scr_net_server_port(ScrNetServer *s); /* address().port */ bool scr_net_server_listening(ScrNetServer *s); void scr_net_sock_set_ref(ScrNetSocket *s, bool ref); +void scr_net_sock_move_writes(ScrNetSocket *from, ScrNetSocket *to); void scr_net_server_set_ref(ScrNetServer *s, bool ref); bool scr_net_sock_has_ref(ScrNetSocket *s); double scr_net_server_keep_alive_ms(ScrNetServer *s); diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 1f481c4fd..392aa261f 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -1089,15 +1089,68 @@ void scr_tls_fetch_client_wrap(ScrNetSocket *sock, void *ctx) { scr_tls_cli_wrap(sock, ctx); } +/* https.request's `servername` option: the request's lowering stores it + * just before the construction call, which takes it (+1, NULL = none). */ +static SCR_TL ScrStr *scr_https_next_servername = NULL; + +void scr_https_set_next_servername(ScrStr *name /*borrowed*/) { + scr_str_release(scr_https_next_servername); + scr_https_next_servername = scr_str_retain(name); +} + +static ScrStr *scr_https_take_servername(void) { + ScrStr *name = scr_https_next_servername; + scr_https_next_servername = NULL; + return name; +} + +/* The client config with the servername (when given) as the name the + * handshake sends and verifies, in place of the host. "" sends no SNI, + * and the certificate is still checked against the host, as Node's + * checkServerIdentity does. */ +static ScrTlsCli *scr_tls_cli_for(ScrStr *host, bool reject_unauthorized, const char *ca, + size_t ca_len, const ScrStr *servername) { + ScrTlsCli *cli = scr_tls_cli_new(host, reject_unauthorized, ca, ca_len); + if (servername != NULL) scr_tls_cli_set_servername(cli, servername); + return cli; +} + +/* The TLS options a pooled https socket was set up with: an agent reuses + * the socket only for a request with the same verification settings and + * servername, as Node's https getName keys them (a servername that is + * empty or equal to the host keys like none). +1. */ +static ScrStr *scr_tls_pool_key(ScrStr *host, bool reject_unauthorized, const char *ca, + size_t ca_len, ScrStr *servername) { + uint64_t h = 1469598103934665603ull; + for (size_t i = 0; i < ca_len; i++) h = (h ^ (unsigned char)ca[i]) * 1099511628211ull; + char buf[64]; + int n = snprintf(buf, sizeof buf, "%d:%zu:%016llx:", reject_unauthorized ? 1 : 0, ca_len, + (unsigned long long)h); + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, buf, (size_t)n); + if (servername != NULL && servername->len > 0 && !scr_str_eq(servername, host)) { + scr_jb_putc(&b, 's'); + scr_jb_write(&b, servername->data, servername->len); + } + return scr_jb_finish(&b); +} + ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, const char *ca /*borrowed, len 0 = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { - ScrTlsCli *cli = scr_tls_cli_new(host, reject_unauthorized, ca, ca_len); - return scr_http_request_ex(host, port, path, method, timeout_ms, header_pairs, auto_end, cb, - fn, 443, &scr_tls_cli_wrap, cli); + ScrStr *servername = scr_https_take_servername(); + ScrTlsCli *cli = scr_tls_cli_for(host, reject_unauthorized, ca, ca_len, servername); + ScrStr *key = scr_tls_pool_key(host, reject_unauthorized, ca, ca_len, servername); + scr_str_release(servername); + ScrHttpClientReq *c = scr_http_request_agent_ex(host, port, path, method, timeout_ms, header_pairs, + auto_end, NULL, cb, fn, 443, &scr_tls_cli_wrap, + cli, key); + scr_str_release(key); + return c; } /* https.get('https://host/path') — the URL-string spelling, sharing @@ -1131,9 +1184,15 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port const char *ca /*borrowed, len 0 = none*/, size_t ca_len, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { - ScrTlsCli *cli = scr_tls_cli_new(host, reject_unauthorized, ca, ca_len); - return scr_http_request_agent_ex(host, port, path, method, timeout_ms, header_pairs, auto_end, - agent, cb, fn, 443, &scr_tls_cli_wrap, cli); + ScrStr *servername = scr_https_take_servername(); + ScrTlsCli *cli = scr_tls_cli_for(host, reject_unauthorized, ca, ca_len, servername); + ScrStr *key = scr_tls_pool_key(host, reject_unauthorized, ca, ca_len, servername); + scr_str_release(servername); + ScrHttpClientReq *c = scr_http_request_agent_ex(host, port, path, method, timeout_ms, header_pairs, + auto_end, agent, cb, fn, 443, &scr_tls_cli_wrap, + cli, key); + scr_str_release(key); + return c; } /* ── RUNTIME options records (the divergence-66 stance) ──────────────── @@ -1735,21 +1794,6 @@ static ScrTlsSock *scr_tls_sock_of(ScrNetSocket *s) { return (ScrTlsSock *)scr_net_sock_transport_ctx_for(s, &SCR_TLS_OPS); } -/* https.request's `servername` option: the TLS name the handshake sends - * (SNI) and verifies the certificate against, in place of the host. An - * empty servername sends no SNI, and the certificate is still checked - * against the host, as Node's checkServerIdentity does. The handshake - * starts once the dial connects, after this runs. */ -void scr_https_client_servername(ScrHttpClientReq *c, ScrStr *name /*borrowed*/) { - ScrNetSocket *sock = scr_http_client_socket(c); - if (sock == NULL) return; - ScrTlsSock *t = scr_tls_sock_of(sock); - if (t != NULL && t->cli != NULL) { - scr_tls_cli_set_servername(t->cli, name); - (void)mbedtls_ssl_set_hostname(&t->ssl, t->cli->hostname); - } - scr_net_sock_release(sock); -} /* The client 'session' event: fires ONCE per socket with the serialized * mbedTLS session — a real ticket where one arrived (the 1.3 receipt diff --git a/tests/corpus/5564-http-request-signal.ts b/tests/corpus/5564-http-request-signal.ts index 86dbc6b72..07cd0cdbd 100644 --- a/tests/corpus/5564-http-request-signal.ts +++ b/tests/corpus/5564-http-request-signal.ts @@ -1,7 +1,9 @@ // http.request's `signal` option: aborting destroys the request with an // AbortError (code ABORT_ERR, the signal's reason as `cause`), including a // signal that is already aborted, and an abort after the response has no -// effect. req.destroy(err) reports err as the request's 'error'. +// effect. req.destroy(err) reports err as the request's 'error'. A value +// that is not an AbortSignal throws from http.request before any request +// is made. import * as http from "node:http"; const server = http.createServer((req, res) => { @@ -70,6 +72,13 @@ server.listen(0, "127.0.0.1", async () => { }); }); }); + try { + http.request({ host: "127.0.0.1", port, path: "/", signal: {} as AbortSignal }); + console.log("no throw"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log("not a signal", err.code, err.message); + } server.close(); server.closeAllConnections(); }); diff --git a/tests/corpus/5596-http-agent-keep-alive.ts b/tests/corpus/5596-http-agent-keep-alive.ts new file mode 100644 index 000000000..06e879920 --- /dev/null +++ b/tests/corpus/5596-http-agent-keep-alive.ts @@ -0,0 +1,109 @@ +// An Agent with keepAlive: true keeps each finished connection in its free +// list and hands it to the next request for the same host and port +// (req.reusedSocket is true), including requests that waited for a +// maxSockets slot. A response with Connection: close is not reused, and +// agent.destroy() closes the idle sockets. scheduling: 'lifo' (the +// default) reuses the most recently freed socket and 'fifo' the oldest; +// any other value throws. +import * as http from "node:http"; +import * as net from "node:net"; + +let connections = 0; +const server = http.createServer((req, res) => { + if (req.url === "/close") res.setHeader("Connection", "close"); + res.end(`${req.url} ${req.headers.connection}`); +}); +server.on("connection", () => connections++); + +function get(agent: http.Agent, path: string): Promise { + return new Promise((resolve) => { + const port = (server.address() as { port: number }).port; + const req = http.request( + { host: "127.0.0.1", port, path, agent: agent }, + (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => resolve(`${body} reused=${req.reusedSocket}`)); + }, + ); + req.end(); + }); +} + +function tryScheduling(scheduling: any): void { + try { + new http.Agent({ scheduling }); + console.log("scheduling", JSON.stringify(scheduling)); + } catch (err) { + const e = err as NodeJS.ErrnoException; + console.log(e.name, e.code, e.message); + } +} + +function track( + agent: http.Agent, + freed: number[], + id: number, +): Promise { + return new Promise((resolve) => { + const port = (server.address() as { port: number }).port; + const req = http.request( + { host: "127.0.0.1", port, path: "/s", agent: agent }, + (res) => { + res.resume(); + res.on("end", () => { + freed.push(id); + resolve(req.socket); + }); + }, + ); + req.end(); + }); +} + +server.listen(0, "127.0.0.1", async () => { + const keep = new http.Agent({ keepAlive: true }); + console.log(await get(keep, "/a")); + console.log(await get(keep, "/b")); + await new Promise((r) => setTimeout(r, 10)); + console.log( + "free", + Object.keys(keep.freeSockets).length, + "connections", + connections, + ); + console.log(await get(keep, "/close")); + await new Promise((r) => setTimeout(r, 10)); + console.log(await get(keep, "/after-close")); + console.log("connections", connections); + const one = new http.Agent({ keepAlive: true, maxSockets: 1 }); + const pending: Promise[] = [ + get(one, "/q1"), + get(one, "/q2"), + get(one, "/q3"), + ]; + const results: string[] = await Promise.all(pending); + console.log(results.join(" | ")); + console.log("connections", connections); + for (const scheduling of ["lifo", "fifo", undefined] as const) { + // the requests in the order their sockets were freed, then which of + // those sockets the next request reuses + const agent = new http.Agent({ keepAlive: true, scheduling }); + const freed: number[] = []; + const p0 = track(agent, freed, 0); + const p1 = track(agent, freed, 1); + const p2 = track(agent, freed, 2); + const s0 = await p0; + const s1 = await p1; + const s2 = await p2; + await new Promise((r) => setTimeout(r, 10)); + const next = await track(agent, [], 3); + const id = next === s0 ? 0 : next === s1 ? 1 : next === s2 ? 2 : -1; + console.log(scheduling, freed.indexOf(id)); + agent.destroy(); + } + for (const scheduling of ["LIFO", "", 1]) tryScheduling(scheduling); + keep.destroy(); + one.destroy(); + server.close(() => console.log("closed")); +}); diff --git a/tests/corpus/5597-http-agent-idle-sockets.ts b/tests/corpus/5597-http-agent-idle-sockets.ts new file mode 100644 index 000000000..881993de2 --- /dev/null +++ b/tests/corpus/5597-http-agent-idle-sockets.ts @@ -0,0 +1,79 @@ +// A pooled socket the server closes while idle leaves the agent's free +// list, so the next request dials a new connection. An https keepAlive +// agent reuses its TLS connections the same way (a servername that is +// empty or equal to the host keys like none), and idle pooled sockets do +// not keep the process alive. +import * as http from "node:http"; +import * as https from "node:https"; +import { readFileSync } from "node:fs"; + +let connections = 0; +const server = http.createServer((req, res) => res.end(`ok ${req.url}`)); +server.keepAliveTimeout = 100; +server.keepAliveTimeoutBuffer = 0; +server.on("connection", () => connections++); + +const cert = readFileSync("tests/fixtures/server/certs/localhost.pem"); +const key = readFileSync("tests/fixtures/server/certs/localhost-key.pem"); +const ca = readFileSync("tests/fixtures/server/certs/ca.pem"); +let tlsConnections = 0; +const secure = https.createServer({ key, cert }, (req, res) => res.end(`secure ${req.url}`)); +secure.on("connection", () => tlsConnections++); + +function get(port: number, agent: http.Agent, path: string): Promise { + return new Promise((resolve) => { + const req = http.request({ host: "127.0.0.1", port, path, agent: agent }, (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => resolve(`${body} reused=${req.reusedSocket}`)); + }); + req.end(); + }); +} + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as { port: number }).port; + const agent = new http.Agent({ keepAlive: true }); + console.log(await get(port, agent, "/1")); + await new Promise((r) => setTimeout(r, 300)); + console.log("free after server timeout", Object.keys(agent.freeSockets).length); + console.log(await get(port, agent, "/2")); + console.log("connections", connections); + server.close(); + secure.listen(0, "127.0.0.1", async () => { + const sport = (secure.address() as { port: number }).port; + const sagent = new https.Agent({ keepAlive: true }); + for (const path of ["/s1", "/s2"]) { + const out = await new Promise((resolve) => { + const req = https.request({ host: "localhost", port: sport, path, agent: sagent, ca: ca }, (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => resolve(`${body} reused=${req.reusedSocket}`)); + }); + req.on("error", (err: Error) => resolve(`error ${err.message}`)); + req.end(); + }); + console.log(out); + } + for (const name of ["", "localhost"]) { + const out = await new Promise((resolve) => { + const req = https.request( + { host: "localhost", port: sport, path: "/sn", agent: sagent, ca: ca, servername: name }, + (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => resolve(`${body} servername=${JSON.stringify(name)} reused=${req.reusedSocket}`)); + }, + ); + req.on("error", (err: Error) => resolve(`error ${err.message}`)); + req.end(); + }); + console.log(out); + } + console.log("tls connections", tlsConnections); + secure.close(); + secure.closeIdleConnections(); + // the pooled client sockets are unref'd: the process exits with them open + }); +}); +process.on("exit", () => console.log("exit")); diff --git a/tests/corpus/5598-http-global-agent.ts b/tests/corpus/5598-http-global-agent.ts new file mode 100644 index 000000000..2a2e3b83e --- /dev/null +++ b/tests/corpus/5598-http-global-agent.ts @@ -0,0 +1,30 @@ +// Requests without an agent option go through http.globalAgent, which in +// Node 24 has keepAlive: true, so sequential requests share one +// connection, and the process still exits with the idle socket open. +import * as http from "node:http"; +import * as https from "node:https"; + +let connections = 0; +const server = http.createServer((req, res) => res.end(`ok ${req.url}`)); +server.on("connection", () => connections++); + +function get(port: number, path: string): Promise { + return new Promise((resolve) => { + const req = http.get({ host: "127.0.0.1", port, path }, (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => resolve(`${body} reused=${req.reusedSocket}`)); + }); + }); +} + +console.log("global", http.globalAgent.keepAlive, http.globalAgent.maxSockets, https.globalAgent.keepAlive); +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as { port: number }).port; + console.log(await get(port, "/1")); + console.log(await get(port, "/2")); + console.log(await get(port, "/3")); + console.log("connections", connections, "free", Object.keys(http.globalAgent.freeSockets).length); + server.close(() => console.log("server closed")); +}); +process.on("exit", () => console.log("exit")); From a3feec9b3554299a7672498189817c965e11da89 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 04:11:02 -0500 Subject: [PATCH 02/53] Add ThreadSlots, per-thread values keyed by objects, to @scriptc/experimental ThreadSlots holds one value per key and thread, like a WeakMap that each thread has its own copy of; under Node.js it is a WeakMap per name and thread. Setting a value never writes the key, so values can be set on published keys. Classes used as keys carry a 32-bit slot word after their header words, assigned on the first set for a key (with a compare-and-swap for a published key) and never reused. Each family has one block per thread that maps slot indices to values through pages of 1024 entries. get and has are inline reads of the slot word, the page and the entry; set and delete call scr_slots.c, which holds one reference to each value. A key class's free paths release the key's values, and thread exit releases the rest. --- benchmarks/runtime/README.md | 1 + benchmarks/runtime/workloads.json | 20 ++ benchmarks/runtime/workloads/thread-slots.ts | 122 +++++++++ docs/content/docs/experimental.mdx | 93 ++++++- docs/test/fixtures/migration-baseline.json | 16 ++ .../compiler/src/backend/cycle-mutability.ts | 12 +- packages/compiler/src/backend/llvm/classes.ts | 64 ++++- packages/compiler/src/backend/llvm/emitter.ts | 15 ++ .../src/backend/llvm/reference-writes.ts | 7 +- packages/compiler/src/backend/llvm/regions.ts | 4 +- .../src/backend/llvm/stack-objects.ts | 4 + .../src/backend/llvm/thread-slots.test.ts | 142 ++++++++++ .../compiler/src/backend/llvm/thread-slots.ts | 170 ++++++++++++ .../src/backend/native/runtime-inputs.ts | 1 + .../src/frontend/experimental-import.ts | 33 ++- .../src/frontend/lowering/lower-classes.ts | 3 + .../src/frontend/lowering/lower-exprs.ts | 8 + .../compiler/src/frontend/lowering/lowerer.ts | 21 +- .../src/frontend/lowering/thread-slots.ts | 254 ++++++++++++++++++ packages/compiler/src/frontend/type-mapper.ts | 16 +- .../src/ir/function-references.test.ts | 1 + packages/compiler/src/ir/ir.ts | 18 +- packages/compiler/src/ir/validate.ts | 40 +++ .../corpus/5727-thread-slots-basics.ts.json | 6 + .../corpus/5728-thread-slots-workers.ts.json | 6 + .../corpus/5729-thread-slots-values.ts.json | 6 + .../diagnostics/thread-slots-fences.ts.json | 6 + packages/experimental/README.md | 45 +++- packages/experimental/index.d.ts | 21 ++ packages/experimental/index.js | 38 +++ packages/experimental/package.json | 2 +- .../runtime-pack-matrix.mjs | 1 + .../runtime-pack-matrix.mjs | 1 + packages/runtime/src/scr_runtime.h | 1 + packages/runtime/src/scr_slots.c | 145 ++++++++++ packages/runtime/src/scr_slots.h | 73 +++++ packages/runtime/src/scr_slots.test.c | 113 ++++++++ packages/runtime/src/scr_slots.test.ts | 52 ++++ tests/corpus/5727-thread-slots-basics.ts | 85 ++++++ tests/corpus/5728-thread-slots-workers.ts | 107 ++++++++ tests/corpus/5729-thread-slots-values.ts | 89 ++++++ tests/diagnostics/thread-slots-fences.ts | 29 ++ .../__snapshots__/thread-slots-fences.ts.txt | 62 +++++ tests/harness/corpus-costs.json | 6 + tests/harness/wasi-differential.test.ts | 3 + 45 files changed, 1934 insertions(+), 28 deletions(-) create mode 100644 benchmarks/runtime/workloads/thread-slots.ts create mode 100644 packages/compiler/src/backend/llvm/thread-slots.test.ts create mode 100644 packages/compiler/src/backend/llvm/thread-slots.ts create mode 100644 packages/compiler/src/frontend/lowering/thread-slots.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5727-thread-slots-basics.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5728-thread-slots-workers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5729-thread-slots-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/thread-slots-fences.ts.json create mode 100644 packages/runtime/src/scr_slots.c create mode 100644 packages/runtime/src/scr_slots.h create mode 100644 packages/runtime/src/scr_slots.test.c create mode 100644 packages/runtime/src/scr_slots.test.ts create mode 100644 tests/corpus/5727-thread-slots-basics.ts create mode 100644 tests/corpus/5728-thread-slots-workers.ts create mode 100644 tests/corpus/5729-thread-slots-values.ts create mode 100644 tests/diagnostics/thread-slots-fences.ts create mode 100644 tests/harness/__snapshots__/thread-slots-fences.ts.txt diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index ed9ee0532..a3c8c73b9 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -17,6 +17,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `numeric-kernels` | cpu | sieve, hashing, `number[][]` matmul, `Float64Array` | | `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | +| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread | | `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs | | `value-objects` | cpu | small immutable records and class instances: vector helpers, method chains, particle updates | | `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds | diff --git a/benchmarks/runtime/workloads.json b/benchmarks/runtime/workloads.json index c0d643a58..1526b7ec9 100644 --- a/benchmarks/runtime/workloads.json +++ b/benchmarks/runtime/workloads.json @@ -127,6 +127,26 @@ "100000" ] }, + { + "name": "thread-slots", + "entry": "workloads/thread-slots.ts", + "args": [ + "slots", + "8", + "50000", + "20" + ] + }, + { + "name": "thread-slots-cache", + "entry": "workloads/thread-slots.ts", + "args": [ + "cache", + "8", + "50000", + "20" + ] + }, { "name": "atomics-contended", "entry": "workloads/atomics-contended.ts", diff --git a/benchmarks/runtime/workloads/thread-slots.ts b/benchmarks/runtime/workloads/thread-slots.ts new file mode 100644 index 000000000..9791b5a1a --- /dev/null +++ b/benchmarks/runtime/workloads/thread-slots.ts @@ -0,0 +1,122 @@ +// Per-thread state for shared objects: worker threads read the same +// published type graph from an InternTable and keep per-thread state for +// each type they visit (a resolved flag and a visit count), then walk the +// graph many times. Arguments: the store ("slots" for a ThreadSlots family, +// "cache" for a direct-mapped cache by id in front of a Map by id, the +// layout programs use without slots), the thread count, the type count and +// the number of walks. Under Node.js both stores are per-thread. +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { InternTable, ThreadSlots } from "@scriptc/experimental"; + +class Type { + readonly id: number; + readonly members: readonly Type[]; + constructor(id: number, members: readonly Type[]) { + this.id = id; + this.members = members; + } +} + +class TypeState { + resolved = false; + visits = 0; +} + +const types = new InternTable("bench.slot-types"); +const states = new ThreadSlots("bench.type-states"); + +const CACHE = 1024; +const cacheIds: number[] = new Array(CACHE).fill(-1); +const cacheStates: (TypeState | undefined)[] = new Array(CACHE).fill( + undefined, +); +const byId = new Map(); + +function slotState(t: Type): TypeState { + let state = states.get(t); + if (state === undefined) { + state = new TypeState(); + states.set(t, state); + } + return state; +} + +function cachedState(t: Type): TypeState { + const slot = t.id & (CACHE - 1); + if (cacheIds[slot] === t.id) return cacheStates[slot]!; + let state = byId.get(t.id); + if (state === undefined) { + state = new TypeState(); + byId.set(t.id, state); + } + cacheIds[slot] = t.id; + cacheStates[slot] = state; + return state; +} + +function typeOf(id: number): Type { + return types.getOrInsertComputed(id, () => { + const members: Type[] = []; + if (id >= 3) members.push(typeOf(Math.floor(id / 3))); + if (id >= 7) members.push(typeOf(id - 7)); + return new Type(id, members); + }); +} + +function run(useSlots: boolean, count: number, walks: number): string { + const all: Type[] = []; + for (let id = 0; id < count; id++) all.push(typeOf(id)); + // Visits follow member edges from a pseudo-random start, as a checker's + // relation checks do: neighbours are often visited together. + let seed = 1; + let resolved = 0; + let visits = 0; + for (let w = 0; w < walks; w++) { + for (let i = 0; i < count; i++) { + seed = (seed * 48271) % 2147483647; + let t = all[seed % count]!; + for (let depth = 0; depth < 4; depth++) { + const state = useSlots ? slotState(t) : cachedState(t); + if (!state.resolved) { + state.resolved = true; + resolved++; + } + state.visits++; + visits++; + if (t.members.length === 0) break; + t = t.members[depth % t.members.length]!; + } + } + } + return `${resolved} resolved, ${visits} visits`; +} + +if (isMainThread) { + const store = process.argv[2] ?? "slots"; + const threads = Number(process.argv[3] ?? "8"); + const count = Number(process.argv[4] ?? "50000"); + const walks = Number(process.argv[5] ?? "20"); + const results: string[] = []; + let pending = threads; + for (let i = 0; i < threads; i++) { + const worker = new Worker(new URL(import.meta.url), { + workerData: { useSlots: store === "slots", count, walks }, + }); + worker.on("message", (text: string) => { + results[i] = text; + }); + worker.on("exit", () => { + if (--pending === 0) { + const same = results.every((r) => r === results[0]); + console.log(`${threads} threads: ${results[0]}, same in every thread ${same}`); + } + }); + } +} else { + const { useSlots, count, walks } = workerData as { + useSlots: boolean; + count: number; + walks: number; + }; + parentPort!.postMessage(run(useSlots, count, walks)); +} diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx index ac5891ec9..e7ab2d194 100644 --- a/docs/content/docs/experimental.mdx +++ b/docs/content/docs/experimental.mdx @@ -183,8 +183,99 @@ No lock is held while a callback runs. Two threads that miss the same key at the 2. It does not compare identities or ids across threads. 3. A thread does not depend on another thread's inserts: `size` and `has` reflect every thread's inserts in scriptc and the calling thread's under Node.js. -State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a module-level `Map` keyed by the value or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. +State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a [`ThreadSlots`](#threadslots) family, a module-level `Map` keyed by the value or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. ### Memory Lookups take no lock and write nothing. Inserts lock one of 64 segments of the table for a probe and a few stores. Stored values are immortal in scriptc builds: they stay allocated until the process exits, including a value a callback computed for a key that another thread stored first. Programs that never construct an `InternTable` do not run any of its code. + +## ThreadSlots + +Threads that share values through an `InternTable` often compute more about each shared value later: resolved members, cached results, flags. That state differs per thread, so it cannot live in the shared value, which is immutable. A `ThreadSlots` family holds one value per key and thread, like a `WeakMap` that each thread has its own copy of. A thread sees only the values it set, including for keys that every thread shares. + +```ts:slots.ts +import { Worker, isMainThread, parentPort, threadId } from "node:worker_threads"; +import { InternTable, ThreadSlots } from "@scriptc/experimental"; + +class Shape { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +class ShapeState { + uses = 0; + readonly thread: number; + constructor(thread: number) { + this.thread = thread; + } +} + +const shapes = new InternTable("example.slot-shapes"); +// Module scope: every thread that evaluates this line opens the same family. +const states = new ThreadSlots("example.shape-states"); + +function use(name: string): ShapeState { + const shape = shapes.getOrInsertComputed(name, () => new Shape(name)); + let state = states.get(shape); + if (state === undefined) { + state = new ShapeState(threadId); + states.set(shape, state); + } + state.uses++; + return state; +} + +if (isMainThread) { + for (let i = 0; i < 2; i++) { + new Worker(new URL(import.meta.url)).on("message", (text: string) => console.log(text)); + } +} else { + use("square"); + const state = use("square"); + parentPort!.postMessage(`worker: ${state.uses} uses, own state ${state.thread === threadId}`); +} +``` + +In the scriptc executable both workers read the same `Shape` object from the table, and each keeps its own `ShapeState` for it. Both runtimes print the same: + +```console +$ node slots.ts +worker: 2 uses, own state true +worker: 2 uses, own state true +$ scriptc build slots.ts -o slots >/dev/null +$ ./slots +worker: 2 uses, own state true +worker: 2 uses, own state true +``` + +### Slot methods + +
+
new ThreadSlots<K, V>(name)
+
Opens the family called name. Within one thread every construction with the same name refers to the same family.
+
slots.get(key), slots.has(key)
+
Read the calling thread's value for key.
+
slots.set(key, value)
+
Sets the calling thread's value for key and returns the slots. It never writes the key, so it works on published keys.
+
slots.delete(key)
+
Removes the calling thread's value for key and returns whether there was one.
+
+ +Under Node.js each family is a `WeakMap` per thread. + +### Keys and values + +scriptc requires: + +- the name to be a string literal, and every construction of one name to use the same type arguments; +- the key type to be a class declared in the program; keys may be instances of its subclasses; +- the value type to be a class, an object type, an array, a `Map` or a `Set`; +- the methods to be called through a module-level `const` initialized with `new ThreadSlots("name")`, as in the example. A family's storage is found by its name when the program is compiled. + +### Slot cost + +Each instance of a key class, and of every class in its hierarchy, carries a 4-byte slot index, 0 until a value is set for it. The first `set` for a key assigns the index; indices are never reused. `get` and `has` read the index, a page of the thread's family and the entry, with no lock, hash or atomic update. A family stores values in pages of 1024 entries, allocated as the thread sets values in them. + +A family holds a reference to each of the thread's values. A value is released when it is replaced or deleted, when its key is freed, and when the thread exits. Keys that are freed with a [`LifetimeRegion`](#lifetimeregion) keep their values until the thread exits, a value that belongs to a region keeps the region allocated while the family holds it, and a value that refers to its own key keeps the key alive. Programs that never construct a `ThreadSlots` do not run any of its code, and their classes have no slot index. diff --git a/docs/test/fixtures/migration-baseline.json b/docs/test/fixtures/migration-baseline.json index 6aaab5f70..1b99faf9a 100644 --- a/docs/test/fixtures/migration-baseline.json +++ b/docs/test/fixtures/migration-baseline.json @@ -296,6 +296,22 @@ { "text": "Memory", "id": "memory" + }, + { + "text": "ThreadSlots", + "id": "threadslots" + }, + { + "text": "Slot methods", + "id": "slot-methods" + }, + { + "text": "Keys and values", + "id": "keys-and-values" + }, + { + "text": "Slot cost", + "id": "slot-cost" } ] }, diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts index c7d476527..79993fdff 100644 --- a/packages/compiler/src/backend/cycle-mutability.ts +++ b/packages/compiler/src/backend/cycle-mutability.ts @@ -97,16 +97,20 @@ export function computeCycleMutability( // can store; a commit rewrites every field, reference fields possibly // with fresh conversions). Without any such store in the program, // crossing into checked-dynamic code writes nothing. FFI calls and - // intrinsics other than publish (which only marks objects immortal) and - // the intern-table operations (which store published values) stay - // conservative. + // intrinsics other than publish (which only marks objects immortal), the + // intern-table operations (which store published values) and ThreadSlots + // reads stay conservative. const dynStores = dynamicFieldStores(mod); const dynamicStores = dynStores.all || dynStores.names.size > 0; result.emptyPropertyBags = !dynamicStores; const boundaryWrites = (e: IrExpr): boolean => dynamicStores || e.kind === "ffiCall" || - (e.kind === "intrinsic" && e.name !== "threads.publish" && !e.name.startsWith("itable.")); + (e.kind === "intrinsic" && + e.name !== "threads.publish" && + !e.name.startsWith("itable.") && + e.name !== "slots.get" && + e.name !== "slots.has"); // Types that can become checked-dynamic values: mark their whole closure. const exposedTypes = new Set(); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 8a13d83ad..02036e4d9 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -75,6 +75,10 @@ export class LlClassMeta { membership: { pre: number; post: number }[] = []; /** Type-only references retain layouts without retaining construction. */ constructorRetained = false; + /** The hierarchy holds ThreadSlots keys (IrModule.threadSlotKeys): every + * member's struct carries the i32 slot word before its fields + * (classSlotWordIndex). */ + threadSlot = false; constructor(readonly def: IrClassDef) { this.root = this; @@ -116,6 +120,12 @@ export function buildClassGraph( if (meta.base === null) number(meta, meta); meta.hierarchy = meta.base !== null || meta.children.length > 0; } + const slotRoots = new Set(); + for (const key of mod.threadSlotKeys ?? []) { + const meta = metaMap.get(key); + if (meta !== undefined) slotRoots.add(meta.root); + } + for (const meta of metaMap.values()) meta.threadSlot = slotRoots.has(meta.root); const declares = (m: LlClassMeta, method: string): boolean => m.def.methods?.includes(method) ?? false; const declaredBelow = (m: LlClassMeta, method: string): boolean => @@ -323,16 +333,31 @@ function runtimePrefixOf(meta: LlClassMeta): RuntimePrefix | null { return RUNTIME_PREFIXES.get(meta.root.def.name) ?? null; } -/** The GEP index where a class's own fields start: rc at 0, the vtable - * word at 1 on hierarchy members, then a runtime prefix (state + display - * name) on classes rooted at a runtime base, then the stream-state slot - * on stream-rooted ones. */ -function fieldBase(meta: LlClassMeta): number { +/** The GEP index after a class's header words: rc at 0, the vtable word + * at 1 on hierarchy members, then a runtime prefix (state + display name) + * on classes rooted at a runtime base, then the stream-state slot on + * stream-rooted ones. */ +function headerEnd(meta: LlClassMeta): number { if (!meta.hierarchy) return 1; if (streamRooted(meta)) return 5; return runtimePrefixOf(meta) ? 4 : 2; } +/** The GEP index of the ThreadSlots slot word (an i32) in a class whose + * hierarchy holds ThreadSlots keys: right after the header words, so every + * member of the hierarchy has it at the same offset. */ +export function classSlotWordIndex(meta: LlClassMeta): number { + if (!meta.threadSlot) + throw new InternalCompilerError(`llvm emitter bug: class ${meta.def.name} has no slot word`); + return headerEnd(meta); +} + +/** The GEP index where a class's own fields start: after the header words + * and the slot word. */ +export function classFieldBase(meta: LlClassMeta): number { + return headerEnd(meta) + (meta.threadSlot ? 1 : 0); +} + /** Append the lexical environment after inherited fields to preserve base layouts. */ export function classEnvironmentIndex(meta: LlClassMeta): number { return classFieldIndex(meta, `%classEnvironment:${meta.def.name}`).index; @@ -346,7 +371,28 @@ export function classFieldIndex(meta: LlClassMeta, field: string): { index: numb throw new InternalCompilerError( `llvm emitter bug: unknown field ${field} on class ${meta.def.name}`, ); - return { index: fieldBase(meta) + idx, type: meta.def.fields[idx]!.type }; + return { index: classFieldBase(meta) + idx, type: meta.def.fields[idx]!.type }; +} + +/** Lines that release the ThreadSlots values of the object `%o` before it + * is freed: its slot word is 0 unless a value was ever set for it. */ +function slotForgetLines( + host: ClassHost, + struct: string, + meta: LlClassMeta, + tag: string, +): string[] { + host.declare(`declare void @scr_slots_forget(i32)`); + return [ + ` %${tag}p = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${classSlotWordIndex(meta)}`, + ` %${tag} = load i32, ptr %${tag}p, align 4`, + ` %${tag}.set = icmp ne i32 %${tag}, 0`, + ` br i1 %${tag}.set, label %${tag}.forget, label %${tag}.done`, + `${tag}.forget: ; release its ThreadSlots values`, + ` call void @scr_slots_forget(i32 %${tag})`, + ` br label %${tag}.done`, + `${tag}.done:`, + ]; } /** What class emission needs beyond ShapeHost: interned unit instances for @@ -483,7 +529,7 @@ export function emitClassShapes( ? ["ptr", "ptr", "ptr"] : ["ptr"] : []; - const members = [...prefix, ...fieldTys]; + const members = [...prefix, ...(meta.threadSlot ? ["i32"] : []), ...fieldTys]; typeDefs.push( `%${mangleClassStruct(cls.name)} = type { ${host.sizeType}${members.length ? ", " + members.join(", ") : ""} } ` + `; class ${cls.name}${meta.hierarchy ? " (vt at 1)" : ""}${streamRooted(meta) ? " (ScrStream prefix at 2)" : runtimePrefix ? ` (${runtimePrefix.struct} prefix at 2)` : ""} { ${cls.fields.map((f) => llvmCommentText(f.name)).join("; ")} }`, @@ -569,7 +615,7 @@ export function emitClassShapes( const traced = host.tracedShapes.has(`object:${cls.name}`); const runtimePrefix = runtimePrefixOf(meta); const isStreamRooted = streamRooted(meta); - const fieldIndex = (i: number): number => fieldBase(meta) + i; + const fieldIndex = (i: number): number => classFieldBase(meta) + i; // Teardown, trace and gcFree see each slot's storage type: a nullable // class field holds the instance pointer (NULL for the unit arm), and // every class retain/release/visit entry point is NULL-tolerant. @@ -640,6 +686,7 @@ export function emitClassShapes( }); if (runtimePrefix) lines.push(...regCall("td", runtimePrefix.drop, "")); if (isStreamRooted) lines.push(...stCall("tds", "scr_stream_st_release", "")); + if (meta.threadSlot) lines.push(...slotForgetLines(host, struct, meta, "tdw")); lines.push(...emitObjectFree(host, traced)); }; @@ -841,6 +888,7 @@ export function emitClassShapes( ` call void ${releaseSym(host, f.type)}(ptr %v${i}) ; ${llvmCommentText(f.name)} (acyclic)`, ); }); + if (meta.threadSlot) gf.push(...slotForgetLines(host, struct, meta, "gfw")); gf.push(...emitObjectFree(host, true), ` ret void`, `}`, ``); defs.push(...gf); } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index bd9a4b883..6adaa4678 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,6 +1,7 @@ import { type ArraySearchKind, emitArraySearchHelpers } from "./array-search.js"; import { emitStringInputs } from "./string-lifetimes.js"; import { emitLlvmLayouts } from "./layouts.js"; +import { emitThreadSlotsIntrinsic, threadSlotsBlockDefinitions } from "./thread-slots.js"; import { compileRegexMatcher, type RegexMatcherCode } from "./regex-matchers.js"; import { CYC_BUF, CYC_HDR, FIRST_EMITTED_CYC_KIND, inlineAllocSupported } from "./alloc.js"; import { hasLoneSurrogate, llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; @@ -834,6 +835,8 @@ export class LlEmitter { /** Constant-key reads on checked-dynamic objects, each with its own * zero-initialized ScrDynReadIc (scr_runtime.h). */ private dynReadIcSites = 0; + /** ThreadSlots blocks by family name (thread-slots.ts). */ + private readonly threadSlotsBlocks = new Map(); /** May-throw analysis (computeMayThrow): pending * checks are emitted only after calls that can actually raise. */ readonly mayThrow: Set; @@ -2369,6 +2372,7 @@ export class LlEmitter { { const tl = this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""; + out.push(...threadSlotsBlockDefinitions(this.threadSlotsBlocks, tl !== "")); for (let site = 0; site < this.dynReadIcSites; site++) out.push( `@sc_dyn_read_ic_${site} = internal ${tl}global { ptr, ${this.sizeType}, ${this.sizeType}, ptr } zeroinitializer`, @@ -9816,9 +9820,20 @@ export class LlEmitter { ) return this.emitRegionIntrinsic(e); if (e.name.startsWith("itable.")) return this.emitInternTableIntrinsic(e); + if (e.name.startsWith("slots.")) return emitThreadSlotsIntrinsic(this, e); return emitIntrinsicExpr(this, e); } + /** The ScrSlots block of the ThreadSlots family `name`. */ + threadSlotsBlock(name: string): string { + let sym = this.threadSlotsBlocks.get(name); + if (sym === undefined) { + sym = `@sc_slots_${this.threadSlotsBlocks.size}`; + this.threadSlotsBlocks.set(name, sym); + } + return sym; + } + /** InternTable's runtime calls (scr_itable.c). Table and entry handles * are f64 values carrying pointer bits; entry values are 64-bit slots * (double bits, a bool, or a pointer to an immortal value) that the diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 33110eaa4..317104400 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -468,11 +468,14 @@ export class ReferenceWrites { return; case "intrinsic": // A TDZ test reads; publication only makes objects immortal; intern - // tables store published values and write no script object. + // tables store published values and write no script object; + // ThreadSlots reads write nothing. if ( e.name !== "module.tdzCheck" && e.name !== "threads.publish" && - !e.name.startsWith("itable.") + !e.name.startsWith("itable.") && + e.name !== "slots.get" && + e.name !== "slots.has" ) sink.all(); return; diff --git a/packages/compiler/src/backend/llvm/regions.ts b/packages/compiler/src/backend/llvm/regions.ts index 41f67c0cb..fbd41a3f7 100644 --- a/packages/compiler/src/backend/llvm/regions.ts +++ b/packages/compiler/src/backend/llvm/regions.ts @@ -36,7 +36,7 @@ import { mangleRecordStruct, } from "../mangle.js"; import { threadHotAddressLines } from "./alloc.js"; -import type { LlClassMeta } from "./classes.js"; +import { classFieldBase, type LlClassMeta } from "./classes.js"; import { llvmCommentText } from "./common.js"; import type { NullableRefFields } from "./nullable-fields.js"; import { FN_ATTRS, rcMembers, releaseSym, vAdapters, type ShapeHost } from "./shapes.js"; @@ -84,7 +84,7 @@ export class LifetimeRegions { /** The reference-holding fields; inline scalar unions own nothing. */ private classFields(def: IrClassDef, meta: LlClassMeta): Field[] { - const base = meta.hierarchy ? 2 : 1; + const base = classFieldBase(meta); return def.fields.flatMap((f, i) => this.host.inlineUnions.classField(def.name, f.name) ? [] diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index c03c7fb44..8183ce920 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -23,6 +23,10 @@ export type StackObjectSource = IrExpr & { kind: "recordLit" | "new" | "call" }; * (record-results.ts). The caller proves that every use of the value only * projects it. */ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource { + // A ThreadSlots key keeps its slot index for its lifetime and has its + // values released when it is freed: it lives on the heap. + if (e.type.kind === "object" && host.classMeta.get(e.type.className)?.threadSlot === true) + return false; // A producer with a result-slot entry builds its fresh result in the // caller's storage. if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee)); diff --git a/packages/compiler/src/backend/llvm/thread-slots.test.ts b/packages/compiler/src/backend/llvm/thread-slots.test.ts new file mode 100644 index 000000000..c7260b9fe --- /dev/null +++ b/packages/compiler/src/backend/llvm/thread-slots.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, STRING, VOID, type IrExpr, type IrModule, type IrType } from "../../ir/ir.js"; +import { validateModule } from "../../ir/validate.js"; +import { emitLlvmModule } from "./emitter.js"; +import { mangleClassStruct } from "../mangle.js"; + +const loc = { file: "thread-slots.ts", start: 0, end: 1 }; +const slotsType: IrType = { kind: "object", className: "%ThreadSlots" }; +const node: IrType = { kind: "object", className: "Node" }; +const leaf: IrType = { kind: "object", className: "Leaf" }; +const plain: IrType = { kind: "object", className: "Plain" }; +const state: IrType = { kind: "object", className: "State" }; +const maybeState: IrType = { kind: "union", unionId: "u0" }; + +/** A key hierarchy (Node, Leaf), a class that is not a key (Plain), and the + * value class (State), with `slots` a module global of the slots class. */ +function module(body: IrExpr[]): IrModule { + const cls = (name: string, fields: [string, IrType][], base?: string) => ({ + name, + ...(base !== undefined ? { base } : {}), + fields: fields.map(([field, type]) => ({ name: field, type })), + methods: [], + loc, + }); + return { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + threadSlotKeys: ["Node"], + globals: [ + ["slots", slotsType], + ["node", node], + ["leaf", leaf], + ["plain", plain], + ["state", state], + ].map(([name, type]) => ({ id: `%g.${name as string}`, name, type, mutable: false })), + functions: [ + { + name: "main", + params: [], + returnType: VOID, + locals: [], + body: body.map((expr) => ({ kind: "exprStmt" as const, expr, loc })), + loc, + }, + ], + classes: [ + cls("%ThreadSlots", []), + cls("Node", [["name", STRING]]), + cls( + "Leaf", + [ + ["name", STRING], + ["weight", F64], + ], + "Node", + ), + cls("Plain", [["name", STRING]]), + cls("State", [["count", F64]]), + ], + unions: [{ id: "u0", arms: [state, { kind: "undefinedT" }] }], + } as unknown as IrModule; +} + +function body(llvm: string, name: string): string { + const start = llvm.indexOf(`@${name}(`); + return start < 0 ? "" : llvm.slice(start, llvm.indexOf("\n}", start)); +} + +const intrinsic = (name: string, args: IrExpr[], type: IrType): IrExpr => + ({ kind: "intrinsic", name, args, type, loc }) as IrExpr; +const str = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc }); +const global = (id: string, type: IrType): IrExpr => + ({ kind: "varRef", localId: `%g.${id}`, type, loc }) as IrExpr; +const slots = global("slots", slotsType); +const fresh = (type: IrType & { kind: "object" }): IrExpr => + global(type.className.toLowerCase(), type); + +test("slots.get reads the slot word, the page and the entry inline", () => { + const calls = [ + intrinsic("slots.get", [str("s"), slots, fresh(leaf)], maybeState), + intrinsic("slots.has", [str("s"), slots, fresh(node)], BOOL), + intrinsic("slots.set", [str("s"), slots, fresh(leaf), fresh(state)], VOID), + intrinsic("slots.delete", [str("t"), slots, fresh(node)], BOOL), + ]; + expect(validateModule(module(calls))).toEqual([]); + const llvm = emitLlvmModule(module(calls), { pointerBits: 64 }); + // The slot word sits after the header words of every hierarchy member. + expect(llvm).toMatch( + new RegExp(`%${mangleClassStruct("Node")} = type \\{ i64, ptr, i32, ptr \\}`), + ); + expect(llvm).toMatch( + new RegExp(`%${mangleClassStruct("Leaf")} = type \\{ i64, ptr, i32, ptr, (i32|double) \\}`), + ); + expect(llvm).toMatch(new RegExp(`%${mangleClassStruct("Plain")} = type \\{ i64, ptr \\}`)); + // One block per family name. + expect(llvm).toMatch( + /@sc_slots_0 = internal global \{ ptr, i32, i32, ptr, ptr \} zeroinitializer/, + ); + expect(llvm).toMatch( + /@sc_slots_1 = internal global \{ ptr, i32, i32, ptr, ptr \} zeroinitializer/, + ); + const main = body(llvm, "sc_f_main"); + expect(main).toMatch( + /getelementptr inbounds %\S+, ptr %\S+, i64 0, i32 2\n {2}(%\S+) = load atomic i32, ptr %\S+ monotonic, align 4\n {2}%\S+ = lshr i32 \1, 10/, + ); + expect(main).not.toMatch(/call ptr @scr_slots_get/); + expect(main).toMatch( + /call void @scr_slots_set\(ptr @sc_slots_0, ptr %\S+, ptr %\S+, ptr %\S+, ptr @\S+\)/, + ); + expect(main).toMatch(/call zeroext i1 @scr_slots_delete\(ptr @sc_slots_1, ptr %\S+\)/); +}); + +test("freeing a key releases its values when its slot word is set", () => { + const llvm = emitLlvmModule( + module([intrinsic("slots.has", [str("s"), slots, fresh(node)], BOOL)]), + { pointerBits: 64 }, + ); + expect(llvm).toMatch(/icmp ne i32 %tdw, 0\n {2}br i1 %tdw\.set, label %tdw\.forget/); + expect(llvm).toMatch(/call void @scr_slots_forget\(i32 %tdw\)/); +}); + +test("the validator checks ThreadSlots intrinsic operands", () => { + const bad = [ + intrinsic("slots.get", [str("s"), slots], maybeState), + intrinsic("slots.has", [slots, slots, fresh(node)], BOOL), + intrinsic("slots.has", [str("s"), slots, fresh(plain)], BOOL), + intrinsic( + "slots.set", + [str("s"), slots, fresh(node), { kind: "numLit", value: 1, type: F64, loc }], + VOID, + ), + intrinsic("slots.get", [str("s"), slots, fresh(node)], state), + ]; + expect(validateModule(module(bad)).map((e) => e.message)).toEqual([ + "in main: slots.get takes 3 arguments", + "in main: slots.has takes a name literal and a %ThreadSlots value", + "in main: slots.has key object:Plain is not a ThreadSlots key class", + "in main: slots.set value f64 is not a ThreadSlots value type", + "in main: slots.get has a V | undefined union type", + ]); +}); diff --git a/packages/compiler/src/backend/llvm/thread-slots.ts b/packages/compiler/src/backend/llvm/thread-slots.ts new file mode 100644 index 000000000..da182bde2 --- /dev/null +++ b/packages/compiler/src/backend/llvm/thread-slots.ts @@ -0,0 +1,170 @@ +/* ThreadSlots (@scriptc/experimental): the slots.* intrinsics (scr_slots.c, + * frontend/lowering/thread-slots.ts). + * + * Each family name has one `{ ptr, i32, i32, ptr, ptr }` ScrSlots block, + * an internal global that is thread-local where module globals are. A key + * class's hierarchy carries an i32 slot word after its header words + * (classSlotWordIndex). A read is inline: the slot word, then the page + * and the entry, + * + * page = word >> 10 < block.npages ? block.pages[word >> 10] : none + * value = page[word & 1023] + * + * and absent pages are a shared page of NULL entries (scr_slots_none), so + * a key without a value reads NULL, the undefined of `V | undefined`. Set + * and delete call the runtime, which holds one reference to each value and + * releases the value it replaces or removes. */ +import { InternalCompilerError } from "../../errors.js"; +import { isUnitType, typeKey, type IrType } from "../../ir/ir.js"; +import { classSlotWordIndex, classStructSym } from "./classes.js"; +import { llvmCommentText } from "./common.js"; +import type { ExprOf, LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { releaseSym } from "./shapes.js"; + +const BLOCK = "{ ptr, i32, i32, ptr, ptr }"; + +/** The slots.* intrinsics. */ +export function emitThreadSlotsIntrinsic( + host: LlvmEmitterContext, + e: ExprOf<"intrinsic">, +): LlValue { + const B = host.B; + const [nameArg, slotsArg, keyArg, valueArg] = e.args; + if (nameArg?.kind !== "strLit" || slotsArg === undefined || keyArg === undefined) + throw new InternalCompilerError(`llvm emitter bug: ${e.name} operands`); + const block = host.threadSlotsBlock(nameArg.value); + // The slots value is read for its effects (a module const's TDZ check). + const slots = host.emitReadReceiver(slotsArg); + const key = host.emitReadReceiver(keyArg); + const value = valueArg !== undefined ? host.emitExpr(valueArg) : null; + if (B.isTerminated()) return { name: "", type: e.type }; + if (keyArg.type.kind !== "object") + throw new InternalCompilerError(`llvm emitter bug: ${e.name} key of kind ${keyArg.type.kind}`); + const className = keyArg.type.className; + const word = B.tmp(); + B.line( + `${word} = getelementptr inbounds %${classStructSym(className)}, ptr ${key.name}, i64 0, i32 ${classSlotWordIndex(host.classMetaOf(className))}`, + ); + switch (e.name) { + case "slots.get": { + const read = readSlot(host, block, word); + if (host.nullableUnions.of(e.type) !== null) + return host.own({ name: host.retainValue(read, e.type), type: e.type }); + return boxedRead(host, read, e.type); + } + case "slots.has": { + const read = readSlot(host, block, word); + const has = B.tmp(); + B.line(`${has} = icmp ne ptr ${read}, null`); + return { name: has, type: e.type }; + } + case "slots.delete": { + host.declare(`declare zeroext i1 @scr_slots_delete(ptr, ptr)`); + const had = B.tmp(); + B.line(`${had} = call zeroext i1 @scr_slots_delete(ptr ${block}, ptr ${word})`); + return { name: had, type: e.type }; + } + case "slots.set": { + if (value === null) throw new InternalCompilerError("llvm emitter bug: slots.set value"); + // The value moves into the block. + host.moveTemp(value); + host.declare(`declare void @scr_slots_set(ptr, ptr, ptr, ptr, ptr)`); + B.line( + `call void @scr_slots_set(ptr ${block}, ptr ${key.name}, ptr ${word}, ptr ${value.name}, ptr ${releaseSym(host.shapeHost, value.type)})`, + ); + if (e.type.kind === "void") return { name: "", type: e.type }; + return host.own({ name: host.retainValue(slots.name, e.type), type: e.type }); + } + default: + throw new InternalCompilerError(`llvm emitter bug: unknown intrinsic ${e.name}`); + } +} + +/** A read as a tagged union box, for `V | undefined` types that the module + * keeps boxed (Map keys and Set elements of that type): the value's arm, or + * the interned undefined instance for NULL. */ +function boxedRead(host: LlvmEmitterContext, read: string, type: IrType): LlValue { + const B = host.B; + const def = type.kind === "union" ? host.unionsById.get(type.unionId) : undefined; + const refTag = def?.arms.findIndex((a) => !isUnitType(a)) ?? -1; + const unitTag = def?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1; + if (def === undefined || refTag < 0 || unitTag < 0 || def.arms.length !== 2) + throw new InternalCompilerError(`llvm emitter bug: slots.get of type ${typeKey(type)}`); + const result = B.slot(); + B.entryAllocas.push(`${result} = alloca ptr`); + const has = B.tmp(); + B.line(`${has} = icmp ne ptr ${read}, null`); + const some = B.newLabel("slots.some"), + none = B.newLabel("slots.none"), + done = B.newLabel("slots.box"); + B.condBr(has, some, none); + B.startBlock(some); + const arm = def.arms[refTag]!; + const box = host.unionNewOwned(def.id, refTag, { name: host.retainValue(read, arm), type: arm }); + B.line(`store ptr ${box}, ptr ${result}`); + B.br(done); + B.startBlock(none); + B.line(`store ptr ${host.unitInstanceRef(def.id, unitTag)}, ptr ${result}`); + B.br(done); + B.startBlock(done); + const out = B.tmp(); + B.line(`${out} = load ptr, ptr ${result}`); + return host.own({ name: out, type }); +} + +/** This thread's value for the slot word at `word`, borrowed, or NULL. */ +function readSlot(host: LlvmEmitterContext, block: string, word: string): string { + const B = host.B; + host.declare(`@scr_slots_none = external constant [1024 x ptr]`); + const index = B.tmp(), + page = B.tmp(), + pages = B.tmp(), + count = B.tmp(), + inRange = B.tmp(); + B.line(`${index} = load atomic i32, ptr ${word} monotonic, align 4`); + B.line(`${page} = lshr i32 ${index}, 10`); + B.line(`${count} = getelementptr inbounds ${BLOCK}, ptr ${block}, i64 0, i32 1`); + const npages = B.tmp(); + B.line(`${npages} = load i32, ptr ${count}`); + B.line(`${inRange} = icmp ult i32 ${page}, ${npages}`); + const result = B.slot(); + B.entryAllocas.push(`${result} = alloca ptr`); + B.line(`store ptr null, ptr ${result}`); + const found = B.newLabel("slots.page"), + done = B.newLabel("slots.j"); + B.condBr(inRange, found, done); + B.startBlock(found); + B.line(`${pages} = load ptr, ptr ${block}`); + const pageIndex = B.tmp(), + pagePtr = B.tmp(), + entries = B.tmp(), + entryIndex = B.tmp(), + entryWide = B.tmp(), + entry = B.tmp(), + v = B.tmp(); + B.line(`${pageIndex} = zext i32 ${page} to i64`); + B.line(`${pagePtr} = getelementptr inbounds ptr, ptr ${pages}, i64 ${pageIndex}`); + B.line(`${entries} = load ptr, ptr ${pagePtr}`); + B.line(`${entryIndex} = and i32 ${index}, 1023`); + B.line(`${entryWide} = zext i32 ${entryIndex} to i64`); + B.line(`${entry} = getelementptr inbounds ptr, ptr ${entries}, i64 ${entryWide}`); + B.line(`${v} = load ptr, ptr ${entry}`); + B.line(`store ptr ${v}, ptr ${result}`); + B.br(done); + B.startBlock(done); + const out = B.tmp(); + B.line(`${out} = load ptr, ptr ${result}`); + return out; +} + +/** The ScrSlots block definitions of the families a module uses. */ +export function threadSlotsBlockDefinitions( + blocks: ReadonlyMap, + threadLocal: boolean, +): string[] { + const tl = threadLocal ? "thread_local " : ""; + return [...blocks].map( + ([name, sym]) => + `${sym} = internal ${tl}global ${BLOCK} zeroinitializer ; ThreadSlots ${llvmCommentText(JSON.stringify(name))}`, + ); +} diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts index 88f049b7c..bc9643670 100644 --- a/packages/compiler/src/backend/native/runtime-inputs.ts +++ b/packages/compiler/src/backend/native/runtime-inputs.ts @@ -70,6 +70,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [ "scr_publish.c", "scr_region.c", "scr_itable.c", + "scr_slots.c", "scr_alloc.c", ] as const; diff --git a/packages/compiler/src/frontend/experimental-import.ts b/packages/compiler/src/frontend/experimental-import.ts index 3b15bfeb9..86aa10933 100644 --- a/packages/compiler/src/frontend/experimental-import.ts +++ b/packages/compiler/src/frontend/experimental-import.ts @@ -1,14 +1,15 @@ /* Provenance of the @scriptc/experimental exports. The package is scriptc's * own (packages/experimental): Node.js runs its JavaScript implementation, * scriptc builds lower its exports (lowering/lifetime-regions.ts, - * lowering/intern-tables.ts) and never compile its body. */ + * lowering/intern-tables.ts, lowering/thread-slots.ts) and never compile + * its body. */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import * as ts from "./ts7/adapter.js"; export const EXPERIMENTAL_MODULE = "@scriptc/experimental"; -export type ExperimentalMember = "LifetimeRegion" | "InternTable"; +export type ExperimentalMember = "LifetimeRegion" | "InternTable" | "ThreadSlots"; /** The @scriptc/experimental export an identifier is a named-import binding of. */ export function experimentalImportOf( @@ -27,7 +28,9 @@ export function experimentalImportOf( ) return null; const member = decl.propertyName?.text ?? decl.name.text; - return member === "LifetimeRegion" || member === "InternTable" ? member : null; + return member === "LifetimeRegion" || member === "InternTable" || member === "ThreadSlots" + ? member + : null; } /** Whether `sf` is the package's own declaration file: its directory's @@ -114,3 +117,27 @@ export function internTableMemberOf( ? name : null; } + +export type ThreadSlotsMember = "get" | "has" | "set" | "delete"; + +/** The ThreadSlots member a property access names (`slots.get`), by the + * declaration it resolves to. */ +export function threadSlotsMemberOf( + program: ts.Program, + access: ts.PropertyAccessExpression, + known: Map, +): ThreadSlotsMember | null { + const checker = program.getTypeChecker(); + const symbol = checker.getSymbolAtLocation(access.name); + const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined; + if (decl === undefined || !ts.isMethodDeclaration(decl)) return null; + const owner = decl.parent; + if ( + !ts.isClassDeclaration(owner) || + owner.name?.text !== "ThreadSlots" || + !isExperimentalDeclarationFile(owner.getSourceFile(), known) + ) + return null; + const name = access.name.text; + return name === "get" || name === "has" || name === "set" || name === "delete" ? name : null; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8856ae9b5..e3f82048e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -504,6 +504,9 @@ export interface ClassInfo { /** The synthetic InternTable class (@scriptc/experimental, * intern-tables.ts): synthesized like lifetimeRegion. */ internTable?: true; + /** The synthetic ThreadSlots class (@scriptc/experimental, + * thread-slots.ts): synthesized like lifetimeRegion. */ + threadSlots?: true; /** This class's own `emit` override in the FORWARDING SHAPE (the one * EventEmitter member a subclass may re-declare): never in `methods` — * emit calls keep routing through the emitter spoke, which lowers the diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 391dd7c88..d51d34a6e 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -16,6 +16,7 @@ import { STATS_TYPE_TESTS } from "./builtins/filesystem.js"; import { threadsImportOf } from "../threads-import.js"; import { experimentalImportOf, lifetimeRegionMemberOf } from "../experimental-import.js"; import { lowerInternTableProperty } from "./intern-tables.js"; +import { lowerThreadSlotsProperty } from "./thread-slots.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; import { SYMBOL_T } from "../../ir/ir.js"; @@ -1142,6 +1143,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { expr, 'open tables with new InternTable("name")', ); + if (experimentalImportOf(lowerer.program, expr) === "ThreadSlots") + lowerer.noLowering( + "ThreadSlots (@scriptc/experimental) as a value", + expr, + 'open slots with new ThreadSlots("name")', + ); } { const bi = lowerer.builtinImportOf(expr); @@ -1759,6 +1766,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const table = lowerInternTableProperty(lowerer, expr); if (table) return table; } + lowerThreadSlotsProperty(lowerer, expr); // Expando statics may still be absent even when JS inference sees // the eventual assignment's scalar type. Preserve the checked value. const staticField = lowerStaticFieldRead(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 12deec7ad..01328d526 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4,6 +4,7 @@ import { lowerLifetimeRegionNew, } from "./lifetime-regions.js"; import { internTableType, lowerInternTableCall, lowerInternTableNew } from "./intern-tables.js"; +import { lowerThreadSlotsCall, lowerThreadSlotsNew, threadSlotsType } from "./thread-slots.js"; import { isExperimentalDeclarationFile } from "../experimental-import.js"; import { checkCycleBindingAccesses, @@ -2488,6 +2489,15 @@ export class Lowerer { readonly internTableHelpers = new Map(); /** Each InternTable name's type arguments and first construction site. */ readonly internTableNames = new Map(); + /** Each ThreadSlots name's type arguments (thread-slots.ts). */ + readonly threadSlotNames = new Map(); + /** The family each `new ThreadSlots(...)` opens. */ + readonly threadSlotFamilies = new Map< + ts.NewExpression, + { name: string; key: IrType; value: IrType } + >(); + /** Classes used as ThreadSlots keys: their hierarchies carry a slot word. */ + readonly threadSlotKeys = new Set(); readonly forkTargetIdByPath = new Map(); /** Namespace ModuleBlocks this program lowers, filled by splitFiles: * "flattened" — an instantiated namespace whose body joined the file's @@ -2849,6 +2859,10 @@ export class Lowerer { isExperimentalDeclarationFile(decl.getSourceFile(), this.experimentalFiles) ? internTableType(this) : null, + threadSlotsClass: (decl) => + isExperimentalDeclarationFile(decl.getSourceFile(), this.experimentalFiles) + ? threadSlotsType(this) + : null, moduleNamespaceId: (type) => this.moduleNamespaceIdOfType(type), }; // Modules reachable only through literal import() joined @@ -6302,6 +6316,9 @@ export class Lowerer { entry: ENTRY_NAME, ...(this.usesWorkers ? { workers: true } : {}), ...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}), + ...(this.threadSlotKeys.size > 0 + ? { threadSlotKeys: [...this.threadSlotKeys].sort() } + : {}), ...(isNodeEsmFile(this.entry, this.program) ? {} : { commonJsEntry: true }), ...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}), }; @@ -6500,7 +6517,7 @@ export class Lowerer { } for (const info of this.classes.values()) { if (info.builtinError) continue; // runtime-provided; nothing lowers - if (info.lifetimeRegion || info.internTable) continue; // synthesized with the class + if (info.lifetimeRegion || info.internTable || info.threadSlots) continue; // synthesized with the class // Generic-class INSTANTIATIONS (and mixin instantiations) are // demand-driven, not units: their members lower unconditionally in // the instance drain below — the generic-fn instance rule. @@ -10916,6 +10933,7 @@ export class Lowerer { return ( lowerLifetimeRegionCall(this, expr) ?? lowerInternTableCall(this, expr) ?? + lowerThreadSlotsCall(this, expr) ?? lowerFfiCall(this, expr) ?? lowerFetchCall(this, expr) ?? lowerStaticFetchCompanionCall(this, expr) ?? @@ -11141,6 +11159,7 @@ export class Lowerer { const builtin = lowerLifetimeRegionNew(this, expr) ?? lowerInternTableNew(this, expr) ?? + lowerThreadSlotsNew(this, expr) ?? lowerAbortControllerNew(this, expr) ?? lowerDisposableStackNew(this, expr) ?? lowerEventTargetNew(this, expr) ?? diff --git a/packages/compiler/src/frontend/lowering/thread-slots.ts b/packages/compiler/src/frontend/lowering/thread-slots.ts new file mode 100644 index 000000000..9247703f5 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/thread-slots.ts @@ -0,0 +1,254 @@ +/* ThreadSlots (@scriptc/experimental): named families of per-thread values + * keyed by class instances (scr_slots.c, backend/llvm/thread-slots.ts). + * + * A ThreadSlots value is an instance of the synthetic class `%ThreadSlots` + * with no fields, so it has the identity, typeof and inspection of the + * Node.js class (whose state is private). A family's per-thread storage is + * found by its name at compile time, so its methods are called through a + * module-level const initialized with `new ThreadSlots("name")`, and + * lower at their call sites to slots.get, slots.has, slots.set and + * slots.delete, with the family name, the const's value and the key: + * - the name must be a string literal, and every construction of one name + * must use the same type arguments; + * - the key type must be a class (its hierarchy carries the slot word); + * - the value type must be a class, an object type, an array, a Map or a + * Set, so `V | undefined` is a nullable reference. + * Any other use of the class or its members as values is refused. */ +import { STRING, VOID, typeKey } from "../../ir/ir.js"; +import type { IrExpr, IrType, SrcLoc } from "../../ir/ir.js"; +import { + type ThreadSlotsMember, + experimentalImportOf, + threadSlotsMemberOf, +} from "../experimental-import.js"; +import * as ts from "../ts7/adapter.js"; +import type { ClassInfo } from "./lower-classes.js"; +import type { Lowerer } from "./lowerer.js"; +import { locOf } from "../program.js"; + +export const THREAD_SLOTS_CLASS = "%ThreadSlots"; + +const BUILTIN_LOC: SrcLoc = { file: "", start: 0, end: 0 }; + +/** Value types a family stores: references that `V | undefined` holds as a + * nullable pointer. */ +const VALUE_KINDS = new Set(["object", "record", "array", "map", "set"]); + +/** The ThreadSlots instance type, registering the class (and its + * constructor) on first use. */ +export function threadSlotsType(lowerer: Lowerer): IrType { + const type: IrType = { kind: "object", className: THREAD_SLOTS_CLASS }; + if (lowerer.classes.has(THREAD_SLOTS_CLASS)) return type; + const info: ClassInfo = { + def: { name: THREAD_SLOTS_CLASS, fields: [], loc: BUILTIN_LOC }, + fields: new Map(), + fieldOrder: [], + methods: new Map(), + decl: null, + threadSlots: true, + ctor: null, + ctorParams: [], + base: null, + subclasses: [], + throwingSetters: [], + staticFields: [], + }; + lowerer.classes.set(THREAD_SLOTS_CLASS, info); + lowerer.liftedFns.push({ + name: `%${THREAD_SLOTS_CLASS}.constructor`, + params: [{ localId: "this.0", name: "this", type }], + returnType: VOID, + locals: [{ id: "this.0", name: "this", type, mutable: false }], + body: [], + loc: BUILTIN_LOC, + }); + return type; +} + +interface Family { + name: string; + key: IrType; + value: IrType; +} + +/** The root of a class's hierarchy. */ +function rootOf(info: ClassInfo): ClassInfo { + let root = info; + while (root.base !== null) root = root.base; + return root; +} + +/** The family a `new ThreadSlots("name")` expression opens, + * validated and registered once per expression. */ +function familyOf(lowerer: Lowerer, expr: ts.NewExpression): Family { + const known = lowerer.threadSlotFamilies.get(expr); + if (known !== undefined) return known; + const args = expr.arguments ?? []; + const arg = args[0]; + if (args.length !== 1 || arg === undefined || ts.isSpreadElement(arg)) + lowerer.noLowering(`new ThreadSlots with ${args.length} arguments`, expr); + if (!ts.isStringLiteral(arg!) && !ts.isNoSubstitutionTemplateLiteral(arg!)) + lowerer.unsupported( + "SC1090", + arg!, + "ThreadSlots names other than string literals", + "name the slots with a string literal: every construction of the name opens the same slots", + ); + const name = (arg as ts.StringLiteral).text; + const instance = lowerer.typeOf(expr); + const typeArgs = lowerer.checker.getTypeArguments(instance as ts.TypeReference); + if (typeArgs.length !== 2) lowerer.noLowering("ThreadSlots without type arguments", expr); + const key = lowerer.mapTypeOf(typeArgs[0]!); + const keyInfo = key?.kind === "object" ? lowerer.classes.get(key.className) : undefined; + if ( + key?.kind !== "object" || + keyInfo === undefined || + keyInfo.decl === null || + rootOf(keyInfo).def.runtime === true + ) + lowerer.unsupported( + "SC1090", + expr, + `'${lowerer.checker.typeToString(typeArgs[0]!)}' as a ThreadSlots key type`, + "key the slots by instances of a class declared in the program", + ); + const value = lowerer.mapTypeOf(typeArgs[1]!); + if (value === null || !VALUE_KINDS.has(value.kind)) + lowerer.unsupported( + "SC1090", + expr, + `'${lowerer.checker.typeToString(typeArgs[1]!)}' as a ThreadSlots value type`, + "store class instances, objects, arrays, Maps or Sets", + ); + const signature = `${typeKey(key!)}|${typeKey(value!)}`; + const first = lowerer.threadSlotNames.get(name); + if (first === undefined) lowerer.threadSlotNames.set(name, signature); + else if (first !== signature) + lowerer.unsupported( + "SC1090", + expr, + `opening ThreadSlots ${JSON.stringify(name)} with different type arguments than its first construction`, + "every construction of one slots name must use the same key and value types", + ); + lowerer.threadSlotKeys.add((key as IrType & { kind: "object" }).className); + const family = { name, key: key!, value: value! }; + lowerer.threadSlotFamilies.set(expr, family); + return family; +} + +/** `new ThreadSlots("name")`. */ +export function lowerThreadSlotsNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + if (!ts.isIdentifier(expr.expression)) return null; + if (experimentalImportOf(lowerer.program, expr.expression) !== "ThreadSlots") return null; + familyOf(lowerer, expr); + return { + kind: "new", + className: THREAD_SLOTS_CLASS, + args: [], + type: threadSlotsType(lowerer), + loc: locOf(expr), + }; +} + +/** The `new ThreadSlots(...)` initializer of the module-level const that + * `receiver` names (through imports and namespace imports), or of the + * slots whose `set` call + * `receiver` is (`slots.set(a, x).set(b, y)`), or null. */ +function declaringNew(lowerer: Lowerer, receiver: ts.Expression): ts.NewExpression | null { + if ( + ts.isCallExpression(receiver) && + ts.isPropertyAccessExpression(receiver.expression) && + threadSlotsMemberOf(lowerer.program, receiver.expression, lowerer.experimentalFiles) === "set" + ) + return declaringNew(lowerer, receiver.expression.expression); + // `slots`, or `ns.slots` through a namespace import. + const ref = ts.isPropertyAccessExpression(receiver) ? receiver.name : receiver; + if (!ts.isIdentifier(ref)) return null; + const checker = lowerer.checker; + let symbol = checker.getSymbolAtLocation(ref); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = checker.getAliasedSymbol(symbol); + const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined; + if (decl === undefined || !ts.isVariableDeclaration(decl)) return null; + const list = decl.parent; + if ( + !ts.isVariableDeclarationList(list) || + (list.flags & ts.NodeFlags.Const) === 0 || + !ts.isVariableStatement(list.parent) || + !ts.isSourceFile(list.parent.parent) + ) + return null; + const init = decl.initializer; + if (init === undefined || !ts.isNewExpression(init) || !ts.isIdentifier(init.expression)) + return null; + return experimentalImportOf(lowerer.program, init.expression) === "ThreadSlots" ? init : null; +} + +/** The refusal of ThreadSlots members used as values. */ +export function lowerThreadSlotsProperty( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + const member = threadSlotsMemberOf(lowerer.program, expr, lowerer.experimentalFiles); + if (member === null) return null; + return lowerer.noLowering( + `ThreadSlots.${member} (@scriptc/experimental) as a value`, + expr, + `call slots.${member}(...) directly`, + ); +} + +/** `slots.get(k)`, `has`, `set` and `delete`. */ +export function lowerThreadSlotsCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr | null { + const callee = expr.expression; + if (!ts.isPropertyAccessExpression(callee)) return null; + const member: ThreadSlotsMember | null = threadSlotsMemberOf( + lowerer.program, + callee, + lowerer.experimentalFiles, + ); + if (member === null) return null; + if (callee.questionDotToken !== undefined || expr.questionDotToken !== undefined) + lowerer.noLowering(`ThreadSlots.${member} through ?.`, expr, `call slots.${member}() directly`); + const arity = member === "set" ? 2 : 1; + if (expr.arguments.length !== arity || expr.arguments.some((a) => ts.isSpreadElement(a))) + lowerer.noLowering(`ThreadSlots.${member} with ${expr.arguments.length} arguments`, expr); + const init = declaringNew(lowerer, callee.expression); + if (init === null) + lowerer.unsupported( + "SC1090", + callee.expression, + "ThreadSlots calls through a value other than a module-level const", + 'call the methods through `const slots = new ThreadSlots("name")` declared at module scope', + ); + const family = familyOf(lowerer, init!); + const loc = locOf(expr); + const slotsType = threadSlotsType(lowerer); + const receiver = lowerer.coerceInto( + callee.expression, + lowerer.lowerExpr(callee.expression), + slotsType, + ); + const keyNode = expr.arguments[0]!; + const key = lowerer.coerceInto(keyNode, lowerer.lowerExpr(keyNode), family.key); + const name: IrExpr = { kind: "strLit", value: family.name, type: STRING, loc }; + const args = [name, receiver, key]; + switch (member) { + case "get": { + const resultTs = lowerer.typeOf(expr); + const result = lowerer.mapTypeOf(resultTs); + if (result === null) lowerer.badType(expr, resultTs); + return { kind: "intrinsic", name: "slots.get", args, type: result!, loc }; + } + case "has": + case "delete": + return { kind: "intrinsic", name: `slots.${member}`, args, type: { kind: "bool" }, loc }; + case "set": { + const valueNode = expr.arguments[1]!; + const value = lowerer.coerceInto(valueNode, lowerer.lowerExpr(valueNode), family.value); + // `set` returns the slots; a statement discards them. + const type = ts.isExpressionStatement(expr.parent) ? VOID : slotsType; + return { kind: "intrinsic", name: "slots.set", args: [...args, value], type, loc }; + } + } +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 52964e1bd..eab7fd56d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1075,6 +1075,9 @@ export interface TypeMapperCtx { /** The InternTable instance type (@scriptc/experimental), when `decl` is * that package's class declaration (lowering/intern-tables.ts). */ internTableClass?: (decl: ts.ClassDeclaration) => IrType | null; + /** The ThreadSlots instance type (@scriptc/experimental), when `decl` is + * that package's class declaration (lowering/thread-slots.ts). */ + threadSlotsClass?: (decl: ts.ClassDeclaration) => IrType | null; } /** A generator type's normalized value channels (Generator and the IteratorResult alias share this): @@ -1653,16 +1656,21 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return hasStr ? STRING : F64; } } - // @scriptc/experimental's LifetimeRegion and InternTable: synthetic - // program classes, provenance-checked by the package's declaration file. + // @scriptc/experimental's LifetimeRegion, InternTable and ThreadSlots: + // synthetic program classes, provenance-checked by the package's + // declaration file. { const classSym = widened.getSymbol(); const name = classSym?.name; - if (name === "LifetimeRegion" || name === "InternTable") { + if (name === "LifetimeRegion" || name === "InternTable" || name === "ThreadSlots") { for (const d of checker.declarationsOf(classSym!)) { if (!ts.isClassDeclaration(d)) continue; const synthetic = - name === "LifetimeRegion" ? ctx.lifetimeRegionClass?.(d) : ctx.internTableClass?.(d); + name === "LifetimeRegion" + ? ctx.lifetimeRegionClass?.(d) + : name === "InternTable" + ? ctx.internTableClass?.(d) + : ctx.threadSlotsClass?.(d); if (synthetic) return synthetic; } } diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 0b1f9fb49..ab4b05c8b 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -209,6 +209,7 @@ const MODULE_FIELDS: Record>, Role> = { "contracts.acyclic[]": "other", "contracts.immortalGlobals[]": "other", "contracts.programLifetime[]": "other", + "threadSlotKeys[]": "other", }; const loc = { file: "references.ts", start: 0, end: 0 }; diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 4041cd265..0f3d0811a 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -955,6 +955,10 @@ export interface IrModule { * ProgramLifetime instance lives in a counted region (the region.* * intrinsics; backend/llvm/regions.ts). */ lifetimeRegions?: true; + /** Classes used as ThreadSlots keys (@scriptc/experimental): the root of + * each one's hierarchy carries a slot word (the slots.* intrinsics; + * backend/llvm/thread-slots.ts). */ + threadSlotKeys?: string[]; /** The entry module is CommonJS under Node's classification. Node runs a * CommonJS main synchronously and drains process.nextTick callbacks * before promise jobs at the first checkpoint; an ES-module main's @@ -3563,7 +3567,19 @@ export type IrExpr = | "itable.find" | "itable.insert" | "itable.value" - | "itable.size"; + | "itable.size" + /* ThreadSlots (@scriptc/experimental; scr_slots.c). Every one takes + * the family name (a string literal), the family's %ThreadSlots + * value (read for its effects only) and the key, a class instance + * whose hierarchy is in IrModule.threadSlotKeys: + * slots.get → V | undefined, a nullable union, owned (+1); + * slots.has → bool; slots.delete → bool, releasing the value; + * slots.set (…, V value) → void, or the %ThreadSlots value when the + * result is used. Nothing throws. */ + | "slots.get" + | "slots.has" + | "slots.set" + | "slots.delete"; args: IrExpr[]; type: IrType; loc: SrcLoc; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index dec6ab201..9a6294f5a 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -985,6 +985,7 @@ export function validateModule(mod: IrModule): IrValidationError[] { errors, mod.workers === true, mod.lifetimeRegions === true, + new Set(mod.threadSlotKeys ?? []), ); } return errors; @@ -1003,6 +1004,7 @@ function validateFunction( errors: IrValidationError[], workers: boolean, lifetimeRegions: boolean, + threadSlotKeys: ReadonlySet, ): void { const locals = new Map(fn.locals.map((l) => [l.id, l])); const err = (message: string, loc: SrcLoc) => @@ -3870,6 +3872,44 @@ function validateFunction( } else if (e.type.kind !== "f64") err(`${e.name} has type f64`, e.loc); return; } + if ( + e.name === "slots.get" || + e.name === "slots.has" || + e.name === "slots.set" || + e.name === "slots.delete" + ) { + for (const a of e.args) checkExpr(a); + const [name, slots, key, value] = e.args; + const arity = e.name === "slots.set" ? 4 : 3; + const isSlots = (t: IrType): boolean => t.kind === "object" && t.className === "%ThreadSlots"; + // The key's class or one of its bases is a declared key. + const keyed = (className: string): boolean => { + for (let c: string | undefined = className; c !== undefined; c = classes.get(c)?.base) + if (threadSlotKeys.has(c)) return true; + return false; + }; + if (e.args.length !== arity) err(`${e.name} takes ${arity} arguments`, e.loc); + else if (name!.kind !== "strLit" || !isSlots(slots!.type)) + err(`${e.name} takes a name literal and a %ThreadSlots value`, e.loc); + else if (key!.type.kind !== "object" || !keyed(key!.type.className)) + err(`${e.name} key ${typeKey(key!.type)} is not a ThreadSlots key class`, e.loc); + else if (e.name === "slots.set") { + const kind = value!.type.kind; + if ( + kind !== "object" && + kind !== "record" && + kind !== "array" && + kind !== "map" && + kind !== "set" + ) + err(`slots.set value ${typeKey(value!.type)} is not a ThreadSlots value type`, e.loc); + if (e.type.kind !== "void" && !isSlots(e.type)) + err("slots.set has type void or %ThreadSlots", e.loc); + } else if (e.name === "slots.get") { + if (e.type.kind !== "union") err("slots.get has a V | undefined union type", e.loc); + } else if (e.type.kind !== "bool") err(`${e.name} has type bool`, e.loc); + return; + } if (e.name === "threads.publish") { if (e.args.length !== 1) err("threads.publish takes exactly one argument", e.loc); for (const a of e.args) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5727-thread-slots-basics.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5727-thread-slots-basics.ts.json new file mode 100644 index 000000000..574f51b5d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5727-thread-slots-basics.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5727-thread-slots-basics.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5728-thread-slots-workers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5728-thread-slots-workers.ts.json new file mode 100644 index 000000000..1fb5e7a76 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5728-thread-slots-workers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5728-thread-slots-workers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5729-thread-slots-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5729-thread-slots-values.ts.json new file mode 100644 index 000000000..186761e38 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5729-thread-slots-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5729-thread-slots-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/thread-slots-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/thread-slots-fences.ts.json new file mode 100644 index 000000000..f278d25e8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/thread-slots-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/thread-slots-fences.ts" + ], + "diags": [] +} diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 413deeff2..8f52f9a5b 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -70,6 +70,49 @@ Programs that behave identically under scriptc and Node.js follow the `@scriptc/ 2. Identity and ids are not compared across threads. Ids assigned inside callbacks are unique in both runtimes but differ between them. 3. A thread does not depend on another thread's inserts. `size` and `has` reflect every thread's inserts in scriptc and the calling thread's under Node.js. -Per-thread state about a shared value (lazily computed results, caches) belongs in thread-local state, for example a module-level `Map` keyed by the value, or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. +Per-thread state about a shared value (lazily computed results, caches) belongs in thread-local state, for example a `ThreadSlots` family (below), a module-level `Map` keyed by the value, or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. In scriptc builds stored values are immortal: they stay allocated until the process exits, including values a callback computed for a key that another thread stored first. + +## ThreadSlots + +A `ThreadSlots` family holds one value per key and thread, like a `WeakMap` that each thread has its own copy of. A thread sees only the values it set, including for keys that every thread shares, such as values read from an `InternTable`, and setting a value never writes the key. + +```ts +import { InternTable, ThreadSlots } from "@scriptc/experimental"; + +class Type { + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +class TypeState { + resolved = false; +} + +const types = new InternTable("checker.types"); +// Module scope: every thread that evaluates this line opens the same family. +const states = new ThreadSlots("checker.typeStates"); + +function stateOf(t: Type): TypeState { + let state = states.get(t); + if (state === undefined) { + state = new TypeState(); + states.set(t, state); + } + return state; +} +``` + +- `new ThreadSlots(name)` opens the family called `name`. Within one thread, every construction with the same name refers to the same family. +- `get(key)` and `has(key)` read the calling thread's value for `key`. +- `set(key, value)` sets the calling thread's value and returns the slots. +- `delete(key)` removes the calling thread's value and returns whether there was one. + +scriptc requires the name to be a string literal, every construction of one name to use the same type arguments, the key type to be a class declared in the program (keys may be instances of its subclasses), and the value type to be a class, an object type, an array, a `Map` or a `Set`. The methods must be called through a module-level `const` initialized with `new ThreadSlots("name")`, since a family's storage is found by its name when the program is compiled. + +In scriptc builds every instance of a key class's hierarchy carries a 4-byte slot index, assigned by the first `set` for it and never reused. `get` reads the index, a page of the thread's family and the entry, with no lock, hash or atomic update. A family holds a reference to each of the thread's values and releases it when it is replaced or deleted, when its key is freed, and when the thread exits. Keys freed with a `LifetimeRegion` keep their values until the thread exits, a value that belongs to a region keeps the region allocated while the family holds it, and a value that refers to its own key keeps the key alive. + +Under Node.js each family is a `WeakMap` per thread. diff --git a/packages/experimental/index.d.ts b/packages/experimental/index.d.ts index d831cbff9..77377e25f 100644 --- a/packages/experimental/index.d.ts +++ b/packages/experimental/index.d.ts @@ -52,3 +52,24 @@ export declare class InternTable< * call it. */ getOrInsertComputed(key: K, callback: (key: K) => V): V; } + +/** A named family of per-thread values keyed by objects, like a WeakMap + * that each thread has its own copy of. Every construction with one name + * opens the same family. A value set on one thread is visible only to that + * thread, including for keys that every thread shares (published objects, + * @scriptc/threads), and setting one never writes the key. scriptc requires + * the name to be a string literal, every construction of one name to use + * the same type arguments, the key type to be a class, and the slots to be + * called through a module-level const initialized with `new ThreadSlots`. */ +export declare class ThreadSlots { + /** Opens the family called `name`. */ + constructor(name: string); + /** This thread's value for `key`, or undefined. */ + get(key: K): V | undefined; + /** Whether this thread has a value for `key`. */ + has(key: K): boolean; + /** Sets this thread's value for `key`. Returns the slots. */ + set(key: K, value: V): this; + /** Removes this thread's value for `key`. Returns whether there was one. */ + delete(key: K): boolean; +} diff --git a/packages/experimental/index.js b/packages/experimental/index.js index 8a1b28c26..e5b824ed0 100644 --- a/packages/experimental/index.js +++ b/packages/experimental/index.js @@ -6,6 +6,8 @@ // program no longer references. An InternTable is a Map per name and // thread: Node.js threads share no objects, so every thread has its own // tables, and stored values are published (deep-frozen) as in scriptc. +// ThreadSlots is a WeakMap per name and thread: module state is per thread +// under Node.js, so each thread sees only the values it set. import { publish } from "@scriptc/threads"; @@ -90,3 +92,39 @@ export class InternTable { return value; } } + +/** This thread's slot families, by name. */ +const families = new Map(); + +export class ThreadSlots { + #values; + + /** Opens the family called `name`. */ + constructor(name) { + if (typeof name !== "string") throw new TypeError("The slots name must be a string"); + let values = families.get(name); + if (values === undefined) families.set(name, (values = new WeakMap())); + this.#values = values; + } + + /** This thread's value for `key`, or undefined. */ + get(key) { + return this.#values.get(key); + } + + /** Whether this thread has a value for `key`. */ + has(key) { + return this.#values.has(key); + } + + /** Sets this thread's value for `key`. Returns the slots. */ + set(key, value) { + this.#values.set(key, value); + return this; + } + + /** Removes this thread's value for `key`. Returns whether there was one. */ + delete(key) { + return this.#values.delete(key); + } +} diff --git a/packages/experimental/package.json b/packages/experimental/package.json index 114650325..327f6fc97 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -1,7 +1,7 @@ { "name": "@scriptc/experimental", "version": "0.3.6", - "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects and intern tables shared between threads", + "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects, intern tables shared between threads, and per-thread slots on shared objects", "license": "Apache-2.0", "homepage": "https://scriptc.dev", "repository": { diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 54e789624..d4607f694 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -45,6 +45,7 @@ const BASE_RUNTIME_SOURCES = [ "scr_publish.c", "scr_region.c", "scr_itable.c", + "scr_slots.c", "scr_alloc.c", ]; diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6b5052482..96add68b6 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -43,6 +43,7 @@ const BASE_RUNTIME_SOURCES = [ "scr_publish.c", "scr_region.c", "scr_itable.c", + "scr_slots.c", "scr_alloc.c", ]; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index a66d1c554..59cafe208 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -56,6 +56,7 @@ #include "scr_object.h" #include "scr_region.h" #include "scr_itable.h" +#include "scr_slots.h" #include "scr_async.h" #include "scr_events.h" #include "scr_readline.h" diff --git a/packages/runtime/src/scr_slots.c b/packages/runtime/src/scr_slots.c new file mode 100644 index 000000000..7c5bc7f88 --- /dev/null +++ b/packages/runtime/src/scr_slots.c @@ -0,0 +1,145 @@ +/* Per-thread slots (ThreadSlots, @scriptc/experimental): see scr_slots.h. */ + +#include "scr_runtime.h" + +#include +#include + +void *const scr_slots_none[SCR_SLOTS_PAGE] = {0}; + +/* The next slot index to assign. 0 means "no index". */ +static _Atomic uint32_t scr_slots_next = 1; + +/* This thread's blocks that hold or held values, and whether their release + * at thread exit is registered. */ +static SCR_TL ScrSlots *scr_slots_blocks; +static SCR_TL bool scr_slots_exit_registered; + +static _Noreturn void scr_slots_oom(void) { scr_trap("scriptc: out of memory\n"); } + +/* A page's entries follow its count of non-NULL entries. */ +static inline uintptr_t *scr_slots_count(void **page) { return (uintptr_t *)page - 1; } + +/* The slot index of the key `obj` with slot word `word`, assigned on first + * use. A published key may be read by every thread, so its index is set + * once with a compare-and-swap and every thread uses the winner's. An + * unpublished key is reachable from this thread only. */ +static uint32_t scr_slots_index(void *obj, uint32_t *word) { + uint32_t index = __atomic_load_n(word, __ATOMIC_RELAXED); + if (index != 0) return index; + index = atomic_fetch_add_explicit(&scr_slots_next, 1, memory_order_relaxed); + if (index == 0) scr_trap("scriptc: ThreadSlots ran out of slot indices\n"); + if (*(size_t *)obj != SIZE_MAX) { + __atomic_store_n(word, index, __ATOMIC_RELAXED); + return index; + } + uint32_t expected = 0; + if (__atomic_compare_exchange_n(word, &expected, index, false, __ATOMIC_RELAXED, + __ATOMIC_RELAXED)) + return index; + return expected; +} + +/* Releases the values of every block of this thread and frees their + * pages, leaving the blocks as a later first set finds them. A released + * value can free a key, whose scr_slots_forget then finds an already + * emptied block. */ +static void scr_slots_thread_exit(void) { + if (!scr_exit_frees()) return; + ScrSlots *blocks = scr_slots_blocks; + scr_slots_blocks = NULL; + scr_slots_exit_registered = false; + for (ScrSlots *s = blocks, *next; s != NULL; s = next) { + next = s->next; + void ***pages = s->pages; + uint32_t npages = s->npages; + s->pages = NULL; + s->npages = 0; + s->registered = 0; + s->next = NULL; + for (uint32_t p = 0; p < npages; p++) { + void **page = pages[p]; + if (page == (void **)scr_slots_none) continue; + for (uint32_t i = 0; i < SCR_SLOTS_PAGE; i++) + if (page[i] != NULL) s->release(page[i]); + free(scr_slots_count(page)); + } + free(pages); + } +} + +static void scr_slots_register(ScrSlots *s, void (*release)(void *)) { + s->release = release; + s->registered = 1; + s->next = scr_slots_blocks; + scr_slots_blocks = s; + if (!scr_slots_exit_registered) { + scr_slots_exit_registered = true; + scr_atexit(scr_slots_thread_exit); + } +} + +/* The page holding `index`, created when absent. */ +static void **scr_slots_page(ScrSlots *s, uint32_t index) { + uint32_t p = index >> SCR_SLOTS_PAGE_BITS; + if (p >= s->npages) { + uint32_t n = s->npages ? s->npages : 4; + while (n <= p) n = n > UINT32_MAX / 2 ? (UINT32_MAX >> SCR_SLOTS_PAGE_BITS) + 1 : n * 2; + void ***pages = realloc(s->pages, (size_t)n * sizeof *pages); + if (!pages) scr_slots_oom(); + for (uint32_t i = s->npages; i < n; i++) pages[i] = (void **)scr_slots_none; + s->pages = pages; + s->npages = n; + } + void **page = s->pages[p]; + if (page == (void **)scr_slots_none) { + uintptr_t *block = calloc(SCR_SLOTS_PAGE + 1, sizeof(void *)); + if (!block) scr_slots_oom(); + page = (void **)(block + 1); + s->pages[p] = page; + } + return page; +} + +/* Takes the entry `index` out of `s`; returns its value, or NULL. The + * page goes when its last entry does. */ +static void *scr_slots_take(ScrSlots *s, uint32_t index) { + uint32_t p = index >> SCR_SLOTS_PAGE_BITS; + if (p >= s->npages) return NULL; + void **page = s->pages[p]; + void **entry = &page[index & (SCR_SLOTS_PAGE - 1)]; + void *value = *entry; + if (value == NULL) return NULL; + *entry = NULL; + if (--*scr_slots_count(page) == 0) { + free(scr_slots_count(page)); + s->pages[p] = (void **)scr_slots_none; + } + return value; +} + +void scr_slots_set(ScrSlots *s, void *obj, uint32_t *word, void *value, void (*release)(void *)) { + if (!s->registered) scr_slots_register(s, release); + uint32_t index = scr_slots_index(obj, word); + void **page = scr_slots_page(s, index); + void **entry = &page[index & (SCR_SLOTS_PAGE - 1)]; + void *old = *entry; + *entry = value; + if (old == NULL) ++*scr_slots_count(page); + else s->release(old); +} + +bool scr_slots_delete(ScrSlots *s, const uint32_t *word) { + uint32_t index = __atomic_load_n(word, __ATOMIC_RELAXED); + void *old = index ? scr_slots_take(s, index) : NULL; + if (old == NULL) return false; + s->release(old); + return true; +} + +void scr_slots_forget(uint32_t index) { + for (ScrSlots *s = scr_slots_blocks; s != NULL; s = s->next) { + void *old = scr_slots_take(s, index); + if (old != NULL) s->release(old); + } +} diff --git a/packages/runtime/src/scr_slots.h b/packages/runtime/src/scr_slots.h new file mode 100644 index 000000000..5e0307b00 --- /dev/null +++ b/packages/runtime/src/scr_slots.h @@ -0,0 +1,73 @@ +/* scriptc runtime — per-thread slots (scr_slots.c). Included by + * scr_runtime.h, which says where new declarations go. */ +#ifndef SCR_SLOTS_H +#define SCR_SLOTS_H + +#include +#include +#include + +/* ── per-thread slots (scr_slots.c) ─────────────────────────────────── + * ThreadSlots (@scriptc/experimental): a family of per-thread values keyed + * by class instances. Each instance of a class used as a key carries a + * 32-bit slot word, 0 until a value is first set for it on any thread. + * Setting assigns the word an index that is unique in the process and + * never reused; a published instance, which every thread may read, gets + * its index with a compare-and-swap, so all threads agree on it. + * + * Each family has one ScrSlots block per thread (an emitted thread-local + * global). The block maps indices to that thread's values through pages + * of SCR_SLOTS_PAGE entries, so a read is the slot word, the page and the + * entry, with no hashing, lock or atomic read-modify-write: + * + * page = word >> SCR_SLOTS_PAGE_BITS < npages ? pages[word >> BITS] : none + * value = page[word & (SCR_SLOTS_PAGE - 1)] + * + * Absent pages point at a shared page of NULL entries, and index 0 is + * never assigned, so a word of 0 reads NULL. The emitted read inlines + * this (llvm/thread-slots.ts); scr_slots_get is the same read in C. + * + * A block holds one reference to each value it stores. Values belong to + * the thread: the block releases them when they are replaced or deleted, + * when their key is freed (scr_slots_forget, called by the key class's + * free path), and when the thread exits. */ + +#define SCR_SLOTS_PAGE_BITS 10 +#define SCR_SLOTS_PAGE (1u << SCR_SLOTS_PAGE_BITS) + +/* The emitted layout is `{ ptr, i32, i32, ptr, ptr }`. */ +typedef struct ScrSlots { + void ***pages; /* npages entries; scr_slots_none where unset */ + uint32_t npages; + uint32_t registered; /* on this thread's list of blocks */ + void (*release)(void *); + struct ScrSlots *next; +} ScrSlots; + +/* The page that absent pages point at. */ +extern void *const scr_slots_none[SCR_SLOTS_PAGE]; + +/* This thread's value for the key whose slot word is `word`, borrowed, or + * NULL. */ +static inline void *scr_slots_get(const ScrSlots *s, const uint32_t *word) { + uint32_t index = __atomic_load_n(word, __ATOMIC_RELAXED); + uint32_t page = index >> SCR_SLOTS_PAGE_BITS; + if (page >= s->npages) return NULL; + return s->pages[page][index & (SCR_SLOTS_PAGE - 1)]; +} + +/* Stores `value` (one reference, moved in) for the key `obj`, whose slot + * word is `word`, and releases the value it replaces. `release` releases a + * value of the family's type. */ +void scr_slots_set(ScrSlots *s, void *obj, uint32_t *word, void *value, void (*release)(void *)); + +/* Removes and releases this thread's value for the key; false when there + * was none. */ +bool scr_slots_delete(ScrSlots *s, const uint32_t *word); + +/* The key with slot index `index` is being freed: releases its values in + * every block of the calling thread. Only the thread that owns an + * unpublished key can hold values for it. */ +void scr_slots_forget(uint32_t index); + +#endif /* SCR_SLOTS_H */ diff --git a/packages/runtime/src/scr_slots.test.c b/packages/runtime/src/scr_slots.test.c new file mode 100644 index 000000000..5aba5b266 --- /dev/null +++ b/packages/runtime/src/scr_slots.test.c @@ -0,0 +1,113 @@ +#undef NDEBUG +#include "scr_runtime.h" +#include +#include +#include +#include +#include + +/* The slots unit needs scr_trap, scr_exit_frees and the context's exit + * registration from the rest of the runtime; the exit callback is run + * here at the end of each thread. */ +_Noreturn void scr_trap(const char *msg) { + fputs(msg, stderr); + abort(); +} +bool scr_exit_frees(void) { return true; } +static _Thread_local void (*exit_fn)(void); +int scr_context_atexit(void (*fn)(void)) { + assert(exit_fn == NULL || exit_fn == fn); + exit_fn = fn; + return 0; +} + +enum { THREADS = 8, KEYS = 5000 }; + +/* A stand-in for a key object: the count word first, the slot word after. */ +typedef struct { + size_t rc; + uint32_t word; +} Key; + +/* A stand-in for a value: its owner thread and key. */ +typedef struct { + unsigned thread; + unsigned key; +} Value; + +static atomic_size_t released; +static void release(void *v) { + free(v); + atomic_fetch_add(&released, 1); +} + +static Value *value(unsigned thread, unsigned key) { + Value *v = malloc(sizeof *v); + assert(v); + v->thread = thread; + v->key = key; + return v; +} + +static Key shared[KEYS]; +static _Thread_local ScrSlots states; +static _Thread_local ScrSlots others; + +/* Every thread sets its own value for every shared key, in its own order, + * so threads race to assign each key's index, then reads its values back. */ +static void *run(void *arg) { + unsigned t = (unsigned)(uintptr_t)arg; + for (unsigned i = 0; i < KEYS; i++) { + unsigned k = (i * 7919u + t * 104729u) % KEYS; + scr_slots_set(&states, &shared[k], &shared[k].word, value(t, k), release); + } + for (unsigned k = 0; k < KEYS; k++) { + Value *v = scr_slots_get(&states, &shared[k].word); + assert(v && v->thread == t && v->key == k); + assert(scr_slots_get(&others, &shared[k].word) == NULL); + } + /* Replacing releases the old value; deleting releases and reports. */ + scr_slots_set(&states, &shared[0], &shared[0].word, value(t, 0), release); + assert(scr_slots_delete(&states, &shared[1].word)); + assert(!scr_slots_delete(&states, &shared[1].word)); + assert(scr_slots_get(&states, &shared[1].word) == NULL); + + /* Unpublished keys: forget releases a freed key's values in every block + * of the thread. */ + Key *local = calloc(KEYS, sizeof *local); + assert(local); + for (unsigned k = 0; k < KEYS; k++) { + local[k].rc = 1; + scr_slots_set(&states, &local[k], &local[k].word, value(t, k), release); + if (k % 2 == 0) scr_slots_set(&others, &local[k], &local[k].word, value(t, k), release); + } + for (unsigned k = 0; k < KEYS; k += 3) scr_slots_forget(local[k].word); + for (unsigned k = 0; k < KEYS; k++) { + Value *v = scr_slots_get(&states, &local[k].word); + assert(k % 3 == 0 ? v == NULL : v != NULL && v->key == k); + Value *o = scr_slots_get(&others, &local[k].word); + assert(k % 3 == 0 || k % 2 != 0 ? o == NULL : o != NULL && o->key == k); + } + free(local); + + /* Thread exit releases the rest and leaves the blocks empty. */ + assert(exit_fn != NULL); + exit_fn(); + assert(states.npages == 0 && others.npages == 0); + assert(scr_slots_get(&states, &shared[2].word) == NULL); + return NULL; +} + +int main(void) { + for (unsigned k = 0; k < KEYS; k++) shared[k].rc = SIZE_MAX; + pthread_t threads[THREADS]; + for (unsigned t = 0; t < THREADS; t++) + assert(pthread_create(&threads[t], NULL, run, (void *)(uintptr_t)t) == 0); + for (unsigned t = 0; t < THREADS; t++) assert(pthread_join(threads[t], NULL) == 0); + /* Every key got one index, and every value set was released. */ + for (unsigned k = 0; k < KEYS; k++) assert(shared[k].word != 0); + size_t sets = THREADS * ((size_t)KEYS + 1 + KEYS + (KEYS + 1) / 2); + assert(atomic_load(&released) == sets); + printf("thread slots keep one value per thread and key\n"); + return 0; +} diff --git a/packages/runtime/src/scr_slots.test.ts b/packages/runtime/src/scr_slots.test.ts new file mode 100644 index 000000000..80eb8e68b --- /dev/null +++ b/packages/runtime/src/scr_slots.test.ts @@ -0,0 +1,52 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const exec = promisify(execFile); + +/* The slots unit links alone (the test supplies what it calls), so the + * test of racing index assignment runs under ThreadSanitizer as well as + * AddressSanitizer. */ +const lanes = [ + { name: "address", flags: ["-fsanitize=address,undefined"], run: process.platform !== "win32" }, + { name: "thread", flags: ["-fsanitize=thread"], run: process.platform === "linux" }, +]; + +for (const lane of lanes) + test.skipIf(!lane.run)( + `thread slots keep one value per thread and key (${lane.name})`, + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-slots-")); + try { + const bin = join(dir, "slots"); + await exec("clang", [ + "-std=c11", + "-O1", + "-g", + "-pthread", + "-DSCR_WORKERS", + ...lane.flags, + ...(process.platform === "linux" ? ["-D_GNU_SOURCE"] : []), + join(import.meta.dirname, "scr_slots.test.c"), + join(import.meta.dirname, "scr_slots.c"), + "-o", + bin, + ]); + const result = await exec(bin, [], { + timeout: 60000, + env: { + ...process.env, + UBSAN_OPTIONS: "halt_on_error=1", + TSAN_OPTIONS: "halt_on_error=1", + }, + }); + expect(result.stdout).toBe("thread slots keep one value per thread and key\n"); + expect(result.stderr).toBe(""); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); diff --git a/tests/corpus/5727-thread-slots-basics.ts b/tests/corpus/5727-thread-slots-basics.ts new file mode 100644 index 000000000..b8a1c43b5 --- /dev/null +++ b/tests/corpus/5727-thread-slots-basics.ts @@ -0,0 +1,85 @@ +import { ThreadSlots } from "@scriptc/experimental"; + +// ThreadSlots (@scriptc/experimental): per-thread values keyed by class +// instances, like a WeakMap. Keys of a base class and its subclasses share +// one family; constructions with one name open the same family. Node.js +// prints the same. + +class Node { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +class Leaf extends Node { + readonly weight: number; + constructor(name: string, weight: number) { + super(name); + this.weight = weight; + } +} + +class Info { + count = 0; + readonly label: string; + constructor(label: string) { + this.label = label; + } +} + +const info = new ThreadSlots("basics.info"); +const sameInfo = new ThreadSlots("basics.info"); +const other = new ThreadSlots("basics.other"); + +function infoOf(n: Node): Info { + let found = info.get(n); + if (found === undefined) { + found = new Info(`info of ${n.name}`); + info.set(n, found); + } + found.count++; + return found; +} + +const a = new Node("a"); +const b = new Leaf("b", 2); +console.log(info.get(a), info.has(a), info.delete(a)); +console.log(infoOf(a).label, infoOf(a).count, infoOf(b).label, infoOf(b).count); +console.log(info.has(a), info.has(b), sameInfo.get(a)?.count, other.has(a)); + +// set returns the slots, so calls chain. +const c = new Leaf("c", 3); +const chained = other.set(a, new Info("other a")).set(c, new Info("other c")); +console.log(chained === other, other.get(a)?.label, other.get(c)?.label, other.has(b)); + +// Replacing and deleting. +other.set(a, new Info("other a again")); +console.log(other.get(a)?.label, other.delete(a), other.delete(a), other.get(a)); +console.log(sameInfo.delete(b), info.has(b), info.get(b)); + +// Many keys: values come back for each, after keys around them go away. +const kept: Node[] = []; +for (let i = 0; i < 5000; i++) { + const n = i % 2 === 0 ? new Node(`n${i}`) : new Leaf(`l${i}`, i); + info.set(n, new Info(`#${i}`)); + if (i % 3 === 0) kept.push(n); +} +let sum = 0; +for (const n of kept) sum += Number(info.get(n)!.label.slice(1)); +console.log(kept.length, sum, info.get(kept[kept.length - 1]!)?.label); + +// A key whose value refers back to the key. +class Holder { + readonly node: Node; + constructor(node: Node) { + this.node = node; + } +} +const backs = new ThreadSlots("basics.back"); +for (let i = 0; i < 100; i++) { + const n = new Node(`back${i}`); + backs.set(n, new Holder(n)); + if (i === 99) console.log(backs.get(n)?.node === n, backs.get(n)?.node.name); +} +console.log(typeof info); diff --git a/tests/corpus/5728-thread-slots-workers.ts b/tests/corpus/5728-thread-slots-workers.ts new file mode 100644 index 000000000..70bee484f --- /dev/null +++ b/tests/corpus/5728-thread-slots-workers.ts @@ -0,0 +1,107 @@ +import { Worker, isMainThread, parentPort, threadId, workerData } from "node:worker_threads"; +import { InternTable, ThreadSlots } from "@scriptc/experimental"; +import type { ProgramLifetime } from "@scriptc/types"; + +// ThreadSlots on shared keys: workers intern types in an InternTable, so in +// scriptc builds every thread reads the same published Type objects, and +// each thread keeps its own derived state for them in one ThreadSlots +// family. A thread sees only the values it set. Keys are ProgramLifetime +// instances, and some get a value before they are published. Node.js +// prints the same. + +class Type implements ProgramLifetime { + readonly id: number; + readonly name: string; + readonly members: readonly Type[]; + constructor(id: number, name: string, members: readonly Type[]) { + this.id = id; + this.name = name; + this.members = members; + } +} + +class TypeState { + readonly thread: number; + printed: string; + reads = 0; + constructor(thread: number, printed: string) { + this.thread = thread; + this.printed = printed; + } +} + +let nextLocalId = 0; +function nextId(): number { + return threadId * 1000000 + nextLocalId++; +} + +const interned = new InternTable("slots.types"); +const states = new ThreadSlots("slots.states"); + +function named(name: string): Type { + return interned.getOrInsertComputed(name, () => new Type(nextId(), name, [])); +} + +function listOf(element: Type): Type { + const key = `list:${print(element)}`; + const found = interned.get(key); + if (found !== undefined) return found; + // A value set before the type is published stays with it. + const fresh = new Type(nextId(), "list", [element]); + states.set(fresh, new TypeState(threadId, `${print(element)}[]`)); + return interned.getOrInsert(key, fresh); +} + +function print(t: Type): string { + let state = states.get(t); + if (state === undefined) { + const text = t.name === "list" ? `${print(t.members[0]!)}[]` : t.name; + state = new TypeState(threadId, text); + states.set(t, state); + } + state.reads++; + return state.printed; +} + +function run(seed: number): string { + const base = ["string", "number", "boolean", "bigint"].map(named); + const made: Type[] = [...base]; + let state = seed; + for (let r = 0; r < 2000; r++) { + state = (state * 1103515245 + 12345) % 2147483648; + const t = made[state % made.length]!; + made.push(r % 4 === 0 ? listOf(t) : t); + } + let reads = 0; + let own = 0; + const forms = new Set(); + for (const t of made) { + forms.add(print(t)); + const s = states.get(t)!; + reads += s.reads; + if (s.thread === threadId) own++; + } + let removed = 0; + for (const t of base) if (states.delete(t)) removed++; + return `${made.length} types, ${forms.size} forms, own state ${own === made.length}, reads ${reads > made.length}, removed ${removed}, after ${states.has(base[0]!)}`; +} + +if (isMainThread) { + const lines: string[] = []; + let pending = 3; + for (let w = 0; w < 3; w++) { + const worker = new Worker(new URL(import.meta.url), { workerData: { seed: w * 31 + 7 } }); + worker.on("message", (message: unknown) => { + lines[w] = `worker ${w}: ${message as string}`; + }); + worker.on("exit", () => { + if (--pending === 0) { + for (const line of lines) console.log(line); + console.log(`main: ${run(3)}`); + } + }); + } +} else { + const { seed } = workerData as { seed: number }; + parentPort!.postMessage(run(seed)); +} diff --git a/tests/corpus/5729-thread-slots-values.ts b/tests/corpus/5729-thread-slots-values.ts new file mode 100644 index 000000000..dbe905a06 --- /dev/null +++ b/tests/corpus/5729-thread-slots-values.ts @@ -0,0 +1,89 @@ +import { ThreadSlots } from "@scriptc/experimental"; +import { publish } from "@scriptc/threads"; + +// ThreadSlots value types (class instances, objects, arrays, Maps and +// Sets), families keyed by a base class and by one of its subclasses, and +// published keys. Node.js prints the same. + +class Shape { + readonly sides: number; + constructor(sides: number) { + this.sides = sides; + } +} + +class Square extends Shape { + readonly size: number; + constructor(size: number) { + super(4); + this.size = size; + } +} + +class Point { + readonly x: number; + readonly y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +interface Note { + text: string; + seen: number; +} + +const notes = new ThreadSlots("values.notes"); +const lists = new ThreadSlots("values.lists"); +const maps = new ThreadSlots>("values.maps"); +const sets = new ThreadSlots>("values.sets"); +const points = new ThreadSlots("values.points"); + +const triangle = new Shape(3); +const square = new Square(2); +notes.set(triangle, { text: "three", seen: 0 }); +notes.set(square, { text: "four", seen: 0 }); +notes.get(square)!.seen++; +lists.set(square, ["a"]); +lists.get(square)!.push("b"); +maps.set(square, new Map([["area", 4]])); +maps.get(square)!.set("perimeter", 8); +console.log(notes.get(triangle), notes.get(square), lists.get(square), lists.has(triangle)); +console.log([...maps.get(square)!], maps.has(new Square(2))); + +const origin = new Point(0, 0); +sets.set(origin, new Set([1, 2])); +sets.get(origin)!.add(3); +points.set(origin, new Point(1, 1)); +points.set(points.get(origin)!, origin); +console.log([...sets.get(origin)!], points.get(points.get(origin)!) === origin); + +// Published keys: the slots never write the key. +const frozen = publish(new Square(5)); +notes.set(frozen, { text: "published", seen: 1 }); +maps.set(frozen, new Map()); +console.log(notes.get(frozen)?.text, maps.get(frozen)?.size, frozen.size); +console.log(notes.delete(frozen), notes.has(frozen), maps.has(frozen)); + +// Replaced values and freed keys. +let total = 0; +for (let round = 0; round < 3; round++) { + const shapes: Shape[] = []; + for (let i = 0; i < 2000; i++) { + const s = i % 2 === 0 ? new Shape(i) : new Square(i); + shapes.push(s); + notes.set(s, { text: `first ${i}`, seen: 0 }); + notes.set(s, { text: `second ${i}`, seen: round }); + lists.set(s, [String(i)]); + } + for (const s of shapes) total += notes.get(s)!.seen + lists.get(s)!.length; +} +console.log(total, notes.get(triangle)?.text); + +// `Note | undefined` as Set elements: reads of that type are tagged boxes. +const seen = new Set(); +seen.add(notes.get(triangle)); +seen.add(notes.get(new Shape(9))); +seen.add(notes.get(triangle)); +console.log(seen.size, [...seen].map((n) => n?.text ?? "none").join(",")); diff --git a/tests/diagnostics/thread-slots-fences.ts b/tests/diagnostics/thread-slots-fences.ts new file mode 100644 index 000000000..7ec71db34 --- /dev/null +++ b/tests/diagnostics/thread-slots-fences.ts @@ -0,0 +1,29 @@ +import { ThreadSlots } from "@scriptc/experimental"; + +// ThreadSlots (@scriptc/experimental) forms scriptc refuses: names that are +// not string literals, one name opened with two value types, key and value +// types the slots cannot hold, calls through values other than a +// module-level const, and the class or its methods as values. + +class Key { + readonly id = 0; +} +class State { + count = 0; +} +interface Shape { + readonly id: number; +} + +const name = "dynamic"; +const named = new ThreadSlots(name); +const first = new ThreadSlots("fences.shared"); +const second = new ThreadSlots("fences.shared"); +const shaped = new ThreadSlots("fences.shaped"); +const callbacks = new ThreadSlots number>("fences.callbacks"); +function local(slots: ThreadSlots, key: Key): State | undefined { + return slots.get(key); +} +const ctor = ThreadSlots; +const getter = first.get; +console.log(named, second, shaped, callbacks, local(first, new Key()), ctor, getter); diff --git a/tests/harness/__snapshots__/thread-slots-fences.ts.txt b/tests/harness/__snapshots__/thread-slots-fences.ts.txt new file mode 100644 index 000000000..566ddfba6 --- /dev/null +++ b/tests/harness/__snapshots__/thread-slots-fences.ts.txt @@ -0,0 +1,62 @@ +thread-slots-fences.ts:19:43 - error SC1090: ThreadSlots names other than string literals are not supported yet + + 18 | const name = "dynamic"; + 19 | const named = new ThreadSlots(name); + | ^~~~ + 20 | const first = new ThreadSlots("fences.shared"); + + hint: name the slots with a string literal: every construction of the name opens the same slots + +thread-slots-fences.ts:21:16 - error SC1090: opening ThreadSlots "fences.shared" with different type arguments than its first construction is not supported yet + + 20 | const first = new ThreadSlots("fences.shared"); + 21 | const second = new ThreadSlots("fences.shared"); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 22 | const shaped = new ThreadSlots("fences.shaped"); + + hint: every construction of one slots name must use the same key and value types + +thread-slots-fences.ts:22:16 - error SC1090: 'Shape' as a ThreadSlots key type is not supported yet + + 21 | const second = new ThreadSlots("fences.shared"); + 22 | const shaped = new ThreadSlots("fences.shaped"); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 23 | const callbacks = new ThreadSlots number>("fences.callbacks"); + + hint: key the slots by instances of a class declared in the program + +thread-slots-fences.ts:23:19 - error SC1090: '() => number' as a ThreadSlots value type is not supported yet + + 22 | const shaped = new ThreadSlots("fences.shaped"); + 23 | const callbacks = new ThreadSlots number>("fences.callbacks"); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 24 | function local(slots: ThreadSlots, key: Key): State | undefined { + + hint: store class instances, objects, arrays, Maps or Sets + +thread-slots-fences.ts:25:10 - error SC1090: ThreadSlots calls through a value other than a module-level const is not supported yet + + 24 | function local(slots: ThreadSlots, key: Key): State | undefined { + 25 | return slots.get(key); + | ^~~~~ + 26 | } + + hint: call the methods through `const slots = new ThreadSlots("name")` declared at module scope + +thread-slots-fences.ts:27:14 - error SC2020: 'ThreadSlots (@scriptc/experimental) as a value' is part of the standard library types but has no scriptc lowering yet + + 26 | } + 27 | const ctor = ThreadSlots; + | ^~~~~~~~~~~ + 28 | const getter = first.get; + + hint: open slots with new ThreadSlots("name") + +thread-slots-fences.ts:28:16 - error SC2020: 'ThreadSlots.get (@scriptc/experimental) as a value' is part of the standard library types but has no scriptc lowering yet + + 27 | const ctor = ThreadSlots; + 28 | const getter = first.get; + | ^~~~~~~~~ + 29 | console.log(named, second, shaped, callbacks, local(first, new Key()), ctor, getter); + + hint: call slots.get(...) directly \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 7f89295df..e66295a04 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1841,6 +1841,9 @@ "5648-dns-lookup-forms.ts": 2992, "5649-dns-service-servers.ts": 2712, "5650-dns-lookup-errors-untyped.cjs": 2822, + "5727-thread-slots-basics.ts": 3100, + "5728-thread-slots-workers.ts": 2500, + "5729-thread-slots-values.ts": 2550, "5800-broadcast-channel.ts": 2899, "5801-broadcast-channel.cjs": 2727, "5802-broadcast-channel-workers.ts": 2809, @@ -4525,6 +4528,9 @@ "5648-dns-lookup-forms.ts": 3416, "5649-dns-service-servers.ts": 4916, "5650-dns-lookup-errors-untyped.cjs": 3889, + "5727-thread-slots-basics.ts": 4500, + "5728-thread-slots-workers.ts": 5000, + "5729-thread-slots-values.ts": 4400, "5800-broadcast-channel.ts": 28374, "5801-broadcast-channel.cjs": 27251, "5802-broadcast-channel-workers.ts": 27251, diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 0cb55f690..5d814b384 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -310,6 +310,9 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { "5721-intern-table-values.ts", // Array key sizes are checked in 32-bit size_t arithmetic. "5726-intern-table-key-limits.ts", + // ThreadSlots blocks and pages hold 32-bit pointers. + "5727-thread-slots-basics.ts", + "5729-thread-slots-values.ts", ])("the LLVM target runs %s byte-identically to Node", async (fixture) => { const entry = join(repoRoot, "tests/corpus", fixture); const outDir = await mkdtemp("/tmp/scriptc-wasi-"); From 56e472a225ebe8888c2e4d802a29d57705d69176 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 11:04:03 -0500 Subject: [PATCH 03/53] Return undefined from generic functions that read a missing element A generic function, arrow or method whose body returns an array element or an index-signature entry now returns undefined for a missing one, as in Node, instead of throwing. Only declarations whose bodies return such a read change their instance result type. Fixes #964 Co-authored-by: Quim T. <26749475+espetro@users.noreply.github.com> --- .../src/frontend/lowering/function-returns.ts | 7 ++ .../frontend/lowering/generic-functions.ts | 9 +- .../compiler/src/frontend/lowering/lowerer.ts | 92 +++++++++++++++++++ .../5954-generic-optional-returns.ts.json | 6 ++ tests/corpus/5954-generic-optional-returns.ts | 51 ++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json create mode 100644 tests/corpus/5954-generic-optional-returns.ts diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index d3c5eb1ba..4f519691f 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -157,6 +157,13 @@ export function declaredReturnType( } if (mappedReturn && isJsSourceFile(decl.getSourceFile()) && functionCanReturnUndefined(decl)) mappedReturn = lowerer.withUndefinedArmOf(mappedReturn) ?? mappedReturn; + // A generic-class method body that can return an indexed read that + // misses returns undefined there, whatever the declared result says. + if (mappedReturn && lowerer.runtimeOptionalGenericReturns.has(decl)) + mappedReturn = + mappedReturn.kind === "promise" + ? { kind: "promise", inner: lowerer.runtimeOptionalType(mappedReturn.inner) } + : lowerer.runtimeOptionalType(mappedReturn); if (isJsSourceFile(decl.getSourceFile()) && mappedReturn?.kind === "void") { let returnsValue = false; const scan = (node: ts.Node): void => { diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index bd785a256..90f83ec2c 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -292,11 +292,16 @@ export function genericCallInstance( (_param, i) => expr.arguments[i] ?? expr, ); const retTs = lowerer.checker.getReturnTypeOfSignature(rsig); - const returnType = lowerer.mapTypeOf(retTs); - if (!returnType) { + const mappedReturn = lowerer.mapTypeOf(retTs); + if (!mappedReturn) { fenceGenericSignatureResult(lowerer, expr, retTs); lowerer.badType(expr, retTs); } + // A body that can return an indexed read that misses returns undefined + // there, as in JavaScript, whatever the declared result says. + const returnType = lowerer.runtimeOptionalGenericReturns.has(info.decl) + ? lowerer.runtimeOptionalType(mappedReturn) + : mappedReturn; // keyof-constrained type parameters (`K extends keyof T`): the bound // LITERAL is semantic — the instance body reads the named field — so diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b77768c28..40cf40de9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1880,6 +1880,10 @@ export class Lowerer { * declared optional binding to that read's bare type, so presence tests * on these bindings consult the stored tag instead. */ readonly runtimeOptionalAssignedSymbols = new Set(); + /** Generic function declarations whose body can return an indexed read + * that misses. + * Each instance's result admits undefined (generic-functions.ts). */ + readonly runtimeOptionalGenericReturns = new Set(); /** Concise callback/lambda returns promoted by the HOF callback prepass; * arrows have no declaration symbol to key in fnSigsBySymbol. */ readonly runtimeOptionalFunctionReturns = new Map(); @@ -4289,6 +4293,79 @@ export class Lowerer { const read = this.mapTypeOf(this.typeOf(e)); return !(read?.kind === "union" && this.armTag(read.unionId, UNDEFINED_T) >= 0); }; + /** An optional value in a generic body, where element types are type + * parameters with no IR mapping yet: an element read of an array or a + * read of a string index signature, judged on checker types. */ + const genericClassMethods: ts.MethodDeclaration[] = []; + for (const sf of sourceFiles) { + if (isJsSourceFile(sf) || sf.isDeclarationFile) continue; + ts.walkPreorder(sf, (node) => { + if (!ts.isClassDeclaration(node)) return undefined; + const genericClass = (node.typeParameters?.length ?? 0) > 0; + for (const member of node.members) + if ( + ts.isMethodDeclaration(member) && + member.body && + (genericClass || (member.typeParameters?.length ?? 0) > 0) + ) + genericClassMethods.push(member); + return undefined; + }); + } + const genericMayBeOptional = (node: ts.Expression): boolean => { + if (explicitlyNonNull(node)) return false; + if (mayBeOptional(node)) return true; + const e = peel(node); + if (ts.isConditionalExpression(e)) + return genericMayBeOptional(e.whenTrue) || genericMayBeOptional(e.whenFalse); + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken) + return genericMayBeOptional(e.right); + if (op === ts.SyntaxKind.AmpersandAmpersandToken) + return genericMayBeOptional(e.left) || genericMayBeOptional(e.right); + return false; + } + if (ts.isIdentifier(e)) { + // A binding initialized from such a read and never reassigned. + const symbol = symbolOf(e); + const decl = symbol ? this.checker.valueDeclarationOf(symbol) : undefined; + return ( + symbol !== null && + !reassigned.has(symbol) && + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + ts.isIdentifier(decl.name) && + !provenPresent(e, symbol) && + genericIndexedRead(decl.initializer) + ); + } + return genericIndexedRead(e); + }; + const genericIndexedRead = (node: ts.Expression): boolean => { + const e = peel(node); + if (!ts.isElementAccessExpression(e) || explicitlyNonNull(node)) return false; + const read = typeOrNull(e); + if (!read || (read.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any)) !== 0) return false; + if ( + read.isUnionType() && + ts.constituentTypes(read).some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0) + ) + return false; + const receiver = typeOrNull(e.expression); + if (!receiver || this.checker.isTupleType(receiver)) return false; + if (this.checker.isArrayType(receiver)) return true; + const key = e.argumentExpression; + if ( + (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) && + this.checker.getPropertyOfType(receiver, key.text) !== undefined + ) + return false; + return this.checker + .getIndexInfosOfType(receiver) + .some((info) => (info.keyType.flags & ts.TypeFlags.StringLike) !== 0); + }; const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const read = peel(node); if (!ts.isElementAccessExpression(read) || !ts.isIdentifier(read.expression)) return false; @@ -5815,6 +5892,21 @@ export class Lowerer { for (const sf of sourceFiles) if (scanFile(sf)) changed = true; for (const [symbol, decl] of functionDeclBySymbol) if (scanReturns(symbol, decl)) changed = true; + const noteGenericReturn = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): void => { + if (optionalReturns.has(symbol) || isJsSourceFile(decl.getSourceFile())) return; + if (!returnsOf(decl).some(genericMayBeOptional)) return; + optionalReturns.add(symbol); + this.runtimeOptionalGenericReturns.add(decl); + provenance?.note("return", decl.name ?? decl, decl); + changed = true; + }; + for (const [symbol, info] of this.genericFnsBySymbol) noteGenericReturn(symbol, info.decl); + // Generic methods and methods of a generic class collect with their + // class, after this analysis; their result admits undefined there. + for (const method of genericClassMethods) { + const symbol = method.name ? symbolOf(method.name) : null; + if (symbol) noteGenericReturn(symbol, method); + } for (const fn of tsLambdas) { // An annotated return is the author's contract, as for parameters, // except that a var read before its first store is undefined diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json new file mode 100644 index 000000000..a57275c12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5954-generic-optional-returns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5954-generic-optional-returns.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5954-generic-optional-returns.ts b/tests/corpus/5954-generic-optional-returns.ts new file mode 100644 index 000000000..9aaec5889 --- /dev/null +++ b/tests/corpus/5954-generic-optional-returns.ts @@ -0,0 +1,51 @@ +// A generic function, method or arrow that returns an element read or an +// index-signature read answers undefined for a missing element or key, as +// in Node, for every instantiation. +export {}; + +function at(items: T[], index: number): T { + return items[index]; +} +function lookup(table: Record, key: string): T { + return table[key]; +} +function viaBinding(items: T[], index: number): T { + const item = items[index]; + return item; +} +function either(items: T[], index: number, fallback: T): T { + return index >= 0 ? items[index] : fallback; +} +const first = (items: T[]): T => items[0]; + +console.log(at([1, 2], 1), at([1, 2], 5), at(["a"], 0), at(["a"], 3)); +console.log(lookup({ a: 1 }, "a"), lookup({ a: 1 }, "b"), lookup({ s: "x" }, "t")); +console.log(viaBinding([true], 0), viaBinding([true], 2)); +console.log(either([1], 4, 9), either([1], -1, 9)); +console.log(first([7]), first([]), first(["only"])); + +const missing = at([10, 20], 7); +console.log(missing === undefined, typeof missing); +const label = lookup({ a: "A" }, "z"); +console.log(label ?? "none", label === undefined); + +class Store { + items: Record; + constructor(items: Record) { + this.items = items; + } + get(key: string): T { + return this.items[key]; + } + pick(keys: K[], index: number): K { + return keys[index]; + } +} +const store = new Store({ a: 1 }); +console.log(store.get("a"), store.get("b"), store.pick(["x", "y"], 1), store.pick(["x"], 4)); + +const pairs: [string, number][] = [["a", 1]]; +function second(rows: T[][], row: number, column: number): T { + return rows[row][column]; +} +console.log(second([[1, 2]], 0, 1), second([[1, 2]], 0, 9), second(pairs, 0, 1)); From 2ae1f72d0bc9613d30325db8f7ecf7e8e4b69b97 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:13:57 -0700 Subject: [PATCH 04/53] Read self-capturing closures through selfRef instead of their box `const visit = (node) => { ... visit(child) ... }` lowered to a closure over the box of `visit`, and the box then held the closure: a two-object reference cycle that only the cycle collector could free, along with everything the closure captured. Nested function declarations already read their own name through selfRef; arrows and function expressions stored into a binding now do too, when the IR proves the binding holds the closure itself whenever the closure can run. resolveSelfCaptures runs with the other backend IR passes, before copyStableCaptures. A capture qualifies when every creation site stores the new closure, unadapted and with the binding's own type, into a boxed local of the creating function (its only declaration's initializer, or the only assignment after an empty declaration in the same statement list), nothing else writes the binding in any body that shares its box, and the closure body is synchronous, not a generator, and does not hand the binding to a nested closure. The capture then leaves the body and every creation site; its reads become selfRef. A creating binding that no other closure captures loses its box, except dead-zone bindings. Self-hosted compiler (native, compiling its CLI to IR): the cycle collector frees 14.5M objects instead of 22.1M; closures that held a box containing themselves drop from 3.5M to 54k. Of the runtime suite and the cg-* cycle workloads, only published-graphs compiles differently (-1.3%, 31 interleaved runs). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 9 +- .../compiler/src/ir/self-captures.test.ts | 206 ++++++++++++++++ packages/compiler/src/ir/self-captures.ts | 229 ++++++++++++++++++ .../5990-self-capturing-closures.ts.json | 6 + tests/corpus/5990-self-capturing-closures.ts | 124 ++++++++++ 5 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/ir/self-captures.test.ts create mode 100644 packages/compiler/src/ir/self-captures.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json create mode 100644 tests/corpus/5990-self-capturing-closures.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2c3a71457..126ff36e3 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -100,6 +100,7 @@ import { } from "../../ir/integer-ranges.js"; import { unshareNodes } from "../../ir/unshare.js"; import { copyStableCaptures } from "../../ir/capture-values.js"; +import { resolveSelfCaptures } from "../../ir/self-captures.js"; import { analyzeInt32Slots, dynamicFieldStores, type Int32Slots } from "../../ir/int32-slots.js"; import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; @@ -486,7 +487,9 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) specializeNumericCalls( foldConstantGlobals( copyStableCaptures( - hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + resolveSelfCaptures( + hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + ), ), ), mangleFunction, @@ -514,7 +517,9 @@ export function emitLlvmModuleSource( specializeNumericCalls( foldConstantGlobals( copyStableCaptures( - hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + resolveSelfCaptures( + hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + ), ), ), mangleFunction, diff --git a/packages/compiler/src/ir/self-captures.test.ts b/packages/compiler/src/ir/self-captures.test.ts new file mode 100644 index 000000000..bd03725aa --- /dev/null +++ b/packages/compiler/src/ir/self-captures.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "vitest"; +import { + F64, + funcOf, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { resolveSelfCaptures } from "./self-captures.js"; + +const loc = { file: "self.ts", start: 0, end: 0 }; +const FN = funcOf([], F64); +const ref = (localId: string, type = FN): IrExpr => ({ kind: "varRef", localId, type, loc }); +const call = (localId: string): IrExpr => ({ + kind: "callValue", + callee: ref(localId), + args: [], + type: F64, + loc, +}); +const self: IrExpr = { kind: "selfRef", type: FN, loc }; +const closure = (fnName: string, captures: string[], extra: object = {}): IrExpr => ({ + kind: "closure", + fnName, + captures, + type: FN, + loc, + ...extra, +}); +const decl = (localId: string, init: IrExpr | null): IrStmt => ({ + kind: "varDecl", + localId, + init, + loc, +}); +const assign = (localId: string, value: IrExpr, initializes = false): IrStmt => ({ + kind: "assign", + localId, + value, + ...(initializes ? { initializes: true as const } : {}), + loc, +}); +const ret = (e: IrExpr): IrStmt => ({ kind: "return", value: e, loc }); +const use = (e: IrExpr): IrStmt => ({ kind: "exprStmt", expr: e, loc }); +const local = (id: string, extra: Partial = {}): IrLocal => ({ + id, + name: id.split(".")[0]!, + type: FN, + mutable: false, + boxed: true, + ...extra, +}); + +function outer(body: IrStmt[], locals: IrLocal[]): IrFunction { + return { name: "outer", params: [], returnType: F64, locals, body, loc }; +} +function lifted(name: string, body: IrStmt[], captured: IrLocal[]): IrFunction { + return { + name, + params: [], + returnType: F64, + captures: captured.map((l) => ({ localId: l.id, name: l.name, type: l.type })), + locals: captured, + body, + loc, + }; +} +function run(...functions: IrFunction[]): Map { + const mod = { functions } as unknown as IrModule; + return new Map(resolveSelfCaptures(mod).functions.map((fn) => [fn.name, fn])); +} + +test("a closure stored in the dead-zone binding it captures reads itself", () => { + const fns = run( + outer( + [decl("f.0", null), assign("f.0", closure("walk", ["f.0"]), true), ret(call("f.0"))], + [local("f.0", { mutable: true, tdz: true })], + ), + lifted("walk", [ret(call("f.0"))], [local("f.0", { tdz: true })]), + ); + const walk = fns.get("walk")!; + expect(walk.captures).toEqual([]); + expect(walk.locals).toEqual([]); + expect(walk.body).toEqual([ret({ kind: "callValue", callee: self, args: [], type: F64, loc })]); + const origin = fns.get("outer")!; + expect(origin.body[1]).toEqual(assign("f.0", closure("walk", []), true)); + // The dead-zone box still represents the binding's uninitialized state. + expect(origin.locals[0]).toMatchObject({ boxed: true, tdz: true }); +}); + +test("a binding that only held the closure loses its box", () => { + const n = local("n.0", { type: F64 }); + const fns = run( + outer( + [ + decl("n.0", { kind: "numLit", value: 1, type: F64, loc }), + decl("f.0", closure("walk", ["n.0", "f.0"])), + ret(call("f.0")), + ], + [n, local("f.0")], + ), + lifted("walk", [use(ref("n.0", F64)), ret(call("f.0"))], [n, local("f.0")]), + ); + expect(fns.get("walk")!.captures!.map((c) => c.localId)).toEqual(["n.0"]); + const origin = fns.get("outer")!; + expect(origin.body[1]).toEqual(decl("f.0", closure("walk", ["n.0"]))); + expect(origin.locals.find((l) => l.id === "f.0")!.boxed).toBeUndefined(); + expect(origin.locals.find((l) => l.id === "n.0")!.boxed).toBe(true); +}); + +test("hoisted declarations qualify; later writes and second sites do not", () => { + const body = (extra: IrStmt[]): IrStmt[] => [ + decl("f.0", null), + assign("f.0", closure("walk", ["f.0"])), + ...extra, + ret(call("f.0")), + ]; + const walk = lifted("walk", [ret(call("f.0"))], [local("f.0")]); + const hoisted = run(outer(body([]), [local("f.0", { mutable: true })]), walk); + expect(hoisted.get("walk")!.captures).toEqual([]); + expect(hoisted.get("outer")!.locals[0]!.boxed).toBeUndefined(); + + const rewritten = run( + outer(body([assign("f.0", closure("other", []))]), [local("f.0", { mutable: true })]), + walk, + lifted("other", [ret({ kind: "numLit", value: 0, type: F64, loc })], []), + ); + expect(rewritten.get("walk")).toBe(walk); + + // The same body stored somewhere else: its environment must keep the box. + const elsewhere = run( + outer( + [...body([]).slice(0, 2), use(closure("walk", ["f.0"])), ret(call("f.0"))], + [local("f.0", { mutable: true })], + ), + walk, + ); + expect(elsewhere.get("walk")).toBe(walk); +}); + +test("an assignment in another statement list, an adapter or a nested capture keeps the box", () => { + const walk = lifted("walk", [ret(call("f.0"))], [local("f.0")]); + const branch = run( + outer( + [ + decl("f.0", null), + { kind: "block", body: [assign("f.0", closure("walk", ["f.0"]))], loc }, + ret(call("f.0")), + ], + [local("f.0", { mutable: true })], + ), + walk, + ); + expect(branch.get("walk")).toBe(walk); + + const adapter = run( + outer( + [decl("f.0", closure("walk", ["f.0"], { adapts: true })), ret(call("f.0"))], + [local("f.0")], + ), + walk, + ); + expect(adapter.get("walk")).toBe(walk); + + const nested = lifted("walk", [use(closure("inner", ["f.0"])), ret(call("f.0"))], [local("f.0")]); + const recaptured = run( + outer([decl("f.0", closure("walk", ["f.0"])), ret(call("f.0"))], [local("f.0")]), + nested, + lifted("inner", [ret(call("f.1"))], [local("f.1")]), + ); + expect(recaptured.get("walk")).toBe(nested); +}); + +test("mutually recursive closures only drop their own capture", () => { + const fns = run( + outer( + [ + decl("a.0", null), + decl("b.0", null), + assign("a.0", closure("fa", ["a.0", "b.0"]), true), + assign("b.0", closure("fb", ["a.0", "b.0"]), true), + ret(call("a.0")), + ], + [local("a.0", { mutable: true, tdz: true }), local("b.0", { mutable: true, tdz: true })], + ), + lifted( + "fa", + [use(call("b.0")), ret(call("a.0"))], + [local("a.0", { tdz: true }), local("b.0", { tdz: true })], + ), + lifted( + "fb", + [use(call("a.0")), ret(call("b.0"))], + [local("a.0", { tdz: true }), local("b.0", { tdz: true })], + ), + ); + expect(fns.get("fa")!.captures!.map((c) => c.localId)).toEqual(["b.0"]); + expect(fns.get("fb")!.captures!.map((c) => c.localId)).toEqual(["a.0"]); + expect(fns.get("outer")!.body.slice(2)).toEqual([ + assign("a.0", closure("fa", ["b.0"]), true), + assign("b.0", closure("fb", ["a.0"]), true), + ret(call("a.0")), + ]); +}); diff --git a/packages/compiler/src/ir/self-captures.ts b/packages/compiler/src/ir/self-captures.ts new file mode 100644 index 000000000..e009e9a11 --- /dev/null +++ b/packages/compiler/src/ir/self-captures.ts @@ -0,0 +1,229 @@ +import { CaptureFlow } from "./capture-values.js"; +import { + typeEquals, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { everyExprChild, everyStmtChild, mapExprChildren, mapStmtChildren } from "./traverse.js"; + +/** Closures that capture the binding they are stored in read themselves + * through `selfRef` instead of through that binding's box. + * + * `const visit = (node) => { ... visit(child) ... }` lowers to a closure + * over the box of `visit`, and the box then holds the closure: a two-object + * reference cycle that reference counting cannot free. Each closure made + * this way is buffered as a cycle candidate and reclaimed only when a + * collector pass walks it. Nested function declarations already read their + * own name through `selfRef`; this pass gives arrows and function + * expressions bound to a binding the same form once the IR proves that the + * binding always holds the closure itself while the closure can run: + * + * - every creation site of the closure body stores the new closure, as is, + * into a boxed local of the creating function: either the initializer of + * that local's only declaration, or the only assignment of a local whose + * only declaration is empty and precedes the assignment in the same + * statement list (so each box is filled once, and a closure can only run + * after the statement that stores it); + * - nothing else writes the binding, in the creating function or in any + * body that shares its box (CaptureFlow.unchanged), and no local class + * captures it; + * - the closure body is synchronous, is not a generator, and does not pass + * the binding on to a nested closure (its environment would need a box of + * its own, allocated on every call). + * + * The capture then disappears from the body and from every creation site, + * and each read of it becomes `selfRef` (the running closure, retained), + * which is the same pointer the box would have produced. The creating + * function keeps its binding; it loses its box when no other closure + * captures it, except for temporal-dead-zone bindings, whose box still + * carries the empty state its own reads test. */ +export function resolveSelfCaptures(mod: IrModule): IrModule { + const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); + const sites = new Map(); + /** Closure nodes stored into a local by a qualifying definition, with that + * local and the number of writes the definition itself makes. */ + const definitions = new Map(); + const declarations = new Map>(); + for (const fn of mod.functions) { + const counts = new Map(); + declarations.set(fn.name, counts); + const expr = (e: IrExpr): boolean => { + if (e.kind === "closure") { + let list = sites.get(e.fnName); + if (list === undefined) sites.set(e.fnName, (list = [])); + list.push({ owner: fn.name, closure: e }); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "varDecl") counts.set(s.localId, (counts.get(s.localId) ?? 0) + 1); + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + for (const list of statementLists(fn.body)) { + list.forEach((s, i) => { + if (s.kind !== "varDecl") return; + if (s.init !== null) { + if (s.init.kind === "closure") definitions.set(s.init, { local: s.localId, writes: 0 }); + return; + } + for (let j = i + 1; j < list.length; j++) { + const next = list[j]!; + if (next.kind === "assign" && next.localId === s.localId) { + if (next.value.kind === "closure") + definitions.set(next.value, { local: s.localId, writes: 1 }); + return; + } + } + }); + } + } + + let flow: CaptureFlow | null = null; + /** Per closure body, the capture index that names its own closure. */ + const resolved = new Map(); + for (const fn of mod.functions) { + const captures = fn.captures; + if (captures === undefined || captures.length === 0) continue; + if (fn.async === true || fn.generator !== undefined || fn.classCaptures !== undefined) continue; + const list = sites.get(fn.name); + if (list === undefined || list.length === 0) continue; + const recaptured = capturedIds(fn.body); + for (let index = 0; index < captures.length; index++) { + const capture = captures[index]!; + if (capture.type.kind !== "func" || recaptured.has(capture.localId)) continue; + const qualifies = list.every(({ owner, closure }) => { + if (closure.adapts === true || !typeEquals(closure.type, capture.type)) return false; + const definition = definitions.get(closure); + const source = closure.captures[index]; + if (definition === undefined || source !== definition.local) return false; + const creator = functions.get(owner)!; + const local = creator.locals.find((l) => l.id === source); + if (local?.boxed !== true || !typeEquals(local.type, closure.type)) return false; + if (creator.params.some((p) => p.localId === source)) return false; + if (creator.captures?.some((c) => c.localId === source)) return false; + if (creator.classCaptures?.some((c) => c.localId === source)) return false; + if (declarations.get(owner)!.get(source) !== 1) return false; + flow ??= new CaptureFlow(mod.functions); + return flow.unchanged(owner, source, definition.writes); + }); + if (qualifies) { + resolved.set(fn.name, index); + break; + } + } + } + if (resolved.size === 0) return mod; + + const rewritten = mod.functions.map((fn) => { + const own = resolved.get(fn.name); + const self = own === undefined ? null : fn.captures![own]!; + let changed = false; + const expr = (e: IrExpr): IrExpr => { + if (self !== null && e.kind === "varRef" && e.localId === self.localId) { + changed = true; + return { kind: "selfRef", type: self.type, loc: e.loc }; + } + if (e.kind === "closure") { + const drop = resolved.get(e.fnName); + if (drop !== undefined) { + changed = true; + return { ...e, captures: e.captures.filter((_, i) => i !== drop) }; + } + } + return mapExprChildren(e, expr, stmt); + }; + const stmt = (s: IrStmt): IrStmt => mapStmtChildren(s, expr, stmt); + const body = fn.body.map(stmt); + if (!changed && self === null) return fn; + // A binding no environment captures any more needs no box. A dead-zone + // binding keeps the box that represents its uninitialized state. + const stillCaptured = capturedIds(body); + // The callback returns IrLocal explicitly: inferred from both returns, + // its type would be the unboxed copy's, and the self-hosted compiler + // would narrow every kept local to that shape, dropping `boxed`. + const locals = fn.locals + .filter((local) => self === null || local.id !== self.localId) + .map((local): IrLocal => { + if (local.boxed !== true || local.tdz === true || stillCaptured.has(local.id)) return local; + if (fn.captures?.some((c) => c.localId === local.id)) return local; + if (fn.classCaptures?.some((c) => c.localId === local.id)) return local; + if (!wasSource(fn, local.id, resolved)) return local; + const { boxed: _boxed, ...plain } = local; + return plain; + }); + const next: IrFunction = { ...fn, locals, body }; + if (self !== null) next.captures = fn.captures!.filter((_, i) => i !== own); + return next; + }); + return { ...mod, functions: rewritten }; +} + +/** Whether `fn` stored a resolved closure into `id`, the only case in which + * this pass may unbox a binding (others stay exactly as lowered). */ +function wasSource(fn: IrFunction, id: string, resolved: ReadonlyMap): boolean { + let found = false; + const expr = (e: IrExpr): boolean => { + if (e.kind === "closure") { + const index = resolved.get(e.fnName); + if (index !== undefined && e.captures[index] === id) found = true; + } + return !found && everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + fn.body.every(stmt); + return found; +} + +/** Locals some closure or class created in `body` captures. */ +function capturedIds(body: readonly IrStmt[]): Set { + const ids = new Set(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "closure" || e.kind === "classRef") + for (const id of e.captures ?? []) ids.add(id); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + body.every(stmt); + return ids; +} + +/** `body` and every statement list nested in its statements (not the ones + * inside expressions). Statements of one list run in order, each at most + * once per execution of the list. */ +function statementLists(body: readonly IrStmt[]): (readonly IrStmt[])[] { + const lists: (readonly IrStmt[])[] = []; + const visit = (list: readonly IrStmt[]): void => { + lists.push(list); + for (const s of list) { + switch (s.kind) { + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + visit(s.body); + break; + case "if": + visit(s.then); + if (s.else_ !== null) visit(s.else_); + break; + case "switch": + for (const c of s.cases) visit(c.body); + break; + case "tryCatch": + visit(s.tryBody); + if (s.catchBody !== null) visit(s.catchBody); + if (s.finallyBody !== null) visit(s.finallyBody); + break; + default: + break; + } + } + }; + visit(body); + return lists; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json new file mode 100644 index 000000000..5f707aaf2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-self-capturing-closures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5990-self-capturing-closures.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5990-self-capturing-closures.ts b/tests/corpus/5990-self-capturing-closures.ts new file mode 100644 index 000000000..274e7b8d7 --- /dev/null +++ b/tests/corpus/5990-self-capturing-closures.ts @@ -0,0 +1,124 @@ +// Closures that call themselves through the binding they are stored in: +// const arrows, function expressions, closures made in loops, closures that +// also hand themselves to nested callbacks, and a binding that is later +// rebound (its closure must see the new value). Each read of the binding +// inside the body is the same function object the binding holds. The +// sanitized lane's RC audit fails if any of these closures leaks or is +// freed while reachable. + +const fact = (n: number): number => (n <= 1 ? 1 : n * fact(n - 1)); +console.log(fact(10)); + +const pick = (n: number): (() => number) | null => (n === 0 ? null : () => n); +const identity = (depth: number): unknown => (depth === 0 ? identity : identity(depth - 1)); +console.log(identity(3) === identity, pick(0) === null, pick(2)!()); + +const fib = function (n: number): number { + return n < 2 ? n : fib(n - 1) + fib(n - 2); +}; +console.log(fib(15)); + +const outerName = function innerName(n: number): number { + return n <= 0 ? 0 : 1 + outerName(n - 1) + innerName(0); +}; +console.log(outerName(5)); + +// One closure per iteration, each recursing into itself and reading its +// own iteration's value. +const perIteration: ((n: number) => number)[] = []; +for (let i = 0; i < 4; i++) { + const step = (n: number): number => (n === 0 ? i : step(n - 1) + 10); + perIteration.push(step); +} +console.log(perIteration.map((f) => f(2)).join(" ")); +console.log(perIteration[0] === perIteration[1], perIteration[2] === perIteration[2]); + +// The closure also passes itself to nested callbacks. +interface Tree { + value: number; + kids: Tree[]; +} +const leaf = (value: number): Tree => ({ value, kids: [] }); +const tree: Tree = { + value: 1, + kids: [{ value: 2, kids: [leaf(3), leaf(4)] }, leaf(5)], +}; +const sum = (node: Tree): number => node.value + node.kids.map((kid) => sum(kid)).reduce((a, b) => a + b, 0); +console.log(sum(tree)); +const visited: number[] = []; +const walk = (node: Tree): void => { + visited.push(node.value); + node.kids.forEach(walk); +}; +walk(tree); +console.log(visited.join(",")); + +// Captured state next to the self reference. +function counterTo(limit: number): () => number { + let calls = 0; + const tick = (): number => { + calls++; + return calls < limit ? tick() : calls; + }; + return tick; +} +const tick = counterTo(5); +console.log(tick(), tick()); + +// A rebound binding: the first closure calls whatever the binding holds now. +let rebound = (n: number): number => (n <= 0 ? 0 : 1 + rebound(n - 1)); +const first = rebound; +console.log(first(3)); +rebound = (n: number): number => n * 100; +console.log(first(3), rebound(3)); + +// Mutual recursion keeps both bindings live. +const isEven = (n: number): boolean => (n === 0 ? true : isOdd(n - 1)); +const isOdd = (n: number): boolean => (n === 0 ? false : isEven(n - 1)); +console.log(isEven(10), isOdd(7), isEven(3)); + +// Identity as a map key from inside the body. +const seen = new Map(); +const keyed = (n: number): number => { + seen.set(keyed, (seen.get(keyed) ?? 0) + 1); + return n === 0 ? seen.size : keyed(n - 1); +}; +console.log(keyed(4), seen.get(keyed)); + +// An object that holds a closure capturing the object (a real cycle). +class Widget { + label: string; + onChange: (() => string) | null = null; + constructor(label: string) { + this.label = label; + } +} +function widgets(count: number): number { + let total = 0; + for (let i = 0; i < count; i++) { + const widget = new Widget(`w${i}`); + const describe = (depth: number): string => (depth === 0 ? widget.label : describe(depth - 1)); + widget.onChange = () => describe(2); + total += widget.onChange().length; + } + return total; +} +console.log(widgets(1000)); + +// Many short-lived recursive closures. +let checksum = 0; +for (let round = 0; round < 20000; round++) { + const down = (n: number): number => (n === 0 ? round % 7 : down(n - 1)); + checksum += down(3); +} +console.log(checksum); + +// Generators and async functions that name themselves. +const countdown = function* (n: number): Generator { + if (n < 0) return; + yield n; + yield* countdown(n - 1); +}; +console.log([...countdown(3)].join("")); +const later = async (n: number): Promise => (n === 0 ? 0 : 1 + (await later(n - 1))); +later(3).then((value) => console.log("async", value)); From 989d0d6bc2d03ad2202c974143533b3d033c8bb6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 09:12:14 -0700 Subject: [PATCH 05/53] Buffer nursery cycle candidates on the release fast paths Every release that left a headered object alive and unbuffered called scr_cyc_on_release, which looked up the type's bypass statistics, pushed the object, kept a running total and compared it with two triggers. The nursery case now has a fast path that appends the object to the nursery buffer itself, while the runtime-maintained window says the append can neither make a collection due nor grow the buffer, and the live count is below the allocation trigger: - scr_rc.h exports the candidate buffers (scr_cyc_buffers: the three buffers, the window and the allocation trigger) where the collector state is ordinary globals, and scr_cyc_append. The runtime's own release paths (scr_cyc_released) append inline, and scr_cyc_on_release tries the append first. - Optimized emitted code buffers through one sc_cyc_append helper per module, called with preserve_mostcc, so release fast paths keep the size of the plain call they had (inlining the append into every release fast path grew the self-hosted compiler's text by 4.6%) and their callers keep their registers. Its runtime call goes through a second preserve_mostcc function, which keeps the register saves off the append path. - scr_cycle.c keeps the window: recomputed where buffers, capacity or the trigger change in bulk, lowered by one on older buffering and nursery deaths, 0 during passes. The running total is gone; the slow path sums the buffers. - An appended candidate whose type bypasses the nursery would have been buffered past it. scr_cyc_route_bypassed moves such candidates before any scheduling decision or pass reads the buffers, consuming the bypass counts as scr_cyc_on_release does (skipped while no type bypasses). test_cycle checks the window (the release that reaches the trigger still collects) and the routing of inline-buffered candidates of a bypassed type; its mature-bypass probe routes before reading generations. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/alloc.ts | 91 ++++++++++ .../compiler/src/backend/llvm/shapes.test.ts | 37 ++++ packages/compiler/src/backend/llvm/shapes.ts | 57 +++++- packages/runtime/src/scr_cycle.c | 167 ++++++++++++++---- packages/runtime/src/scr_rc.h | 68 ++++++- packages/runtime/test/test_cycle.c | 74 +++++++- 6 files changed, 450 insertions(+), 44 deletions(-) diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index 7dfc385aa..72631ba1c 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -141,6 +141,97 @@ function declareGlobal(host: AllocHost, decl: string): void { if (host.threadLocalAlloc !== true) host.declare(decl); } +/** `scr_cyc_buffers` (scr_rc.h ScrCycBuffers): the three candidate buffers + * `{ v, n, cap, used }`, the inline buffering window and the allocation + * trigger. */ +const CYC_BUFFERS_TYPE = "{ [3 x { ptr, i64, i64, i64 }], i64, i64 }"; +/** Byte offsets in it (asserted in scr_rc.h). */ +const CYC_NURSERY_COUNT = 8; +const CYC_WINDOW = 96; +const CYC_ALLOC_TRIGGER = 104; + +/** Whether emitted release paths can append nursery candidates to the + * collector's buffer themselves (through {@link cycleAppendHelper}): where + * the runtime keeps that state in ordinary globals (executables without + * workers and classic libraries, on the 64-bit targets with inline + * allocation). */ +export function inlineBufferingSupported(host: AllocHost): boolean { + return host.inlineAlloc === true && host.threadLocalAlloc !== true && host.sizeType === "i64"; +} + +/** The emitted symbol of {@link cycleAppendHelper}. */ +export const CYC_APPEND = "sc_cyc_append"; + +/** The runtime symbols {@link cycleAppendHelper} uses. The helper is + * defined after the module's extern block, so its caller declares them. */ +export function cycleAppendDecls(): string[] { + return [ + `@scr_cyc_buffers = external global ${CYC_BUFFERS_TYPE}`, + `@scr_cyc_live = external global i64`, + `declare void @scr_cyc_on_release(ptr)`, + ]; +} + +/** The candidate-buffering step of every emitted release that leaves a + * headered object alive and unbuffered: scr_rc.h's scr_cyc_append, then + * scr_cyc_on_release when it declines. A nursery object goes onto the + * nursery buffer while the buffer holds fewer entries than the runtime's + * window and the live count is below the allocation trigger. + * + * One out-of-line definition per module keeps the release fast paths, which + * LLVM inlines into their callers, as small as the plain call they had. It + * uses preserve_mostcc, so the call clobbers almost no caller registers: + * the callers' code around a release stays what it was, and the append + * itself saves only the few registers it uses. Its slow path enters the + * runtime through a second preserve_mostcc function, which saves the rest. */ +export function cycleAppendHelper(): string[] { + return [ + `define internal preserve_mostcc void @${CYC_APPEND}(ptr %o) noinline #0 { ; scr_cyc_append, else scr_cyc_on_release`, + `entry:`, + ` %genp = getelementptr i8, ptr %o, i64 -${CYC_HDR - CYC_GEN}`, + ` %gen = load i8, ptr %genp ; ScrCycHdr.gen`, + ` %young = icmp eq i8 %gen, 0`, + ` br i1 %young, label %window, label %call`, + `window:`, + ` %np = getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 ${CYC_NURSERY_COUNT}`, + ` %n = load i64, ptr %np ; nursery buffer entries`, + ` %wp = getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 ${CYC_WINDOW}`, + ` %w = load i64, ptr %wp ; window`, + ` %room = icmp ult i64 %n, %w`, + ` br i1 %room, label %live, label %call`, + `live:`, + ` %lv = load i64, ptr @scr_cyc_live`, + ` %tp = getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 ${CYC_ALLOC_TRIGGER}`, + ` %t = load i64, ptr %tp ; allocation trigger`, + ` %due = icmp uge i64 %lv, %t`, + ` br i1 %due, label %call, label %append`, + `append:`, + ` %v = load ptr, ptr @scr_cyc_buffers`, + ` %slot = getelementptr inbounds ptr, ptr %v, i64 %n`, + ` store ptr %o, ptr %slot`, + ` %n1 = add i64 %n, 1`, + ` store i64 %n1, ptr %np`, + ` %i = trunc i64 %n to i32`, + ` %b = or i32 %i, -2147483648 ; SCR_CYC_BUFFERED_BIT | position`, + ` %bufp = getelementptr i8, ptr %o, i64 -${CYC_HDR - CYC_BUF}`, + ` store i32 %b, ptr %bufp`, + ` ret void`, + `call:`, + ` tail call preserve_mostcc void @${CYC_APPEND}_call(ptr %o)`, + ` ret void`, + `}`, + // The runtime call clobbers every caller-saved register, which this + // preserve_mostcc function must restore: saving them here keeps those + // saves out of the append path above. + `define internal preserve_mostcc void @${CYC_APPEND}_call(ptr %o) noinline #0 {`, + `entry:`, + ` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`, + ` ret void`, + `}`, + ``, + ]; +} + /** A collector header to install: the object type's constant ScrCycType * descriptor (its trace and teardown; see cycTypeDescriptor). */ export interface CycHeader { diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts index 8c2c46541..f25dca3a3 100644 --- a/packages/compiler/src/backend/llvm/shapes.test.ts +++ b/packages/compiler/src/backend/llvm/shapes.test.ts @@ -205,6 +205,43 @@ describe("LLVM runtime RC symbols", () => { expect(fn("sc_rc_retain_promise")).not.toContain("store i32"); }); + test("optimized builds buffer candidates through the module's append helper", () => { + const emit = (inlineRc: true | "release", extra: Partial) => { + const { host } = declarationHost(64, inlineRc); + const configured: ShapeHost = { ...host, inlineAlloc: true, ...extra }; + releaseSym(configured, { kind: "dyn" }); + return { + ir: emitInlineRcHelpers(configured).join("\n"), + decls: inlineRcDecls(configured), + }; + }; + for (const mode of [true, "release"] as const) { + const { ir, decls } = emit(mode, {}); + expect(ir).toContain("call preserve_mostcc void @sc_cyc_append(ptr %o)"); + expect(ir).toContain("define internal preserve_mostcc void @sc_cyc_append(ptr %o) noinline"); + // The young test reads the generation byte at obj-5; the append fills + // scr_cyc_buffers' nursery buffer and the header's position at obj-4. + expect(ir).toContain("getelementptr i8, ptr %o, i64 -5"); + expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 96"); + expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 104"); + expect(ir).toContain("load i64, ptr @scr_cyc_live"); + expect(ir).toContain("or i32 %i, -2147483648"); + expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)"); + expect(decls).toContain( + "@scr_cyc_buffers = external global { [3 x { ptr, i64, i64, i64 }], i64, i64 }", + ); + expect(decls).toContain("@scr_cyc_live = external global i64"); + } + // Worker executables and targets without inline allocation call + // scr_cyc_on_release, which tries the same append first. + for (const extra of [{ threadLocalAlloc: true }, { inlineAlloc: false }]) { + const { ir, decls } = emit(true, extra); + expect(ir).not.toContain("sc_cyc_append"); + expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)"); + expect(decls.join("\n")).not.toContain("scr_cyc_buffers"); + } + }); + test("uses the wasm32 header offsets", () => { const { host } = declarationHost(32, true); releaseSym(host, { kind: "union", unionId: "u0" } as never); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 23565eff4..b62e7d034 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -28,7 +28,15 @@ import { mangleRecordStruct, mangleRecordTrace, } from "../mangle.js"; -import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js"; +import { + cycTypeDescriptor, + emitObjectAlloc, + emitObjectFree, + CYC_APPEND, + cycleAppendDecls, + cycleAppendHelper, + inlineBufferingSupported, +} from "./alloc.js"; import type { BlockBuilder } from "./blocks.js"; import { SCR_ARR_FIELD, llvmCommentText } from "./common.js"; import type { NullableUnions } from "./nullable-unions.js"; @@ -308,6 +316,10 @@ export const INLINE_RC_FAMILIES: Readonly> = { type RcOp = "retain" | "release"; +/** The helper key (with the "family:op" keys) of the module's candidate + * append helper (alloc.ts cycleAppendHelper). */ +const CYC_APPEND_KEY = "cyc:append"; + /** The mirrored family for an RC operation on a runtime stem (`scr_str`), * or null when it stays a call. */ export function inlineRcFamily(host: ShapeHost, stem: string, op: RcOp): InlineRcFamily | null { @@ -369,6 +381,10 @@ function requestRcHelper(host: ShapeHost, family: string, op: RcOp): string { } host.rcHelpers.add(`${family}:${op}`); for (const decl of rcHelperDecls(family, op)) host.declare(decl); + // Its body is written last (emitInlineRcHelpers), after the requests are + // collected: a release that can buffer a candidate requests the append + // helper now. + if (op === "release" && INLINE_RC_FAMILIES[family]!.cycle === "always") requestCycleAppend(host); return `@${inlineRcName(family, op)}`; } @@ -466,6 +482,10 @@ function rcHelperKeys(host: ShapeHost): string[] { export function inlineRcDecls(host: ShapeHost): string[] { const decls = new Set(); for (const key of rcHelperKeys(host)) { + if (key === CYC_APPEND_KEY) { + for (const decl of cycleAppendDecls()) decls.add(decl); + continue; + } const [family, op] = key.split(":") as [string, RcOp]; for (const decl of rcHelperDecls(family, op)) decls.add(decl); } @@ -611,6 +631,10 @@ function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): st export function emitInlineRcHelpers(host: ShapeHost): string[] { const out: string[] = []; for (const key of rcHelperKeys(host).sort()) { + if (key === CYC_APPEND_KEY) { + out.push(...cycleAppendHelper()); + continue; + } const [family, op] = key.split(":") as [string, RcOp]; const row = INLINE_RC_FAMILIES[family]!; out.push( @@ -1254,12 +1278,6 @@ export function releaseFastPath( ]; } -/** The possible-cycle-root step of a release that left a headered object - * alive: scr_cyc_on_release with its early return inlined. Test the sign - * of `buf` (the i32 at obj-4; negative for a queued candidate or a tenured - * object) and call the runtime only to enqueue a new candidate. - * Every emitted release of a headered object shares this sequence - * (scr_runtime.h asserts the offset); it ends by branching to `done`. */ /** One traced field of an emitted trace function: load the slot and visit * it unless it is null. Every collector visitor ignores null children, and * most traced slots of a typical heap are empty (optional links, unset @@ -1283,16 +1301,39 @@ export function traceFieldVisitLines( ]; } +/** Whether candidate buffering goes through the module's append helper + * (alloc.ts cycleAppendHelper), requesting its definition if so: in + * optimized builds (those with inline RC helpers) that support it. */ +function requestCycleAppend(host: ShapeHost): boolean { + if (host.rcHelpers === null || !inlineBufferingSupported(host)) return false; + if (!host.rcHelpers.has(CYC_APPEND_KEY)) { + host.rcHelpers.add(CYC_APPEND_KEY); + for (const decl of cycleAppendDecls()) host.declare(decl); + } + return true; +} + +/** The possible-cycle-root step of a release that left a headered object + * alive: scr_cyc_on_release with its early return inlined. Test the sign + * of `buf` (the i32 at obj-4; negative for a queued candidate or a tenured + * object), and buffer a new candidate: through the module's + * cycleAppendHelper in optimized builds that support it, through + * scr_cyc_on_release otherwise. Every emitted release of a headered object + * shares this sequence (scr_runtime.h asserts the offsets); it ends by + * branching to `done`. */ export function cycleRootLines(host: ShapeHost, done: string): string[] { const S = host.sizeType; host.declare(`declare void @scr_cyc_on_release(ptr)`); + const append = requestCycleAppend(host); return [ ` %bufp = getelementptr i8, ptr %o, ${S} -${host.cycleBufOffset}`, ` %buf = load i32, ptr %bufp`, ` %queued = icmp slt i32 %buf, 0`, ` br i1 %queued, label %${done}, label %enqueue`, `enqueue:`, - ` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`, + append + ? ` call preserve_mostcc void @${CYC_APPEND}(ptr %o) ; buffer the candidate; may collect` + : ` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`, ` br label %${done}`, ]; } diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index c2be13a03..974c2e66d 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -156,14 +156,11 @@ static void scr_cyc_oom(void) { * struct on purpose: buffering a candidate is the hot path, and splitting * them across three arrays indexed by generation would touch three cache * lines per release where the original single buffer touched one. */ -typedef struct { - void **v; - size_t n, cap; - /* The most entries a pass held, for the work vectors a pass fills - * (scr_cyc_push_work); scr_cyc_pass_done frees a large vector that the - * pass used little of. */ - size_t used; -} ScrVec; +/* Its layout is scr_rc.h's ScrCycBuffer (the candidate buffers are part of + * the inline buffering ABI). `used`: the most entries a pass held, for the + * work vectors a pass fills (scr_cyc_push_work); scr_cyc_pass_done frees a + * large vector that the pass used little of. */ +typedef ScrCycBuffer ScrVec; typedef struct { void *object; @@ -201,7 +198,6 @@ typedef struct { unsigned char growth_shift[SCR_CYC_NGENS]; unsigned char growth_streak[SCR_CYC_NGENS]; unsigned backlog_shift[SCR_CYC_NGENS]; - size_t nbuffered; size_t trigger; size_t alloc_trigger; size_t gray_referenced; @@ -235,7 +231,6 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_growth_shift (scr_cyc_thread.growth_shift) #define scr_cyc_growth_streak (scr_cyc_thread.growth_streak) #define scr_cyc_backlog_shift (scr_cyc_thread.backlog_shift) -#define scr_cyc_nbuffered (scr_cyc_thread.nbuffered) #define scr_cyc_trigger (scr_cyc_thread.trigger) #define scr_cyc_alloc_trigger (scr_cyc_thread.alloc_trigger) #define scr_gray_referenced (scr_cyc_thread.gray_referenced) @@ -361,7 +356,16 @@ static inline void scr_cyc_note_used(ScrVec *vec) { /* ── the candidate-root buffers (one per generation) ──────────────────── */ #ifndef SCR_WORKERS -static SCR_TL ScrVec scr_roots[SCR_CYC_NGENS]; +/* Emitted and inline releases append nursery candidates to these buffers + * directly where the build allows it (scr_rc.h). Explicitly initialized so + * library localization can demote it (see scr_cyc_types). */ +#if SCR_CYC_INLINE_BUFFERING +ScrCycBuffers scr_cyc_buffers = {0}; +#else +static SCR_TL ScrCycBuffers scr_cyc_buffers; +#endif +#define scr_roots (scr_cyc_buffers.roots) +#define scr_cyc_alloc_trigger (scr_cyc_buffers.alloc_trigger) static SCR_TL bool scr_collecting = false; #endif @@ -409,6 +413,7 @@ static SCR_TL unsigned scr_gen_limit = SCR_CYC_OLD; #endif static size_t scr_cyc_pass(unsigned gen_limit); +static void scr_cyc_route_bypassed(void); /* A limited pass (scr_cyc_collect_set) takes only the candidates in this * set from the buffers up to `scr_cyc_take_gen`; NULL takes them all. */ static SCR_TL const ScrCycSet *scr_cyc_take = NULL; @@ -505,18 +510,18 @@ static size_t scr_cyc_nursery_threshold(void) { return cached; } -/* Buffered candidates across every generation, and the count at which the - * release path next collects. Keeping a running total (rather than summing - * the buffers) is what holds the hot path to one load and one compare, as - * it was when there was a single buffer. The trigger is re-armed at the end - * of every pass to "whatever survived, plus a nursery's worth": without that +/* The number of buffered candidates (across every generation) at which the + * release path next collects, and the live count at which it collects + * however few candidates there are. The trigger is re-armed at the end of + * every pass to "whatever survived, plus a nursery's worth": without that * hysteresis a large older buffer — which a nursery pass cannot drain — * would re-arm on every single release. Both start at zero, so the first - * release runs one trivial pass that arms them properly. */ + * release runs one trivial pass that arms them properly. The inline + * appends (scr_rc.h) compare against the window scr_cyc_window_update + * derives from these, so the slow path sums the buffers when it needs the + * total. */ #ifndef SCR_WORKERS -static SCR_TL size_t scr_cyc_nbuffered = 0; static SCR_TL size_t scr_cyc_trigger = 0; -static SCR_TL size_t scr_cyc_alloc_trigger = 0; #endif /* Gray objects whose count is above zero after the trial decrements so far * (see scr_mg_visit). markGray records the gray objects in scr_white while @@ -549,6 +554,34 @@ static size_t scr_cyc_buffered(void) { + scr_roots[SCR_CYC_OLD].n; } +/* The inline buffering window (scr_rc.h): how many entries the nursery + * buffer may hold before an append would bring the buffered total to the + * trigger or would need the buffer to grow. While a pass runs, every + * release takes the call. Recomputed wherever the buffers, the nursery + * buffer's capacity or the trigger change in bulk; the release and death + * paths adjust it by one (scr_cyc_window_shrink). */ +static inline void scr_cyc_window_update(void) { +#if SCR_CYC_INLINE_BUFFERING + size_t window = 0; + if (!scr_collecting) { + size_t older = scr_roots[SCR_CYC_MATURE].n + scr_roots[SCR_CYC_OLD].n; + if (scr_cyc_trigger > older + 1) window = scr_cyc_trigger - older - 1; + if (window > scr_roots[SCR_CYC_NURSERY].cap) window = scr_roots[SCR_CYC_NURSERY].cap; + } + scr_cyc_buffers.window = window; +#endif +} + +/* One step of the window's formula going down by one: an older candidate + * was buffered, or a nursery death lowered the trigger. A window below the + * formula only sends more releases through the call, so the clamp to the + * capacity can stay where it is. */ +static inline void scr_cyc_window_shrink(void) { +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_buffers.window) scr_cyc_buffers.window--; +#endif +} + static bool scr_cyc_older_backlog(void) { return scr_roots[SCR_CYC_MATURE].n != 0 || scr_roots[SCR_CYC_OLD].n != 0; } @@ -682,6 +715,7 @@ static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_grow * per pass, so it is kept out of line and built for size, which leaves the * hot buffering path in scr_cyc_on_release small. */ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { + scr_cyc_route_bypassed(); unsigned gen = SCR_CYC_OLD; bool by_growth = scr_cyc_grown(SCR_CYC_OLD); if (!by_growth && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { @@ -699,6 +733,7 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { * per turn would walk the whole live heap every turn — which is exactly the * cost the generations exist to avoid. */ void scr_cyc_collect_scheduled(void) { + scr_cyc_route_bypassed(); if (scr_cyc_buffered() == 0) { scr_cyc_scheduled_mature_age = 0; return; @@ -778,6 +813,10 @@ typedef struct ScrCycKind { static SCR_TL ScrCycKind *scr_cyc_kinds; static SCR_TL size_t scr_cyc_kinds_cap; #endif +#if SCR_CYC_INLINE_BUFFERING +/* Types that bypass the nursery right now (scr_cyc_route_bypassed). */ +static size_t scr_cyc_nursery_bypassing = 0; +#endif /* The statistics of `kind` (nonzero), growing the table to cover it. */ static SCR_CYC_COMPACT ScrCycKind *scr_cyc_kind_grow(uint32_t kind) { @@ -802,7 +841,12 @@ static inline bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { if (kind >= scr_cyc_kinds_cap) return false; ScrCycLevelStat *k = &scr_cyc_kinds[kind].level[gen]; if (k->bypass == 0) return false; - if (--k->bypass == 0) k->walked = k->freed = 0; + if (--k->bypass == 0) { + k->walked = k->freed = 0; +#if SCR_CYC_INLINE_BUFFERING + if (gen == SCR_CYC_NURSERY) scr_cyc_nursery_bypassing--; +#endif + } return true; } @@ -820,9 +864,16 @@ static inline void scr_cyc_walked(const ScrCycType *type, unsigned gen) { if (!type->kind) return; ScrCycLevelStat *k = &scr_cyc_kind(type->kind)->level[gen]; if (++k->walked == SCR_CYC_BYPASS_WINDOW) { +#if SCR_CYC_INLINE_BUFFERING + bool before = k->bypass != 0; +#endif k->bypass = k->freed * SCR_CYC_BYPASS_RATIO < SCR_CYC_BYPASS_WINDOW ? SCR_CYC_BYPASS_WINDOW * SCR_CYC_BYPASS_PROBE : 0; +#if SCR_CYC_INLINE_BUFFERING + if (gen == SCR_CYC_NURSERY && before != (k->bypass != 0)) + scr_cyc_nursery_bypassing += before ? (size_t)-1 : 1; +#endif k->walked = k->freed = 0; } } @@ -831,6 +882,40 @@ static inline void scr_cyc_freed(const ScrCycType *type, unsigned gen) { if (type->kind) scr_cyc_kind(type->kind)->level[gen].freed++; } +/* An inline append (scr_rc.h) leaves every nursery candidate in the nursery + * buffer, including one whose type bypasses the nursery, which + * scr_cyc_on_release would have buffered past it. Where a candidate waits + * matters only once something reads the buffers, so every scheduling + * decision and every pass first moves such candidates to the generation + * scr_cyc_on_release would have chosen, consuming the bypass counts as it + * would have, in buffer order. Candidates that scr_cyc_on_release buffered + * already passed the same test with the counts unchanged since (they only + * change during passes), so they stay. */ +static SCR_CYC_COMPACT void scr_cyc_route_bypassed(void) { +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_nursery_bypassing == 0) return; + ScrVec *nursery = &scr_roots[SCR_CYC_NURSERY]; + for (size_t i = 0; i < nursery->n;) { + void *obj = nursery->v[i]; + ScrCycHdr *h = scr_cyc_hdr(obj); + unsigned gen = scr_cyc_buffer_gen(scr_cyc_type(h), SCR_CYC_NURSERY); + if (gen == SCR_CYC_NURSERY) { + i++; + continue; + } + void *last = nursery->v[--nursery->n]; + nursery->v[i] = last; + if (last != obj) scr_cyc_set_buffered(scr_cyc_hdr(last), (uint32_t)i); + scr_cyc_set_gen(h, gen); + ScrVec *b = &scr_roots[gen]; + if (b->n == b->cap) scr_cyc_grow(b); + scr_cyc_set_buffered(h, (uint32_t)b->n); + b->v[b->n++] = obj; + } + scr_cyc_window_update(); +#endif +} + void scr_cyc_on_dead(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); if (!scr_cyc_is_buffered(h)) return; @@ -843,11 +928,12 @@ void scr_cyc_on_dead(void *obj) { b->v[i] = last; if (last != obj) scr_cyc_set_buffered(scr_cyc_hdr(last), (uint32_t)i); scr_cyc_clear_buffered(h); - scr_cyc_nbuffered--; /* A death must not consume room reserved for a future candidate. */ scr_cyc_trigger--; - if (scr_cyc_gen(h) != SCR_CYC_NURSERY && !scr_cyc_older_backlog()) - scr_cyc_scheduled_mature_age = 0; + /* An older death lowers the older count with the trigger: the window + * stays. A nursery death lowers only the trigger. */ + if (scr_cyc_gen(h) == SCR_CYC_NURSERY) scr_cyc_window_shrink(); + else if (!scr_cyc_older_backlog()) scr_cyc_scheduled_mature_age = 0; } /* Publication (scr_publish.c) takes buffered objects out of their candidate @@ -896,31 +982,39 @@ void scr_cyc_pub_fill(void) { } holes->n = 0; size_t closed = before - b->n; - scr_cyc_nbuffered -= closed; /* A death must not consume room reserved for a future candidate. */ scr_cyc_trigger -= closed; if (closed && gen != SCR_CYC_NURSERY) older = true; } if (older && !scr_cyc_older_backlog()) scr_cyc_scheduled_mature_age = 0; + scr_cyc_window_update(); } /* Every release that leaves an unbuffered object alive lands here (the - * emitted and inline release paths test `buffered` first), so it stays what - * it has always been: buffer inline, then one compare. */ + * emitted and inline release paths test `buffered` first) unless the window + * let it append a nursery candidate on its own fast path (scr_rc.h), so it + * stays what it has always been: the same append, then buffer and compare. */ void scr_cyc_on_release(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); /* Releasing an existing candidate cannot advance the trigger. A pass * always re-arms after its teardowns, including any candidates they add. */ if (scr_cyc_is_buffered(h)) return; +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_append(h, obj)) return; +#endif if (scr_cyc_gen(h) < SCR_CYC_OLD) scr_cyc_set_gen(h, scr_cyc_buffer_gen(scr_cyc_type(h), scr_cyc_gen(h))); - ScrVec *b = &scr_roots[scr_cyc_gen(h)]; - if (b->n == b->cap) scr_cyc_grow(b); + unsigned gen = scr_cyc_gen(h); + ScrVec *b = &scr_roots[gen]; + if (b->n == b->cap) { + scr_cyc_grow(b); + scr_cyc_window_update(); + } scr_cyc_set_buffered(h, (uint32_t)b->n); b->v[b->n++] = obj; - scr_cyc_nbuffered++; + if (gen != SCR_CYC_NURSERY) scr_cyc_window_shrink(); /* Never re-entered: a teardown's releases of untraced children can buffer * new candidates mid-collection, but they only wait for the next pass. */ - if (!scr_collecting && (scr_cyc_nbuffered >= scr_cyc_trigger || scr_cyc_live >= scr_cyc_alloc_trigger)) + if (!scr_collecting && (scr_cyc_buffered() >= scr_cyc_trigger || scr_cyc_live >= scr_cyc_alloc_trigger)) scr_cyc_collect_due(); } @@ -1272,10 +1366,10 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit, bool doomed) { } /* markRoots drained buffers in bulk and teardowns moved the count around; * resync from the buffers themselves and re-arm. */ - scr_cyc_nbuffered = scr_cyc_buffered(); - scr_cyc_trigger = scr_cyc_nbuffered + scr_cyc_nursery_threshold(); + scr_cyc_trigger = scr_cyc_buffered() + scr_cyc_nursery_threshold(); scr_cyc_alloc_trigger = scr_cyc_live + SCR_CYC_NURSERY_ALLOCATIONS; scr_collecting = false; + scr_cyc_window_update(); return freed; } @@ -1315,7 +1409,9 @@ static size_t scr_cyc_pass_done(size_t freed) { static size_t scr_cyc_pass(unsigned gen_limit) { if (scr_collecting) return 0; + scr_cyc_route_bypassed(); scr_collecting = true; + scr_cyc_window_update(); scr_gen_limit = gen_limit; /* markRoots: every buffered entry is a candidate (retains do not mark @@ -1488,6 +1584,9 @@ static size_t scr_cyc_pass(unsigned gen_limit) { #ifdef SCR_CYC_TESTING /* Tests only: one pass at exactly `gen`, outside the schedule. */ size_t scr_cyc_pass_at(unsigned gen) { return scr_cyc_pass(gen); } +/* Tests only: route appended candidates of bypassing types, as the next + * scheduling decision or pass would. */ +void scr_cyc_route_bypassed_at(void) { scr_cyc_route_bypassed(); } #endif size_t scr_cyc_collect_set(const ScrCycSet *set, unsigned gen) { @@ -1526,4 +1625,8 @@ void scr_cyc_context_cleanup(void) { free(scr_cyc_kinds); scr_cyc_kinds = NULL; scr_cyc_kinds_cap = 0; +#if SCR_CYC_INLINE_BUFFERING + scr_cyc_nursery_bypassing = 0; +#endif + scr_cyc_window_update(); } diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index d5d83a72a..b4c61a6ff 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -463,12 +463,76 @@ uint32_t scr_cyc_pub_take(void *obj); void scr_cyc_pub_restore(void *obj, uint32_t index); void scr_cyc_pub_fill(void); +/* The candidate buffers, one per generation (scr_cycle.c), and the window + * through which a release buffers a nursery candidate on its fast path. A + * release that leaves an unbuffered nursery object alive appends it to the + * nursery buffer while that buffer holds fewer than `window` entries and the + * live count is below `alloc_trigger` (scr_cyc_append); anything else takes + * the full scr_cyc_on_release. scr_cycle.c keeps `window` at most at the + * number of nursery entries the buffers can hold before a collection falls + * due or the buffer must grow, and at 0 while a pass runs, so the append is + * exactly what scr_cyc_on_release would do with a nursery candidate. A + * candidate whose type bypasses the nursery is moved past it before the + * buffers are next read (scr_cycle.c, "level bypass"). + * + * The runtime's own release paths append inline (scr_cyc_released), and + * scr_cyc_on_release tries the append first. The layout is an ABI: + * optimized emitted code appends through a per-module helper + * (llvm/alloc.ts cycleAppendHelper) that addresses roots[0].v, roots[0].n, + * window and alloc_trigger at fixed offsets. Builds whose collector state + * is thread-local (worker executables, thread-instanced libraries) never + * append on a fast path. */ +typedef struct ScrCycBuffer { + void **v; + size_t n, cap; + /* The most entries a pass held, for the work vectors a pass fills + * (scr_cycle.c ScrVec); unused by the candidate buffers. */ + size_t used; +} ScrCycBuffer; +typedef struct ScrCycBuffers { + ScrCycBuffer roots[SCR_CYC_NGENS]; + size_t window; + size_t alloc_trigger; +} ScrCycBuffers; +#if !defined(SCR_WORKERS) && !(defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)) +#define SCR_CYC_INLINE_BUFFERING 1 +extern ScrCycBuffers scr_cyc_buffers; +#if UINTPTR_MAX == UINT64_MAX +_Static_assert(offsetof(ScrCycBuffers, roots) == 0 && offsetof(ScrCycBuffer, v) == 0 && + offsetof(ScrCycBuffer, n) == 8 && offsetof(ScrCycBuffers, window) == 96 && + offsetof(ScrCycBuffers, alloc_trigger) == 104, + "llvm/alloc.ts appends nursery candidates against this layout"); +#endif +/* Append the unbuffered object `obj` (header `h`) to the nursery buffer if + * it is a nursery object and the window allows; false leaves it to the rest + * of scr_cyc_on_release. */ +static inline bool scr_cyc_append(ScrCycHdr *h, void *obj) { + ScrCycBuffer *nursery = &scr_cyc_buffers.roots[SCR_CYC_NURSERY]; + size_t n = nursery->n; + if (scr_cyc_gen(h) != SCR_CYC_NURSERY || n >= scr_cyc_buffers.window || + scr_cyc_live >= scr_cyc_buffers.alloc_trigger) + return false; + nursery->v[n] = obj; + nursery->n = n + 1; + scr_cyc_set_buffered(h, (uint32_t)n); + return true; +} +#else +#define SCR_CYC_INLINE_BUFFERING 0 +#endif + /* Inline halves of the hooks for runtime release paths, mirroring the * emitted ones (llvm/shapes.ts): a release of an already-buffered - * object, and the death of an object that was never buffered, need no call. + * object, and the death of an object that was never buffered, need no call, + * and a nursery candidate is appended inline while the window allows. * Most releases of long-lived containers take the first early return. */ static inline void scr_cyc_released(void *obj) { - if (!scr_cyc_is_buffered(scr_cyc_hdr(obj))) scr_cyc_on_release(obj); + ScrCycHdr *h = scr_cyc_hdr(obj); + if (scr_cyc_is_buffered(h)) return; +#if SCR_CYC_INLINE_BUFFERING + if (scr_cyc_append(h, obj)) return; +#endif + scr_cyc_on_release(obj); } static inline void scr_cyc_dead(void *obj) { if (scr_cyc_is_buffered(scr_cyc_hdr(obj))) scr_cyc_on_dead(obj); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index ba0bc0819..c50f0d5c5 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -712,8 +712,11 @@ static void check_deferred_white_restoration(void) { * once the type's objects die in nursery cycles again, the next probe window * finds that and it is walked again. Window, ratio and probe interval mirror * SCR_CYC_BYPASS_*. */ -/* Tests only (SCR_CYC_TESTING): one pass at exactly `gen`. */ +/* Tests only (SCR_CYC_TESTING): one pass at exactly `gen`, and the routing + * of appended candidates whose type bypasses a level, which the next + * scheduling decision or pass would do. */ size_t scr_cyc_pass_at(unsigned gen); +void scr_cyc_route_bypassed_at(void); static size_t bypass_traced; /* trace calls on bypass-kind nodes */ static size_t bypass_freed; /* bypass-kind nodes freed */ static void bypass_trace(void *obj, ScrTraceVisit visit, void *ctx) { @@ -785,6 +788,22 @@ static void check_nursery_bypass(void) { scr_collect_cycles(); check(bypass_freed - before == 2 * RINGS, "an older pass missed dead rings of a bypassed type"); + /* The same through the inline release path, which leaves the candidates + * in the nursery buffer: the next pass routes them past the nursery. */ + before = bypass_freed; + for (size_t r = 0; r < RINGS; r++) { + Node *a = make_bypass_node(), *b = make_bypass_node(); + a->next = b; + b->next = a; + a->rc++; + a->rc--; + scr_cyc_released(a); + } + run_release_passes(); + check(bypass_freed - before < 2 * RINGS, "inline-buffered bypassed rings were walked by the nursery"); + scr_collect_cycles(); + check(bypass_freed - before == 2 * RINGS, "an older pass missed inline-buffered rings"); + /* Now the type's objects die in nursery cycles. Once INTERVAL windows' * worth of candidates went past the nursery, a probe window walks them * there again, and its yield turns the bypass off. A nursery pass after @@ -832,13 +851,16 @@ static Node *make_mature_kind_node(void) { } /* How many of PROBE fresh, externally owned candidates a release buffers - * straight into the old generation. */ + * straight into the old generation, by the time anything reads the buffers + * (a release may append the candidate to the nursery buffer, which the next + * decision routes past the levels its type bypasses). */ static size_t probe_old_on_release(Node **owned, size_t probe) { scr_collect_cycles(); /* reset the growth baselines: no pass runs below */ size_t old = 0; for (size_t i = 0; i < probe; i++) { owned[i] = make_mature_kind_node(); read_live(owned[i]); + scr_cyc_route_bypassed_at(); old += scr_cyc_gen(scr_cyc_hdr(owned[i])) == SCR_CYC_OLD; } return old; @@ -1187,7 +1209,55 @@ static void check_large_old_buffer_with_nursery_candidates(void) { } } +/* Releases through scr_cyc_released append nursery candidates inline while + * the window allows, and fall back to scr_cyc_on_release exactly when a + * collection is due: the candidate that brings the buffered total to the + * trigger runs the pass, as it would through the call. */ +static void check_inline_buffering(void) { + enum { MAX = 4096 }; + static Node *nodes[MAX]; + size_t threshold = configured_nursery_threshold(); + if (threshold > MAX) return; + scr_collect_cycles(); + size_t before = traced; + for (size_t i = 0; i < threshold; i++) { + nodes[i] = make_ring(1); + nodes[i]->rc--; /* drop the self edge: a live leaf held by its owner */ + nodes[i]->next = NULL; + nodes[i]->rc++; + nodes[i]->rc--; + scr_cyc_released(nodes[i]); + ScrCycHdr *h = scr_cyc_hdr(nodes[i]); + if (i + 1 < threshold) { + check(scr_cyc_is_buffered(h) && scr_cyc_gen(h) == SCR_CYC_NURSERY, + "a nursery release did not buffer its candidate"); +#if SCR_CYC_INLINE_BUFFERING + check(scr_cyc_buffers.roots[SCR_CYC_NURSERY].v[scr_cyc_buf_index(h)] == nodes[i], + "an inline append is not where the header says"); +#endif + check(traced == before, "a pass ran before the trigger"); + } + } + /* The last release reached the trigger: one nursery pass walked them all + * and promoted the survivors, which wait in the mature buffer. */ + check(traced > before, "the release that reached the trigger did not collect"); + for (size_t i = 0; i < threshold; i++) { + ScrCycHdr *h = scr_cyc_hdr(nodes[i]); + check(scr_cyc_gen(h) == SCR_CYC_MATURE, "a nursery survivor was not promoted"); + check(nodes[i]->rc == 1, "a survivor lost its count"); + } + size_t freed_before = freed; + for (size_t i = 0; i < threshold; i++) { + nodes[i]->rc = 0; + scr_cyc_on_dead(nodes[i]); + node_free(nodes[i]); + } + check(freed - freed_before == threshold, "the leaves were not freed"); + scr_collect_cycles(); +} + int main(void) { + check_inline_buffering(); check_old_garbage_reclaimed_while_growing(); check_older_rings_into_old(); check_cycle_spanning_mature_and_old(); From 8aa69ad16912c7494fcf98a4589d935c879ea1e5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:12:28 -0700 Subject: [PATCH 06/53] Stop checking the storage fill of early-reachable let globals A top-level `let x;` in a JavaScript module holds a checked dynamic value that the module %init fills with undefined at entry. When the module's early-running code (a published function, a function declaration called above the binding) can reach the binding, it also gets an initialization flag, and every write before the flag is set is TDZ-checked. The entry fill was rewritten like a source write, so the module threw "Cannot access 'x' before initialization" before its first statement ran. That broke json5, cliui and cosmiconfig on load. Mark the fill as initializing when the global has a flag: it is storage setup, not the source write, so it neither runs the check nor sets the flag. Accesses that really happen before the declaration still throw. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 11 ++++++++- .../6180-cjs-let-early-reachable/main.js.json | 7 ++++++ .../6181-esm-let-early-reachable/main.js.json | 7 ++++++ .../6180-cjs-let-early-reachable/lib.js | 24 +++++++++++++++++++ .../6180-cjs-let-early-reachable/main.js | 11 +++++++++ .../6180-cjs-let-early-reachable/package.json | 1 + .../6181-esm-let-early-reachable/lib.js | 19 +++++++++++++++ .../6181-esm-let-early-reachable/main.js | 8 +++++++ .../6181-esm-let-early-reachable/package.json | 1 + 9 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-cjs-let-early-reachable/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-esm-let-early-reachable/main.js.json create mode 100644 tests/corpus/6180-cjs-let-early-reachable/lib.js create mode 100644 tests/corpus/6180-cjs-let-early-reachable/main.js create mode 100644 tests/corpus/6180-cjs-let-early-reachable/package.json create mode 100644 tests/corpus/6181-esm-let-early-reachable/lib.js create mode 100644 tests/corpus/6181-esm-let-early-reachable/main.js create mode 100644 tests/corpus/6181-esm-let-early-reachable/package.json diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3baab9dd0..df6677d5b 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2791,7 +2791,16 @@ export function lowerFileInit( const wrapped = g.type.kind === "dyn" ? dynUndefinedExpr(loc0) : lowerer.unassignedSlotInit(g.type, loc0); if (wrapped) { - header.push({ kind: "assign", localId: g.id, value: wrapped, loc: loc0 }); + // A let global whose early reads are checked (initFlag) only + // gets its storage filled here: this is not the source write, so + // it must neither run the TDZ check nor set the flag. + header.push({ + kind: "assign", + localId: g.id, + value: wrapped, + ...(g.initFlag !== undefined ? { initializes: true as const } : {}), + loc: loc0, + }); } } const prelude = lowerer.jsonInitActions.get(sf) ?? []; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-cjs-let-early-reachable/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-cjs-let-early-reachable/main.js.json new file mode 100644 index 000000000..c83e326cd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-cjs-let-early-reachable/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6180-cjs-let-early-reachable/lib.js", + "/tests/corpus/6180-cjs-let-early-reachable/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-esm-let-early-reachable/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-esm-let-early-reachable/main.js.json new file mode 100644 index 000000000..cc09432a8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-esm-let-early-reachable/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6181-esm-let-early-reachable/lib.js", + "/tests/corpus/6181-esm-let-early-reachable/main.js" + ], + "diags": [] +} diff --git a/tests/corpus/6180-cjs-let-early-reachable/lib.js b/tests/corpus/6180-cjs-let-early-reachable/lib.js new file mode 100644 index 000000000..ce4300b1f --- /dev/null +++ b/tests/corpus/6180-cjs-let-early-reachable/lib.js @@ -0,0 +1,24 @@ +'use strict'; + +let source; +const parse = function parse(text) { + source = text; + return lex(); +}; +module.exports = parse; + +try { + console.log('early', parse('early')); +} catch (e) { + console.log('early', e instanceof ReferenceError, e.message); +} + +let state; +let count; +function lex() { + state = state === undefined ? 'fresh' : 'seen'; + count = count === undefined ? 1 : count + 1; + return source + ':' + state + ':' + count; +} + +console.log('loaded', state, count); diff --git a/tests/corpus/6180-cjs-let-early-reachable/main.js b/tests/corpus/6180-cjs-let-early-reachable/main.js new file mode 100644 index 000000000..58c0e5044 --- /dev/null +++ b/tests/corpus/6180-cjs-let-early-reachable/main.js @@ -0,0 +1,11 @@ +// A CommonJS module's top-level `let x;` bindings that a published +// function can reach before their declarations run. Node only throws +// for an access that really happens before the declaration; once the +// module body has run, every call sees the bindings (undefined until +// assigned). +'use strict'; + +const parse = require('./lib.js'); + +console.log(parse('a'), parse('b')); +console.log(parse('c')); diff --git a/tests/corpus/6180-cjs-let-early-reachable/package.json b/tests/corpus/6180-cjs-let-early-reachable/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6180-cjs-let-early-reachable/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6181-esm-let-early-reachable/lib.js b/tests/corpus/6181-esm-let-early-reachable/lib.js new file mode 100644 index 000000000..47a688101 --- /dev/null +++ b/tests/corpus/6181-esm-let-early-reachable/lib.js @@ -0,0 +1,19 @@ +let mixin; +export function cliui(opts) { + if (opts !== undefined) mixin = opts; + return describe(); +} + +let earlyResult; +try { + earlyResult = cliui({ width: 1 }); +} catch (e) { + earlyResult = `${e.name}: ${e.message}`; +} +export const early = earlyResult; + +let calls; +function describe() { + calls = calls === undefined ? 1 : calls + 1; + return `${calls} ${mixin === undefined ? 'none' : mixin.width}`; +} diff --git a/tests/corpus/6181-esm-let-early-reachable/main.js b/tests/corpus/6181-esm-let-early-reachable/main.js new file mode 100644 index 000000000..a27a418e2 --- /dev/null +++ b/tests/corpus/6181-esm-let-early-reachable/main.js @@ -0,0 +1,8 @@ +// An ES module's top-level `let x;` bindings that an exported function +// can reach before their declarations run. Only an access that really +// happens before the declaration throws; after the module body ran, +// calls see the bindings (undefined until assigned). +import { cliui, early } from './lib.js'; + +console.log(early); +console.log(cliui({ width: 3 }), cliui(undefined)); diff --git a/tests/corpus/6181-esm-let-early-reachable/package.json b/tests/corpus/6181-esm-let-early-reachable/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/6181-esm-let-early-reachable/package.json @@ -0,0 +1 @@ +{ "type": "module" } From 6d6a140064aef2537e6a35ca598dca279dc73e36 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:16:48 -0700 Subject: [PATCH 07/53] Lower a JavaScript class method that reads itself as a value once A reflected JavaScript class method lowers its lambda the first time its value is read, and caches the closure only after the body lowered. A method whose own body reads the method as a value, like i18next's `instance.createInstance = I18n.createInstance`, reentered the lowering for the same method until the compiler overflowed its stack. Record the method while its lambda lowers; lowerLambda names the closure it will produce, and a read of the method inside its own body takes that closure, so every read sees the same function. When the body later changes the closure (its type is settled by an inferred return, or it captures), the self read is refused with a named diagnostic instead. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/class-method-values.ts | 37 +++++++++++++++++++ .../src/frontend/lowering/lower-functions.ts | 2 + .../compiler/src/frontend/lowering/lowerer.ts | 9 +++++ .../6182-class-method-self-value.js.json | 6 +++ tests/corpus/6182-class-method-self-value.js | 28 ++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-class-method-self-value.js.json create mode 100644 tests/corpus/6182-class-method-self-value.js diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 7c8770021..50278c3b7 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -379,12 +379,48 @@ function reflectedMethodValue( needsNativeReceiver = true; }); if (needsNativeReceiver) return null; + // The method reads itself as a value inside its own body: it is the + // closure its lowering in progress produces. + if (lowerer.methodLambdasInProgress.has(member)) { + const pending = lowerer.methodLambdasInProgress.get(member); + if (!pending) { + lowerer.unsupported( + "SC1090", + member, + "class methods that read their own value while their signature is computed", + ); + } + pending.selfReads++; + return { + kind: "closure", + fnName: pending.fnName, + captures: [], + type: pending.type, + loc: locOf(member), + }; + } const context = newFnCtx(false, null, null, DYN); const previousClass = lowerer.currentClass; lowerer.currentClass = info; lowerer.fnStack.push(context); + lowerer.methodLambdasInProgress.set(member, null); try { const value = lowerer.lowerLambda(member); + const pending = lowerer.methodLambdasInProgress.get(member); + if ( + pending && + pending.selfReads > 0 && + (value.kind !== "closure" || + value.fnName !== pending.fnName || + value.captures.length > 0 || + !typeEquals(value.type, pending.type)) + ) { + lowerer.unsupported( + "SC1090", + member, + "class methods that read their own value inside their body when the method's value type is only settled by its body", + ); + } if (value.kind === "closure" && value.captures.length === 0) { const body = lowerer.liftedFns.find((fn) => fn.name === value.fnName); if (body?.captures?.length === 0) delete body.captures; @@ -395,6 +431,7 @@ function reflectedMethodValue( cache.set(method, value); return value; } finally { + lowerer.methodLambdasInProgress.delete(member); lowerer.fnStack.pop(); lowerer.currentClass = previousClass; } diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 2991c0d39..d1707101e 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -270,6 +270,8 @@ export function lowerLambda( : lowerer.bodyReturnType(isAsync, funcType.ret); const fnCtx = newFnCtx(true, selfSymbol, funcType, bodyReturn); + if (lowerer.methodLambdasInProgress.has(node)) + lowerer.methodLambdasInProgress.set(node, { fnName, type: funcType, selfReads: 0 }); const inferredSignature = isJsSourceFile(node.getSourceFile()) && !node.type ? lowerer.checker.getSignatureFromDeclaration(node) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index bf4ed5464..adca3dfc5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2332,6 +2332,15 @@ export class Lowerer { /** Method adapter names are owned by class-method-values. Looking them * up must not scan and retain every unrelated lifted function. */ readonly classMethodValueHelpers = new Set(); + /** Reflected class methods whose lambda is lowering right now (null + * until lowerLambda named it), with the closure it will produce: a + * method that reads itself as a value inside its own body + * (`o.create = K.create`) takes this closure instead of lowering the + * method again. */ + readonly methodLambdasInProgress = new Map< + ts.Node, + { fnName: string; type: IrType; selfReads: number } | null + >(); readonly classMethodValueSelections = new Map< string, import("./class-method-values.js").ClassMethodValueSelection diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-class-method-self-value.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-class-method-self-value.js.json new file mode 100644 index 000000000..7c8016a0e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-class-method-self-value.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6182-class-method-self-value.js" + ], + "diags": [] +} diff --git a/tests/corpus/6182-class-method-self-value.js b/tests/corpus/6182-class-method-self-value.js new file mode 100644 index 000000000..a5527dbe7 --- /dev/null +++ b/tests/corpus/6182-class-method-self-value.js @@ -0,0 +1,28 @@ +// JavaScript class methods that read themselves as a value inside their +// own body: i18next's `instance.createInstance = I18n.createInstance`. +// The value is the same function every other read of the method sees. + +class Registry { + /** @param {string} name */ + constructor(name) { + this.name = name; + /** @type {((name: string) => Registry) | undefined} */ + this.create = undefined; + } + + /** @param {string} name */ + static create(name) { + const registry = new Registry(name); + registry.create = Registry.create; + return registry; + } + + describe() { + return `${this.name} ${typeof this.create}`; + } +} + +const a = Registry.create('a'); +const b = a.create?.('b') ?? a; +console.log(a.describe(), b.describe()); +console.log(a.create === Registry.create, b.create === a.create); From 7ab6e8c6262e1d392aca4b518932743409794856 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:33:11 -0700 Subject: [PATCH 08/53] Store typed-array elements through storage wider than a typed array `b[i] = v` lowered to bytesSet whenever the checker's flow type of `b` was a typed array, even when the binding's storage was wider. A hoisted JavaScript `var` holds `bytes | undefined`, so the plain `var dst = Buffer.alloc(n); dst[i] = x` (ecdsa-sig-formatter, dotenv) and a field assigned after construction (@noble/hashes' HashMD buffer) failed IR validation with SC9001 "bytesSet on non-bytes union". For `bytes | undefined` storage with a number index, evaluate the receiver, index and value in order, then throw Node's TypeError for an undefined receiver or take the typed store. Any other storage takes the checked-dynamic store, which applies the value the receiver holds; a type with no checked-dynamic form is refused with a named diagnostic. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 85 +++++++++++++++++++ .../6184-js-bytes-write-wider-storage.js.json | 6 ++ .../6184-js-bytes-write-wider-storage.js | 57 +++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6184-js-bytes-write-wider-storage.js.json create mode 100644 tests/corpus/6184-js-bytes-write-wider-storage.js diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d4d03591d..d8d969973 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -7328,6 +7328,89 @@ function lowerGuardedRecordWrite( }; } +/** `b[i] = v` where the checker's flow type of `b` is a typed array but + * its storage is wider. A hoisted JavaScript `var` (or a field written + * after construction) holds `bytes | undefined`: the receiver, index and + * value evaluate in order, then an undefined receiver throws Node's + * TypeError and a present one takes the typed store. Any other storage (a + * `var` later reassigned a string, a checked-dynamic value) takes the + * checked-dynamic store, which applies whatever value the receiver holds. */ +function lowerWiderBytesWrite( + lowerer: Lowerer, + expr: ts.BinaryExpression, + target: ts.ElementAccessExpression, + recv: IrExpr, +): IrStmt { + const loc = locOf(expr); + if (recv.type.kind === "union") { + const unionId = recv.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + const bytes = arms?.length === 2 && undefTag >= 0 ? arms[1 - undefTag]! : null; + const keyType = lowerer.mapTypeOf(lowerer.typeOf(target.argumentExpression)); + const index = + bytes?.kind === "bytes" && keyType?.kind === "f64" + ? lowerer.lowerExpr(target.argumentExpression) + : null; + // An index stored wider than a number (a hoisted `var` counter) keeps + // the checked-dynamic store below, which converts the key exactly. + if (bytes?.kind === "bytes" && index?.type.kind === "f64") { + const stmts: IrStmt[] = []; + const stable = stabilized(lowerer, recv, "%bytesReceiver", stmts); + const key = stabilized(lowerer, index, "%bytesIndex", stmts); + const value = stabilized( + lowerer, + lowerer.lowerExprExpecting(expr.right, F64), + "%bytesValue", + stmts, + ); + const name = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); + const present: IrExpr = { + kind: "unionNarrow", + unionId, + tag: 1 - undefTag, + value: stable, + type: bytes, + loc, + }; + return { + kind: "block", + body: [ + ...stmts, + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + negated: false, + value: stable, + type: BOOL, + loc, + }, + then: [{ kind: "exprStmt", expr: undefinedReceiverThrow(name, "set", VOID, loc), loc }], + else_: [{ kind: "bytesSet", arr: present, index: key, value, loc }], + loc, + }, + ], + loc, + }; + } + } + if (!lowerer.dynConvertible(recv.type)) { + lowerer.unsupported( + "SC1090", + target.expression, + `typed-array element writes through '${lowerer.fmt(recv.type)}' storage`, + ); + } + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment(lowerer, expr, lowerer.coerceInto(target.expression, recv, DYN)), + loc, + }; +} + /** `r[k]` over a (non-tuple) record shape. A LITERAL key naming a declared * field is the bracket spelling of field access (the ONLY spelling tsc * permits for signature-declared fields under @@ -8374,6 +8457,8 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): // the runtime coerces JS-exactly; invalid indices are ignored. if (receiverIr?.kind === "bytes") { const recv = lowerer.lowerExpr(target.expression); + // The checker's flow type is a typed array, but the storage is wider. + if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv); const index = lowerer.lowerExpr(target.argumentExpression); if (index.type.kind === "dyn" && recv.type.kind === "bytes") { return { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6184-js-bytes-write-wider-storage.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6184-js-bytes-write-wider-storage.js.json new file mode 100644 index 000000000..7c4e146e1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6184-js-bytes-write-wider-storage.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6184-js-bytes-write-wider-storage.js" + ], + "diags": [] +} diff --git a/tests/corpus/6184-js-bytes-write-wider-storage.js b/tests/corpus/6184-js-bytes-write-wider-storage.js new file mode 100644 index 000000000..ff0e3295f --- /dev/null +++ b/tests/corpus/6184-js-bytes-write-wider-storage.js @@ -0,0 +1,57 @@ +// Typed-array element writes through storage wider than the checker's +// flow type: a hoisted `var` (ecdsa-sig-formatter's joseToDer) and a +// field that holds undefined until it is assigned (@noble/hashes' HashMD +// buffer). Each write stores into the typed array the receiver holds, or +// throws Node's TypeError for an undefined one after the index and the +// value evaluated. +'use strict'; + +/** @param {number} n */ +function der(n) { + var dst = Buffer.alloc(n); + var offset = 0; + dst[offset++] = 48; + dst[offset++] = n; + dst[offset] = 257; + return dst.toString('hex'); +} + +/** @param {number} n */ +function grow(n) { + var dst = Buffer.alloc(n); + for (var i = 0; i < n; i++) dst[i] = i * 3; + dst = Buffer.concat([dst, Buffer.from([255])]); + dst[0] = 7; + return dst.toString('hex'); +} + +console.log(der(3), grow(4)); + +/** @param {number} v */ +function note(v) { + console.log('value', v); + return v; +} + +class Hash { + buffer; + /** @param {number} n */ + constructor(n) { + if (n > 0) this.buffer = new Uint8Array(n); + this.pos = 0; + } + digest() { + const { buffer } = this; + let { pos } = this; + buffer[pos++] = note(0x80); + buffer[pos] = 300; + return Array.from(buffer).join(','); + } +} + +console.log(new Hash(4).digest()); +try { + console.log(new Hash(0).digest()); +} catch (e) { + console.log(e instanceof TypeError, e.message); +} From 21ae43bd88ed3d616dc542ff2e55b1fc529f6726 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:33:17 -0700 Subject: [PATCH 09/53] Guard keyed reads of a null-initialized record table A keyed read whose checker type is a record but whose storage is `record | null` (iconv-lite's `module.exports.encodings = null`, filled on first use and read as `encodings[enc]`) built a recordKeyGet on the union and failed IR validation with SC9001. The optional-receiver guard that keyed reads already use for `record | undefined` now also takes a null arm for string and number keys: the receiver and key evaluate once, a null receiver throws Node's "Cannot read properties of null (reading 'k')", and a present one reads the record. Other keys keep their checked-dynamic read, and keyed writes are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 44 ++++++++++++++----- .../6185-cjs-null-table-keyed-read.cjs.json | 6 +++ .../corpus/6185-cjs-null-table-keyed-read.cjs | 23 ++++++++++ 3 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6185-cjs-null-table-keyed-read.cjs.json create mode 100644 tests/corpus/6185-cjs-null-table-keyed-read.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d8d969973..c54341df8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -7173,25 +7173,38 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre /** A record receiver that an outer keyed read left optional * (`nested[a][b]`, where `nested[a]` can miss). The receiver evaluates * once into `stmts`; `present` is its record arm, valid only once the - * access has tested `stable` against the undefined arm. */ + * access has tested `stable` against the undefined (or, for reads, null) + * arm. */ interface OptionalRecordReceiver { stmts: IrStmt[]; stable: IrExpr; present: IrExpr; unionId: string; undefTag: number; + nullish: "undefined" | "null"; } function optionalRecordReceiver( lowerer: Lowerer, value: IrExpr, loc: SrcLoc, + allowNull = false, ): OptionalRecordReceiver | null { if (value.type.kind !== "union") return null; const unionId = value.type.unionId; - const undefTag = lowerer.armTag(unionId, UNDEFINED_T); - if (undefTag < 0 || lowerer.unions.get(unionId)?.arms.length !== 2) return null; - const record = lowerer.stripUndefinedArm(value.type); + const arms = lowerer.unions.get(unionId)?.arms; + if (arms?.length !== 2) return null; + let undefTag = lowerer.armTag(unionId, UNDEFINED_T); + let nullish: OptionalRecordReceiver["nullish"] = "undefined"; + // A null-initialized slot assigned a record later (iconv-lite's + // `encodings = null` table, filled on first use): the checker narrows + // the read to the record, the storage keeps the null arm. + if (undefTag < 0 && allowNull) { + undefTag = lowerer.armTag(unionId, NULL_T); + nullish = "null"; + } + if (undefTag < 0) return null; + const record = arms[1 - undefTag]!; if (record.kind !== "record") return null; const stmts: IrStmt[] = []; const stable = stabilized(lowerer, value, "%recordReceiver", stmts); @@ -7203,7 +7216,7 @@ function optionalRecordReceiver( type: record, loc, }; - return { stmts, stable, present, unionId, undefTag }; + return { stmts, stable, present, unionId, undefTag, nullish }; } /** `value`, evaluated once at this point of `out`. */ @@ -7226,14 +7239,16 @@ function undefinedReceiver(receiver: OptionalRecordReceiver, loc: SrcLoc): IrExp }; } -/** Node's TypeError for a keyed read or write on an undefined receiver. */ +/** Node's TypeError for a keyed read or write on an undefined (or null) + * receiver. */ function undefinedReceiverThrow( key: IrExpr, action: "read" | "set", type: IrType, loc: SrcLoc, + nullish: OptionalRecordReceiver["nullish"] = "undefined", ): IrExpr { - const head = `Cannot ${action} properties of undefined (${action === "read" ? "reading" : "setting"} '`; + const head = `Cannot ${action} properties of ${nullish} (${action === "read" ? "reading" : "setting"} '`; const message: IrExpr = { kind: "strConcat", left: { @@ -7263,8 +7278,9 @@ function guardedRecordAccess( lowerer: Lowerer, access: ts.ElementAccessExpression, rawObj: IrExpr, + allowNull = false, ): { receiver: OptionalRecordReceiver; key: IrExpr; name: IrExpr } | null { - const receiver = optionalRecordReceiver(lowerer, rawObj, locOf(access.expression)); + const receiver = optionalRecordReceiver(lowerer, rawObj, locOf(access.expression), allowNull); if (!receiver) return null; const keyNode = access.argumentExpression; const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode); @@ -7450,7 +7466,15 @@ export function lowerRecordKeyRead( // evaluates the receiver before the key). const obj = lowerer.lowerExpr(expr.expression); { - const guarded = guardedRecordAccess(lowerer, expr, obj); + // A null arm is guarded only for string and number keys: other keys + // already take the checked-dynamic read, which handles null itself. + // Only a receiver with a null arm asks about the key. + let allowNull = false; + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, NULL_T) >= 0) { + const keyType = litKey !== null ? STRING : lowerer.mapTypeOf(lowerer.typeOf(keyNode)); + allowNull = keyType?.kind === "string" || keyType?.kind === "f64"; + } + const guarded = guardedRecordAccess(lowerer, expr, obj, allowNull); if (guarded) { const { receiver, key, name } = guarded; const read = lowerer.withExpressionOverride(expr.expression, receiver.present, () => @@ -7466,7 +7490,7 @@ export function lowerRecordKeyRead( result: { kind: "ternary", cond: undefinedReceiver(receiver, loc), - then: undefinedReceiverThrow(name, "read", read.type, loc), + then: undefinedReceiverThrow(name, "read", read.type, loc, receiver.nullish), else_: read, type: read.type, loc, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6185-cjs-null-table-keyed-read.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6185-cjs-null-table-keyed-read.cjs.json new file mode 100644 index 000000000..c3a6e5492 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6185-cjs-null-table-keyed-read.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6185-cjs-null-table-keyed-read.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/6185-cjs-null-table-keyed-read.cjs b/tests/corpus/6185-cjs-null-table-keyed-read.cjs new file mode 100644 index 000000000..41bfb1f1f --- /dev/null +++ b/tests/corpus/6185-cjs-null-table-keyed-read.cjs @@ -0,0 +1,23 @@ +// A keyed read of an exported table that starts out null and is filled +// later (iconv-lite's `encodings`). The checker narrows the read to the +// table, while the export's storage keeps the null arm. +'use strict'; + +/** @type {{ [name: string]: string } | null} */ +module.exports.encodings = null; + +/** @param {string} name */ +function note(name) { + console.log('key', name); + return name; +} + +/** @param {string} name */ +const lookup = function lookup(name) { + if (!module.exports.encodings) return 'none'; + return module.exports.encodings[note(name)]; +}; + +console.log(lookup('utf8')); +module.exports.encodings = { utf8: 'UTF-8', latin1: 'ISO-8859-1' }; +console.log(lookup('utf8'), lookup('latin1'), lookup('ascii')); From 74646b783ff75d1dabdf0d10be1f01c474913614 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:33:18 -0700 Subject: [PATCH 10/53] Check a dynamic `{ encoding }` option into its record for encoded reads `fs.readFileSync(file, { encoding })` and `fs.promises.readFile` read the encoding field straight off the lowered options literal. When the encoding value is checked-dynamic (dotenv's `t = e.encoding`), the literal lowers to a dynamic object, and the record field read failed IR validation with SC9001 "recordGet receiver: expected record, got dyn". Check such a literal into the `{ encoding: string }` record the checker describes before reading the field. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/filesystem-calls.ts | 13 +++++++++-- ...186-js-fs-encoding-dynamic-option.cjs.json | 6 +++++ .../6186-js-fs-encoding-dynamic-option.cjs | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs.json create mode 100644 tests/corpus/6186-js-fs-encoding-dynamic-option.cjs diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 754eb6aa9..d6864a2d9 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -23,6 +23,7 @@ import { VOID, arrayOf, funcOf, + typeEquals, } from "../../../ir/ir.js"; import { lowerBuiltinOptionalDefault, optionMember, literalBoolOptions } from "./arguments.js"; import { @@ -603,6 +604,14 @@ export function lowerFsReadDescriptorCall( return { kind: "libCall", fn: "fs.readFdSync", args: [fd, enc], type: STRING, loc }; } +/** The `{ encoding }` options record. A literal whose value is checked- + * dynamic (an encoding variable reassigned from an untyped source) lowers + * to a dynamic object: check it into the record the checker describes. */ +function encodingOptions(lowerer: Lowerer, options: ts.Expression, type: IrType): IrExpr { + const value = lowerer.lowerExpr(options); + return typeEquals(value.type, type) ? value : lowerer.coerceInto(options, value, type); +} + /** Encoding-bearing reads share Buffer's decoder and runtime alias checks. * Keep the read and encoding evaluation in source order in one helper. */ export function lowerFsEncodedRead( @@ -631,7 +640,7 @@ export function lowerFsEncodedRead( ? lowerer.lowerExprExpecting(options, STRING) : { kind: "recordGet", - obj: lowerer.lowerExpr(options), + obj: encodingOptions(lowerer, options, optionsType), shapeId: optionsType.shapeId, field: "encoding", type: STRING, @@ -692,7 +701,7 @@ export function lowerFsPromisesEncodedRead( ? lowerer.lowerExprExpecting(options, STRING) : { kind: "recordGet", - obj: lowerer.lowerExpr(options), + obj: encodingOptions(lowerer, options, optionsType), shapeId: optionsType.shapeId, field: "encoding", type: STRING, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs.json new file mode 100644 index 000000000..7e6f4d8d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs b/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs new file mode 100644 index 000000000..cea0fae8f --- /dev/null +++ b/tests/corpus/6186-js-fs-encoding-dynamic-option.cjs @@ -0,0 +1,22 @@ +// readFileSync with a `{ encoding }` literal whose value comes from an +// untyped source (dotenv's configDotenv): the option object is checked +// into the record the checker describes. +'use strict'; + +const fs = require('fs'); + +/** + * @param {string} file + * @param {any} options + */ +function read(file, options) { + let encoding = 'utf8'; + if (options && options.encoding) encoding = options.encoding; + return fs.readFileSync(file, { encoding }); +} + +const latin1 = read(__filename, { encoding: 'latin1' }); +const utf8 = read(__filename, null); +if (typeof latin1 === 'string' && typeof utf8 === 'string') { + console.log(latin1.length === utf8.length, utf8.split('\n')[0]); +} From 3b9272e8f899192f4c7aa448e9929ab64b53bc73 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:33:18 -0700 Subject: [PATCH 11/53] Keep Date slots out of runtime-optional undefined widening The runtime-optional analysis widens a parameter or binding that can observe undefined with an undefined arm. For a Date it interned the union `Date | undefined`, which is not a valid union (Date is not a union arm kind), and the whole program failed IR validation with SC9001 "union uN: arm 0 is date" (morgan's date token and clfdate). Like generators, a Date keeps its type: the canonical undefined-arm helper already refuses Date, and a site that would store undefined into the slot keeps its own diagnostic. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 6 +++- .../corpus/6183-js-date-optional-slot.js.json | 6 ++++ tests/corpus/6183-js-date-optional-slot.js | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-js-date-optional-slot.js.json create mode 100644 tests/corpus/6183-js-date-optional-slot.js diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index adca3dfc5..5d7713728 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4353,7 +4353,11 @@ export class Lowerer { const addUndefined = (t: IrType): IrType => { if (t.kind === "union") return this.armTag(t.unionId, UNDEFINED_T) >= 0 ? t : (this.withUndefinedArmOf(t) ?? t); - if (t.kind === "void" || t.kind === "dyn" || t.kind === "generator") return t; + // A Date has no undefined-armed union (it is not a union arm kind): + // like a generator it keeps its type, and the site that would store + // undefined into it keeps its own diagnostic. + if (t.kind === "void" || t.kind === "dyn" || t.kind === "generator" || t.kind === "date") + return t; return this.withUndefinedArm(t); }; const fieldName = (node: ts.PropertyAccessExpression): string => node.name.text; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-js-date-optional-slot.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-js-date-optional-slot.js.json new file mode 100644 index 000000000..ded598761 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-js-date-optional-slot.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6183-js-date-optional-slot.js" + ], + "diags": [] +} diff --git a/tests/corpus/6183-js-date-optional-slot.js b/tests/corpus/6183-js-date-optional-slot.js new file mode 100644 index 000000000..9c37d85b9 --- /dev/null +++ b/tests/corpus/6183-js-date-optional-slot.js @@ -0,0 +1,36 @@ +// A Date binding and parameter that the runtime-optional analysis would +// widen with an undefined arm (morgan's date token). Date values have no +// undefined-armed union, so the bindings keep their Date type. +'use strict'; + +/** @type {Record string | undefined>} */ +const tokens = {}; + +/** + * @param {string} name + * @param {(format: string | undefined) => string | undefined} fn + */ +function token(name, fn) { + tokens[name] = fn; +} + +token('date', function getDateToken(format) { + var date = new Date(86400000 * 400); + switch (format || 'iso') { + case 'clf': + return clfdate(date); + case 'iso': + return date.toISOString(); + } +}); + +/** + * @param {Date} dateTime + * @return {string} + */ +function clfdate(dateTime) { + return dateTime.getUTCDate() + '/' + dateTime.getUTCMonth() + '/' + dateTime.getUTCFullYear(); +} + +const date = tokens['date']; +if (date) console.log(date('clf'), date('iso'), date(undefined), date('other')); From 92310a68fc573e7c6583e13d6e1c872bc0a1962d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:43:15 -0700 Subject: [PATCH 12/53] Keep statement fences in functions hoisted during a speculative lowering A reference to a later function declaration in the same function scope lowers that declaration on the spot (predeclareForwardFnDecl), and the result is kept. When the reference sat inside a speculative attempt (tryLowerExpression's temporary diagnostic sink, here the native function assignment in bignumber.js's `var P = BigNumber.prototype = {...}`), the hoisted body lowered under that sink: its failing JavaScript statements were dropped instead of becoming runtime fences. bignumber's `isBigNumber` lost its only `return`, so every program using the package aborted at load with SC9002 "a non-void function completed without returning", and its constructor silently lost its instanceof check. Lower the hoisted declaration without the probe's sink, as eager generic instances already do. A declaration that fails as a whole is not kept, so its diagnostics go back to the probe. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 11 ++++ .../speculative-hoisted-functions.test.ts | 64 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 packages/compiler/test/speculative-hoisted-functions.test.ts diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 0f1b606ef..65f39c6ab 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -935,12 +935,23 @@ export function predeclareForwardFnDecl(lowerer: Lowerer, symbol: ts.Symbol): bo entry.ctx.hoistedFnDecls.add(decl); const fnTail = lowerer.fnStack.splice(depth + 1); const scopeTail = entry.ctx.scopes.splice(frameIdx + 1); + // The hoisted function is kept even when the reference lowers inside + // a speculative attempt (a probe's temporary diagnostic sink): lower + // it without that sink, so its failing JS statements keep their + // runtime fences instead of vanishing from a body that is kept. A + // declaration that fails as a whole is not kept: its diagnostics go + // back to the probe. + const previousSink = lowerer.diagSink; + const diagsBefore = lowerer.diags.length; + lowerer.diagSink = null; try { entry.out.push(lowerer.lowerNestedFunctionDecl(decl)); } catch (e) { entry.ctx.hoistedFnDecls.delete(decl); + if (previousSink !== null) previousSink.push(...lowerer.diags.splice(diagsBefore)); throw e; } finally { + lowerer.diagSink = previousSink; entry.ctx.scopes.push(...scopeTail); lowerer.fnStack.push(...fnTail); } diff --git a/packages/compiler/test/speculative-hoisted-functions.test.ts b/packages/compiler/test/speculative-hoisted-functions.test.ts new file mode 100644 index 000000000..a58310b2a --- /dev/null +++ b/packages/compiler/test/speculative-hoisted-functions.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +// bignumber.js's clone(): `P = BigNumber.prototype = {...}` in a `var` +// list above the constructor. The initializer lowers speculatively first, +// which hoists the constructor and the helper it calls. Those bodies are +// kept, so their failing statements must keep runtime fences; dropping +// them left `isNum` without its return (an SC9002 trap at load). +const source = ` +function clone(config) { + var P = (Num.prototype = { constructor: Num, toString: null }), + unit = config ? 2 : 1; + function Num(v) { + var x = this; + if (!(x instanceof Num)) return new Num(v); + x.v = isNum(v) ? 0 : v * unit; + } + function isNum(v) { + return v instanceof Num || (!!v && v._isNum === true); + } + P._isNum = true; + return Num; +} +const Num = clone(); +console.log(typeof Num); +`; + +async function lower(): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-speculative-hoisted-")); + try { + const entry = join(dir, "main.mjs"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function fenceCodes(node: unknown, out: string[] = []): string[] { + if (Array.isArray(node)) { + for (const item of node) fenceCodes(item, out); + } else if (node !== null && typeof node === "object") { + const record = node as Record; + if (record["kind"] === "runtimeFence") out.push(String(record["code"])); + for (const [key, value] of Object.entries(record)) if (key !== "loc") fenceCodes(value, out); + } + return out; +} + +test("speculatively hoisted JavaScript functions keep their statement fences", async () => { + const mod = await lower(); + const isNum = mod.functions.find((fn) => fn.name.endsWith("_isNum")); + expect(isNum).toBeDefined(); + expect(fenceCodes(isNum!.body)).toEqual(["SC1090"]); + expect(mod.functions.flatMap((fn) => fenceCodes(fn.body))).not.toContain("SC9002"); +}); From beddc5f94ab61c0cfc0ad2503d971e83199eb1fa Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:52:04 -0700 Subject: [PATCH 13/53] Keep typescript-go's tuple-literal panic with the statements that ask `s.match(re) || []` contextually types the empty array by the tuple-like RegExpMatchArray, so typescript-go builds an empty tuple target and clones it as an array literal. The clone keeps ObjectFlagsTuple on TypeReference data, and the API's newTypeResponse panics (interface conversion to *TupleType) whenever it serializes the clone, for example as a member of `RegExpMatchArray | []`. Whole-program analyses asked for such union members first, so every program containing the pattern (moment.js, or a two-line TypeScript file) failed at 1:1 with SC0004. A union whose members typescript-go panics to serialize is now unmappable at the top of mapType, the any-array probe answers false for it, and the panic is remembered per type instead of re-requested. Queries that still need the members keep the source-anchored SC0004 on their own statement (a runtime fence in JavaScript). Other checker panics are unchanged. The panic itself is an upstream bug in internal/api/proto.go. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 8 +++++++- packages/compiler/src/frontend/ts7/checker.ts | 10 ++++++++++ .../src/frontend/ts7/semantic-model.ts | 20 +++++++++++++++++-- packages/compiler/src/frontend/type-mapper.ts | 11 +++++++++- .../6187-js-match-or-empty-array.js.json | 6 ++++++ tests/corpus/6187-js-match-or-empty-array.js | 20 +++++++++++++++++++ tests/diagnostics/checker-panic-tuple.ts | 5 +++-- .../__snapshots__/checker-panic-tuple.ts.txt | 18 ++++++++--------- 8 files changed, 83 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6187-js-match-or-empty-array.js.json create mode 100644 tests/corpus/6187-js-match-or-empty-array.js diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 5d7713728..5bcf15b1a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -7746,7 +7746,13 @@ export class Lowerer { if (part.isUnionType()) return ts.constituentTypes(part).every(visit); return false; }; - return visit(t); + // A union typescript-go cannot describe is not proven an array. + try { + return visit(t); + } catch (e) { + if (ts.isUnreadableConstituentsPanic(e)) return false; + throw e; + } } /** The lowered static array/tuple value behind checkerAnyArray's diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index 9d7634591..bf83b367c 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -120,6 +120,16 @@ export function constituentTypes(type: Type): readonly Type[] { return type.getTypes() ?? []; } +/** True for the panic typescript-go's API raises serializing a union's + * constituents (an empty tuple cloned as an array literal keeps the tuple + * flag without tuple data: `s.match(re) || []`). The union itself is + * readable; its members are not. */ +export function isUnreadableConstituentsPanic(e: unknown): boolean { + return ( + e instanceof Error && (e as { unreadableConstituents?: true }).unreadableConstituents === true + ); +} + /** Function-like declarations whose body is deferred until reachability * asks for it. Header prefetch walks their names, type parameters, params, * and return types but leaves the body for a later explicit body wave. */ diff --git a/packages/compiler/src/frontend/ts7/semantic-model.ts b/packages/compiler/src/frontend/ts7/semantic-model.ts index 28625dd03..095021922 100644 --- a/packages/compiler/src/frontend/ts7/semantic-model.ts +++ b/packages/compiler/src/frontend/ts7/semantic-model.ts @@ -408,6 +408,7 @@ export class SemanticType { private trueType: number | false = false; private falseType: number | false = false; private constituents: SemanticType[] | undefined; + private constituentsPanic: Error | undefined; constructor( private readonly data: TypeResponse, @@ -497,12 +498,27 @@ export class SemanticType { this.project.ensureActive(); if ((this.flags & (TypeFlags.UnionOrIntersection | TypeFlags.TemplateLiteral)) === 0) return undefined; - if (this.constituents === undefined) - this.constituents = this.project.fetchTypes(this.id, "getTypesOfType"); + if (this.constituentsPanic !== undefined) throw this.constituentsPanic; + if (this.constituents === undefined) { + try { + this.constituents = this.project.fetchTypes(this.id, "getTypesOfType"); + } catch (e) { + // typescript-go panics serializing some constituents (an empty + // tuple cloned as an array literal): answer every later read of + // this snapshot's type with the same panic instead of a new + // request. + if (e instanceof Error && e.message.startsWith("panic:")) { + (e as { unreadableConstituents?: true }).unreadableConstituents = true; + this.constituentsPanic = e; + } + throw e; + } + } return this.constituents; } dispose(): void { this.constituents = undefined; + this.constituentsPanic = undefined; } getBaseTypes(): SemanticType[] | undefined { this.project.ensureActive(); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index f37703fd8..e0558ae5f 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1336,7 +1336,16 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const sensitivityAtEntry = contextResolutions; mapTypeDepth++; try { - const mapped = mapTypeInner(type, ctx); + let mapped: IrType | null; + try { + mapped = mapTypeInner(type, ctx); + } catch (e) { + // typescript-go's API can panic serializing a union's members (an + // empty tuple cloned as an array literal, `s.match(re) || []`): a + // union the checker cannot describe has no representation. + if (!topLevel || !ts.isUnreadableConstituentsPanic(e)) throw e; + mapped = null; + } if ( mapped !== null && memoKey !== null && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6187-js-match-or-empty-array.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6187-js-match-or-empty-array.js.json new file mode 100644 index 000000000..75792b997 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6187-js-match-or-empty-array.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6187-js-match-or-empty-array.js" + ], + "diags": [] +} diff --git a/tests/corpus/6187-js-match-or-empty-array.js b/tests/corpus/6187-js-match-or-empty-array.js new file mode 100644 index 000000000..2cce0e17d --- /dev/null +++ b/tests/corpus/6187-js-match-or-empty-array.js @@ -0,0 +1,20 @@ +// @deferred-fences: 1 +// `s.match(re) || []` types the empty array as an empty tuple that the +// typescript-go API panics to describe (moment.js's configFromStringAndFormat). +// The panic stays with the statements whose lowering asks about that +// type, so the rest of the program compiles and runs. + +/** @param {string} s */ +function firstDigit(s) { + return (s.match(/\d/) || [])[0]; +} + +/** @param {string} s */ +function digitCount(s) { + let count = 0; + for (const ch of s) if (ch >= '0' && ch <= '9') count++; + return count; +} + +const parsers = { firstDigit, digitCount }; +console.log(typeof parsers.firstDigit, parsers.digitCount('a1b22')); diff --git a/tests/diagnostics/checker-panic-tuple.ts b/tests/diagnostics/checker-panic-tuple.ts index fd6eacb8c..cf59bc1f0 100644 --- a/tests/diagnostics/checker-panic-tuple.ts +++ b/tests/diagnostics/checker-panic-tuple.ts @@ -1,5 +1,6 @@ // `''.match(/x/) || []` drives tsgo into the TupleType interface-conversion -// panic (upstream signature 01/13): the panic fence turns it into the -// source-anchored SC0004 — a failed compile, never a crashed CLI. +// panic (upstream signature 01/13) whenever the union's members are +// serialized: the union stays unmappable, so the statement reports its own +// refusals — a failed compile, never a crashed CLI. const a = ''.match(/x/) || []; console.log(a.length); diff --git a/tests/harness/__snapshots__/checker-panic-tuple.ts.txt b/tests/harness/__snapshots__/checker-panic-tuple.ts.txt index 46d22bcd0..3927e5518 100644 --- a/tests/harness/__snapshots__/checker-panic-tuple.ts.txt +++ b/tests/harness/__snapshots__/checker-panic-tuple.ts.txt @@ -1,13 +1,13 @@ -checker-panic-tuple.ts:4:1 - error SC0004: the TypeScript checker crashed answering a query here (an upstream typescript-go bug): panic: interface conversion: checker.TypeData is *checker.TypeReference, not *checker.TupleType +checker-panic-tuple.ts:5:11 - error SC1090: logical operators on union-typed values outside conditions (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) are not supported yet - 3 | // source-anchored SC0004 — a failed compile, never a crashed CLI. - 4 | const a = ''.match(/x/) || []; - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 5 | console.log(a.length); + 4 | // refusals — a failed compile, never a crashed CLI. + 5 | const a = ''.match(/x/) || []; + | ^~~~~~~~~~~~~~~~~~~ + 6 | console.log(a.length); -checker-panic-tuple.ts:5:13 - error SC1090: reading 'length' on a union-typed value (every arm must be an object/record with a same-typed field 'length'; narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet +checker-panic-tuple.ts:6:13 - error SC1090: reading 'length' on a union-typed value (every arm must be an object/record with a same-typed field 'length'; narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - 4 | const a = ''.match(/x/) || []; - 5 | console.log(a.length); + 5 | const a = ''.match(/x/) || []; + 6 | console.log(a.length); | ^~~~~~~~ - 6 | \ No newline at end of file + 7 | \ No newline at end of file From afef6f09f58ca84de5e2510c7f247afd2a5a829e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 16:12:14 -0500 Subject: [PATCH 14/53] Apply each request's maxHeaderSize to a pooled socket's parser A request that took a socket from a keepAlive agent, either queued for a freed socket or taking a free one, parsed its response with the parser of the request that used the socket before, and with that request's maxHeaderSize. A smaller limit was not enforced, and a larger or default limit was cut down to the earlier one. The request now keeps its limit (0 for the default) and applies it to the pooled parser when it takes the socket, before any response bytes are parsed. Corpus 5594 covers both handoffs with smaller, larger and default limits. --- .../5594-http-agent-header-limit.ts.json | 6 ++ packages/runtime/src/scr_http.c | 7 +- tests/corpus/5594-http-agent-header-limit.ts | 70 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5594-http-agent-header-limit.ts.json create mode 100644 tests/corpus/5594-http-agent-header-limit.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5594-http-agent-header-limit.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5594-http-agent-header-limit.ts.json new file mode 100644 index 000000000..a5ad20f1f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5594-http-agent-header-limit.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5594-http-agent-header-limit.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index a931ba1f6..f4e44bf6e 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -3763,6 +3763,8 @@ struct ScrHttpClientReq { * fired in place of the teardown's own error */ ScrDyn *abort_dispose; /* options.signal: removes the abort listener (+1) */ double timeout_ms; /* the timeout option (0 = none), reapplied on a pooled socket */ + size_t max_header_size; /* the maxHeaderSize option (0 = the default), applied to + * whichever parser serves the request */ bool reused; /* req.reusedSocket: the socket came from an agent's free list */ bool sock_pooled; /* the socket went back to the agent after the response */ ScrStr *pool_key; /* https: TLS options a pooled socket must match (+1), or NULL */ @@ -4345,7 +4347,8 @@ void scr_http_client_max_header_size(ScrHttpClientReq *c, double value) { scr_http_client_destroy(c); return; } - if (c->conn != NULL) c->conn->max_header_size = (size_t)value; + c->max_header_size = (size_t)value; + if (c->conn != NULL) c->conn->max_header_size = c->max_header_size; } /* req.destroy(err): tears the request down like destroy() and reports @@ -5284,6 +5287,8 @@ static void scr_http_client_rebind(ScrHttpClientReq *c, ScrHttpAgentFree *f, boo c->conn = conn; conn->client = scr_http_client_retain(c); conn->pool_agent = NULL; + /* the pooled parser still holds the previous request's limit */ + conn->max_header_size = c->max_header_size; conn->state = SCR_HTTP_HEAD; conn->len = 0; conn->body_remaining = 0; diff --git a/tests/corpus/5594-http-agent-header-limit.ts b/tests/corpus/5594-http-agent-header-limit.ts new file mode 100644 index 000000000..e3deb9954 --- /dev/null +++ b/tests/corpus/5594-http-agent-header-limit.ts @@ -0,0 +1,70 @@ +// A request's maxHeaderSize bounds its own response head, also when the +// request runs on a socket an earlier request used. Through a keepAlive +// agent with maxSockets 1, a queued request receives the freed socket and +// a later request takes the free one (reusedSocket). In both cases a +// smaller limit fails the response with HPE_HEADER_OVERFLOW, and a larger +// limit, or the default one, accepts a head the earlier request's limit +// would reject. +import * as http from "node:http"; +import * as net from "node:net"; + +// Answers each request with an X-Big header of the size in its path, on a +// connection that stays open. +const upstream = net.createServer((s) => { + s.on("error", () => {}); + let pending = ""; + s.on("data", (chunk: Buffer) => { + pending += chunk.toString("latin1"); + let end = pending.indexOf("\r\n\r\n"); + while (end >= 0) { + const head = pending.slice(0, end); + pending = pending.slice(end + 4); + const size = Number(head.split(" ")[1]!.slice(1)); + s.write(`HTTP/1.1 200 OK\r\nX-Big: ${"y".repeat(size)}\r\nContent-Length: 2\r\n\r\nok`); + end = pending.indexOf("\r\n\r\n"); + } + }); +}); + +function get(agent: http.Agent, port: number, label: string, size: number, limit?: number): Promise { + return new Promise((resolve) => { + const onResponse = (res: http.IncomingMessage) => { + res.resume(); + res.on("end", () => { + console.log(label, "status", res.statusCode, "reused", req.reusedSocket); + resolve(); + }); + }; + const path = `/${size}`; + const req = + limit === undefined + ? http.get({ host: "127.0.0.1", port, path, agent }, onResponse) + : http.get({ host: "127.0.0.1", port, path, agent, maxHeaderSize: limit }, onResponse); + req.on("error", (err: NodeJS.ErrnoException) => { + console.log(label, "error", err.code, err.message, "reused", req.reusedSocket); + resolve(); + }); + }); +} + +upstream.listen(0, "127.0.0.1", async () => { + const port = (upstream.address() as { port: number }).port; + + // Queued: the second request waits for the slot and receives the socket. + const queued = new http.Agent({ keepAlive: true, maxSockets: 1 }); + await Promise.all([get(queued, port, "queued first", 100, 1000), get(queued, port, "queued smaller", 100, 50)]); + await Promise.all([get(queued, port, "queued first", 10, 100), get(queued, port, "queued larger", 17000, 20000)]); + await Promise.all([get(queued, port, "queued first", 10, 100), get(queued, port, "queued default", 1000)]); + queued.destroy(); + + // Free: each later request starts after the earlier one finished. + const free = new http.Agent({ keepAlive: true, maxSockets: 1 }); + await get(free, port, "free first", 100, 1000); + await get(free, port, "free smaller", 100, 50); + await get(free, port, "free first", 10, 100); + await get(free, port, "free larger", 17000, 20000); + await get(free, port, "free first", 10, 100); + await get(free, port, "free default", 1000); + free.destroy(); + upstream.close(); +}); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 88a1dea36..d16266dc9 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1945,6 +1945,7 @@ "5570-fs-cp-async.ts": 3348, "5571-fs-cp-deep.ts": 4817, "5585-record-results-inlined.ts": 3419, + "5594-http-agent-header-limit.ts": 2622, "5595-net-ref-unref.ts": 3245, "5596-http-agent-keep-alive.ts": 3194, "5597-http-agent-idle-sockets.ts": 4248, @@ -4970,6 +4971,7 @@ "5570-fs-cp-async.ts": 4176, "5571-fs-cp-deep.ts": 5306, "5585-record-results-inlined.ts": 3957, + "5594-http-agent-header-limit.ts": 2691, "5595-net-ref-unref.ts": 5321, "5596-http-agent-keep-alive.ts": 3568, "5597-http-agent-idle-sockets.ts": 5008, From 5a574e86ce5196d26f9eaed772f26d7f4b971c79 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 16:46:10 -0500 Subject: [PATCH 15/53] Raise the static hello size ceiling to 96 KiB on Linux The tree-shaking test failed on Linux x64 after main was merged into this branch: "expected 84496 to be less than 81920". Measured with the release runtime pack (hello = console.log("hello", "world")): tree file R+E segment main b866ae323 80288 0x5fe9 989d0d6bc 79928 0x5fe9 merge 474012f04 84488 0x6579 No runtime family comes back: hello's absence checks still pass. The code that grows is the cycle collector and the release paths that feed it, which hello's exit reaches (scr_lib_init -> scr_lib_cleanup -> scr_arr_release -> scr_cyc_on_release -> scr_cyc_collect_due -> the pass). Main grew it by 1,447 bytes (the death rule and scr_mem_order_for_free), this branch by 1,386 (the inline nursery appends in the runtime's release functions, scr_cyc_route_bypassed and the window). Main left .text 23 bytes under the executable segment's 24 KiB page, so together they cross it, and every later segment and the file move by 4 KiB. 96 KiB, the Mach-O ceiling, restores several pages of slack. --- tests/harness/runtime-tree-shaking.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/harness/runtime-tree-shaking.test.ts b/tests/harness/runtime-tree-shaking.test.ts index b0d09545f..7d85a48c3 100644 --- a/tests/harness/runtime-tree-shaking.test.ts +++ b/tests/harness/runtime-tree-shaking.test.ts @@ -160,12 +160,14 @@ nativeToolchainTest( // Symbol absence is the primary reachability contract. Keep a deliberately // roomy, platform-specific hello-world ceiling too: it catches losing // section GC without pinning an exact linker/SDK byte count. The canonical - // Linux build is about 64-68KB and current Mach-O builds are about 70KB. - // ELF segments are page aligned, so the Linux size moves in 4 KiB steps - // whenever .text crosses a page boundary. These limits leave several - // native pages of linker-version slack while remaining far below the - // former roughly-400KB always-linked runtime. - const helloSizeLimit = process.platform === "linux" ? 80 * 1024 : 96 * 1024; + // Linux build is about 84KB and current Mach-O builds are about 70KB. Most + // of Linux hello's runtime code is the cycle collector, which its exit + // path reaches (scr_lib_cleanup releases an array). ELF segments are page + // aligned, so the Linux size moves in 4 KiB steps whenever .text crosses + // a page boundary. These limits leave several native pages of + // linker-version slack while remaining far below the former roughly-400KB + // always-linked runtime. + const helloSizeLimit = 96 * 1024; expect(statSync(hello.binaryPath).size).toBeLessThan(helloSizeLimit); }, ); From 8a204b8e0f06f3162b225552438c10aada14e26b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:57:19 -0700 Subject: [PATCH 16/53] Dispatch methods on class instances passed to untyped JS callbacks `list.map((rec) => rec.match(x))` over an array of class instances in a JavaScript file lowers the callback with a checked-dynamic parameter and checks the boxed closure into the native callback type. The instances cross into the dynamic tier only when the callback is called, inside that function boundary, so the native-capsule dispatch never learned their classes and `rec.match` threw "rec.match is not a function". One class or several, inherited methods or own, all failed (chardet's recogniser list, any `.map`/`.filter`/`.forEach` with an untyped arrow). A dynamic value checked into a function type now registers the classes its parameters carry, as a dynFrom of those values already does. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 6 +++ .../6188-js-callback-class-instances.js.json | 6 +++ .../6188-js-callback-class-instances.js | 39 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6188-js-callback-class-instances.js.json create mode 100644 tests/corpus/6188-js-callback-class-instances.js diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 120032bf5..a0ed496e1 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -830,6 +830,12 @@ export class ClassDynamicDispatch { break; case "dynCheck": if (isDynTypedRefType(expr.type)) rewrite.add(fn); + // A dynamic function checked into a native function type + // receives its arguments as dynamic values: class instances + // passed to it cross the boundary when it is called (a JS + // `list.map((rec) => rec.match(x))` callback with an untyped + // parameter). + if (expr.type.kind === "func") expr.type.params.forEach(discover); break; case "libCall": if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6188-js-callback-class-instances.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6188-js-callback-class-instances.js.json new file mode 100644 index 000000000..baf651d61 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6188-js-callback-class-instances.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6188-js-callback-class-instances.js" + ], + "diags": [] +} diff --git a/tests/corpus/6188-js-callback-class-instances.js b/tests/corpus/6188-js-callback-class-instances.js new file mode 100644 index 000000000..22df40f9b --- /dev/null +++ b/tests/corpus/6188-js-callback-class-instances.js @@ -0,0 +1,39 @@ +// Class instances passed to an untyped JavaScript callback (chardet's +// `recognisers.map((rec) => rec.match(context))`): the callback reads its +// parameter as a dynamic value, and method calls on it reach the +// instances' compiled methods, inherited ones included. + +class Recogniser { + name() { + return 'base'; + } + match(input) { + return input.length > 2 ? `${this.name()}:${input.length}` : null; + } +} +class ShiftJis extends Recogniser { + name() { + return 'Shift_JIS'; + } +} +class Big5 extends Recogniser { + name() { + return 'Big5'; + } + match(input) { + return `big5 ${super.match(input)}`; + } +} +class Utf8 { + match(input) { + return `utf8:${input}`; + } +} + +const one = [new ShiftJis(), new ShiftJis()]; +console.log(one.map((rec) => rec.match('abc')).join(' ')); + +const mixed = [new ShiftJis(), new Big5(), new Utf8()]; +console.log(JSON.stringify(mixed.map((rec) => rec.match('abcd')))); +console.log(mixed.filter((rec) => rec.match('ab') !== null).length); +mixed.forEach((rec, i) => console.log(i, rec.match('xyz'))); From 29909ed1aa6011ece16726cc26a23d62f98fd645 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:57:20 -0700 Subject: [PATCH 17/53] Give tsc's void-init export preamble no storage for class exports tsc opens every CommonJS file with `exports.A = exports.B = void 0;` and later aliases `exports.A = A;`. Collection registered an export global for the chain's first name at the preamble, while the preamble lowered to nothing and the class alias registered and stored nothing. Importers then read `lib.A` from a global that was never assigned, and `new lib.A()` crashed with a segfault (chardet's encoding modules). The preamble detection moves to program.ts so collection and lowering share it. A dropped preamble whose real assignment is a class alias now registers no storage, so importers resolve to the class. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 6 ++ .../src/frontend/lowering/lower-stmts.ts | 43 +------------ packages/compiler/src/frontend/program.ts | 61 +++++++++++++++++++ .../main.js.json | 7 +++ .../lib.js | 25 ++++++++ .../main.js | 9 +++ .../package.json | 1 + 7 files changed, 111 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6189-cjs-void-preamble-class-exports/main.js.json create mode 100644 tests/corpus/6189-cjs-void-preamble-class-exports/lib.js create mode 100644 tests/corpus/6189-cjs-void-preamble-class-exports/main.js create mode 100644 tests/corpus/6189-cjs-void-preamble-class-exports/package.json diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index df6677d5b..9f651925b 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -18,6 +18,7 @@ import { isRelativeSpecifier } from "../workspace-registry.js"; import { canonicalBuiltinModule, cjsExportAssignmentOf, + cjsVoidInitPreambleReals, cjsExportDiscardReason, entryPackageFilePredicate, isCjsJsFile, @@ -1398,6 +1399,11 @@ export function collectGlobals( // symbol (cjsMemberExportClassSymbol) and the class registry // applies unchanged. if (lowerer.cjsMemberExportClassSymbol(cjs.expr) !== null) continue; + // A dropped tsc void-init preamble whose real assignment is such a + // class alias registers nothing either: storage here would never + // be assigned, and importers would read it instead of the class. + const real = cjsVoidInitPreambleReals(cjs)?.get(cjs.name.text); + if (real && lowerer.cjsMemberExportClassSymbol(real.expr) !== null) continue; registerExport(cjs.name, cjs.name.text, cjs.value); continue; } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 65f39c6ab..40dc3ccae 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -60,6 +60,7 @@ import { import { enforceLibBoundary, lowerSurplusCalls } from "./lib-boundary.js"; import { cjsExportAssignmentOf, + cjsVoidInitPreambleReals, cjsExportDiscardReason, cjsExportTargetLiteral, isCjsJsFile, @@ -6006,47 +6007,7 @@ function lowerCjsExportStatement( // the real assignment's position already orders. Preambles with no // later real assignment keep the ordinary path (the export IS // undefined then). - if (ts.isIdentifier(cjs.name)) { - const isExportsMember = (e: ts.Expression): e is ts.PropertyAccessExpression => - ts.isPropertyAccessExpression(e) && - ts.isIdentifier(e.name) && - ((ts.isIdentifier(e.expression) && e.expression.text === "exports") || - (ts.isPropertyAccessExpression(e.expression) && - ts.isIdentifier(e.expression.expression) && - e.expression.expression.text === "module" && - ts.isIdentifier(e.expression.name) && - e.expression.name.text === "exports")); - const isVoid = (e: ts.Expression): boolean => { - while (ts.isParenthesizedExpression(e)) e = e.expression; - return ( - e.kind === ts.SyntaxKind.VoidExpression || (ts.isIdentifier(e) && e.text === "undefined") - ); - }; - // chains: `exports.a = exports.b = void 0;` collects every name - const names: string[] = [cjs.name.text]; - let tail: ts.Expression = cjs.value; - while ( - ts.isBinaryExpression(tail) && - tail.operatorToken.kind === ts.SyntaxKind.EqualsToken && - isExportsMember(tail.left) - ) { - names.push((tail.left.name as ts.Identifier).text); - tail = tail.right; - } - if (isVoid(tail)) { - const sfHere = cjs.expr.getSourceFile(); - const myPos = cjs.expr.getStart(sfHere); - const laterRealOf = (name: string): boolean => - sfHere.statements.some((s) => { - const other = cjsExportAssignmentOf(s); - if (other?.kind !== "member" || !ts.isIdentifier(other.name)) return false; - if (other.name.text !== name) return false; - if (other.expr.getStart(sfHere) <= myPos) return false; - return !isVoid(other.value); - }); - if (names.every(laterRealOf)) return { kind: "block", body: [], loc }; - } - } + if (cjsVoidInitPreambleReals(cjs) !== null) return { kind: "block", body: [], loc }; const sf = cjs.expr.getSourceFile(); let root: IrGlobal | undefined; for (const stmt of sf.statements) { diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index df0cb673e..0deb5261d 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5830,6 +5830,67 @@ export function cjsExportAssignmentOf( return null; } +/** The tsc VOID-INIT PREAMBLE (`exports.a = exports.b = void 0;` with the + * real `exports.a = a;` further down; every tsc-emitted dist file opens its + * exports this way): when every name in the chain has a later real + * assignment, the export takes its value there and the preamble lowers to + * nothing. Returns those later assignments by name, or null when this is + * not such a preamble (a preamble with no later real assignment keeps the + * ordinary path: the export IS undefined then). */ +export function cjsVoidInitPreambleReals( + cjs: Extract, { kind: "member" }>, +): Map, { kind: "member" }>> | null { + if (!ts.isIdentifier(cjs.name)) return null; + const isExportsMember = (e: ts.Expression): e is ts.PropertyAccessExpression => + ts.isPropertyAccessExpression(e) && + ts.isIdentifier(e.name) && + ((ts.isIdentifier(e.expression) && e.expression.text === "exports") || + (ts.isPropertyAccessExpression(e.expression) && + ts.isIdentifier(e.expression.expression) && + e.expression.expression.text === "module" && + ts.isIdentifier(e.expression.name) && + e.expression.name.text === "exports")); + const isVoid = (e: ts.Expression): boolean => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ( + e.kind === ts.SyntaxKind.VoidExpression || (ts.isIdentifier(e) && e.text === "undefined") + ); + }; + // chains: `exports.a = exports.b = void 0;` collects every name + const names: string[] = [cjs.name.text]; + let tail: ts.Expression = cjs.value; + while ( + ts.isBinaryExpression(tail) && + tail.operatorToken.kind === ts.SyntaxKind.EqualsToken && + isExportsMember(tail.left) + ) { + names.push((tail.left.name as ts.Identifier).text); + tail = tail.right; + } + if (!isVoid(tail)) return null; + const sf = cjs.expr.getSourceFile(); + const myPos = cjs.expr.getStart(sf); + const reals = new Map< + string, + Extract, { kind: "member" }> + >(); + for (const name of names) { + const real = sf.statements + .map((s) => cjsExportAssignmentOf(s)) + .find( + (other) => + other?.kind === "member" && + ts.isIdentifier(other.name) && + other.name.text === name && + other.expr.getStart(sf) > myPos && + !isVoid(other.value), + ); + if (real?.kind !== "member") return null; + reals.set(name, real); + } + return reals; +} + /** Why Node would DISCARD this CommonJS export statement, or null when it * survives (Node's export-object identity rules — checker-free, pure * AST): `module.exports = {...}` REPLACES the export object, so members diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6189-cjs-void-preamble-class-exports/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6189-cjs-void-preamble-class-exports/main.js.json new file mode 100644 index 000000000..3e7347489 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6189-cjs-void-preamble-class-exports/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6189-cjs-void-preamble-class-exports/lib.js", + "/tests/corpus/6189-cjs-void-preamble-class-exports/main.js" + ], + "diags": [] +} diff --git a/tests/corpus/6189-cjs-void-preamble-class-exports/lib.js b/tests/corpus/6189-cjs-void-preamble-class-exports/lib.js new file mode 100644 index 000000000..dfbfcc30e --- /dev/null +++ b/tests/corpus/6189-cjs-void-preamble-class-exports/lib.js @@ -0,0 +1,25 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Big5 = exports.ShiftJis = exports.describe = void 0; +class Recogniser { + name() { + return 'base'; + } +} +class ShiftJis extends Recogniser { + name() { + return 'Shift_JIS'; + } +} +exports.ShiftJis = ShiftJis; +class Big5 extends Recogniser { + name() { + return 'Big5'; + } +} +exports.Big5 = Big5; +/** @param {Recogniser} rec */ +function describe(rec) { + return `recogniser ${rec.name()}`; +} +exports.describe = describe; diff --git a/tests/corpus/6189-cjs-void-preamble-class-exports/main.js b/tests/corpus/6189-cjs-void-preamble-class-exports/main.js new file mode 100644 index 000000000..385bc7a4f --- /dev/null +++ b/tests/corpus/6189-cjs-void-preamble-class-exports/main.js @@ -0,0 +1,9 @@ +// tsc's CommonJS export preamble (`exports.A = exports.B = void 0;`) ahead +// of class exports (chardet's encoding modules): the class read through +// the module object is the class itself, for every name in the chain. +"use strict"; +const lib = require("./lib.js"); + +const list = [new lib.ShiftJis(), new lib.Big5()]; +for (const rec of list) console.log(lib.describe(rec), rec instanceof lib.ShiftJis); +console.log(typeof lib.Big5, new lib.Big5().name()); diff --git a/tests/corpus/6189-cjs-void-preamble-class-exports/package.json b/tests/corpus/6189-cjs-void-preamble-class-exports/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6189-cjs-void-preamble-class-exports/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } From 77b137268d528b2c7d88b7038c9287b8e9959283 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:15:13 -0700 Subject: [PATCH 18/53] Visit each Set-Cookie value separately in Headers#forEach Fetch's "sort and combine" never combines Set-Cookie: Node's Headers iterator yields one entry per Set-Cookie value, in insertion order, while get() still joins them with ", ". The native forEach walked the sorted unique names and read the combined value, so repeated cookies reached the callback as one joined string. forEach now indexes the expanded list per step (sf_headers_sorted_entry), keeping the live re-sort so callback mutations shift later entries as in Node. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_fetch.c | 63 +++++++++++++++---- .../fetch-headers-set-cookie-iteration.ts | 63 +++++++++++++++++++ 2 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 tests/corpus/fetch-headers-set-cookie-iteration.ts diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index f586b96f8..dc53c7f22 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -2386,6 +2386,50 @@ static ScrStr **sf_headers_sorted_names(SfHeaders *h, size_t *count_out) { return names; } +static bool sf_headers_is_set_cookie(const ScrStr *name) { + return name->len == 10 && memcmp(name->data, "set-cookie", 10) == 0; +} + +/* The `index`th entry of Fetch's "sort and combine" list: names in + * byte order, each with its combined value, except that every set-cookie + * value is its own entry (in insertion order), as Node's Headers + * iterator yields them. False past the end. Outputs are +1. */ +static bool sf_headers_sorted_entry(SfHeaders *h, size_t index, + ScrStr **name_out, + ScrStr **value_out) { + size_t count = 0; + ScrStr **names = sf_headers_sorted_names(h, &count); + bool found = false; + size_t at = 0; + for (size_t i = 0; i < count && !found; i++) { + if (!sf_headers_is_set_cookie(names[i])) { + if (at == index) { + *name_out = scr_str_retain(names[i]); + *value_out = sf_headers_get_value(h, names[i]); + found = true; + } + at++; + continue; + } + size_t n = (size_t)scr_arr_len(h->pairs); + for (size_t j = 0; j + 1 < n && !found; j += 2) { + ScrStr *key = scr_arr_get_ref(h->pairs, (double)j); + bool match = sf_headers_is_set_cookie(key); + scr_str_release(key); + if (!match) continue; + if (at == index) { + *name_out = scr_str_retain(names[i]); + *value_out = scr_arr_get_ref(h->pairs, (double)(j + 1)); + found = true; + } + at++; + } + } + for (size_t j = 0; j < count; j++) scr_str_release(names[j]); + free(names); + return found; +} + static ScrDyn *sf_headers_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, @@ -2438,20 +2482,15 @@ static ScrDyn *sf_headers_invoke(void *ptr, ScrDyn *self, * a future name skips it, inserting a later name visits it, and * inserting before the current index can make the current name appear * again. The old one-time snapshot was only safe while every native - * Headers handle was immutable. */ + * Headers handle was immutable. Set-Cookie values are visited one by + * one (sf_headers_sorted_entry). */ for (size_t i = 0;; i++) { - size_t count = 0; - ScrStr **names = sf_headers_sorted_names(h, &count); - if (i >= count) { - for (size_t j = 0; j < count; j++) scr_str_release(names[j]); - free(names); - break; - } - ScrStr *value = sf_headers_get_value(h, names[i]); + ScrStr *name = NULL; + ScrStr *value = NULL; + if (!sf_headers_sorted_entry(h, i, &name, &value)) break; ScrDyn *boxed_value = scr_dyn_new_str(value); - ScrDyn *boxed_name = scr_dyn_new_str(names[i]); - for (size_t j = 0; j < count; j++) scr_str_release(names[j]); - free(names); + ScrDyn *boxed_name = scr_dyn_new_str(name); + scr_str_release(name); ScrDyn *call_args[3] = {boxed_value, boxed_name, self}; scr_dyn_this_push_dyn(this_arg); ScrDyn *result = diff --git a/tests/corpus/fetch-headers-set-cookie-iteration.ts b/tests/corpus/fetch-headers-set-cookie-iteration.ts new file mode 100644 index 000000000..4d39bab70 --- /dev/null +++ b/tests/corpus/fetch-headers-set-cookie-iteration.ts @@ -0,0 +1,63 @@ +// Headers iteration follows Fetch's "sort and combine": names in byte +// order with their combined values, except that every Set-Cookie value is +// visited on its own. get() still joins them; getSetCookie() lists them. +const h = new Headers(); +h.append("set-cookie", "a=1; Path=/"); +h.append("x-b", "1"); +h.append("Set-Cookie", "b=2, c=3"); +h.append("x-a", "1"); +h.append("x-a", "2"); +h.append("set-cookie", "d=4"); +h.forEach((value, name) => console.log(`${name}: ${value}`)); +console.log(JSON.stringify(h.get("set-cookie"))); +console.log(JSON.stringify(h.getSetCookie())); + +// A thisArg and the Headers object reach the callback. +const self = { tag: "this" }; +h.forEach(function (this: typeof self, _value, name, headers) { + if (name === "x-b") console.log(this.tag, headers === h); +}, self); + +// Iteration is live and indexes the expanded list: deleting the cookies +// while visiting the first one shifts the next name into its slot. +const live = new Headers(); +live.append("set-cookie", "a=1"); +live.append("set-cookie", "b=2"); +live.append("x-z", "z"); +const seen: string[] = []; +live.forEach((value, name) => { + seen.push(`${name}=${value}`); + if (value === "a=1") live.delete("set-cookie"); +}); +console.log(seen.join(" | ")); + +// Appending a cookie during the walk is visited when it sorts later. +const grow = new Headers(); +grow.append("set-cookie", "a=1"); +grow.append("x-y", "y"); +const grown: string[] = []; +grow.forEach((value, name) => { + grown.push(`${name}=${value}`); + if (value === "a=1") grow.append("set-cookie", "b=2"); +}); +console.log(grown.join(" | ")); + +// Only set-cookie stays separate; cookie and other repeats combine. +const plain = new Headers([ + ["cookie", "a=1"], + ["cookie", "b=2"], + ["accept", "x"], + ["accept", "y"], +]); +plain.forEach((value, name) => console.log(`${name}: ${value}`)); +console.log(JSON.stringify(plain.getSetCookie())); + +// A response's headers walk the same way. +const response = new Response("ok", { + headers: [ + ["set-cookie", "s=1"], + ["set-cookie", "t=2"], + ["content-type", "text/plain"], + ], +}); +response.headers.forEach((value, name) => console.log(`${name}: ${value}`)); From 46d238fc0a70613d011ec5fb5eefd7b17fb92371 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:15:21 -0700 Subject: [PATCH 19/53] Send repeated Set-Cookie request headers as one combined line Headers stores each Set-Cookie value separately, but undici dispatches its header list's combined value: one line with the values joined by ", " at the position of the first one. fetch() wrote one line per value. The request serializer now joins later Set-Cookie values into the first entry and skips the rest, for every HeadersInit form. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_fetch.c | 27 +++++++++++- tests/corpus/fetch-request-set-cookie-wire.ts | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/fetch-request-set-cookie-wire.ts diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index dc53c7f22..f5970ea0f 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -4454,14 +4454,39 @@ static bool sf_start_hop(SfTransfer *t) { } if (connection_close) sf_push_header_text(headers, "connection", "close", 5); else sf_push_header_text(headers, "connection", "keep-alive", 10); + /* Headers keeps each Set-Cookie value separately, but undici sends its + * header list's combined value: one line, the values joined by ", ", + * at the position of the first one. */ + bool set_cookie_sent = false; for (size_t i = 0; i + 1 < user_len; i += 2) { ScrStr *name = scr_arr_get_ref(t->headers, (double)i); ScrStr *value = scr_arr_get_ref(t->headers, (double)(i + 1)); - if (sf_eq_ci(name, "host") || sf_eq_ci(name, "connection")) { + if (sf_eq_ci(name, "host") || sf_eq_ci(name, "connection") || + (set_cookie_sent && sf_eq_ci(name, "set-cookie"))) { scr_str_release(name); scr_str_release(value); continue; } + if (sf_eq_ci(name, "set-cookie")) { + set_cookie_sent = true; + for (size_t j = i + 2; j + 1 < user_len; j += 2) { + ScrStr *later = scr_arr_get_ref(t->headers, (double)j); + bool match = sf_eq_ci(later, "set-cookie"); + scr_str_release(later); + if (!match) continue; + ScrStr *next = scr_arr_get_ref(t->headers, (double)(j + 1)); + size_t joined_len = value->len + 2 + next->len; + char *joined = malloc(joined_len ? joined_len : 1); + if (!joined) sf_oom(); + memcpy(joined, value->data, value->len); + memcpy(joined + value->len, ", ", 2); + memcpy(joined + value->len + 2, next->data, next->len); + scr_str_release(next); + scr_str_release(value); + value = scr_str_new(joined, joined_len); + free(joined); + } + } scr_arr_push_ref(headers, name); scr_arr_push_ref(headers, value); } diff --git a/tests/corpus/fetch-request-set-cookie-wire.ts b/tests/corpus/fetch-request-set-cookie-wire.ts new file mode 100644 index 000000000..54789a3fb --- /dev/null +++ b/tests/corpus/fetch-request-set-cookie-wire.ts @@ -0,0 +1,44 @@ +// fetch() sends its header list's combined values: repeated Set-Cookie +// request headers go out as one line, the values joined by ", ", at the +// position of the first one, whatever form the headers were given in. +import { createServer } from "node:http"; + +function shown(raw: string[]): string { + const lines: string[] = []; + for (let i = 0; i < raw.length; i += 2) { + const name = raw[i]!; + const lower = name.toLowerCase(); + if (lower === "host" || lower === "connection" || lower.startsWith("accept") || lower.startsWith("sec-") || lower === "user-agent") continue; + lines.push(`${name}: ${raw[i + 1]}`); + } + return lines.join(" | "); +} + +const server = createServer((req, res) => { + res.end(shown(req.rawHeaders)); +}); + +server.listen(0, "127.0.0.1", async () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + const url = `http://127.0.0.1:${port}/`; + + const headers = new Headers(); + headers.append("set-cookie", "a=1"); + headers.append("x-first", "1"); + headers.append("set-cookie", "b=2"); + console.log(await (await fetch(url, { headers })).text()); + + const pairs: [string, string][] = [ + ["x-before", "0"], + ["set-cookie", "c=3"], + ["set-cookie", "d=4; Path=/"], + ]; + console.log(await (await fetch(url, { headers: pairs })).text()); + + console.log(await (await fetch(url, { headers: { "set-cookie": "e=5", "x-record": "r" } })).text()); + + const single = new Headers({ "set-cookie": "only=1" }); + console.log(await (await fetch(url, { headers: single })).text()); + server.close(); +}); From 2957a9c042f6f51e0f90910b068e50a51fd4d072 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:15:21 -0700 Subject: [PATCH 20/53] Report unparsable fetch and Request URLs as "Failed to parse URL from" undici's Request constructor wraps the URL parser's failure: TypeError "Failed to parse URL from " with the parser's TypeError as its non-enumerable cause. fetch() rejected, and new Request() threw, with the bare "Invalid URL" error instead. Both paths now replace the pending parser error with the wrapping TypeError (sf_throw_url_parse_failure). Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_fetch.c | 27 ++++++++++++++++ tests/corpus/fetch-invalid-url-cause.ts | 41 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/corpus/fetch-invalid-url-cause.ts diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index f5970ea0f..7aba5a4b2 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -312,6 +312,31 @@ static ScrDyn *sf_type_reason(const char *message, ScrError **error_out) { return d; } +/* Request's URL parse failure: with the URL parser's TypeError pending, + * replace it by undici's TypeError "Failed to parse URL from " + * whose (non-enumerable) cause is the parser error. */ +static void sf_throw_url_parse_failure(const ScrStr *input) { + ScrCaught *caught = scr_exc_take(); + ScrDyn *cause = scr_caught_to_dyn(caught); + scr_caught_release(caught); + /* Sized without the NUL so it stays out of the unit's shared mergeable + * string section (which every program linking this unit carries). */ + static const char prefix[25] = "Failed to parse URL from "; + size_t message_len = sizeof prefix + input->len; + char *message = malloc(message_len); + if (!message) sf_oom(); + memcpy(message, prefix, sizeof prefix); + memcpy(message + sizeof prefix, input->data, input->len); + ScrStr *text = scr_str_new(message, message_len); + free(message); + ScrError *e = scr_error_new(SCR_ERR_TYPE, text); + scr_str_release(text); + scr_error_set_cause(e, cause); + e->cause_enumerable = false; + scr_dyn_release(cause); + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + /* ── AbortSignal ─────────────────────────────────────────────────── */ static void sf_signal_track_reason(SfSignal *s) { @@ -4873,6 +4898,7 @@ ScrDyn *scr_fetch_request_new(ScrDyn *input, ScrDyn *init) { ScrStr *url_text = source ? scr_str_retain(source->url) : scr_dyn_string_coerce_js(input); if (!url_text) return NULL; ScrUrl *url = scr_url_new(url_text); + if (!url) sf_throw_url_parse_failure(url_text); scr_str_release(url_text); if (!url) return NULL; if (url->userinfo->len) { scr_url_release(url); sf_type_error("Request cannot be constructed from a URL that includes credentials"); return NULL; } @@ -5182,6 +5208,7 @@ ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init) { ScrUrl *u = scr_url_new(url); if (!u) { + sf_throw_url_parse_failure(url); scr_dyn_release(coerced_body); scr_arr_release(headers); scr_str_release(method); diff --git a/tests/corpus/fetch-invalid-url-cause.ts b/tests/corpus/fetch-invalid-url-cause.ts new file mode 100644 index 000000000..5517c4ae2 --- /dev/null +++ b/tests/corpus/fetch-invalid-url-cause.ts @@ -0,0 +1,41 @@ +// An unparsable fetch() or Request URL is undici's TypeError +// "Failed to parse URL from ", whose non-enumerable cause is the +// URL parser's TypeError. +function report(label: string, e: unknown): void { + const serialized = JSON.stringify(e); + if (!(e instanceof Error)) { + console.log(label, "not an Error"); + return; + } + console.log(label, e.name, e.message, e instanceof TypeError); + console.log(" has cause:", "cause" in e, "own enumerable:", serialized); + console.log( + " cause:", + e.cause instanceof Error ? `${e.cause.name} ${e.cause.message} ${e.cause instanceof TypeError}` : String(e.cause), + ); +} + +async function main(): Promise { + for (const input of ["http://127.0.0.1:1http://x/", "/relative/path", "", "http://[::1"]) { + try { + await fetch(input); + console.log("resolved?"); + } catch (e) { + report(`fetch(${JSON.stringify(input)})`, e); + } + } + try { + new Request("not a url"); + console.log("constructed?"); + } catch (e) { + report("new Request", e); + } + // A valid URL with credentials keeps its own message. + try { + await fetch("http://user:pass@127.0.0.1:1/"); + } catch (e) { + report("credentials", e); + } +} + +void main(); From 43cefeb56b7c559507185239296f38fe006c7815 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:15:28 -0700 Subject: [PATCH 21/53] Retag flow-narrowed unions before primitive toString() `headers.host.toString()` after `!Array.isArray(headers.host)` narrows a `string | number | string[]` slot to `string | number`, and the union toString lowering accepted the narrowed checker type but emitted the toString node over the wider storage union. The validator rejected the non-stringable arm as an internal compiler error (SC9001). The lowering now retags the read to the proven union (the same checked coercion other narrowed reads use) before the per-union ToString helper. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 9 ++++++- tests/corpus/union-narrowed-tostring.ts | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/union-narrowed-tostring.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..cd96f2aae 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4210,8 +4210,15 @@ function lowerUnionToStringCall( (a.kind === "bytes" && a.elem === "u8"), ); if (!stringable) return null; - const operand = lowerer.lowerExpr(access.expression); + let operand = lowerer.lowerExpr(access.expression); if (operand.type.kind !== "union") return null; + // A flow-narrowed read keeps its wider storage union (`string | number` + // after `!Array.isArray(h.host)` over a `string | number | string[]` + // slot): retag it to the proven union before the per-union helper. + if (operand.type.unionId !== recvT.unionId) { + operand = lowerer.coerceInto(access.expression, operand, recvT); + if (operand.type.kind !== "union" || operand.type.unionId !== recvT.unionId) return null; + } return { kind: "toString", operand, type: STRING, loc: locOf(call) }; } diff --git a/tests/corpus/union-narrowed-tostring.ts b/tests/corpus/union-narrowed-tostring.ts new file mode 100644 index 000000000..93660b332 --- /dev/null +++ b/tests/corpus/union-narrowed-tostring.ts @@ -0,0 +1,27 @@ +// .toString() on a flow-narrowed union whose storage union is wider: the +// read retags to the proven arms, then formats like String(). +import type { OutgoingHttpHeaders } from "node:http"; + +function host(headers?: OutgoingHttpHeaders): string { + if (headers?.host && !Array.isArray(headers.host)) return headers.host.toString(); + return ""; +} +console.log(JSON.stringify([host({ host: "a:1" }), host({ host: 3 }), host({}), host()])); + +function label(v: string | number | string[] | undefined): string { + if (v === undefined) return "none"; + if (Array.isArray(v)) return `list(${v.length})`; + return v.toString(); +} +console.log(label("x"), label(1.5), label(-0), label(["a", "b"]), label(undefined)); + +function flag(v: boolean | number | null): string { + return v === null ? "null" : v.toString(); +} +console.log(flag(true), flag(false), flag(1e21), flag(null)); + +type Field = string | number | { nested: string }; +function field(f: Field): string { + return typeof f === "object" ? f.nested : f.toString(); +} +console.log(field("s"), field(42), field({ nested: "n" })); From def4626396c81e789fe903fc6b803c4cb830914a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:15:28 -0700 Subject: [PATCH 22/53] Lower optional calls of always-present library methods as plain calls `h.getSetCookie?.()` on a native Headers handle failed with SC2020: the optional-chain lowering first reads the callee as a value, and native handle methods have no first-class value. A required standard-library method on a never-nullish receiver always exists, so `?.()` is the plain call. When that method read has no lowering, the optional chain now re-dispatches the plain call. Reads that lower keep the existing path unchanged. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/optional-chains.ts | 47 ++++++++++++++++++- tests/corpus/optional-call-native-method.ts | 28 +++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/optional-call-native-method.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 7da847062..51afc01ab 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -4,6 +4,8 @@ import { DYN, STRING, UNDEFINED_T, VOID, isUnitType, typeEquals } from "../../.. import type { IrExpr, IrStmt, SrcLoc } from "../../../ir/ir.js"; import { isNodeEsmFile, locOf } from "../../program.js"; import type { Lowerer } from "../lowerer.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { constituentTypes } from "../../ts7/checker.js"; /** One optional-chain STEP: `a?.b`, `a?.m(...)`, `a?.[i]` (the token on * the member access) and `f?.()` (the token on the call). The receiver @@ -121,6 +123,31 @@ export function hasOptionalChainGuard(expr: ts.Expression): boolean { } } +/** `recv.m` where `m` is a required method the standard library declares + * and `recv` itself is a non-optional, never-nullish typed receiver. */ +function alwaysPresentStdlibMethod(lowerer: Lowerer, node: ts.Expression): boolean { + if (!ts.isPropertyAccessExpression(node) || node.questionDotToken) return false; + if (hasOptionalChainGuard(node.expression)) return false; + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + if (!symbol || !(symbol.flags & ts.SymbolFlags.Method) || symbol.flags & ts.SymbolFlags.Optional) + return false; + if (!lowerer.isStdlibSymbol(symbol)) return false; + const nullable = + ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.Void; + const receiverType = lowerer.checker.getTypeAtLocation(node.expression); + const calleeType = lowerer.checker.getTypeAtLocation(node); + const parts = (type: ts.Type): readonly ts.Type[] => + type.flags & ts.TypeFlags.Union ? constituentTypes(type) : [type]; + return ( + parts(receiverType).every((part) => !(part.flags & nullable)) && + parts(calleeType).every((part) => !(part.flags & nullable)) + ); +} + export function lowerOptionalChain( lowerer: Lowerer, expr: ts.CallExpression | ts.PropertyAccessExpression | ts.ElementAccessExpression, @@ -167,7 +194,25 @@ export function lowerOptionalChain( dotNode = tail; recvNode = tail.expression; } - const loweredReceiver = lowerer.lowerExpr(recvNode); + // `h.getSetCookie?.()`: a required standard-library METHOD on a + // never-nullish receiver always exists, so `?.()` is the plain call. + // Native handle methods have no first-class value to guard; when the + // method read itself has no lowering, re-dispatch the plain call (which + // reports its own diagnostics) instead of failing on the value read. + let loweredReceiver: IrExpr | null; + if (dotNode === expr && alwaysPresentStdlibMethod(lowerer, recvNode)) { + loweredReceiver = tryLowerExpression(lowerer, recvNode); + if (loweredReceiver === null) { + lowerer.chainHandled.add(dotNode); + try { + return lowerer.lowerExpr(expr); + } finally { + lowerer.chainHandled.delete(dotNode); + } + } + } else { + loweredReceiver = lowerer.lowerExpr(recvNode); + } const receiver = lowerer.runtimeOptionalSourceValue(recvNode, loweredReceiver) ?? loweredReceiver; if (receiver.type.kind === "dyn") { if (dotNode === expr && ts.isCallExpression(expr)) { diff --git a/tests/corpus/optional-call-native-method.ts b/tests/corpus/optional-call-native-method.ts new file mode 100644 index 000000000..e3b9aa4a7 --- /dev/null +++ b/tests/corpus/optional-call-native-method.ts @@ -0,0 +1,28 @@ +// An optional call of a standard-library method that always exists on a +// never-nullish receiver is the plain call (`getSetCookie?.()` is always +// present on Node 24). +const h = new Headers(); +h.append("set-cookie", "a=1"); +h.append("set-cookie", "b=2"); +const cookies = h.getSetCookie?.() ?? []; +console.log(JSON.stringify(cookies)); +console.log(h.get?.("set-cookie")); +console.log(h.has?.("x-missing")); +h.set?.("x-set", "1"); +h.forEach?.((value, name) => console.log(`${name}=${value}`)); + +// The receiver evaluates once; arguments evaluate after it. +let reads = 0; +function headers(): Headers { + reads++; + return h; +} +function arg(): string { + console.log("argument after receiver", reads); + return "x-set"; +} +console.log(headers().get?.(arg()), reads); + +// Strings and arrays: the same rule. +const s = "Hello"; +console.log(s.toUpperCase?.(), [3, 1, 2].sort?.().join(",")); From 4d3dde55a72992470925d71fac1d17a21668b1e6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:17:48 -0700 Subject: [PATCH 23/53] Return undefined from JSON.stringify of unknown roots where it is observed JSON.stringify answers undefined for an undefined, function, or symbol root. Through `unknown` (or a generic instantiated with it) the dyn-root lowering converted the runtime result to a string, so the result was the text "undefined": `text === undefined` was false and `typeof text` was "string". Where the result reaches a use that distinguishes undefined from that text (undefined/null comparisons, typeof, ??, ?., truth tests, a return from a function declaring undefined, or an unannotated local binding used that way), the call now keeps the checked `string | undefined` result the replacer path already produces; later string uses of such a binding narrow by the checker's flow type. Other uses keep the existing string ABI, so programs that cannot observe the difference compile unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/json.ts | 109 +++++++++++++++++- .../json-stringify-unknown-undefined-root.ts | 38 ++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/corpus/json-stringify-unknown-undefined-root.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index a73c9d5e7..7ce902772 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -15,6 +15,7 @@ import { isUnitType, } from "../../../ir/ir.js"; import { optionalStringTags } from "./arguments.js"; +import { constituentTypes } from "../../ts7/checker.js"; /** JSON parse produces checked-dynamic data; a native reviver walks it * bottom-up before any checked cast. Stringify without a callback keeps @@ -177,9 +178,11 @@ export function lowerJsonMethodCall( type: DYN, loc, }; - // Preserve the existing dyn-root string ABI, including its textual - // "undefined" result, while letting the runtime honor toJSON hooks. - return value.type.kind === "dyn" + // A dyn root can serialize to undefined (undefined, a function, a + // symbol). Where the program can observe that (jsonResultObserved), + // keep the actual undefined; elsewhere the dyn-root string ABI + // stays, whose undefined result prints the same text. + return value.type.kind === "dyn" && !jsonResultObserved(lowerer, call) ? { kind: "toString", operand: raw, type: STRING, loc } : { kind: "dynCheck", value: raw, type: lowerer.withUndefinedArm(STRING), loc }; } @@ -276,6 +279,106 @@ function lowerOptionalStringifyRoot( return { kind: "call", callee: helper, args: [value], type: resultT, loc }; } +/** True when a `JSON.stringify` result is used where JavaScript can tell + * undefined from the text "undefined": compared with undefined/null, + * `typeof`, `??`, `?.`, truth tests, a return from a function declaring + * an undefined result, or an unannotated local binding whose references + * do one of these. The lib types say `string`, so other uses (concatenation, + * printing, string parameters) cannot tell the difference. */ +function jsonResultObserved(lowerer: Lowerer, call: ts.CallExpression): boolean { + let node: ts.Node = call; + while (ts.isParenthesizedExpression(node.parent)) node = node.parent; + if (observesUndefined(node as ts.Expression)) return true; + const parent = node.parent; + if (ts.isReturnStatement(parent)) { + let fn: ts.Node | undefined = parent.parent; + while (fn && !ts.isFunctionLike(fn)) fn = fn.parent; + const annotation = fn && ts.isFunctionLike(fn) ? fn.type : undefined; + if (!annotation) return false; + const type = lowerer.checker.getTypeAtLocation(annotation); + const parts = type.flags & ts.TypeFlags.Union ? constituentTypes(type) : [type]; + return parts.some((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0); + } + // Module-scope bindings are globals whose slot type comes from the + // checker's `string`; only function- and block-scoped locals adopt the + // initializer's checked `string | undefined`. + if ( + !ts.isVariableDeclaration(parent) || + parent.initializer !== node || + parent.type || + !ts.isIdentifier(parent.name) || + (ts.isVariableDeclarationList(parent.parent) && + ts.isVariableStatement(parent.parent.parent) && + ts.isSourceFile(parent.parent.parent.parent)) + ) + return false; + const name = parent.name; + const symbol = lowerer.checker.getSymbolAtLocation(name); + if (!symbol) return false; + let scope: ts.Node = parent; + while (!ts.isBlock(scope.parent) && !ts.isSourceFile(scope.parent) && scope.parent) + scope = scope.parent; + let observed = false; + const visit = (current: ts.Node): void => { + if (observed) return; + if (ts.isIdentifier(current)) { + if ( + current !== name && + current.text === name.text && + lowerer.checker.getSymbolAtLocation(current) === symbol && + observesUndefined(current) + ) + observed = true; + return; + } + ts.forEachChild(current, visit); + }; + visit(scope.parent ?? scope); + return observed; +} + +function observesUndefined(expr: ts.Expression): boolean { + let node: ts.Node = expr; + while (ts.isParenthesizedExpression(node.parent)) node = node.parent; + const parent = node.parent; + if (ts.isTypeOfExpression(parent)) return true; + if (ts.isPrefixUnaryExpression(parent) && parent.operator === ts.SyntaxKind.ExclamationToken) + return true; + if ( + (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && + parent.expression === node && + parent.questionDotToken + ) + return true; + if ( + (ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) && + parent.expression === node + ) + return true; + if (ts.isConditionalExpression(parent) && parent.condition === node) return true; + if (ts.isBinaryExpression(parent)) { + const op = parent.operatorToken.kind; + if (op === ts.SyntaxKind.QuestionQuestionToken) return parent.left === node; + if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) + return parent.left === node; + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.EqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken + ) { + let other: ts.Node = parent.left === node ? parent.right : parent.left; + while (ts.isParenthesizedExpression(other)) other = other.expression; + return ( + other.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(other) && other.text === "undefined") || + ts.isVoidExpression(other) + ); + } + } + return false; +} + /** Only explicit null/undefined select the no-callback path. */ function jsonNullishArgument(lowerer: Lowerer, node: ts.Expression): boolean { if (ts.isParenthesizedExpression(node)) return jsonNullishArgument(lowerer, node.expression); diff --git a/tests/corpus/json-stringify-unknown-undefined-root.ts b/tests/corpus/json-stringify-unknown-undefined-root.ts new file mode 100644 index 000000000..52528afbf --- /dev/null +++ b/tests/corpus/json-stringify-unknown-undefined-root.ts @@ -0,0 +1,38 @@ +// JSON.stringify of an unknown (or generic) root whose value is undefined, +// a function, or a symbol returns undefined, not text. Wherever the program +// can tell the two apart (undefined/null comparisons, typeof, ??, ?., truth +// tests, returns declaring undefined, and bindings used that way) the +// native result is the actual undefined. Nested values follow Node's +// member rules: dropped from objects, null in arrays. +const u: unknown = undefined; +const f: unknown = () => 1; +const s: unknown = Symbol("x"); +const ok: unknown = { a: [1, undefined] }; +for (const v of [u, f, s, ok]) { + const t = JSON.stringify(v); + console.log(t === undefined, typeof t, t ?? "fb", t?.length, t == null, t !== undefined); +} +for (const v of [u, ok]) { + const t = JSON.stringify(v); + if (t) console.log("truthy", t.length); + else console.log("falsy"); + console.log(!t, t ? "y" : "n", t || "or", t && "and"); +} +console.log(JSON.stringify(u) === undefined, typeof JSON.stringify(f), JSON.stringify(s) ?? "fb2", JSON.stringify(u)?.length); +console.log(JSON.stringify(u) || "or2", JSON.stringify(ok) || "or3", !JSON.stringify(u)); +function raw(v: T): string | undefined { + return JSON.stringify(v); +} +console.log(typeof raw(u), raw(ok)); +function ser(v: T): string { + const text = JSON.stringify(v); + if (text === undefined) return "undefined result"; + return `string ${text} ${text.length}`; +} +console.log(ser(u), ser({ a: 1 }), ser(1), ser("s"), ser(ok)); +// Unobserved uses keep the plain string path. +const plain = JSON.stringify(ok); +console.log(plain.length, plain, `${JSON.stringify(u)}`, "x" + JSON.stringify(u)); +// A module-scope binding keeps the string slot; direct uses still observe. +const top = JSON.stringify(ok); +console.log(top.length, JSON.stringify(u) === undefined); From 58d3981476bf1ced130095cb4372c32cd64894f8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:20:14 -0700 Subject: [PATCH 24/53] Accept initializer-less symbol-keyed fields assigned in the constructor A computed TypeScript field without an initializer (`[INTERNALS]: T`) was refused even with a stable Symbol()/Symbol.for() or literal key, which contradicts the documented rule that TypeScript fields need an initializer or an unconditional constructor assignment. Such a field now takes its declared type and the identifier fields' definite-assignment rule: an unconditional top-level `this[key] = ...` in the constructor, otherwise the checked deferred-initialization slot. Types that admit undefined (optional fields) start undefined. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 44 ++++++++++++++++--- ...class-symbol-field-constructor-assigned.ts | 43 ++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 tests/corpus/class-symbol-field-constructor-assigned.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c3..6a24b5417 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2644,9 +2644,15 @@ export function collectClassShapeInner( ? String(Number(member.name.text)) : member.name.text; const fieldName = symbolKey?.fieldName ?? stringKey; + // A TypeScript field without an initializer takes its declared + // type and the identifier fields' definite-assignment rule (an + // unconditional `this[key] = ...` at the constructor's top + // level), checked with the unguarded fields below. + const declaredOnly = + !member.initializer && !isJsSourceFile(member.getSourceFile()) && !!member.type; if ( fieldName === null || - (!member.initializer && !isJsSourceFile(member.getSourceFile())) + (!member.initializer && !isJsSourceFile(member.getSourceFile()) && !declaredOnly) ) { lowerer.unsupported( "SC1090", @@ -2659,9 +2665,10 @@ export function collectClassShapeInner( member.type || !member.initializer ? declared : lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(member.initializer)); - const mapped = member.initializer - ? (lowerer.mapTypeOf(inferred) ?? dynFallbackType(lowerer, member, inferred)) - : DYN; + const mapped = + member.initializer || declaredOnly + ? (lowerer.mapTypeOf(inferred) ?? dynFallbackType(lowerer, member, inferred)) + : DYN; let type = mapped?.kind === "record" && isJsSourceFile(member.getSourceFile()) && !member.type ? DYN @@ -2681,7 +2688,24 @@ export function collectClassShapeInner( if (base?.fields.get(fieldName)?.kind === "dyn") type = DYN; if (base && !fields.has(fieldName) && lowerer.dynConvertible(type)) type = DYN; } - const undefinedInitializer = !member.initializer ? locOf(member) : undefined; + const undefinedInitializer = + !member.initializer && (!declaredOnly || type.kind === "dyn") + ? locOf(member) + : undefined; + if ( + declaredOnly && + type.kind !== "dyn" && + !( + type.kind === "union" && + lowerer.unions.get(type.unionId)?.arms.some((a) => a.kind === "undefinedT") + ) + ) { + unguardedFields.push({ + node: member.name, + name: fieldName, + why: `initializer-less computed fields not assigned at the constructor's top level (nothing guarantees '${symbolKey ? symbolFieldDisplayName(fieldName) : fieldName}' is assigned before a read — assign it unconditionally at the top of the constructor, give it an initializer, or include undefined in its type)`, + }); + } const previous = fields.get(fieldName); if (previous) { if (symbolKey && !symbolFields.has(symbolKey.identity)) { @@ -3588,6 +3612,16 @@ export function collectClassShapeInner( stmt.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword ) { topAssigned.add(stmt.expression.left.name.text); + } else if ( + ts.isExpressionStatement(stmt) && + ts.isBinaryExpression(stmt.expression) && + stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isElementAccessExpression(stmt.expression.left) && + stmt.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + const key = stmt.expression.left.argumentExpression; + const name = classSymbolKeyOf(lowerer, key)?.fieldName ?? lowerer.foldedStringKeyOf(key); + if (name !== null) topAssigned.add(name); } } for (const f of unguardedFields) { diff --git a/tests/corpus/class-symbol-field-constructor-assigned.ts b/tests/corpus/class-symbol-field-constructor-assigned.ts new file mode 100644 index 000000000..55e6e4e4a --- /dev/null +++ b/tests/corpus/class-symbol-field-constructor-assigned.ts @@ -0,0 +1,43 @@ +// Symbol-keyed (and literal-keyed) TypeScript fields without an +// initializer take their declared type when the constructor assigns them +// unconditionally; an optional one starts undefined. A field assigned +// later (here from a method the constructor calls) reads through the +// checked deferred-initialization slot. +const Internal = Symbol("internal"); +const Registered = Symbol.for("pkg.registered"); +const Pending = Symbol("pending"); + +class Box { + [Internal]: { n: number }; + [Registered]: string[]; + ["literal"]: number; + [Pending]?: string[]; + constructor(n: number) { + this[Internal] = { n }; + this[Registered] = ["a"]; + this["literal"] = n * 2; + } + get n(): number { + return this[Internal].n; + } +} + +const b = new Box(5); +console.log(b.n, b[Registered].length, b.literal, b[Pending] === undefined); +b[Internal].n = 7; +b[Registered].push("b"); +b[Pending] = ["p"]; +console.log(b.n, b[Registered].join(","), b[Pending]?.join(",")); +console.log(JSON.stringify(Object.entries({ literal: b.literal }))); + +class Late { + [Internal]!: { n: number }; + constructor(flag: boolean) { + if (flag) this[Internal] = { n: 1 }; + else this.init(); + } + init(): void { + this[Internal] = { n: 2 }; + } +} +console.log(new Late(true)[Internal].n, new Late(false)[Internal].n); From 1fd405c03373a0422f06abb3218d3c5f48b71f05 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:25:55 -0700 Subject: [PATCH 25/53] Add Date.prototype.toUTCString `new Date(ms).toUTCString()` was refused (SC2020). It now lowers to a runtime formatter over the stored time value with V8's output: "Www, DD Mmm YYYY HH:mm:ss GMT" in UTC, a leading "-" and at least four digits for negative years, and "Invalid Date" for an invalid time value. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/builtins/dates.ts | 5 +- .../src/frontend/lowering/surfaces.ts | 7 ++ .../compiler/src/ir/builtin-signatures.ts | 3 + packages/runtime/src/scr_lib.c | 66 +++++++++++++++++++ packages/runtime/src/scr_number.h | 3 + tests/corpus/date-to-utc-string.ts | 26 ++++++++ tests/harness/library-profile.test.ts | 1 + 8 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/date-to-utc-string.ts diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02bf37192..d5aebf0d8 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -314,6 +314,7 @@ export const LIB_FN_SYMS: Record = { "date.getUTCDate": "scr_date_get_date_utc", "date.getDay": "scr_date_get_day_local", "date.getUTCDay": "scr_date_get_day_utc", + "date.toUTCString": "scr_date_to_utc_string", "date.getHours": "scr_date_get_hours_local", "date.getUTCHours": "scr_date_get_hours_utc", "date.getMinutes": "scr_date_get_minutes_local", diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts index fa880bdc9..0101a3ec3 100644 --- a/packages/compiler/src/frontend/lowering/builtins/dates.ts +++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts @@ -34,7 +34,7 @@ const DATE_GETTER_FNS: Readonly> = { }; const DATE_METHOD_HINT = - "getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering"; + "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering"; /** The Date slice: statics plus read-only Date values backed by one * TimeClip'd millisecond scalar. Construction lives in lowerNew; @@ -113,6 +113,9 @@ export function lowerDateCall( if (name === "toISOString") { return { kind: "libCall", fn: "date.toISOStringValue", args: [receiver], type: STRING, loc }; } + if (name === "toUTCString") { + return { kind: "libCall", fn: "date.toUTCString", args: [receiver], type: STRING, loc }; + } const fn = own(DATE_GETTER_FNS, name); if (fn !== undefined) { return { kind: "libCall", fn, args: [receiver], type: F64, loc }; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 73cc58c45..598fb6f9a 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2207,6 +2207,13 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ fns: ["date.toISOString", "date.toISOStringValue"], note: "UTC ISO formatting over constructed and stored Date values", }, + { + id: "stdlib.date.toUTCString", + kind: "stdlib", + name: "Date.prototype.toUTCString", + fns: ["date.toUTCString"], + note: "RFC 7231 IMF-fixdate formatting over stored Date values", + }, { id: "stdlib.date.getFullYear", kind: "stdlib", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 08c493ae4..a3462c7d8 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4169,6 +4169,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "date.valueOf": { argTypes: [DATE_T], result: F64 }, "date.toISOString": { argTypes: [F64], result: STRING }, "date.toISOStringValue": { argTypes: [DATE_T], result: STRING }, + /** Date.prototype.toUTCString over a stored Date ("Invalid Date" for + * NaN; never throws). */ + "date.toUTCString": { argTypes: [DATE_T], result: STRING }, "date.getFullYear": { argTypes: [DATE_T], result: F64 }, "date.getUTCFullYear": { argTypes: [DATE_T], result: F64 }, "date.getMonth": { argTypes: [DATE_T], result: F64 }, diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index fd51e9c78..cd3f5d9a7 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10296,6 +10296,72 @@ ScrStr *scr_date_to_iso(double ms) { return scr_str_new(buf, (size_t)len); } +/* Node's Date.prototype.toUTCString over a stored (TimeClip'd) time + * value: "Www, DD Mmm YYYY HH:mm:ss GMT", a "-" and at least four digits + * for negative years, and "Invalid Date" for NaN. Never throws. +1. + * The text is assembled from function-local arrays rather than string + * literals: literals would land in this unit's shared mergeable string + * section and grow every executable, while these arrays are removed with + * the function by --gc-sections when a program never calls it. */ +ScrStr *scr_date_to_utc_string(double ms) { + /* Sized without the terminating NUL, so none is a C string the + * compiler could move into a mergeable string section. */ + static const char invalid[12] = "Invalid Date"; + static const char weekdays[21] = "SunMonTueWedThuFriSat"; + static const char months[36] = "JanFebMarAprMayJunJulAugSepOctNovDec"; + if (!(fabs(ms) <= 8640000000000000.0)) return scr_str_new(invalid, sizeof invalid); + double t = trunc(ms); + double dayd = floor(t / 86400000.0); + long long msday = (long long)(t - dayd * 86400000.0); + long long days = (long long)dayd; + int weekday = (int)(((days + 4) % 7 + 7) % 7); + long long z = days + 719468; + long long era = (z >= 0 ? z : z - 146096) / 146097; + unsigned long long doe = (unsigned long long)(z - era * 146097); + unsigned long long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long long y = (long long)yoe + era * 400; + unsigned long long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + unsigned long long mp = (5 * doy + 2) / 153; + unsigned long long d = doy - (153 * mp + 2) / 5 + 1; + unsigned long long m = mp < 10 ? mp + 3 : mp - 9; + if (m <= 2) y += 1; + int hh = (int)(msday / 3600000), mi = (int)(msday / 60000 % 60), + ss = (int)(msday / 1000 % 60); + char buf[40]; + size_t n = 0; + memcpy(buf, weekdays + 3 * weekday, 3); + n = 3; + buf[n++] = ','; + buf[n++] = ' '; + buf[n++] = (char)('0' + d / 10); + buf[n++] = (char)('0' + d % 10); + buf[n++] = ' '; + memcpy(buf + n, months + 3 * (m - 1), 3); + n += 3; + buf[n++] = ' '; + if (y < 0) buf[n++] = '-'; + unsigned long long year = (unsigned long long)(y < 0 ? -y : y); + char digits[20]; + size_t count = 0; + do { + digits[count++] = (char)('0' + year % 10); + year /= 10; + } while (year > 0); + while (count < 4) digits[count++] = '0'; + while (count > 0) buf[n++] = digits[--count]; + int parts[3] = {hh, mi, ss}; + for (int i = 0; i < 3; i++) { + buf[n++] = i == 0 ? ' ' : ':'; + buf[n++] = (char)('0' + parts[i] / 10); + buf[n++] = (char)('0' + parts[i] % 10); + } + buf[n++] = ' '; + buf[n++] = 'G'; + buf[n++] = 'M'; + buf[n++] = 'T'; + return scr_str_new(buf, n); +} + /* ── new Date(dateString).getTime() ────────────────────────────────────── * * The BOUNDED date-string parse (documented divergence — V8's parser diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index 84db27075..029a75d05 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -101,6 +101,9 @@ double scr_date_get_time(double ms); * 0–9999). THROWS Node's "Invalid time value" RangeError on NaN or * |ms| > 8.64e15 and returns NULL; +1 otherwise. */ ScrStr *scr_date_to_iso(double ms); +/* Node's Date.prototype.toUTCString ("Thu, 01 Jan 1970 00:00:00 GMT"; + * "Invalid Date" for NaN). Never throws; +1. */ +ScrStr *scr_date_to_utc_string(double ms); /* new Date(dateString).getTime(): the BOUNDED parse (the X509 validity * shape + ECMA's format with an explicit offset); NaN elsewhere. */ double scr_date_parse_get_time(ScrStr *s); diff --git a/tests/corpus/date-to-utc-string.ts b/tests/corpus/date-to-utc-string.ts new file mode 100644 index 000000000..633de5439 --- /dev/null +++ b/tests/corpus/date-to-utc-string.ts @@ -0,0 +1,26 @@ +// Date.prototype.toUTCString: "Www, DD Mmm YYYY HH:mm:ss GMT" in UTC, a +// leading "-" and at least four digits for negative years, "Invalid Date" +// for an invalid time value. +const times = [ + 0, + -1, + 1445412480000, + 951782400000, // 2000-02-29 + Date.UTC(1969, 11, 31, 23, 59, 59, 999), + Date.UTC(0, 0, 1) - 1900 * 365.2425 * 86400000, + Date.UTC(-1, 5, 15, 12, 30, 45), + Date.UTC(-271821, 3, 20), + 8.64e15, + -8.64e15, + 1234.9, + -1234.9, + NaN, + 8.64e15 + 1, +]; +for (const t of times) { + const d = new Date(t); + console.log(d.toUTCString()); +} +for (let day = 0; day < 7; day++) console.log(new Date(Date.UTC(2024, 0, day + 1)).toUTCString()); +for (let month = 0; month < 12; month++) console.log(new Date(Date.UTC(2023, month, 9, 8, 7, 6)).toUTCString()); +console.log(new Date("2015-10-21T07:28:00Z").toUTCString(), new Date("garbage").toUTCString()); diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index fc243ccb5..caa3b4e72 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -552,6 +552,7 @@ describe("library profile fences", () => { "stdlib.date.now", "stdlib.date.parse", "stdlib.date.toISOString", + "stdlib.date.toUTCString", "stdlib.date.valueOf", ]); expect(r.profile.fences[0]!.surfaces.every((s) => s.detector !== undefined)).toBe(true); From 6c1af69d038379f02ab4a33938729904003a7ee5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:25:55 -0700 Subject: [PATCH 26/53] Add static Response.json `Response.json(data, init)` was refused (SC2020). It now builds a native Response the way undici does: ResponseInit converts, the data serializes through the checked-dynamic JSON serializer (toJSON hooks included; TypeError "Value is not JSON serializable" when JSON.stringify answers undefined), then the response initializes like the constructor with "application/json" as the default content type, so status, statusText, header, and null-body-status errors keep their order. The fetch compatibility profile moves Response.json from unsupported to a static operation with the static-response-json differential fixture. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + packages/compiler/src/compat/fetch-profile.ts | 12 +++- .../src/frontend/lowering/lower-calls.ts | 3 + .../src/frontend/lowering/lower-stdlib.ts | 38 +++++++++++- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 1 + packages/runtime/src/scr_fetch.c | 61 +++++++++++++++++-- packages/runtime/src/scr_generator.h | 1 + .../fetch/static-response-json/main.mts | 30 +++++++++ tests/harness/fetch.test.ts | 1 + 10 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/fetch/static-response-json/main.mts diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index d5aebf0d8..82b339b05 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -810,6 +810,7 @@ export const LIB_FN_SYMS: Record = { // ambient-this read never throw. The fs dyn read is the sync-fs story. "fetch.start": "scr_fetch_static", "fetch.responseNew": "scr_fetch_response_new", + "fetch.responseJsonStatic": "scr_fetch_response_json_static", "fetch.input": "scr_fetch_input", "abort.controllerNew": "scr_fetch_abort_controller_new", "event.targetNew": "scr_event_target_new", diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts index c1b43a810..c4d1e3f50 100644 --- a/packages/compiler/src/compat/fetch-profile.ts +++ b/packages/compiler/src/compat/fetch-profile.ts @@ -518,6 +518,15 @@ export const NODE24_FETCH_COMPAT_PROFILE = { evidence: [fixture("static")], }), ), + { + id: "stdlib.response.static.json", + name: "Response.json", + kind: "static-method", + facets: ["webidl-conversion", "error-shape"], + scope: + "data that crosses the checked-dynamic boundary, serialized with JSON.stringify (toJSON hooks included); ResponseInit as for the constructor", + evidence: [fixture("static-response-json")], + }, ...["json", "text", "bytes", "arrayBuffer"].map((member): FetchCompatOperation => ({ id: `stdlib.response.${member}`, name: `Response.${member}`, @@ -681,7 +690,8 @@ export const NODE24_FETCH_COMPAT_PROFILE = { ), staticEntry("stdlib.response.constructor", "Response", "constructor", "constructor"), - ...["error", "json", "redirect"].map((member) => + staticEntry("stdlib.response.static.json", "Response", "json", "static"), + ...["error", "redirect"].map((member) => unsupportedEntry( `stdlib.response.static.${member}`, "Response", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index cd96f2aae..91ef32181 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,6 +1,7 @@ import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { lowerHashStreamCall } from "./builtins/hash-stream.js"; +import { lowerResponseStaticJson } from "./lower-stdlib.js"; import { lowerArrayCallbackCall } from "./containers/array-callback-call.js"; /** Dispatch source calls, builtin members and receiver-specific operations. * Signature collection, argument completion and function bodies have their @@ -1389,6 +1390,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // compile-time-known, so a literal key folds to a constant. lowerClassHasOwnPropertyCall(lowerer, expr, expr.expression) ?? lowerObjectOwnMethodCall(lowerer, expr, expr.expression) ?? + // Static Response.json(data, init?) builds a native Response. + lowerResponseStaticJson(lowerer, expr) ?? // Refuse Response constructor operations before generic-call // fallbacks can report an unrelated SC1090 diagnostic. lowerer.fenceUnsupportedFetchConstructorMember(expr.expression) ?? diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 20caf184b..9fbe87c3d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -13,6 +13,7 @@ import { type IrExpr, type IrStmt, type IrType, + type SrcLoc, STRING, VOID, canConvertToDyn, @@ -1932,11 +1933,39 @@ export function lowerResponseNew(lowerer: Lowerer, expr: ts.NewExpression): IrEx if (stdlibGlobalNameOf(lowerer, expr.expression) !== "Response") return null; const args = expr.arguments ?? []; if (args.some(ts.isSpreadElement)) return null; - const loc = locOf(expr); + return lowerResponseConstruction(lowerer, args, locOf(expr), false); +} +/** Static `Response.json(data, init?)`: the data crosses into the runtime's + * checked-dynamic JSON serializer (toJSON hooks included) after ResponseInit + * converts, then the response initializes as the constructor does. */ +export function lowerResponseStaticJson(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + if (call.questionDotToken || !ts.isPropertyAccessExpression(call.expression)) return null; + const access = call.expression; + if (access.questionDotToken || access.name.text !== "json") return null; + if (!lowerer.isStdlibGlobal(access.expression, "Response")) return null; + if (call.arguments.some(ts.isSpreadElement) || call.arguments.length === 0) return null; + return lowerResponseConstruction(lowerer, call.arguments, locOf(call), true); +} + +function lowerResponseConstruction( + lowerer: Lowerer, + args: readonly ts.Expression[], + loc: SrcLoc, + json: boolean, +): IrExpr { const toDyn = (node: ts.Expression | undefined, value: IrExpr, what: string): IrExpr => { if (node === undefined) return dynUndefinedExpr(loc); if (value.type.kind === "dyn") return value; + if (what === "JSON data") { + const boxed = lowerer.coerceToExpected(value, DYN); + if (boxed.type.kind === "dyn") return boxed; + lowerer.noLowering( + `Response.json of '${lowerer.fmt(value.type)}' values`, + node, + "the data must cross the checked-dynamic boundary (number, string, boolean, null, records, arrays, and 'unknown')", + ); + } /* A user class can satisfy ResponseInit structurally through plain * fields. Project just the declared dictionary members into a record, * then use the ordinary checked-dynamic copy. objRecordWidthHelper @@ -2059,8 +2088,11 @@ export function lowerResponseNew(lowerer: Lowerer, expr: ts.NewExpression): IrEx } const result: IrExpr = { kind: "libCall", - fn: "fetch.responseNew", - args: [toDyn(bodyNode, bodyRef, "a body"), toDyn(initNode, initRef, "an init value")], + fn: json ? "fetch.responseJsonStatic" : "fetch.responseNew", + args: [ + toDyn(bodyNode, bodyRef, json ? "JSON data" : "a body"), + toDyn(initNode, initRef, "an init value"), + ], type: DYN, loc, }; diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1bb9cbd14..19082006b 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -119,6 +119,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fetch.requestNew", "fetch.headersNew", "fetch.responseNew", + "fetch.responseJsonStatic", "fetch.abortTimeout", "fetch.abortAny", "abort.timeout", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a3462c7d8..952df7579 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -64,6 +64,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * are opaque checked-dynamic handles. */ "fetch.start": { argTypes: [STRING, DYN], result: { kind: "promise", inner: DYN } }, "fetch.responseNew": { argTypes: [DYN, DYN], result: DYN }, + "fetch.responseJsonStatic": { argTypes: [DYN, DYN], result: DYN }, "fetch.input": { argTypes: [DYN, DYN], result: { kind: "promise", inner: DYN } }, "abort.controllerNew": { argTypes: [], result: DYN }, /** EventTarget / Event / CustomEvent construction (scr_event_target.c): diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index 7aba5a4b2..a3013c01b 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -4667,14 +4667,33 @@ static ScrDyn *sf_response_init_get( return NULL; } -ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) { +/* The Response constructor, and Response.json(data, init) when json_data + * is non-NULL: undici converts ResponseInit, serializes the data (TypeError + * "Value is not JSON serializable" when JSON.stringify answers undefined), + * then initializes the response exactly as the constructor does, with + * "application/json" as the default content type. */ +typedef ScrDyn *(*SfJsonSerialize)(const ScrDyn *value); + +/* The JSON serializer reaches the constructor only through Response.json's + * entry point, so programs that never call it link no JSON walker. */ +static ScrDyn *sf_response_json_serialize(const ScrDyn *value) { + ScrStr *gap = scr_str_new("", 0); + ScrDyn *serialized = scr_json_stringify_replacer(value, scr_dyn_undefined(), gap); + scr_str_release(gap); + return serialized; +} + +static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init, + const ScrDyn *json_data, + SfJsonSerialize serialize) { ScrArr *header_pairs = NULL; ScrArr *converted_headers = NULL; ScrBytes *body_bytes = NULL; ScrStr *status_text = NULL; SfStream *stream = NULL; - bool null_body = - !body || body->kind == SCR_DYN_UNDEF || body->kind == SCR_DYN_NULL; + bool json_body = json_data != NULL; + bool null_body = !json_body && + (!body || body->kind == SCR_DYN_UNDEF || body->kind == SCR_DYN_NULL); bool text_body = false; int status = 200; bool status_range_error = false; @@ -4683,7 +4702,7 @@ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) { * string arm therefore runs before ResponseInit's dictionary conversion, * while a ReadableStream is only brand-converted here — its locked/ * disturbed state belongs to the later body-extraction phase. */ - if (!null_body && + if (!null_body && !json_body && !(body->kind == SCR_DYN_HANDLE && body->v.handle.tag == SCR_DYNH_WEB_STREAM)) { if (body->kind == SCR_DYN_BYTES) { @@ -4757,7 +4776,25 @@ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) { /* Extract BodyInit before validating the response metadata. This makes a * locked/disturbed stream win over the later status range, statusText HTTP * reason-phrase, and HeadersInit validation exactly as in pinned Node. */ - if (null_body) { + if (json_body) { + ScrDyn *serialized = serialize(json_data); + if (!serialized) goto fail; + if (serialized->kind != SCR_DYN_STR) { + static const char unserializable[30] = "Value is not JSON serializable"; + scr_dyn_release(serialized); + scr_throw_error_msg(SCR_ERR_TYPE, unserializable, sizeof unserializable); + goto fail; + } + ScrStr *encoding = scr_str_new("utf8", 4); + body_bytes = scr_bytes_from_str(serialized->v.str, encoding); + scr_str_release(encoding); + scr_dyn_release(serialized); + stream = sf_stream_new_native(); + sf_stream_enqueue_bytes(stream, body_bytes); + sf_stream_close(stream); + scr_bytes_release(body_bytes); + body_bytes = NULL; + } else if (null_body) { stream = sf_stream_new_native(); sf_stream_close(stream); } else if (body->kind == SCR_DYN_HANDLE && @@ -4808,6 +4845,11 @@ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) { sf_push_header_text(header_pairs, "content-type", "text/plain;charset=UTF-8", 24); } + if (json_body && !sf_pairs_have(header_pairs, "content-type")) { + static const char json_type[16] = "application/json"; + scr_arr_push_ref(header_pairs, scr_str_new("content-type", 12)); + scr_arr_push_ref(header_pairs, scr_str_new(json_type, sizeof json_type)); + } SfResponse *response = calloc(1, sizeof *response); if (!response) sf_oom(); @@ -4833,6 +4875,15 @@ fail: return NULL; } +ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) { + return sf_response_construct(body, init, NULL, NULL); +} + +ScrDyn *scr_fetch_response_json_static(ScrDyn *data, ScrDyn *init) { + return sf_response_construct(NULL, init, data ? data : scr_dyn_undefined(), + &sf_response_json_serialize); +} + /* Request uses the same body stream and Headers ownership as Response. */ typedef struct SfRequest { size_t rc; diff --git a/packages/runtime/src/scr_generator.h b/packages/runtime/src/scr_generator.h index 2c2c0e953..605cabec5 100644 --- a/packages/runtime/src/scr_generator.h +++ b/packages/runtime/src/scr_generator.h @@ -163,6 +163,7 @@ ScrDyn *scr_fetch_request_new(ScrDyn *input, ScrDyn *init); ScrDyn *scr_fetch_headers_new(ScrDyn *init); ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init); /* +1 promise */ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init); /* borrowed args; +1 Response handle or NULL pending */ +ScrDyn *scr_fetch_response_json_static(ScrDyn *data, ScrDyn *init); /* Response.json; borrowed args; +1 Response handle or NULL pending */ ScrPromise *scr_fetch_response_json(ScrDyn *response); /* +1 promise */ ScrPromise *scr_fetch_response_text(ScrDyn *response); /* +1 promise */ ScrPromise *scr_fetch_response_array_buffer(ScrDyn *response); /* +1 promise */ diff --git a/tests/fixtures/fetch/static-response-json/main.mts b/tests/fixtures/fetch/static-response-json/main.mts new file mode 100644 index 000000000..3bbde4977 --- /dev/null +++ b/tests/fixtures/fetch/static-response-json/main.mts @@ -0,0 +1,30 @@ +// Response.json(data, init): ResponseInit converts, the data serializes with +// JSON.stringify (TypeError when it answers undefined), then the response +// initializes like the constructor, defaulting content-type to +// application/json. +function show(label: string, make: () => Response): void { + try { + const r = make(); + console.log(label, r.status, JSON.stringify(r.statusText), r.headers.get("content-type"), r.headers.get("x-a")); + } catch (e) { + console.log(label, e instanceof Error ? `${e.name}: ${e.message}` : String(e)); + } +} +show("plain", () => Response.json({ ok: true })); +show("init", () => Response.json([1, 2], { status: 201, statusText: "Made", headers: { "x-a": "1" } })); +show("content-type", () => Response.json("s", { headers: { "content-type": "text/x" } })); +show("headers object", () => Response.json(null, { headers: new Headers([["x-a", "h"]]) })); +show("undefined", () => Response.json(undefined)); +const fn: unknown = () => 1; +show("function", () => Response.json(fn)); +show("range", () => Response.json({}, { status: 99 })); +show("undefined before range", () => Response.json(undefined, { status: 99 })); +show("null body status", () => Response.json({}, { status: 204 })); +show("bad header", () => Response.json({}, { headers: { "bad name": "x" } })); +show("undefined before bad header", () => Response.json(undefined, { headers: { "bad name": "x" } })); +show("bad statusText", () => Response.json({}, { statusText: "a\nb" })); +const data: unknown = JSON.parse('{"a":[1,"x",null],"n":{"deep":true}}'); +const r = Response.json({ data, d: new Date(0), skip: undefined }); +console.log(r.bodyUsed, await r.text(), r.bodyUsed); +const parsed: unknown = await Response.json({ round: [1, 2] }).json(); +console.log(JSON.stringify(parsed)); diff --git a/tests/harness/fetch.test.ts b/tests/harness/fetch.test.ts index 6b04acf59..337eafec6 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -163,6 +163,7 @@ describe(`static fetch differential${sanitize ? " (sanitized)" : ""}`, () => { "static-listener-noncallable", "static-abort-throw", "static-dispatch-throw", + "static-response-json", ] as const; test.for( staticCases.flatMap((name) => (["llvm"] as const).map((backend) => [name, backend] as const)), From 30e23af63ff1b03bcfc50bca4ce4c942fa9433da Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:27:35 -0700 Subject: [PATCH 27/53] Store fetch Headers, Request, and Response handles in class fields A class field typed `Headers`, `Request`, or `Response` (or an array or nullable form of them) maps to checked-dynamic storage, and the class-field rule refused it (SC1090) although `unknown`, Map values, and closures already store the same native handles. Such fields now take the checked-dynamic slot, like the worker handles; reads keep the handle's identity and the static fetch member lowering. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 7 ++-- packages/compiler/src/frontend/type-mapper.ts | 34 ++++++++++++++++++ tests/corpus/class-fetch-handle-fields.ts | 36 +++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/corpus/class-fetch-handle-fields.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 6a24b5417..a10874928 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2680,7 +2680,7 @@ export function collectClassShapeInner( type.kind === "void" || (type.kind === "dyn" && !isJsSourceFile(member.getSourceFile()) && - !lowerer.classImplementedProtocol(inferred)) + !lowerer.classFieldStoresDyn(inferred)) ) lowerer.badType(member, inferred); if (isJsSourceFile(member.getSourceFile())) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 145fb3ae6..f933b8f5c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -235,6 +235,7 @@ import { isOsConstantsSymbol, isUnitOnlyTsType, isWorkerHandleType, + isFetchHandleType, jsOpenObjectType, mapType, ShapeRegistry, @@ -3004,12 +3005,14 @@ export class Lowerer { /** Declared class fields may store a DYN value only when its static type * is `unknown` or `object` (also with `null` or `undefined`, or an array * of those), names checked protocol identity, or is an owned runtime - * handle (a Worker or MessagePort); `any` fields stay refused. */ + * handle (a Worker or MessagePort, or a fetch Headers/Request/Response); + * `any` fields stay refused. */ classFieldStoresDyn(type: ts.Type): boolean { return ( this.opaqueValueType(type) || this.classImplementedProtocol(type) || - isWorkerHandleType(type, this.typeCtx) + isWorkerHandleType(type, this.typeCtx) || + isFetchHandleType(type, this.typeCtx) ); } diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index f37703fd8..8089304f0 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1313,6 +1313,40 @@ export function isWorkerHandleType( return handle; } +/** The native fetch handles (Headers, Request, Response), optionally in + * arrays or with null/undefined: checked-dynamic handles wherever they are + * stored, like the worker handles above. */ +export function isFetchHandleType( + type: ts.Type, + ctx: Pick, +): boolean { + const members = type.isUnionType() ? ts.constituentTypes(type) : [type]; + let handle = false; + for (const member of members) { + if (member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) continue; + if (ctx.checker.isArrayType(member)) { + const element = ctx.checker.getTypeArguments(member as ts.TypeReference)[0]; + if (element === undefined || !isFetchHandleType(element, ctx)) return false; + } else { + const symbol = member.getSymbol(); + if ( + !symbol || + (symbol.name !== "Headers" && symbol.name !== "Request" && symbol.name !== "Response") || + !ctx.checker + .declarationsOf(symbol) + .some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()), + ) + ) + return false; + } + handle = true; + } + return handle; +} + export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (mapTypeDepth >= MAP_TYPE_MAX_DEPTH) return null; // These intrinsic domains cannot depend on generic bindings, declaration diff --git a/tests/corpus/class-fetch-handle-fields.ts b/tests/corpus/class-fetch-handle-fields.ts new file mode 100644 index 000000000..b06df1313 --- /dev/null +++ b/tests/corpus/class-fetch-handle-fields.ts @@ -0,0 +1,36 @@ +// Class fields typed as native fetch handles (Headers, Request, Response), +// directly, optional, nullable, in arrays, and inside record fields, hold +// the live handle: mutation through one reference is seen through another. +class Holder { + headers: Headers; + list: Headers[] = []; + maybe?: Response; + req: Request | null = null; + bag: { headers: Headers; status: number }; + constructor(headers: Headers) { + this.headers = headers; + this.bag = { headers, status: 1 }; + } +} +const holder = new Holder(new Headers({ a: "1" })); +console.log(holder.headers.get("a"), holder.list.length, holder.maybe === undefined, holder.req); +holder.list.push(new Headers({ b: "2" })); +holder.maybe = new Response("x", { status: 201 }); +holder.req = new Request("http://localhost/p", { method: "POST" }); +console.log(holder.list[0]!.get("b"), holder.maybe.status, holder.req.method, holder.req.url); +holder.headers.set("c", "3"); +console.log(holder.bag.headers.get("c"), holder.bag.headers === holder.headers); + +// Symbol-keyed fields hold the handles the same way. +const NATIVE = Symbol("native"); +class Wrapper { + [NATIVE]: Response; + constructor(body: string) { + this[NATIVE] = new Response(body, { status: 202 }); + } + get status(): number { + return this[NATIVE].status; + } +} +const wrapped = new Wrapper("ok"); +console.log(wrapped.status, wrapped[NATIVE].headers.get("content-type")); From e6dbad9567c88078c08b25b1ff9dd667866a5f76 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:27:35 -0700 Subject: [PATCH 28/53] Destructure Maps and Sets with array patterns `const [[k, v], ...rest] = map` was refused (SC1031). The source now snapshots once into its iteration order (Map entries as pairs, Set values), the spread's own machinery, and the pattern lowers as an array pattern over that snapshot, as string sources already do. Element defaults keep a fence because they run between iterator steps. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 34 +++++++++++++++++++ tests/corpus/destructure-map-set-iterables.ts | 18 ++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/corpus/destructure-map-set-iterables.ts diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 12544979c..d6247a9ef 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, numLit, varRef } from "../../ir/build.js"; +import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; @@ -2380,6 +2381,39 @@ export function lowerBindingPattern( }); return; } + // Map and Set sources (`const [[k, v], ...rest] = map`): the iterator + // yields the collection's entries (Map pairs) or values in order, so + // the source snapshots ONCE into that array — the spread's own + // machinery — and the pattern lowers as an array pattern over it, + // like the string case above. Element defaults keep a fence: they + // run between iterator steps and could mutate the live collection. + if (srcType.kind === "map" || srcType.kind === "set") { + const defaulted = pattern.elements.find( + (el): el is ts.BindingElement => + ts.isBindingElement(el) && el.initializer !== undefined && !el.dotDotDotToken, + ); + if (defaulted) + lowerer.unsupported("SC1031", defaulted, "binding defaults over Map/Set sources"); + const snapshot = lowerCollectionSpread( + lowerer, + srcRef(), + pattern as unknown as ts.Expression, + ); + if (snapshot && snapshot.type.kind === "array") { + const loc = locOf(pattern); + const snapshotT = snapshot.type; + const entries = lowerer.declareHiddenLocal("%dentries", snapshotT); + out.push({ kind: "varDecl", localId: entries.id, init: snapshot, loc }); + lowerer.lowerBindingPattern( + pattern, + () => ({ kind: "varRef", localId: entries.id, type: snapshotT, loc }), + snapshotT, + isLet, + out, + ); + return; + } + } if (srcType.kind !== "array") { lowerer.unsupported( "SC1031", diff --git a/tests/corpus/destructure-map-set-iterables.ts b/tests/corpus/destructure-map-set-iterables.ts new file mode 100644 index 000000000..295a224d8 --- /dev/null +++ b/tests/corpus/destructure-map-set-iterables.ts @@ -0,0 +1,18 @@ +// Array destructuring of a Map or Set walks its iterator: Map entries as +// [key, value] pairs, Set values, holes and rest included. +const m = new Map([["a", "1"], ["b", "2"], ["c", "3"]]); +const [[name, value], ...rest] = m; +console.log(name, value, rest.length, JSON.stringify(rest)); +const [first, , third] = m; +console.log(JSON.stringify(first), JSON.stringify(third)); +const s = new Set([3, 1, 2]); +const [x, y, ...others] = s; +console.log(x, y, others); +let [p] = new Map([["only", 1]]); +p = ["changed", 2]; +console.log(p); +function firstKey(map: Map): string { + const [[k]] = map; + return k; +} +console.log(firstKey(new Map([["z", 26]]))); From a8b7bafefb6b95b8c27eb2dd347ee3b794bd101f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:29:57 -0700 Subject: [PATCH 29/53] Lower `...(cond && { k: v })` spreads as conditional fields Spreading a falsy primitive copies nothing, so `...(cond && { k: v })` is the optional-field idiom `...(cond ? { k: v } : {})`. The `&&` spelling (the @edge-runtime/cookies parseSetCookie shape) was refused with a record shape mismatch (SC2002). When the spread's type mixes the record with primitive arms, the literal lowering now uses the existing conditional-spread desugar: cond evaluates once, v only when taken, and the absent arm leaves the optional field undefined. Record and record-or-unit spreads keep the source-copy path. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 68 +++++++++++++++++-- tests/corpus/object-spread-and-conditional.ts | 37 ++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 tests/corpus/object-spread-and-conditional.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index c95731fc7..78d783a11 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -823,7 +823,7 @@ export function lowerObjectLiteral( // `k?: ...` — which desugar to one conditional field at the // spread's own position (any position: they introduce one fresh // name, collision-fenced below). Everything else stays fenced. - const cs = conditionalSpreadOf(prop.expression); + const cs = conditionalSpreadOf(prop.expression) ?? andSpreadOf(lowerer, prop.expression); if (cs === "unsupported" || (cs && cs.props.length !== 1)) { lowerer.unsupported( "SC1090", @@ -1264,6 +1264,7 @@ export function lowerObjectLiteral( expr.properties.length >= 2 && ts.isSpreadAssignment(expr.properties[0]!) && !conditionalSpreadOf(expr.properties[0]!.expression) && + !andSpreadOf(lowerer, expr.properties[0]!.expression) && expr.properties .slice(1) .every( @@ -1340,7 +1341,7 @@ export function lowerObjectLiteral( let mergeable = true; for (const prop of expr.properties) { if (ts.isSpreadAssignment(prop)) { - const cs = conditionalSpreadOf(prop.expression); + const cs = conditionalSpreadOf(prop.expression) ?? andSpreadOf(lowerer, prop.expression); if (cs === "unsupported" || (cs && cs.props.length !== 1)) { lowerer.unsupported( "SC1090", @@ -1481,7 +1482,12 @@ export function lowerObjectLiteral( !shape.tuple && shape.fields.length > 0 && expr.properties.length > 0 && - expr.properties.every((p) => ts.isSpreadAssignment(p) && !conditionalSpreadOf(p.expression)) && + expr.properties.every( + (p) => + ts.isSpreadAssignment(p) && + !conditionalSpreadOf(p.expression) && + !andSpreadOf(lowerer, p.expression), + ) && expr.properties.some((p) => { const t = lowerer.mapTypeOf(lowerer.typeOf((p as ts.SpreadAssignment).expression)); return ( @@ -1599,7 +1605,7 @@ export function lowerObjectLiteral( } } if (ts.isSpreadAssignment(prop)) { - const cs = conditionalSpreadOf(prop.expression); + const cs = conditionalSpreadOf(prop.expression) ?? andSpreadOf(lowerer, prop.expression); if (cs && cs !== "unsupported") { // `...(c ? { k: v } : {})` — ONE conditional field at the // spread's own position: cond evaluates exactly once, `v` only @@ -1728,7 +1734,8 @@ export function lowerObjectLiteral( const laterNames = new Set(); for (const later of expr.properties.slice(expr.properties.indexOf(prop) + 1)) { if (ts.isSpreadAssignment(later)) { - if (conditionalSpreadOf(later.expression)) continue; + if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression)) + continue; const lt = laterSpreadType(lowerer, later.expression); if (lt?.kind === "record") { // A field the later source may lack defines nothing. @@ -1963,7 +1970,8 @@ export function lowerObjectLiteral( const laterNames = new Set(); for (const later of expr.properties.slice(expr.properties.indexOf(prop) + 1)) { if (ts.isSpreadAssignment(later)) { - if (conditionalSpreadOf(later.expression)) continue; + if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression)) + continue; const lt = laterSpreadType(lowerer, later.expression); if (lt?.kind === "record") { // A field the later source may lack defines nothing. @@ -2517,6 +2525,54 @@ function lowerUnionRecordClone(lowerer: Lowerer, expr: ts.ObjectLiteralExpressio }; } +/** `...(c && { k: v })`: spreading a falsy primitive (false, 0, "", NaN) + * copies nothing, so the idiom is `...(c ? { k: v } : {})` with `c` + * evaluated once. Recognized only where the spread's own type mixes the + * record with primitive arms; a record or record-or-unit spread keeps the + * ordinary source-copy path. */ +export function andSpreadOf( + lowerer: Lowerer, + expr: ts.Expression, +): { cond: ts.Expression; props: CondSpreadProp[]; whenTrue: boolean } | "unsupported" | null { + let e: ts.Expression = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) + return null; + let right: ts.Expression = e.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + if (!ts.isObjectLiteralExpression(right)) return null; + const spreadType = lowerer.mapTypeOf(lowerer.typeOf(e)); + if (spreadType?.kind !== "union") return null; + const arms = lowerer.unions.get(spreadType.unionId)?.arms ?? []; + if ( + arms.filter((arm) => arm.kind === "record").length !== 1 || + !arms.some((arm) => arm.kind === "bool" || arm.kind === "f64" || arm.kind === "string") || + !arms.every( + (arm) => + arm.kind === "record" || + arm.kind === "bool" || + arm.kind === "f64" || + arm.kind === "string" || + isUnitType(arm), + ) + ) + return null; + if (right.properties.length === 0) return "unsupported"; + const props: CondSpreadProp[] = []; + for (const p of right.properties) { + if ( + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + p.name && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) + ) { + props.push(p as CondSpreadProp); + } else { + return "unsupported"; + } + } + return { cond: e.left, props, whenTrue: true }; +} + /** A conditional spread's carrier property: `name: value` or shorthand, * with an identifier/string-literal name. */ export type CondSpreadProp = (ts.PropertyAssignment | ts.ShorthandPropertyAssignment) & { diff --git a/tests/corpus/object-spread-and-conditional.ts b/tests/corpus/object-spread-and-conditional.ts new file mode 100644 index 000000000..dd42063d0 --- /dev/null +++ b/tests/corpus/object-spread-and-conditional.ts @@ -0,0 +1,37 @@ +// `...(cond && { k: v })` spreads nothing when cond is a falsy primitive +// (false, 0, ""), so it is the optional-field idiom `...(cond ? { k: v } : {})`: +// cond evaluates once, v only when taken. (Fields print one by one: the +// declared-shape property order of spread-built records is a documented +// limitation.) +type C = { name: string; name2: string; httpOnly?: boolean; maxAge?: number; path?: string; sameSite?: string }; +function parse(attrs: Record, flag: boolean): C { + const httponly = attrs.httponly; + const maxage = attrs["max-age"]; + const samesite = attrs.samesite; + let calls = 0; + const lower = (s: string): string => { + calls++; + return s.toLowerCase(); + }; + const c: C = { + name: "a", + ...(flag && { httpOnly: true }), + ...(httponly && { path: httponly }), + ...(typeof maxage === "string" && { maxAge: Number(maxage) }), + name2: "/", + ...(samesite && { sameSite: lower(samesite) }), + }; + console.log("calls", calls); + return c; +} +for (const [attrs, flag] of [ + [{}, false], + [{ httponly: "yes", "max-age": "10", samesite: "Lax" }, true], + [{ httponly: "", "max-age": "", samesite: "" }, true], +] as [Record, boolean][]) { + const c = parse(attrs, flag); + console.log(c.name, c.name2, c.httpOnly, c.path, c.maxAge, c.sameSite, "httpOnly" in c, "path" in c); +} +const n: number = Number(process.argv.length > 99 ? "1" : "0"); +const d: { v?: number; name: string } = { name: "n", ...(n && { v: n }) }; +console.log(d.name, d.v, "v" in d); From dd0c255fa7a7b56550ab1a09df974e97aba92424 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:34:42 -0700 Subject: [PATCH 30/53] Stringify class instances that declare toJSON `JSON.stringify(new Url(...))` was refused (SC1090) for every program class, although JSON.stringify of a class with a toJSON hook serializes whatever toJSON(key) returns. A class whose chain declares toJSON now crosses into the runtime JSON walker through its checked-dynamic class view, which calls the hook with the root key "" and handles nested hooks; an undefined hook result keeps the dyn-root undefined rule. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/json.ts | 11 ++++- tests/corpus/json-stringify-class-tojson.ts | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/json-stringify-class-tojson.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index 7ce902772..8cce83beb 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -149,9 +149,18 @@ export function lowerJsonMethodCall( } const optionalString = lowerOptionalStringifyRoot(lowerer, value, indent, loc); if (optionalString) return optionalString; + // A program class instance whose class (or a base) declares toJSON: + // JSON.stringify serializes whatever toJSON(key) returns, so the + // instance crosses into the runtime walker, which calls the hook + // through the checked-dynamic class view. + const toJsonClass = + value.type.kind === "object" && + lowerer.findMethodOn(lowerer.classes.get(value.type.className) ?? null, "toJSON") !== null && + lowerer.dynConvertible(value.type); if ( value.type.kind === "url" || value.type.kind === "dyn" || + toJsonClass || isJsonStringifyDynamicType( value.type, (id) => lowerer.shapes.get(id), @@ -182,7 +191,7 @@ export function lowerJsonMethodCall( // symbol). Where the program can observe that (jsonResultObserved), // keep the actual undefined; elsewhere the dyn-root string ABI // stays, whose undefined result prints the same text. - return value.type.kind === "dyn" && !jsonResultObserved(lowerer, call) + return (value.type.kind === "dyn" || toJsonClass) && !jsonResultObserved(lowerer, call) ? { kind: "toString", operand: raw, type: STRING, loc } : { kind: "dynCheck", value: raw, type: lowerer.withUndefinedArm(STRING), loc }; } diff --git a/tests/corpus/json-stringify-class-tojson.ts b/tests/corpus/json-stringify-class-tojson.ts new file mode 100644 index 000000000..617e5ac25 --- /dev/null +++ b/tests/corpus/json-stringify-class-tojson.ts @@ -0,0 +1,44 @@ +// JSON.stringify of a class instance whose class (or a base) declares +// toJSON serializes what toJSON(key) returns: the root key is "", nested +// hooks run through the same walker, and a toJSON answering undefined +// makes the whole result undefined. +class Url { + href: string; + constructor(href: string) { + this.href = href; + } + toJSON(): string { + return this.href; + } +} +class NextUrl extends Url { + base = "ignored"; +} +class Keyed { + toJSON(key: string): { key: string; at: Url[] } { + return { key, at: [new Url("n")] }; + } +} +class Missing { + toJSON(): undefined { + return undefined; + } +} +console.log(JSON.stringify(new Url("http://x/"))); +console.log(JSON.stringify(new NextUrl("http://y/"))); +console.log(JSON.stringify(new Keyed())); +console.log(JSON.stringify(new Keyed(), null, 2)); +function serialize(value: Missing): string { + const text = JSON.stringify(value); + return text === undefined ? "undefined result" : `text ${text}`; +} +console.log(serialize(new Missing()), JSON.stringify(new Missing()) === undefined); +let calls = 0; +class Counted { + toJSON(): number { + calls++; + return calls; + } +} +const c = new Counted(); +console.log(JSON.stringify(c), JSON.stringify(c), calls); From cd63b935e0dd280eaef5e1fd9c4488ba8d07ffe0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:34:42 -0700 Subject: [PATCH 31/53] Instantiate generics with a literal undefined argument `id(undefined)` binds T = undefined, which the type mapper answers as VOID (the return convention), so the instance's parameter was refused (SC2001) although a declared `v: undefined` parameter compiles. Instantiated parameters are value positions and now take the unit-only slot, as declared ones do. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 14 ++++++++++++-- tests/corpus/generic-undefined-argument.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/corpus/generic-undefined-argument.ts diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 8e30e8b82..65a013bf3 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -13,7 +13,12 @@ import { typeEquals, } from "../../ir/ir.js"; import { isJsSourceFile, isNodeEsmFile } from "../program.js"; -import { genResultRecord, jsOpenObjectType } from "../type-mapper.js"; +import { + genResultRecord, + isUnitOnlyTsType, + jsOpenObjectType, + unitOnlyUnion, +} from "../type-mapper.js"; import { dynFallbackType } from "./lowerer.js"; import { builtinModuleFnOf } from "./surfaces.js"; import { mixinFnShapeOf } from "./lower-mixins.js"; @@ -373,7 +378,7 @@ export function paramShapes( const tsType = symbol ? lowerer.checker.getTypeOfSymbol(symbol) : lowerer.typeOf(declParam.name); - const mapped = jsOpenObjectType( + let mapped = jsOpenObjectType( declParam, lowerer.runtimeOptionalBindingType( declParam.name, @@ -382,6 +387,11 @@ export function paramShapes( lowerer.shapes, lowerer.unions, ); + // An instantiation with a literal `undefined` argument (`id(undefined)` + // binds T = undefined): standalone undefined maps to VOID (the return + // convention), and a parameter is a value position, so it takes the + // unit-only slot like a declared `v: undefined` parameter. + if (mapped?.kind === "void" && isUnitOnlyTsType(tsType)) mapped = unitOnlyUnion(lowerer.unions); if (!mapped || mapped.kind === "void") lowerer.badType(blameOf?.(declParam, i) ?? declParam.name, tsType); if (declParam.dotDotDotToken) { diff --git a/tests/corpus/generic-undefined-argument.ts b/tests/corpus/generic-undefined-argument.ts new file mode 100644 index 000000000..97d438549 --- /dev/null +++ b/tests/corpus/generic-undefined-argument.ts @@ -0,0 +1,19 @@ +// A generic call with a literal `undefined` argument binds T = undefined; +// the parameter holds undefined like a declared `v: undefined` parameter. +function kind(v: T): string { + return typeof v; +} +console.log(kind(undefined), kind(null), kind(1), kind("s")); +function same(a: T, b: T): boolean { + return a === b; +} +console.log(same(undefined, undefined)); +function wrap(v: T): { v: T } { + return { v }; +} +const w = wrap(undefined); +console.log(JSON.stringify(w), w.v === undefined, "v" in w); +function describe(label: string, v: T): string { + return `${label}=${String(v)}`; +} +console.log(describe("u", undefined)); From b831d840d73f32787ed67388ec1aa72701af1d95 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:34:42 -0700 Subject: [PATCH 32/53] Accept getters that never return a value A getter that only throws (`get page(): never`, or Next.js's unannotated removed-API getters) or has no return statement declared a VOID property type, so the class was refused (SC2001). No read of such a getter produces a value other than undefined; the property now takes the checked-dynamic slot, and each read throws the getter's error or answers undefined. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 12 ++++++- tests/corpus/class-never-getter.ts | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/class-never-getter.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index a10874928..f68ff45da 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -3465,7 +3465,17 @@ export function collectClassShapeInner( } let sig: { params: ParamShape[]; ret: IrType }; if (isGet) { - const ret = lowerer.declaredReturnType(member, member.name); + let ret = lowerer.declaredReturnType(member, member.name); + // A getter that never returns a value: typed `never` (it only + // throws, Next.js's removed `page`/`ua` getters) or, unannotated + // with no return statement, inferred `void`. A read answers + // undefined or throws, so the property takes the checked-dynamic + // slot instead of VOID. + if ( + ret.kind === "void" && + (lowerer.typeOf(member.name).flags & (ts.TypeFlags.Never | ts.TypeFlags.Void)) !== 0 + ) + ret = DYN; if (ret.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); sig = { params: [], ret }; } else { diff --git a/tests/corpus/class-never-getter.ts b/tests/corpus/class-never-getter.ts new file mode 100644 index 000000000..497fa3d25 --- /dev/null +++ b/tests/corpus/class-never-getter.ts @@ -0,0 +1,36 @@ +// A getter that never returns a value compiles: annotated `never` or +// unannotated (the removed-API getter idiom, which throws), and a getter +// without a return statement, whose reads answer undefined. +class Req { + ok = 1; + get page(): never { + throw new Error("page has been removed"); + } + get ua(): never { + throw new TypeError("ua has been removed"); + } + public get legacy() { + throw new RangeError("legacy has been removed"); + } + get nothing() { + return; + } +} +const r = new Req(); +try { + console.log(r.page); +} catch (e) { + console.log(e instanceof Error ? `${e.name}: ${e.message}` : e); +} +try { + const ua = r.ua; + console.log("unreachable", ua); +} catch (e) { + console.log(e instanceof Error ? `${e.name}: ${e.message}` : e); +} +try { + console.log(r.legacy); +} catch (e) { + console.log(e instanceof Error ? `${e.name}: ${e.message}` : e); +} +console.log(r.ok, r.nothing); From 3149e7be9177dfd48e03f3c12ece5bee7e6f6970 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:36:51 -0700 Subject: [PATCH 33/53] Regenerate the surface manifest Date.prototype.toUTCString and the static Response.json operation are static surfaces now (pnpm manifest). pnpm node-compat leaves the Node ledger and the public artifact unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/surface-manifest.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index f5313f12d..a71cccaf4 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -4159,6 +4159,13 @@ "status": "static", "note": "UTC ISO formatting over constructed and stored Date values" }, + { + "id": "stdlib.date.toUTCString", + "kind": "stdlib", + "name": "Date.prototype.toUTCString", + "status": "static", + "note": "RFC 7231 IMF-fixdate formatting over stored Date values" + }, { "id": "stdlib.date.valueOf", "kind": "stdlib", @@ -5236,9 +5243,8 @@ "id": "stdlib.response.static.json", "kind": "stdlib", "name": "Response.json", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; Response static constructor-object operations have no compiler lowering" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: webidl-conversion, error-shape; supported scope: data that crosses the checked-dynamic boundary, serialized with JSON.stringify (toJSON hooks included); ResponseInit as for the constructor; differential evidence: fixture:static-response-json" }, { "id": "stdlib.response.static.redirect", From d84e410981499a181df64771c94d55c4dba66ef6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:46:52 -0700 Subject: [PATCH 34/53] Upcast devirtualized calls whose override returns a subclass A base method calling `this.set()` where only a subclass is constructed devirtualizes to the subclass override. With a polymorphic `this` return type, the override's lowered return type is the subclass object while the site keeps the base method's type, and the validator reported an internal compiler error (SC9001: "call %Sub.set type object != return object"). The Next.js extract hit this once its placeholder ResponseCookies instance was gone: MiddlewareResponseCookies became the only constructed class. The direct call now carries the callee's call-site return type and upcasts to the site's class; any other mismatch keeps the virtual call. Calls whose types already matched are emitted exactly as before. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/devirtualize.ts | 34 +++++++++++++++---- packages/compiler/src/ir/validate.ts | 2 +- .../corpus/class-devirtualized-this-return.ts | 27 +++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 tests/corpus/class-devirtualized-this-return.ts diff --git a/packages/compiler/src/frontend/lowering/devirtualize.ts b/packages/compiler/src/frontend/lowering/devirtualize.ts index 48a5712e3..04a53aeb1 100644 --- a/packages/compiler/src/frontend/lowering/devirtualize.ts +++ b/packages/compiler/src/frontend/lowering/devirtualize.ts @@ -1,6 +1,7 @@ import * as ts from "../ts7/adapter.js"; -import type { IrExpr, IrFunction } from "../../ir/ir.js"; +import { type IrExpr, type IrFunction, type IrType, typeEquals } from "../../ir/ir.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import { callSiteReturnType } from "../../ir/validate.js"; import type { ClassInfo } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; @@ -56,6 +57,9 @@ export function devirtualizeVirtualCalls( class ConstructedClasses { private readonly lowered = new Set(); + /** Lowered bodies' return types: an override may return a subclass + * (`set(): this` resolves per declaring class). */ + private readonly returnTypes = new Map(); /** Classes some retained IR allocates or exposes as a class value. */ private readonly allocated = new Set(); private readonly exportedNames: ReadonlySet; @@ -74,6 +78,7 @@ class ConstructedClasses { }; for (const fn of functions) { this.lowered.add(fn.name); + this.returnTypes.set(fn.name, callSiteReturnType(fn)); everyStmtList(fn.body, { stmt: () => true, expr: (e) => { @@ -102,7 +107,7 @@ class ConstructedClasses { this.facts.reachable.has(`%${candidate.def.name}.${e.method}`), ) ) - return this.direct(e, info, nearest.declarer); + return this.direct(e, info, nearest.declarer) ?? e; if (this.open(info)) return e; const targets: ClassInfo[] = []; for (const candidate of this.descendants(info)) { @@ -117,7 +122,7 @@ class ConstructedClasses { const callee = `%${target.def.name}.${e.method}`; if (!this.facts.reachable.has(callee) || !this.lowered.has(callee)) return e; } - if (targets.length === 1) return this.direct(e, info, targets[0]!); + if (targets.length === 1) return this.direct(e, info, targets[0]!) ?? e; // A small closed set: the backend tests the loaded slot against each // implementation and calls it directly (no indirect fallback; every // instance that can reach this site resolves to one of them). @@ -125,7 +130,19 @@ class ConstructedClasses { return { ...e, targets: targets.map((t) => t.def.name) }; } - private direct(e: VirtualCall, info: ClassInfo, target: ClassInfo): IrExpr { + /** The direct call, or null when the target's return type cannot stand + * for the site's: an override returning a subclass object (a polymorphic + * `this` return) upcasts to the site's class; any other mismatch keeps + * the virtual call. */ + private direct(e: VirtualCall, info: ClassInfo, target: ClassInfo): IrExpr | null { + const callee = `%${target.def.name}.${e.method}`; + const ret = this.returnTypes.get(callee) ?? e.type; + const upcast = + !typeEquals(ret, e.type) && + ret.kind === "object" && + e.type.kind === "object" && + this.lowerer.isSubclassOf(ret.className, e.type.className); + if (!typeEquals(ret, e.type) && !upcast) return null; const receiver = e.args[0]!; const typed: IrExpr = target === info || this.lowerer.isSubclassOf(info.def.name, target.def.name) @@ -139,13 +156,16 @@ class ConstructedClasses { type: { kind: "object", className: target.def.name }, loc: receiver.loc, }; - return { + const call: IrExpr = { kind: "call", - callee: `%${target.def.name}.${e.method}`, + callee, args: [typed, ...e.args.slice(1)], - type: e.type, + type: ret, loc: e.loc, }; + return upcast && e.type.kind === "object" + ? this.lowerer.upcastTo(call, e.type.className) + : call; } /** The class and every class below it, in the isSubclassOf sense diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 09ab7d951..979b70115 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -90,7 +90,7 @@ export interface IrValidationError { * calls and closures over it see Promise; a generator body's is the * TReturn channel, so call sites see the generator type. The one place * the body/call-site split is spelled out in the validator. */ -function callSiteReturnType(fn: IrFunction): IrType { +export function callSiteReturnType(fn: IrFunction): IrType { if (fn.async && fn.generator !== undefined) { return { kind: "generator", diff --git a/tests/corpus/class-devirtualized-this-return.ts b/tests/corpus/class-devirtualized-this-return.ts new file mode 100644 index 000000000..e7f2fc9fa --- /dev/null +++ b/tests/corpus/class-devirtualized-this-return.ts @@ -0,0 +1,27 @@ +// A base method calling `this.set()` where only a subclass is ever +// constructed devirtualizes to the subclass override; a polymorphic `this` +// return resolves per declaring class, so the direct call's subclass +// result upcasts to the base method's type (was an internal compiler error). +class Cookies { + values: string[] = []; + set(value: string): this { + this.values.push(value); + return this; + } + delete(name: string): this { + return this.set(`${name}=; Max-Age=0`); + } +} +class MirroredCookies extends Cookies { + mirror: string[] = []; + override set(value: string): this { + super.set(value); + this.mirror.push(value.toUpperCase()); + return this; + } +} +const cookies = new MirroredCookies(); +cookies.set("a=1").delete("b").set("c=3"); +console.log(cookies.values.join(" | ")); +console.log(cookies.mirror.join(" | ")); +console.log(cookies.delete("d") === cookies, cookies.values.length); From 8e457b2b05a632bb919cd7ef208686ae3774b429 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:53:15 -0700 Subject: [PATCH 35/53] Record ts7 preflight/order baselines for the new corpus programs pnpm test:ts7 --baselines-only reported the fifteen new corpus programs as missing. Recorded with SCRIPTC_UPDATE_BASELINES=1 from the native frontend; every entry has no preflight diagnostics and a single-module order, and no existing baseline changed. Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/class-devirtualized-this-return.ts.json | 6 ++++++ .../tests/corpus/class-fetch-handle-fields.ts.json | 6 ++++++ .../order-parity/tests/corpus/class-never-getter.ts.json | 6 ++++++ .../corpus/class-symbol-field-constructor-assigned.ts.json | 6 ++++++ .../order-parity/tests/corpus/date-to-utc-string.ts.json | 6 ++++++ .../tests/corpus/destructure-map-set-iterables.ts.json | 6 ++++++ .../tests/corpus/fetch-headers-set-cookie-iteration.ts.json | 6 ++++++ .../tests/corpus/fetch-invalid-url-cause.ts.json | 6 ++++++ .../tests/corpus/fetch-request-set-cookie-wire.ts.json | 6 ++++++ .../tests/corpus/generic-undefined-argument.ts.json | 6 ++++++ .../tests/corpus/json-stringify-class-tojson.ts.json | 6 ++++++ .../corpus/json-stringify-unknown-undefined-root.ts.json | 6 ++++++ .../tests/corpus/object-spread-and-conditional.ts.json | 6 ++++++ .../tests/corpus/optional-call-native-method.ts.json | 6 ++++++ .../tests/corpus/union-narrowed-tostring.ts.json | 6 ++++++ 15 files changed, 90 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-devirtualized-this-return.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-fetch-handle-fields.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-never-getter.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-symbol-field-constructor-assigned.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/date-to-utc-string.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-map-set-iterables.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-headers-set-cookie-iteration.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-invalid-url-cause.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-request-set-cookie-wire.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-undefined-argument.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-class-tojson.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-unknown-undefined-root.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-spread-and-conditional.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-call-native-method.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-narrowed-tostring.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-devirtualized-this-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-devirtualized-this-return.ts.json new file mode 100644 index 000000000..f75299bf2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-devirtualized-this-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-devirtualized-this-return.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-fetch-handle-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-fetch-handle-fields.ts.json new file mode 100644 index 000000000..69dfd8454 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-fetch-handle-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-fetch-handle-fields.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-never-getter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-never-getter.ts.json new file mode 100644 index 000000000..4b5ee103f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-never-getter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-never-getter.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-symbol-field-constructor-assigned.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-symbol-field-constructor-assigned.ts.json new file mode 100644 index 000000000..4078df7c7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-symbol-field-constructor-assigned.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-symbol-field-constructor-assigned.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/date-to-utc-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/date-to-utc-string.ts.json new file mode 100644 index 000000000..6c5e37d54 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/date-to-utc-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/date-to-utc-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-map-set-iterables.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-map-set-iterables.ts.json new file mode 100644 index 000000000..fee4d0ebc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-map-set-iterables.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/destructure-map-set-iterables.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-headers-set-cookie-iteration.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-headers-set-cookie-iteration.ts.json new file mode 100644 index 000000000..f0d413603 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-headers-set-cookie-iteration.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-headers-set-cookie-iteration.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-invalid-url-cause.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-invalid-url-cause.ts.json new file mode 100644 index 000000000..70f7a83fd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-invalid-url-cause.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-invalid-url-cause.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-request-set-cookie-wire.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-request-set-cookie-wire.ts.json new file mode 100644 index 000000000..9357bc1d8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-request-set-cookie-wire.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-request-set-cookie-wire.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-undefined-argument.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-undefined-argument.ts.json new file mode 100644 index 000000000..bbc59d2ca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-undefined-argument.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-undefined-argument.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-class-tojson.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-class-tojson.ts.json new file mode 100644 index 000000000..9b1aa55ac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-class-tojson.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-stringify-class-tojson.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-unknown-undefined-root.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-unknown-undefined-root.ts.json new file mode 100644 index 000000000..8c424c6a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-stringify-unknown-undefined-root.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-stringify-unknown-undefined-root.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-spread-and-conditional.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-spread-and-conditional.ts.json new file mode 100644 index 000000000..c58247184 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-spread-and-conditional.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/object-spread-and-conditional.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-call-native-method.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-call-native-method.ts.json new file mode 100644 index 000000000..a8678d806 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-call-native-method.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/optional-call-native-method.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-narrowed-tostring.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-narrowed-tostring.ts.json new file mode 100644 index 000000000..454df784e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-narrowed-tostring.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-narrowed-tostring.ts" + ], + "diags": [] +} From f4a8511dca71301bca190291caa0c742046401b1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:58:57 -0700 Subject: [PATCH 36/53] Compile maybe-promise unions, unknown[] rest parameters, and function-valued Maps and Sets A union may now hold one promise beside data members (`T | Promise`, `Promise | R | void`). The union tag is the `instanceof Promise` test, and every operation keeps Node's microtask timing: `await` awaits the promise arm or takes the one non-thenable turn, an async `return` (block bodies, concise arrows, then handlers) adopts the promise arm after the function's own finally blocks, `Promise.resolve` returns the promise arm as is, and `typeof (u as any).then` reads the tag. Data arms that declare `then`, two promise arms, and function arms beside a promise stay refused with diagnostics. Inferred JavaScript literals that mix promises and values keep the checked-dynamic array. Rest parameters typed `unknown[]` or `any[]` in TypeScript use the checked-dynamic variadic ABI that JavaScript rest parameters already use, including spread forwarding (`(...args) => handler(...args)`). Functions are now Map values, Map keys and Set elements, compared by closure identity. Two pre-existing bugs in the same area are fixed: an async lambda in a `Promise | void` slot got a `Promise` ABI and threw when called through the slot, and `return cb()` of such a callback in an async function dropped the promise instead of adopting it. The retired-engine 2251 and 2790 diagnostics fixtures (tagged templates with `any[]` rest tags) now compile and match Node, so they move to the corpus as 6248 and 6249. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-apis/map-and-set.mdx | 2 +- .../null-and-optional-values.mdx | 2 +- .../types-and-class-definitions/unions.mdx | 2 +- .../frontend/lowering/builtins/promises.ts | 29 +- .../src/frontend/lowering/call-arguments.ts | 8 +- .../src/frontend/lowering/call-signatures.ts | 42 ++- .../lowering/coercions/function-adapters.ts | 6 + .../src/frontend/lowering/lower-calls.ts | 31 +- .../src/frontend/lowering/lower-classes.ts | 11 +- .../src/frontend/lowering/lower-exprs.ts | 99 +++++- .../src/frontend/lowering/lower-functions.ts | 18 +- .../compiler/src/frontend/lowering/lowerer.ts | 92 +++++- .../src/frontend/lowering/maybe-promise.ts | 294 ++++++++++++++++++ packages/compiler/src/frontend/type-mapper.ts | 79 ++++- packages/compiler/src/ir/ir.ts | 34 +- packages/compiler/src/ir/validate.test.ts | 30 +- packages/compiler/src/ir/validate.ts | 8 +- .../6240-maybe-promise-await-order.ts.json | 6 + .../6241-maybe-promise-handlers.ts.json | 6 + .../6242-maybe-promise-rejections.ts.json | 6 + .../6243-maybe-promise-narrowing.ts.json | 6 + .../6244-maybe-promise-async-return.ts.json | 6 + .../corpus/6245-rest-unknown-handlers.ts.json | 6 + .../6246-map-set-function-values.ts.json | 6 + .../6247-next-proxy-maybe-promise.ts.json | 6 + .../6248-tagged-template-any-rest.ts.json | 6 + ...ged-template-missing-substitutions.ts.json | 6 + .../diagnostics/maybe-promise-fences.ts.json | 6 + ...d-engine-2251-tagged-templates-dyn.ts.json | 6 - ...array-missing-tagged-substitutions.ts.json | 6 - .../corpus/6240-maybe-promise-await-order.ts | 60 ++++ tests/corpus/6241-maybe-promise-handlers.ts | 77 +++++ tests/corpus/6242-maybe-promise-rejections.ts | 77 +++++ tests/corpus/6243-maybe-promise-narrowing.ts | 61 ++++ .../corpus/6244-maybe-promise-async-return.ts | 61 ++++ tests/corpus/6245-rest-unknown-handlers.ts | 46 +++ tests/corpus/6246-map-set-function-values.ts | 52 ++++ tests/corpus/6247-next-proxy-maybe-promise.ts | 35 +++ .../6248-tagged-template-any-rest.ts} | 0 ...-tagged-template-missing-substitutions.ts} | 0 tests/diagnostics/component-fences.ts | 6 +- tests/diagnostics/maps.ts | 2 +- tests/diagnostics/maybe-promise-fences.ts | 37 +++ .../__snapshots__/component-fences.ts.txt | 30 +- tests/harness/__snapshots__/maps.ts.txt | 16 - .../__snapshots__/maybe-promise-fences.ts.txt | 65 ++++ ...ed-engine-2251-tagged-templates-dyn.ts.txt | 44 --- ...-array-missing-tagged-substitutions.ts.txt | 17 - 48 files changed, 1388 insertions(+), 163 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/maybe-promise.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json create mode 100644 tests/corpus/6240-maybe-promise-await-order.ts create mode 100644 tests/corpus/6241-maybe-promise-handlers.ts create mode 100644 tests/corpus/6242-maybe-promise-rejections.ts create mode 100644 tests/corpus/6243-maybe-promise-narrowing.ts create mode 100644 tests/corpus/6244-maybe-promise-async-return.ts create mode 100644 tests/corpus/6245-rest-unknown-handlers.ts create mode 100644 tests/corpus/6246-map-set-function-values.ts create mode 100644 tests/corpus/6247-next-proxy-maybe-promise.ts rename tests/{diagnostics/retired-engine-2251-tagged-templates-dyn.ts => corpus/6248-tagged-template-any-rest.ts} (100%) rename tests/{diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts => corpus/6249-tagged-template-missing-substitutions.ts} (100%) create mode 100644 tests/diagnostics/maybe-promise-fences.ts create mode 100644 tests/harness/__snapshots__/maybe-promise-fences.ts.txt delete mode 100644 tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt delete mode 100644 tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt diff --git a/docs/content/limitations/builtin-apis/map-and-set.mdx b/docs/content/limitations/builtin-apis/map-and-set.mdx index 04ebcbfca..1c9c28c20 100644 --- a/docs/content/limitations/builtin-apis/map-and-set.mdx +++ b/docs/content/limitations/builtin-apis/map-and-set.mdx @@ -1 +1 @@ -- **Map and Set:** Keys and elements support numbers and strings by value, and records, classes, arrays, symbols, server handles, and unions of these reference types by identity. `unknown` slots use native checked values, comparing supported primitives by value and retaining supported object references. Typed nested collections other than `Map` and `Set`, and promises requiring payload adapters, cannot pass into these slots. `Promise` retains its native representation. Stored `keys()`, `values()`, and `entries()` iterators compile, retain element identity, and observe live collection mutations until exhausted. They support `.next()`, `for...of`, array spreads, and mapper-less `Array.from`. Detached collection methods and iterator helper methods such as `.map()` and `.take()` are unsupported. The Set composition methods (`union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`) accept a Set of the same element type or a Map keyed by it; other set-like arguments are unsupported. +- **Map and Set:** Keys and elements support numbers and strings by value, and records, classes, arrays, functions, symbols, server handles, and unions of these reference types by identity. Map values may also be functions. `unknown` slots use native checked values, comparing supported primitives by value and retaining supported object references. Typed nested collections other than `Map` and `Set`, and promises requiring payload adapters, cannot pass into these slots. `Promise` retains its native representation. Stored `keys()`, `values()`, and `entries()` iterators compile, retain element identity, and observe live collection mutations until exhausted. They support `.next()`, `for...of`, array spreads, and mapper-less `Array.from`. Detached collection methods and iterator helper methods such as `.map()` and `.take()` are unsupported. The Set composition methods (`union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`) accept a Set of the same element type or a Map keyed by it; other set-like arguments are unsupported. diff --git a/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx b/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx index f3d4e6acf..afb8ec9cf 100644 --- a/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx +++ b/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx @@ -1 +1 @@ -- **Null and optional values:** `undefined` and `null` compile as standalone values and union members. Optional record and class fields and optional, default, and rest parameters also compile. +- **Null and optional values:** `undefined` and `null` compile as standalone values and union members. Optional record and class fields and optional, default, and rest parameters also compile, including rest parameters typed `unknown[]` or `any[]`, whose functions take any number of arguments. A function value with fixed parameters cannot be stored where such a variadic function type is expected. diff --git a/docs/content/limitations/types-and-class-definitions/unions.mdx b/docs/content/limitations/types-and-class-definitions/unions.mdx index 5d3bae3c3..403c9be2e 100644 --- a/docs/content/limitations/types-and-class-definitions/unions.mdx +++ b/docs/content/limitations/types-and-class-definitions/unions.mdx @@ -1 +1 @@ -- **Unions:** Narrow a union before an operation whose members require incompatible native representations, such as reading `u.length` on `string | string[]`. Some union-to-union widening and unions combining function and data members are unsupported. Same-representation methods on class unions, `map` and `forEach` on array unions, and shared or joinable field reads compile. `console.log(u)` supports printing a whole union. +- **Unions:** Narrow a union before an operation whose members require incompatible native representations, such as reading `u.length` on `string | string[]`. Some union-to-union widening and unions combining function and data members are unsupported. Same-representation methods on class unions, `map` and `forEach` on array unions, and shared or joinable field reads compile. `console.log(u)` supports printing a whole union. A union may hold one promise beside other members, such as `T | Promise` or `Promise | R | void`: `await`, an async function's `return`, `Promise.resolve`, and `instanceof Promise` act on the member it holds, with Node's microtask timing. A promise member beside a member that declares `then`, two promise members in one union, and `console.log` of a union holding a promise are unsupported. diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index 2dc212f81..da680a86e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -2,6 +2,7 @@ import { checkedPromiseAll } from "../checked-promise-all.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; +import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { DYN, @@ -402,6 +403,22 @@ export function lowerPromiseStaticCall( if (v.type.kind !== "promise") lowerer.badType(argNode, argT); return v; } + const maybePromise = argIr ? maybePromiseUnionOf(lowerer, argIr) : null; + if (maybePromise) { + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const v = lowerer.lowerExpr(argNode); + const mp = maybePromiseUnionOf(lowerer, v.type); + const resolved = + resultT?.kind === "promise" && mp + ? maybePromiseResolve(lowerer, v, mp, resultT, loc) + : null; + if (resolved) return resolved; + lowerer.noLowering( + `Promise.resolve over '${lowerer.fmt(v.type)}' at this result type`, + argNode, + "the promise arm passes through identically, so it must already be the result's promise type", + ); + } if ( argIr?.kind === "union" && (lowerer.unions.get(argIr.unionId)?.arms ?? []).some((a) => a.kind === "promise") @@ -423,7 +440,17 @@ export function lowerPromiseStaticCall( } } } - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + let resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + // A maybe-promise context (`return Promise.resolve(v)` from a function + // returning `Promise | R | void`) names its promise arm: the + // fresh promise takes that payload type directly. + if (argNode !== undefined && resultT?.kind === "promise") { + const ctxT = lowerer.checker.getContextualType(call); + const contextual = ctxT?.isUnionType() ? lowerer.mapTypeOf(ctxT) : null; + const mp = contextual ? maybePromiseUnionOf(lowerer, contextual) : null; + if (mp && !typeEquals(mp.promise, resultT) && mp.promise.inner.kind !== "void") + resultT = mp.promise; + } if (resultT?.kind !== "promise") { lowerer.noLowering( "Promise.resolve at this type", diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index d1d695920..3119fa2ab 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -546,7 +546,13 @@ export function lowerSpreadArgsCall( callee: IrExpr, loc: SrcLoc, ): IrExpr | null { - if (!isJsSourceFile(expr.getSourceFile())) return null; + // TypeScript reaches this lane only for checked-dynamic variadic callees + // (`(...args: unknown[]) => R`), whose ABI is the JS one. + if ( + !isJsSourceFile(expr.getSourceFile()) && + !(callee.type.kind === "func" && callee.type.rest === true && callee.type.restAbi === undefined) + ) + return null; if (!expr.arguments.some((a) => ts.isSpreadElement(a))) return null; if (callee.type.kind !== "dyn" && callee.type.kind !== "func") return null; const calleeName = diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 8e30e8b82..ae59be3b2 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -13,7 +13,7 @@ import { typeEquals, } from "../../ir/ir.js"; import { isJsSourceFile, isNodeEsmFile } from "../program.js"; -import { genResultRecord, jsOpenObjectType } from "../type-mapper.js"; +import { genResultRecord, isAnyArrayType, jsOpenObjectType } from "../type-mapper.js"; import { dynFallbackType } from "./lowerer.js"; import { builtinModuleFnOf } from "./surfaces.js"; import { mixinFnShapeOf } from "./lower-mixins.js"; @@ -286,10 +286,17 @@ export function paramShape(lowerer: Lowerer, param: ts.ParameterDeclaration): Pa return { type: DYN, mode: "dynRest" }; } } + // `...args: unknown[]` / `...args: any[]` in TypeScript: the binding is + // the checked-dynamic array (how `unknown[]` maps everywhere), so the + // ABI is the JS variadic dyn form above — the function type marks it + // with `rest` and no typed slot (the type mapper agrees). + const restTs = lowerer.typeOf(param.name); + if (isAnyArrayType(restTs, lowerer.checker)) return { type: DYN, mode: "dynRest" }; const type = lowerer.runtimeOptionalBindingType(param.name, lowerer.irTypeOf(param.name)); + if (type.kind === "dyn") return { type: DYN, mode: "dynRest" }; // Tuple-typed rest params don't map to an array; generic rest is the // generic path's business. Anything non-array here is unmappable. - if (type.kind !== "array") lowerer.badType(param.name, lowerer.typeOf(param.name)); + if (type.kind !== "array") lowerer.badType(param.name, restTs); return { type, mode: "rest" }; } if (param.initializer) { @@ -843,13 +850,34 @@ export function lambdaSignature( ctxDecl && !ctxDecl.getSourceFile().isDeclarationFile ? lowerer.mapTypeOf(lowerer.checker.getReturnTypeOfSignature(ctxSigs[0]!)) : null; - const ctxRet = isAsyncLike && ctxRetRaw?.kind === "promise" ? ctxRetRaw.inner : ctxRetRaw; + // An async lambda always produces a promise, so a slot returning a + // promise-armed union (`Promise | R | void`, `Promise + // | void`) is met through that promise arm: its payload is the ABI. + const ctxPromiseArm = + isAsyncLike && ctxRetRaw?.kind === "union" + ? lowerer.unions.get(ctxRetRaw.unionId)?.arms.filter((arm) => arm.kind === "promise") + : undefined; + const ctxRet = + isAsyncLike && ctxRetRaw?.kind === "promise" + ? ctxRetRaw.inner + : ctxPromiseArm?.length === 1 && ctxPromiseArm[0]!.kind === "promise" + ? ctxPromiseArm[0]!.inner + : ctxRetRaw; + // A TypeScript variadic lambda (`(...args) => handler(...args)`) in a + // checked-dynamic variadic slot returning `unknown` adopts that result + // too: rest closures have no function-value adapter, so the closure + // must match the slot's ABI exactly. + const dynVariadicSlot = + ctxRet?.kind === "dyn" && + !isJsSourceFile(node.getSourceFile()) && + shapes.some((shape) => shape.mode === "dynRest"); if ( - (ctxRet?.kind === "union" || ctxRet?.kind === "array" || ctxRet?.kind === "record") && - (innerRet.kind !== "void" || - (ctxRet.kind === "union" && lowerer.armTag(ctxRet.unionId, UNDEFINED_T) >= 0)) + dynVariadicSlot || + ((ctxRet?.kind === "union" || ctxRet?.kind === "array" || ctxRet?.kind === "record") && + (innerRet.kind !== "void" || + (ctxRet.kind === "union" && lowerer.armTag(ctxRet.unionId, UNDEFINED_T) >= 0))) ) { - ret = isAsyncLike ? { kind: "promise", inner: ctxRet } : ctxRet; + ret = isAsyncLike ? { kind: "promise", inner: ctxRet! } : ctxRet!; } // A VOID slot discards the callback's result (TS's void-returning // assignability rule; JS ignores the value), so an UNANNOTATED diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index f60fc32b8..b99c24a15 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -13,6 +13,7 @@ import { } from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import type { Lowerer } from "../lowerer.js"; +import { maybePromiseUnionOf } from "../maybe-promise.js"; /** Wrap a zero-argument function whose array result needs an element * conversion. Each invocation creates a fresh converted array; the factory @@ -96,6 +97,11 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool if (dst.kind === "union") { if (src.kind === "union") return lowerer.unionRetagMappable(src.unionId, dst.unionId); if (src.kind === "void") return lowerer.armTag(dst.unionId, UNDEFINED_T) >= 0; + if (src.kind === "promise") { + // A maybe-promise union's promise arm accepts payload widening. + const mp = maybePromiseUnionOf(lowerer, dst); + if (mp) return lowerer.promisePayloadWidens(src, mp.promise); + } return !isUnitType(src) && lowerer.armTag(dst.unionId, src) >= 0; } if (src.kind === "union") { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..fc7069b41 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -207,6 +207,7 @@ import { } from "./call-arguments.js"; import { isThisParameter, type ParamShape } from "./call-signatures.js"; import { checkedArmExtraction } from "./coercions/unions.js"; +import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; import { requireObjLitGenericReceiver, objLitGenericFnNodeOf, @@ -5217,6 +5218,8 @@ export function lowerPromiseMethodCall( const resultOf = (value: IrExpr, returnsPromise = false): IrStmt[] => { if (value.type.kind === "promise") return [lowerer.adoptingReturn(value, R, (e) => lowerer.coerceInto(call, e, R))]; + const flattened = maybePromiseHandlerResult(lowerer, call, value, R); + if (flattened) return [flattened]; if (returnsPromise) { // A boxed handler's promise result adopts too: the then job's // turn after the call, then the await of the returned value. @@ -5567,7 +5570,10 @@ export function lowerPromiseMethodCall( // The handler's result: promise returns flatten exactly like // `return p` in any async body (the spec's thenable adoption); // everything else coerces into R. - if (handlerCall.type.kind === "promise" && R.kind !== "promise") { + const flattened = maybePromiseHandlerResult(lowerer, call, handlerCall, R); + if (flattened) { + body.push(flattened); + } else if (handlerCall.type.kind === "promise" && R.kind !== "promise") { body.push(lowerer.adoptingReturn(handlerCall, R, (e) => lowerer.coerceInto(call, e, R))); } else if (R.kind === "void") { body.push({ kind: "exprStmt", expr: handlerCall, loc }); @@ -9634,3 +9640,26 @@ export function lowerStaticNumberGlobal( } return null; } + +/** A then handler's promise-armed result (`.then(() => cached ?? load())` + * typed `T | Promise`, or a `Promise | void` callback's result): + * the lifted async body adopts the promise arm and fulfills with the + * others, like `return u` in an async function. Null for other results. */ +function maybePromiseHandlerResult( + lowerer: Lowerer, + call: ts.CallExpression, + value: IrExpr, + R: IrType, +): IrStmt | null { + const mp = promiseArmedUnionOf(lowerer, value.type); + if (!mp || promiseArmedUnionOf(lowerer, R)) return null; + const stmt = maybePromiseReturnStmt(lowerer, value, mp, R, (e) => lowerer.coerceInto(call, e, R)); + if (stmt === null) { + lowerer.unsupported( + "SC1090", + call, + `then handlers returning '${lowerer.fmt(value.type)}' into '${lowerer.fmt(R)}' (an arm does not convert to the result)`, + ); + } + return stmt; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c3..d09f40ed8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1566,7 +1566,14 @@ function inferredEmptyCollectionFieldType( const mappedArgument = (argument: ts.Expression): IrType | null => { const type = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(argument)); const mapped = lowerer.mapTypeOf(type); - if (!mapped || mapped.kind === "dyn" || mapped.kind === "void" || isUnitType(mapped)) + // Inferred JS collection fields keep functions in checked slots. + if ( + !mapped || + mapped.kind === "dyn" || + mapped.kind === "void" || + mapped.kind === "func" || + isUnitType(mapped) + ) return null; return mapped; }; @@ -9231,7 +9238,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { "SC1090", expr, `Map values of type '${lowerer.checker.typeToString(targs[1])}' ` + - `(Map values must be number, string, boolean, records, class instances, ` + + `(Map values must be number, string, boolean, records, class instances, functions, ` + `typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values)`, ); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 149c25248..53cb26093 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -45,6 +45,11 @@ import { constituentTypes } from "../ts7/checker.js"; import { dirname } from "node:path"; import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; +import { + lowerMaybePromiseAwait, + maybePromiseInstanceOf, + maybePromiseUnionOf, +} from "./maybe-promise.js"; import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js"; @@ -1766,6 +1771,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { } return { kind: "awaitUnionExpr", value, promiseTag, type, loc }; } + const maybePromise = maybePromiseUnionOf(lowerer, value.type); + if (maybePromise) return lowerMaybePromiseAwait(lowerer, expr, value, maybePromise, loc); } if (value.type.kind !== "promise") { // A CHECKED-DYNAMIC operand (`await v` where v rode an untyped @@ -4699,12 +4706,61 @@ export function lowerCondition(lowerer: Lowerer, expr: ts.Expression): IrExpr { ); } +/** `typeof (u as any).then` / `if ((u as Promise).then)` over a + * maybe-promise union `u` (the cast is erasure in JS): a promise arm has + * the prototype's `then` function, and no data arm is thenable (the type + * mapper's rule), so the union tag answers. Null for every other shape. */ +function maybePromiseThenPresence( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, + result: IrExpr, +): IrExpr | null { + let receiver: ts.Expression = access.expression; + let cast = false; + for (;;) { + if (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression; + else if (ts.isAsExpression(receiver) || ts.isTypeAssertion(receiver)) { + receiver = receiver.expression; + cast = true; + } else break; + } + if (!cast) return null; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(receiver)); + if (!mapped || !maybePromiseUnionOf(lowerer, mapped)) return null; + const value = lowerer.lowerExpr(receiver); + const mp = maybePromiseUnionOf(lowerer, value.type); + if (!mp) return null; + const loc = locOf(access); + const absent: IrExpr = + result.type.kind === "bool" + ? { kind: "boolLit", value: false, type: BOOL, loc } + : { kind: "strLit", value: "undefined", type: STRING, loc }; + return { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: mp.def.id, + tag: mp.promiseTag, + negated: false, + value, + type: BOOL, + loc, + }, + then: result, + else_: absent, + type: result.type, + loc, + }; +} + function lowerPromiseThenPresence( lowerer: Lowerer, access: ts.PropertyAccessExpression, result: IrExpr, ): IrExpr | null { if (access.name.text !== "then") return null; + const maybePromise = maybePromiseThenPresence(lowerer, access, result); + if (maybePromise !== null) return maybePromise; const mapped = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); const promiseType = mapped?.kind === "promise" @@ -5888,6 +5944,11 @@ export function lowerArrayLiteral( const elem = mapped.elem; const arms = elem.kind === "union" ? lowerer.unions.get(elem.unionId)?.arms : [elem]; if (arms?.length && arms.every((arm) => arm.kind === "classval")) mapped = DYN; + // Inferred JavaScript literals mixing promises and plain values + // (`[Promise.resolve(1), 2]`) keep the checked-dynamic array: their + // consumers (Readable.from, Promise.all over iterators) inspect each + // element at run time. + if (maybePromiseUnionOf(lowerer, elem)) mapped = DYN; } if ( !expected && @@ -12607,6 +12668,38 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc }; } } + // `x instanceof Promise` over a promise-armed union (the maybe-promise + // `T | Promise` dispatch): a union tag test — the data arms are + // never promises (scriptc promises are all native, and no data arm + // can hold one). A promise-typed operand answers true after its + // evaluation; a checked-dynamic one asks the runtime. + if ( + ts.isIdentifier(expr.right) && + lowerer.isStdlibGlobal(expr.right, "Promise") && + !lowerer.caughtLocalOf(expr.left) + ) { + const left = lowerer.lowerExpr(expr.left); + const tagTest = maybePromiseInstanceOf(lowerer, left, loc); + if (tagTest) return tagTest; + if (left.type.kind === "dyn") + return { kind: "dynTest", test: "promise", value: left, type: BOOL, loc }; + if (left.type.kind === "promise") { + const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + if (isSafeToDiscard(left)) return yes; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: yes, + type: BOOL, + loc, + }; + } + lowerer.unsupported( + "SC1090", + expr, + `'instanceof Promise' on '${lowerer.fmt(left.type)}' values (only promises, promise-armed unions, and checked-dynamic values have a lowering)`, + ); + } // `x instanceof RegExp` over a union with a regex arm (the // skip-utility `string | RegExp` dispatch): a union tag test — the // socket-narrowing shape; the checker types the branches and @@ -15778,7 +15871,11 @@ export function lowerPromiseRejectCall( ); } const ctxT = lowerer.checker.getContextualType(call); - const type = ctxT ? lowerer.mapTypeOf(ctxT) : null; + const contextual = ctxT ? lowerer.mapTypeOf(ctxT) : null; + // A maybe-promise context (`return Promise.reject(e)` from a function + // returning `T | Promise`) names its one promise arm. + const maybePromise = contextual ? maybePromiseUnionOf(lowerer, contextual) : null; + const type = maybePromise ? maybePromise.promise : contextual; const reasonNode = call.arguments[0]!; // A CHECKED-DYNAMIC reason (`Promise.reject(value)` where value rode // an untyped binding — the tracingChannel suite's shape): the checked-dynamic tree diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 2991c0d39..c7e293707 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -345,9 +345,12 @@ export function lowerLambda( } else { const value = lowerer.lowerExpr(bodyExpr); // `async () => p` with a void result still resolves the - // function's promise with p (its settlement, rejections included). - body = - value.kind === "unitLit" + // function's promise with p (its settlement, rejections included); + // a maybe-promise value adopts only its promise arm. + const flattened = isAsync ? lowerer.maybePromiseReturn(bodyExpr, value, VOID) : null; + body = flattened + ? [flattened] + : value.kind === "unitLit" ? [] : isAsync && value.type.kind === "promise" ? [lowerer.adoptingReturn(value, VOID, (e) => e)] @@ -367,8 +370,13 @@ export function lowerLambda( if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") { value = lowerer.returnedPromiseAwait(value); } - body = - value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!)) + const flattened = + isAsync && !fnCtx.inferReturn + ? lowerer.maybePromiseReturn(bodyExpr, value, bodyReturn) + : null; + body = flattened + ? [flattened] + : value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!)) ? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }] : [ { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a1ded6aee..f7b5febcc 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -596,6 +596,11 @@ import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { lowerTernary } from "./lower-exprs.js"; +import { + maybePromiseReturnStmt, + maybePromiseUnionOf, + promiseArmedUnionOf, +} from "./maybe-promise.js"; /** Entry function name. '%' cannot appear in a TS identifier, so a user * function can never collide with it (mangling is injective per prefix). */ @@ -1275,17 +1280,20 @@ export function dynFallbackType(lowerer: Lowerer, node: ts.Node, t: ts.Type): Ir if (args.length === 2) { const key = lowerer.mapTypeOf(args[0]!); const value = lowerer.mapTypeOf(args[1]!); + // Inferred JS function types keep the checked-dynamic slot (their + // closures carry JS arity, not a typed closure ABI). return { kind: "map", key: key && + key.kind !== "func" && isSupportedMapKey( key, key.kind === "union" ? lowerer.unions.get(key.unionId)?.arms : undefined, ) ? key : DYN, - value: value && isSupportedMapValue(value) ? value : DYN, + value: value && value.kind !== "func" && isSupportedMapValue(value) ? value : DYN, }; } } @@ -8204,6 +8212,18 @@ export class Lowerer { // undefined/null literals store the dyn unit values. Types outside the // dyn's domain (bytes, classes, Maps, ...) fall through to // requireExactShape's SC1101 fence. + // A promise entering a maybe-promise union whose promise arm has a + // wider payload (`Promise` into `Promise | R | void`) + // takes the payload widening below first, then wraps into that arm. + if (expected.kind === "union" && expr.type.kind === "promise") { + const mp = maybePromiseUnionOf(this, expected); + if ( + mp && + !typeEquals(mp.promise, expr.type) && + this.promisePayloadWidens(expr.type, mp.promise) + ) + expr = this.coerceToExpected(expr, mp.promise); + } // Promise payloads may widen at a checked return boundary, including // arrays inferred as tuple results by Promise.all. Keep both payload // ABIs explicit: box settlement, then validate the destination payload. @@ -9402,6 +9422,25 @@ export class Lowerer { return type; } + /** Whether a promise of `from` converts to a promise of `to` through the + * checked payload widening (coerceToExpected's boxed-settlement rule, + * which keeps the promise's identity and settlement turn). */ + promisePayloadWidens( + from: IrType & { kind: "promise" }, + to: IrType & { kind: "promise" }, + ): boolean { + if (typeEquals(from, to)) return true; + if (to.inner.kind === "void") return from.inner.kind !== "void"; + return ( + this.dynConvertible(from) && + canDynCheckTo( + to, + (id) => this.shapes.get(id), + (id) => this.unions.get(id), + ) + ); + } + /** True when a static type converts to a dyn value (the dynFrom * walker's domain): JSON-safe, bytes (Uint8Array/Buffer — the checked-dynamic tree's * bytes kind, payload copied; stdin chunks into unknown-typed helpers), @@ -9441,16 +9480,26 @@ export class Lowerer { // storage directly, just like arguments and variable initializers. // Contextual types (such as Proxy descriptors' symbol-valued fields) // must not force an intermediate record with incompatible field slots. + if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected); + return this.asyncReturnValueOf(node, this.lowerExpr(node), expected); + } + + /** A fresh JS object literal returned into a checked-native (dyn) result, + * which lowers straight into that storage. */ + private returnsLiteralIntoDyn(node: ts.Expression, expected: IrType): boolean { let literal = node; while (ts.isParenthesizedExpression(literal)) literal = literal.expression; - if ( + return ( expected.kind === "dyn" && isJsSourceFile(node.getSourceFile()) && ts.isObjectLiteralExpression(literal) - ) { - return this.lowerExprExpecting(node, expected); - } - const e = this.lowerExpr(node); + ); + } + + /** The value of `return ` (already lowered untyped) in an async or + * void context: a promise flattens through the returned-promise await, + * everything else converts into the declared result. */ + private asyncReturnValueOf(node: ts.Expression, e: IrExpr, expected: IrType): IrExpr | null { if (expected.kind === "void" && e.kind === "unitLit") return null; if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") { const awaited = this.returnedPromiseAwait(e); @@ -9516,10 +9565,32 @@ export class Lowerer { return s; } + /** `return u` of a promise-armed union (maybe-promise, or promise-or- + * unit) in an async function: adopt the promise arm, fulfill with the + * others (maybe-promise.ts). Null when the return is not that shape. */ + maybePromiseReturn(node: ts.Expression, e: IrExpr, expected: IrType): IrStmt | null { + if (!this.adoptsReturnedPromise()) return null; + const mp = promiseArmedUnionOf(this, e.type); + if (!mp || promiseArmedUnionOf(this, expected)) return null; + const stmt = maybePromiseReturnStmt(this, e, mp, expected, (x) => + this.coerceInto(node, x, expected), + ); + if (stmt === null) { + this.unsupported( + "SC1090", + node, + `returning '${this.fmt(e.type)}' from an async function whose result is '${this.fmt(expected)}' (an arm does not convert to the result)`, + ); + } + return stmt; + } + private lowerReturnOf(node: ts.Expression, loc: SrcLoc): IrStmt { const expected = this.ctx.returnType; if (expected.kind === "void") { let e = this.lowerExpr(node); + const flattened = this.maybePromiseReturn(node, e, expected); + if (flattened) return flattened; if (this.ctx.isAsync && e.type.kind === "promise") { if (this.adoptsReturnedPromise()) return this.adoptingReturn(e, VOID, (x) => x); e = this.returnedPromiseAwait(e); @@ -9535,6 +9606,15 @@ export class Lowerer { loc, }; } + if (this.adoptsReturnedPromise() && !this.returnsLiteralIntoDyn(node, expected)) { + // The async return lowers its operand untyped first (a promise + // adopts before its payload converts); a maybe-promise union needs + // the statement form, which branches between adoption and fulfillment. + const e = this.lowerExpr(node); + const flattened = this.maybePromiseReturn(node, e, expected); + if (flattened) return flattened; + return { kind: "return", value: this.asyncReturnValueOf(node, e, expected), loc }; + } return { kind: "return", value: this.lowerReturnValue(node), loc }; } diff --git a/packages/compiler/src/frontend/lowering/maybe-promise.ts b/packages/compiler/src/frontend/lowering/maybe-promise.ts new file mode 100644 index 000000000..8323c2e91 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/maybe-promise.ts @@ -0,0 +1,294 @@ +import { varRef } from "../../ir/build.js"; +import { BOOL, isUnitType, typeEquals, VOID } from "../../ir/ir.js"; +import type { IrExpr, IrStmt, IrType, IrUnionDef, SrcLoc } from "../../ir/ir.js"; +import type * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** A MAYBE-PROMISE union (`T | Promise`, `Promise | R | + * void`): one promise arm beside at least one data arm. The type mapper + * mints these only when no data arm is thenable, so the union tag decides + * exactly what JS's await decides: the promise arm settles, every other + * arm is a non-thenable that answers itself. Promise-or-unit unions keep + * their own lowerings (awaitUnionExpr). */ +export interface MaybePromiseUnion { + def: IrUnionDef; + promiseTag: number; + promise: IrType & { kind: "promise" }; +} + +export function maybePromiseUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null { + const armed = promiseArmedUnionOf(lowerer, t); + if (!armed || armed.def.arms.every((a, i) => i === armed.promiseTag || isUnitType(a))) + return null; + return armed; +} + +/** Any union with exactly one promise arm: a maybe-promise union or a + * promise-or-unit union (`Promise | void`). An async `return` + * resolves both the same way: adopt the promise arm, fulfill otherwise. */ +export function promiseArmedUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null { + if (t.kind !== "union") return null; + const def = lowerer.unions.get(t.unionId); + if (!def) return null; + const tags = def.arms.flatMap((a, i) => (a.kind === "promise" ? [i] : [])); + if (tags.length !== 1) return null; + const promiseTag = tags[0]!; + const promise = def.arms[promiseTag]!; + if (promise.kind !== "promise") return null; + return { def, promiseTag, promise }; +} + +function unionTagTest(mp: MaybePromiseUnion, value: IrExpr, tag: number, loc: SrcLoc): IrExpr { + return { + kind: "unionIsTag", + unionId: mp.def.id, + tag, + negated: false, + value, + type: BOOL, + loc, + }; +} + +/** The value of `ref` (a repeatable read of the maybe-promise union) as + * `target`, given that it does not hold the promise arm: each remaining + * arm extracts and converts. Null when some arm cannot convert. */ +function nonPromiseArmsAs( + lowerer: Lowerer, + mp: MaybePromiseUnion, + ref: IrExpr, + target: IrType, + loc: SrcLoc, +): IrExpr | null { + const pieces: { tag: number; value: IrExpr }[] = []; + for (let tag = 0; tag < mp.def.arms.length; tag++) { + if (tag === mp.promiseTag) continue; + const arm = mp.def.arms[tag]!; + const source: IrExpr = + arm.kind === "undefinedT" || arm.kind === "nullT" + ? { + kind: "unitLit", + unit: arm.kind === "undefinedT" ? "undefined" : "null", + type: arm, + loc, + } + : { kind: "unionNarrow", unionId: mp.def.id, tag, value: ref, type: arm, loc }; + const value = lowerer.coerceToExpected(source, target); + if (!typeEquals(value.type, target)) return null; + pieces.push({ tag, value }); + } + let result: IrExpr | null = null; + for (let i = pieces.length - 1; i >= 0; i--) { + const piece = pieces[i]!; + result = + result === null + ? piece.value + : { + kind: "ternary", + cond: unionTagTest(mp, ref, piece.tag, loc), + then: piece.value, + else_: result, + type: target, + loc, + }; + } + return result; +} + +function hopStmt(loc: SrcLoc): IrStmt { + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "async.hop", args: [], type: VOID, loc }, + loc, + }; +} + +/** `await u` over a maybe-promise union. JS's await resolves the operand: + * a native promise is awaited directly (resumption one turn after it + * settles; a rejection throws here), and any other non-thenable value + * resumes after exactly one microtask turn with the value itself. The + * result type is the checker's type of the await expression (`Awaited` + * of the union), which holds the promise's settled type and every data + * arm. */ +export function lowerMaybePromiseAwait( + lowerer: Lowerer, + expr: ts.AwaitExpression, + value: IrExpr, + mp: MaybePromiseUnion, + loc: SrcLoc, +): IrExpr { + const resultT = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (!resultT || resultT.kind === "void" || resultT.kind === "dyn" || isUnitType(resultT)) { + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (the awaited result type has no compiled representation)`, + ); + } + const local = lowerer.declareHiddenLocal("%maybePromise", value.type); + const ref = varRef(local.id, value.type, loc); + const inner = mp.promise.inner; + const awaited: IrExpr = { + kind: "awaitExpr", + value: { + kind: "unionNarrow", + unionId: mp.def.id, + tag: mp.promiseTag, + value: ref, + type: mp.promise, + loc, + }, + type: inner, + loc, + }; + let settled: IrExpr | null; + if (inner.kind === "void") { + const undef = lowerer.wrappedUndefined(resultT, loc); + settled = + undef === null + ? null + : { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: awaited, loc }], + result: undef, + type: resultT, + loc, + }; + } else { + settled = lowerer.coerceToExpected(awaited, resultT); + if (!typeEquals(settled.type, resultT)) settled = null; + } + const rest = nonPromiseArmsAs(lowerer, mp, ref, resultT, loc); + if (settled === null || rest === null) { + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (its arms do not all convert to the awaited result '${lowerer.fmt(resultT)}')`, + ); + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result: { + kind: "ternary", + cond: unionTagTest(mp, ref, mp.promiseTag, loc), + then: settled, + else_: { kind: "seqExpr", stmts: [hopStmt(loc)], result: rest, type: resultT, loc }, + type: resultT, + loc, + }, + type: resultT, + loc, + }; +} + +/** `return u` of a promise-armed union from an async function (or a then + * handler's lifted body) whose settled type is `R`: the promise arm is + * adopted exactly like `return p` (the spec's thenable resolution, with + * its then-job turn), and the other arms fulfill directly. Null when an + * arm cannot convert to R. */ +export function maybePromiseReturnStmt( + lowerer: Lowerer, + value: IrExpr, + mp: MaybePromiseUnion, + R: IrType, + coerce: (awaited: IrExpr) => IrExpr, +): IrStmt | null { + const loc = value.loc; + const local = lowerer.declareHiddenLocal("%maybePromise", value.type); + const ref = varRef(local.id, value.type, loc); + const promise: IrExpr = { + kind: "unionNarrow", + unionId: mp.def.id, + tag: mp.promiseTag, + value: ref, + type: mp.promise, + loc, + }; + let otherwise: IrStmt[]; + if (R.kind === "void") { + otherwise = [{ kind: "return", value: null, loc }]; + } else { + const rest = nonPromiseArmsAs(lowerer, mp, ref, R, loc); + if (rest === null) return null; + otherwise = [{ kind: "return", value: rest, loc }]; + } + return { + kind: "block", + body: [ + { kind: "varDecl", localId: local.id, init: value, loc }, + { + kind: "if", + cond: unionTagTest(mp, ref, mp.promiseTag, loc), + then: [lowerer.adoptingReturn(promise, R, coerce)], + else_: otherwise, + loc, + }, + ], + loc, + }; +} + +/** `Promise.resolve(u)` over a maybe-promise union: a promise argument is + * returned as is (every scriptc promise is native, so the spec's + * constructor check answers identity), any other value fulfills a fresh + * promise. Null when the promise arm is not already the result type (no + * identity-preserving payload adapter exists) or a data arm cannot convert. */ +export function maybePromiseResolve( + lowerer: Lowerer, + value: IrExpr, + mp: MaybePromiseUnion, + resultT: IrType & { kind: "promise" }, + loc: SrcLoc, +): IrExpr | null { + const local = lowerer.declareHiddenLocal("%maybePromise", value.type); + const ref = varRef(local.id, value.type, loc); + let promise: IrExpr = { + kind: "unionNarrow", + unionId: mp.def.id, + tag: mp.promiseTag, + value: ref, + type: mp.promise, + loc, + }; + if (!typeEquals(mp.promise, resultT)) { + if (resultT.inner.kind !== "void") return null; + promise = { kind: "promiseVoidWiden", value: promise, type: resultT, loc }; + } + let fresh: IrExpr; + if (resultT.inner.kind === "void") { + fresh = { kind: "intrinsic", name: "promise.resolve", args: [], type: resultT, loc }; + } else { + const rest = nonPromiseArmsAs(lowerer, mp, ref, resultT.inner, loc); + if (rest === null) return null; + fresh = { kind: "intrinsic", name: "promise.resolve", args: [rest], type: resultT, loc }; + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result: { + kind: "ternary", + cond: unionTagTest(mp, ref, mp.promiseTag, loc), + then: promise, + else_: fresh, + type: resultT, + loc, + }, + type: resultT, + loc, + }; +} + +/** `u instanceof Promise` over a maybe-promise (or promise-or-unit) union: + * the union tag test. */ +export function maybePromiseInstanceOf( + lowerer: Lowerer, + value: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const def = lowerer.unions.get(value.type.unionId); + const tag = def ? def.arms.findIndex((a) => a.kind === "promise") : -1; + if (!def || tag < 0) return null; + return { kind: "unionIsTag", unionId: def.id, tag, negated: false, value, type: BOOL, loc }; +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 7ecf44659..dc7aca503 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -34,6 +34,7 @@ import { SYMBOL_T, typeEquals, typeKey, + maybePromiseUnionArmsOk, unionContainerArmsOk, UNDEFINED_T, VOID, @@ -3942,6 +3943,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } const params: IrType[] = []; let typedRest = false; + let dynRest = false; for (const p of sig.getParameters()) { const decl = checker.valueDeclarationOf(p); const rest = decl !== undefined && ts.isParameter(decl) && decl.dotDotDotToken !== undefined; @@ -4004,6 +4006,16 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!armed) return null; pt = armed; } + // `...args: unknown[]` / `...args: any[]`: the checked-dynamic + // variadic ABI (no typed slot; the callee's hidden trailing dyn + // array receives every surplus argument, as for JS rest params). + if ( + rest && + (pt?.kind === "dyn" || (!pt && isAnyArrayType(checker.getTypeOfSymbol(p), checker))) + ) { + dynRest = true; + continue; + } if (!pt) return null; if (rest) { if (pt.kind !== "array") return null; @@ -4064,7 +4076,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!ret) return null; return typedRest ? { kind: "func", params, ret, rest: true, restAbi: "typed" } - : funcOf(params, ret); + : dynRest + ? { kind: "func", params, ret, rest: true } + : funcOf(params, ret); } // Records: object types whose members are all data properties (shorthand // methods in type position count — they're func-typed fields) with @@ -4312,7 +4326,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { a.kind === "dyn" || a.kind === "generator", ) || - !unionContainerArmsOk(arms) + !maybePromiseUnionOk(parts, arms, ctx) ) { return null; } @@ -6077,9 +6091,49 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { } } +/** `any[]` (or `readonly any[]`): an array type whose element is `any`. */ +export function isAnyArrayType(t: ts.Type, checker: ts.TypeChecker): boolean { + if (!checker.isArrayType(t)) return false; + const elem = checker.getTypeArguments(t as ts.TypeReference)[0]; + return elem !== undefined && (elem.flags & ts.TypeFlags.Any) !== 0; +} + +/** The maybe-promise union rule (mapTypeInner's union rule; IR + * maybePromiseUnionArmsOk): at most one promise arm, and when it has data + * siblings none of them may be thenable — `await` and async `return` + * dispatch on the union tag, so a data arm whose type declares `then` + * would skip the thenable adoption JS performs. */ +function maybePromiseUnionOk( + parts: readonly ts.Type[], + arms: IrType[], + ctx: TypeMapperCtx, +): boolean { + if (unionContainerArmsOk(arms)) return true; + if (!maybePromiseUnionArmsOk(arms)) return false; + // A function beside a promise (`Promise | (() => Promise)`, the + // assert.rejects argument) keeps its existing representation: callers + // dispatch on callability, not on a then-able promise. + if (arms.some((arm) => arm.kind === "func")) return false; + return maybePromiseThenableArm(parts, ctx) === null; +} + +/** The first non-promise constituent of a promise-armed union whose type + * has a `then` member (a thenable data arm), or null. */ +function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null { + const { checker } = ctx; + for (const part of parts) { + if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null)) continue; + if (mapType(part, ctx)?.kind === "promise") continue; + // Primitive arms answer through their wrapper prototypes, none of + // which has `then`; object arms answer through their own members. + if (checker.getPropertyOfType(part, "then")) return part; + } + return null; +} + /** Arm kinds with no home in a compiled union (mapTypeInner's union rule): * no runtime narrowing test exists against sibling data arms. */ -function armHasUnionHome(arm: IrType, siblingCount: number): boolean { +function armHasUnionHome(arm: IrType): boolean { switch (arm.kind) { case "void": case "union": @@ -6088,9 +6142,6 @@ function armHasUnionHome(arm: IrType, siblingCount: number): boolean { case "generator": case "dyn": return false; - // Promise data siblings still lack a supported narrowing contract. - case "promise": - return siblingCount === 0; default: return true; } @@ -6215,10 +6266,24 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx): if (!mapped) { return `the union shape is supported, but its arm '${text(part)}' does not compile`; } - if (!armHasUnionHome(mapped, dataArms.length - 1)) { + if (!armHasUnionHome(mapped)) { return `the union shape is supported, but '${text(part)}' arms have no home in a compiled union yet (no runtime narrowing test exists against sibling arms)`; } } + const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise"); + if (promiseArms.length > 1) { + return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`; + } + if (promiseArms.length === 1 && dataArms.length > 1) { + const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func"); + if (callable) { + return `the union shape is supported, but its function arm '${text(callable.p)}' sits beside a promise arm, and a maybe-promise union holds only data arms beside its promise`; + } + const thenable = maybePromiseThenableArm(parts, ctx); + if (thenable) { + return `the union shape is supported, but its arm '${text(thenable)}' declares 'then' beside a promise arm, so 'await' would have to adopt it as a thenable (a maybe-promise union's data arms must not be thenable)`; + } + } return null; } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 68a4776f5..be6519a9d 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -667,7 +667,12 @@ export function isIdentityCollectionKey(t: IrType): boolean { t.kind === "object" || t.kind === "array" || t.kind === "netServer" || - t.kind === "symbol" + t.kind === "symbol" || + // A function value is one closure for its whole life (top-level + // functions intern one immortal closure, inner closures allocate once + // per evaluation), so pointer identity is JS function identity. + // Closures are cycle-headered and traced like records. + t.kind === "func" ); } @@ -713,8 +718,8 @@ export function isSupportedSetElem(t: IrType, unionArms?: IrType[]): boolean { return isSupportedMapKey(t, unionArms); } -/** The Map VALUE fence: scalars, supported native references and checked - * values. Functions require checked-value boxing. +/** The Map VALUE fence: scalars, supported native references, closures + * and checked values. * Record/object/union values can point back at the map holding them, which * is exactly why ref-valued maps are cycle-capable (see the backend's * cycle analysis and docs/memory.md). Shared frontend/validator. */ @@ -768,6 +773,12 @@ export function isSupportedMapValue(t: IrType): boolean { // a regex holds no references), map form. case "regex": return true; + // A closure (Map U> — the factory/handler registry): + // the index-signature overflow story (scr_closure adapters; closures + // are cycle-headered and traced, so a handler capturing its own + // registry collects). + case "func": + return true; default: return false; } @@ -783,7 +794,7 @@ export function isSupportedMapValue(t: IrType): boolean { * Nested index-signature RECORDS ride the record kind like any other. * Shared frontend (mapType) / validator. */ export function isSupportedIndexValue(t: IrType): boolean { - return t.kind === "dyn" || t.kind === "func" || isSupportedMapValue(t); + return t.kind === "dyn" || isSupportedMapValue(t); } export function funcOf(params: IrType[], ret: IrType): IrType { @@ -791,14 +802,25 @@ export function funcOf(params: IrType[], ret: IrType): IrType { } /** Maps and sets use tagged union storage and retain their native identity. - * Promises still require nullable-only unions until their data-arm narrowing - * and await boundary have a matching contract. */ + * Joined unions (inferred joins of member reads and array elements) keep + * promises nullable-only; declared maybe-promise unions are admitted by + * maybePromiseUnionArmsOk. */ export function unionContainerArmsOk(arms: IrType[]): boolean { return arms.every( (a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b)), ); } +/** The MAYBE-PROMISE union (`T | Promise`, `Promise | R | + * void`): at most one promise arm, beside unit or data arms. The union tag + * is the `instanceof Promise` test, and `await` / async `return` branch on + * it (the promise arm awaits or adopts, the other arms take the + * non-thenable path). Data arms must not be thenable; the type mapper + * checks that against the checker types before minting such a union. */ +export function maybePromiseUnionArmsOk(arms: IrType[]): boolean { + return arms.filter((a) => a.kind === "promise").length <= 1; +} + /** Canonical, injective text form of an IrType — the building block of * shape/union identity keys, generic-function instantiation keys, and the * backend's per-type helper interning (jsonStringify/dynCheck walkers). diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 8086cc176..e4f423411 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -286,17 +286,25 @@ test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as }, ); -test.each([{ kind: "promise", inner: F64 } as IrType])( - "%j payloads still refuse unrelated data siblings", - (type) => { - const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ - { id: "mixed", arms: [type, STRING, UNDEFINED_T] }, - ]); - expect(validateModule(mod).map((error) => error.message)).toContain( - `union mixed: ${type.kind} arm 0 beside non-unit arms`, - ); - }, -); +test("a maybe-promise union holds one promise arm beside data arms", () => { + const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ + { id: "maybe", arms: [{ kind: "promise", inner: F64 }, STRING, UNDEFINED_T] }, + ]); + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); +}); + +test("a union refuses a second promise arm", () => { + const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ + { + id: "mixed", + arms: [BOOL, { kind: "promise", inner: F64 }, { kind: "promise", inner: STRING }], + }, + ]); + expect(validateModule(mod).map((error) => error.message)).toContain( + "union mixed: promise arm 1 beside another promise arm", + ); +}); test("differently typed collection payloads preserve separate union tags", () => { const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 09ab7d951..73c819ed9 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -53,7 +53,7 @@ import { STRING, typeEquals, typeKey, - unionContainerArmsOk, + maybePromiseUnionArmsOk, VOID, } from "./ir.js"; import { DYN_CLASS_PROPERTIES } from "./ir.js"; @@ -821,7 +821,7 @@ export function validateModule(mod: IrModule): IrValidationError[] { u.arms.forEach((arm, i) => { // The unit kinds (undefinedT/nullT) are valid arms — union membership // is the ONLY place they may appear. Container payloads preserve - // identity; promise data siblings retain their separate fence. + // identity; a union carries at most one promise arm (maybe-promise). if ( arm.kind === "void" || arm.kind === "union" || @@ -831,9 +831,9 @@ export function validateModule(mod: IrModule): IrValidationError[] { ) { errors.push({ message: `union ${u.id}: arm ${i} is ${arm.kind}`, loc: noLoc }); } - if (arm.kind === "promise" && !unionContainerArmsOk(u.arms)) { + if (arm.kind === "promise" && !maybePromiseUnionArmsOk(u.arms)) { errors.push({ - message: `union ${u.id}: ${arm.kind} arm ${i} beside non-unit arms`, + message: `union ${u.id}: ${arm.kind} arm ${i} beside another promise arm`, loc: noLoc, }); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json new file mode 100644 index 000000000..87429c80b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6240-maybe-promise-await-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6240-maybe-promise-await-order.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json new file mode 100644 index 000000000..3b5e1fbed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6241-maybe-promise-handlers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6241-maybe-promise-handlers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json new file mode 100644 index 000000000..b8bec7cac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6242-maybe-promise-rejections.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6242-maybe-promise-rejections.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json new file mode 100644 index 000000000..555b5e987 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6243-maybe-promise-narrowing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6243-maybe-promise-narrowing.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json new file mode 100644 index 000000000..275f06bef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6244-maybe-promise-async-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6244-maybe-promise-async-return.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json new file mode 100644 index 000000000..d367e5eb5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6245-rest-unknown-handlers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6245-rest-unknown-handlers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json new file mode 100644 index 000000000..ee4654507 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6246-map-set-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6246-map-set-function-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json new file mode 100644 index 000000000..83a605ab0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6247-next-proxy-maybe-promise.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6247-next-proxy-maybe-promise.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json new file mode 100644 index 000000000..e5cf59339 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6248-tagged-template-any-rest.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6248-tagged-template-any-rest.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json new file mode 100644 index 000000000..c01942ee0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6249-tagged-template-missing-substitutions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6249-tagged-template-missing-substitutions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json new file mode 100644 index 000000000..2e463b920 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/maybe-promise-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/maybe-promise-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json deleted file mode 100644 index 2af7d599f..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json deleted file mode 100644 index 571eaa53d..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts" - ], - "diags": [] -} diff --git a/tests/corpus/6240-maybe-promise-await-order.ts b/tests/corpus/6240-maybe-promise-await-order.ts new file mode 100644 index 000000000..728278348 --- /dev/null +++ b/tests/corpus/6240-maybe-promise-await-order.ts @@ -0,0 +1,60 @@ +// `await` on a maybe-promise union (`T | Promise`): the promise arm +// resumes one turn after it settles, every other arm is a non-thenable that +// resumes after exactly one microtask turn with the value itself. A ticker +// chain interleaves with each await so the turn counts are observable. + +type MaybeString = string | Promise; + +const log: string[] = []; +let tick = 0; +function ticker(turns: number): void { + if (turns === 0) return; + Promise.resolve().then(() => { + tick++; + log.push("T" + tick); + ticker(turns - 1); + }); +} + +const settled: Promise = Promise.resolve("settled"); +function choose(kind: number): MaybeString { + if (kind === 0) return "plain"; + if (kind === 1) return settled; + // Settles two turns from now. + return new Promise((resolve) => { + Promise.resolve().then(() => resolve("late")); + }); +} + +async function awaitOnce(kind: number): Promise { + log.push("start" + kind); + const value = await choose(kind); + log.push("got" + kind + ":" + value); +} + +async function awaitTwice(): Promise { + const first = await choose(0); + const second = await choose(1); + log.push("twice:" + first + "+" + second); +} + +// A union with unit arms beside the data arm: `undefined` is a +// non-thenable too. +type MaybeCount = number | undefined | Promise; +function count(kind: number): MaybeCount { + return kind === 0 ? 7 : kind === 1 ? undefined : Promise.resolve(kind === 2 ? 9 : undefined); +} +async function counts(): Promise { + for (let kind = 0; kind < 4; kind++) { + const value = await count(kind); + log.push("count" + kind + ":" + String(value)); + } +} + +ticker(12); +awaitOnce(0); +awaitOnce(1); +awaitOnce(2); +awaitTwice(); +counts(); +setTimeout(() => console.log(log.join(" ")), 5); diff --git a/tests/corpus/6241-maybe-promise-handlers.ts b/tests/corpus/6241-maybe-promise-handlers.ts new file mode 100644 index 000000000..49549eaa4 --- /dev/null +++ b/tests/corpus/6241-maybe-promise-handlers.ts @@ -0,0 +1,77 @@ +// Handlers typed `Promise | R | void` (an extension event +// handler) and members typed `(p: string) => boolean | Promise` +// (a tool's filesystem operations): sync and async implementations share +// one slot, and the caller awaits whatever comes back. + +interface Ops { + exists: (absolutePath: string) => boolean | Promise; +} +const syncOps: Ops = { exists: (p) => p.length > 3 }; +const asyncOps: Ops = { exists: async (p) => p.startsWith("/") }; + +interface Result { + block: boolean; + reason: string; +} +type Handler = (n: number) => Promise | Result | void; + +const handlers: Handler[] = []; +handlers.push((n) => { + if (n > 5) return { block: true, reason: "sync big" }; +}); +handlers.push(async (n) => { + if (n === 3) return { block: true, reason: "async three" }; +}); +handlers.push((n) => (n === 4 ? Promise.resolve({ block: false, reason: "promise four" }) : undefined)); + +// Named functions flow into the slot through their declared types. +async function asyncNamed(n: number): Promise { + return { block: n === 2, reason: "async named" }; +} +function syncNamed(n: number): Result { + return { block: n === 1, reason: "sync named" }; +} +function voidNamed(n: number): void { + console.log("void handler saw", n); +} +async function asyncVoid(n: number): Promise { + console.log("async void handler saw", n); +} +const named: Handler[] = [asyncNamed, syncNamed, voidNamed, asyncVoid]; + +async function run(list: Handler[], n: number): Promise { + for (const h of list) { + const r = await h(n); + if (r && r.block) return r.reason; + } + return "none"; +} + +// A class-instance arm beside null and the promise arm. +class Response { + status: number; + constructor(status: number) { + this.status = status; + } +} +type Route = (path: string) => Response | null | Promise; +const routes: Route[] = [ + (path) => (path === "/sync" ? new Response(200) : null), + async (path) => (path === "/async" ? new Response(202) : null), +]; +async function route(path: string): Promise { + for (const r of routes) { + const res = await r(path); + if (res instanceof Response) return path + " " + res.status; + } + return path + " 404"; +} + +async function main(): Promise { + console.log(await syncOps.exists("/tmp/x"), await syncOps.exists("/a")); + console.log(await asyncOps.exists("/tmp/x"), await asyncOps.exists("tmp")); + for (const n of [1, 3, 4, 7]) console.log(n, await run(handlers, n)); + for (const n of [1, 2, 9]) console.log(n, await run(named, n)); + for (const path of ["/sync", "/async", "/missing"]) console.log(await route(path)); +} +main(); diff --git a/tests/corpus/6242-maybe-promise-rejections.ts b/tests/corpus/6242-maybe-promise-rejections.ts new file mode 100644 index 000000000..6323d6242 --- /dev/null +++ b/tests/corpus/6242-maybe-promise-rejections.ts @@ -0,0 +1,77 @@ +// Rejections through maybe-promise values: a rejected promise arm throws at +// the await, a synchronous throw never reaches the await, an async return +// adopts the promise arm (so its rejection settles the caller's promise +// after the function's own handlers), and a `Promise | void` +// callback's promise is adopted by an async function returning it. + +type MaybeBool = boolean | Promise; +function check(kind: number): MaybeBool { + if (kind === 0) return true; + if (kind === 1) return Promise.reject(new Error("rejected " + kind)); + if (kind === 2) throw new Error("thrown " + kind); + return Promise.resolve(false); +} + +async function awaitEach(): Promise { + for (let kind = 0; kind < 4; kind++) { + try { + console.log(kind, await check(kind)); + } catch (e) { + console.log(kind, "caught", (e as Error).message); + } + } +} + +// `return check(1)` inside try/catch/finally: JS completes the return first +// (the finally runs), and only then does the async function's promise adopt +// the rejected arm, so this function's own catch never sees it. +async function returnInTry(kind: number): Promise { + try { + return check(kind); + } catch (e) { + console.log("inner catch", (e as Error).message); + return false; + } finally { + console.log("finally", kind); + } +} + +type Callback = () => Promise | void; +const callbacks: Callback[] = [ + () => { + console.log("sync callback"); + }, + async () => { + throw new Error("async callback failed"); + }, +]; +async function runCallback(cb: Callback): Promise { + return cb(); +} + +async function main(): Promise { + await awaitEach(); + for (const kind of [0, 1, 2]) { + try { + console.log("returned", await returnInTry(kind)); + } catch (e) { + console.log("outer catch", (e as Error).message); + } + } + for (const cb of callbacks) { + try { + await runCallback(cb); + console.log("callback ok"); + } catch (e) { + console.log("callback caught", (e as Error).message); + } + } + // A then handler returning a rejected promise arm rejects the chain. + await Promise.resolve(1) + .then(() => check(1)) + .then( + (v) => console.log("then value", v), + (e: Error) => console.log("then rejected", e.message), + ); +} +main(); diff --git a/tests/corpus/6243-maybe-promise-narrowing.ts b/tests/corpus/6243-maybe-promise-narrowing.ts new file mode 100644 index 000000000..7c1fe6fd2 --- /dev/null +++ b/tests/corpus/6243-maybe-promise-narrowing.ts @@ -0,0 +1,61 @@ +// Telling the arms of a maybe-promise union apart: `instanceof Promise`, +// the `then` probe through a cast, typeof, truthiness, equality, and +// Promise.resolve (which hands a promise back as is and wraps a value). + +type MaybeString = string | Promise; +function make(i: number): MaybeString { + return i % 2 === 1 ? "s" + i : Promise.resolve("p" + i); +} + +interface Box { + v: number; +} +type MaybeBox = Box | Promise; +function box(i: number): MaybeBox { + return i % 2 === 0 ? { v: i } : Promise.resolve({ v: i * 10 }); +} +function isThenable(x: MaybeBox): boolean { + return typeof (x as any).then === "function"; +} + +type Slot = { name: string; value: string | Promise | null }; +const slots: Slot[] = [ + { name: "a", value: "x" }, + { name: "b", value: Promise.resolve("y") }, + { name: "c", value: null }, +]; + +async function main(): Promise { + for (let i = 0; i < 4; i++) { + const v = make(i); + if (v instanceof Promise) console.log(i, "promise", await v); + else console.log(i, "value", v.toUpperCase()); + console.log(typeof v); + } + for (let i = 0; i < 3; i++) { + const b = box(i); + console.log(i, isThenable(b), typeof (b as Promise).then); + if ((b as Promise).then) console.log("has then"); + console.log((await b).v); + } + for (const s of slots) { + const v = await s.value; + console.log(s.name, v === null ? "null" : v, s.value === null, s.value instanceof Promise, typeof s.value); + if (s.value) console.log("truthy", s.name); + } + console.log(slots[1]!.value === slots[1]!.value); + + // Promise.resolve keeps a promise's identity and wraps a value. + const p = Promise.resolve(7); + const get = (b: boolean): number | Promise => (b ? p : 3); + const fromPromise = Promise.resolve(get(true)); + const fromValue = Promise.resolve(get(false)); + console.log(fromPromise === p, fromValue === p); + const order: string[] = []; + fromPromise.then((v) => order.push("promise" + v)); + fromValue.then((v) => order.push("value" + v)); + Promise.resolve().then(() => order.push("other")); + await new Promise((resolve) => setTimeout(resolve, 1)); + console.log(order.join(",")); +} +main(); diff --git a/tests/corpus/6244-maybe-promise-async-return.ts b/tests/corpus/6244-maybe-promise-async-return.ts new file mode 100644 index 000000000..9c38a8f7d --- /dev/null +++ b/tests/corpus/6244-maybe-promise-async-return.ts @@ -0,0 +1,61 @@ +// Returning a maybe-promise value from async code flattens it: the +// promise arm is adopted (the extra then-job turns of a returned promise), +// any other arm fulfills directly. Covers block bodies, concise arrow +// bodies, `return await`, and then handlers. + +type MaybeString = string | Promise; +const settled: Promise = Promise.resolve("settled"); +function choose(kind: number): MaybeString { + if (kind === 0) return "plain"; + if (kind === 1) return settled; + return new Promise((resolve) => setTimeout(() => resolve("timer"), 1)); +} + +async function viaReturn(kind: number): Promise { + return choose(kind); +} +async function viaReturnAwait(kind: number): Promise { + return await choose(kind); +} +const viaArrow = async (kind: number): Promise => choose(kind); + +const log: string[] = []; +let tick = 0; +function ticker(turns: number): void { + if (turns === 0) return; + Promise.resolve().then(() => { + tick++; + log.push("T" + tick); + ticker(turns - 1); + }); +} + +// A then handler returning a maybe-promise value: a cache that answers +// synchronously once loaded. +const cache = new Map(); +function load(key: string): string | Promise { + const hit = cache.get(key); + if (hit !== undefined) return hit; + return new Promise((resolve) => + setTimeout(() => { + cache.set(key, key.toUpperCase()); + resolve(key.toUpperCase()); + }, 1), + ); +} + +ticker(10); +for (let kind = 0; kind < 3; kind++) { + viaReturn(kind).then((v) => log.push("ret" + kind + ":" + v)); + viaReturnAwait(kind).then((v) => log.push("await" + kind + ":" + v)); + viaArrow(kind).then((v) => log.push("arrow" + kind + ":" + v)); +} +const start = Promise.resolve("k"); +start + .then((key) => load(key)) + .then((v) => { + log.push("first:" + v); + return start.then((key) => load(key)); + }) + .then((v) => log.push("second:" + v)); +setTimeout(() => console.log(log.join(" ")), 20); diff --git a/tests/corpus/6245-rest-unknown-handlers.ts b/tests/corpus/6245-rest-unknown-handlers.ts new file mode 100644 index 000000000..664c6160d --- /dev/null +++ b/tests/corpus/6245-rest-unknown-handlers.ts @@ -0,0 +1,46 @@ +// Rest parameters typed `unknown[]` or `any[]` in function types: the +// handler registry of an extension host, with forwarding wrappers and +// spread calls. + +type HandlerFn = (...args: unknown[]) => Promise; +const handlers = new Map(); +function on(event: string, handler: HandlerFn): () => void { + const registered: HandlerFn = (...args) => handler(...args); + const list = handlers.get(event) ?? []; + list.push(registered); + handlers.set(event, list); + return () => { + const current = handlers.get(event); + if (!current) return; + const index = current.indexOf(registered); + if (index !== -1) current.splice(index, 1); + }; +} +async function emit(event: string, ...args: unknown[]): Promise { + const out: unknown[] = []; + for (const h of handlers.get(event) ?? []) out.push(await h(...args)); + return out; +} + +on("x", async (...args) => args.length); +const off = on("x", async (...args: unknown[]) => typeof args[0] + ":" + String(args[1])); +on("y", async (...args) => "no args " + args.length); + +function sum(...nums: unknown[]): number { + let total = 0; + for (const n of nums) if (typeof n === "number") total += n; + return total; +} +const stored: (...values: unknown[]) => number = sum; +const logAll: (...values: any[]) => void = (...values: any[]) => console.log(values.length, values[0]); + +async function main(): Promise { + console.log(JSON.stringify(await emit("x", 1, "two", true))); + off(); + console.log(JSON.stringify(await emit("x", 5))); + console.log(JSON.stringify(await emit("y"))); + console.log(sum(1, 2, "x", 4), stored(5, 6), stored()); + logAll("p", "q"); + logAll(); +} +main(); diff --git a/tests/corpus/6246-map-set-function-values.ts b/tests/corpus/6246-map-set-function-values.ts new file mode 100644 index 000000000..fc9aa187d --- /dev/null +++ b/tests/corpus/6246-map-set-function-values.ts @@ -0,0 +1,52 @@ +// Functions as Map values, Map keys and Set elements: factory and listener +// registries keep each closure's identity, and a handler that captures its +// own registry is collected with it. + +interface Extension { + name: string; + run(x: number): number; +} +type ExtensionFactory = (api: { log: (s: string) => void }) => Extension; +const factories = new Map(); +factories.set("double", (api) => ({ + name: "double", + run: (x) => { + api.log("double " + x); + return x * 2; + }, +})); +factories.set("inc", () => ({ name: "inc", run: (x) => x + 1 })); +const lines: string[] = []; +const api = { log: (s: string) => lines.push(s) }; +for (const [key, make] of factories) { + const ext = make(api); + console.log(key, ext.name, ext.run(10)); +} +console.log(factories.get("missing") === undefined, typeof factories.get("inc"), factories.has("inc")); +const inc = factories.get("inc"); +if (inc) console.log(inc(api).run(41)); +factories.delete("inc"); +console.log([...factories.keys()].join(","), factories.size, lines.join(";")); + +type Listener = (v: string) => void; +const listeners = new Set(); +const a: Listener = (v) => console.log("a", v); +const b: Listener = (v) => console.log("b", v); +listeners.add(a); +listeners.add(b); +listeners.add(a); +console.log(listeners.size, listeners.has(a), listeners.has((v) => console.log(v))); +for (const l of listeners) l("x"); +listeners.delete(a); +listeners.forEach((l) => l("y")); + +const counts = new Map(); +counts.set(a, 1); +counts.set(b, 2); +counts.set(a, 3); +console.log(counts.size, counts.get(a), counts.get(b)); + +const registry = new Map number>(); +registry.set("self", () => registry.size); +registry.set("double", () => registry.size * 2); +console.log([...registry.values()].map((f) => f()).join(",")); diff --git a/tests/corpus/6247-next-proxy-maybe-promise.ts b/tests/corpus/6247-next-proxy-maybe-promise.ts new file mode 100644 index 000000000..264742e2b --- /dev/null +++ b/tests/corpus/6247-next-proxy-maybe-promise.ts @@ -0,0 +1,35 @@ +// The Next.js proxy signature: `(request, event) => NextMiddlewareResult | +// Promise` where the result is a response class, null, +// undefined or void. Sync and async proxies run through one host. + +class NextResponse { + status: number; + constructor(status: number) { + this.status = status; + } +} +type NextMiddlewareResult = NextResponse | null | undefined | void; +type NextProxy = (request: string, event: number) => NextMiddlewareResult | Promise; + +const proxy: NextProxy = (request) => { + if (request === "/health") return new NextResponse(200); + if (request === "/async") return Promise.resolve(new NextResponse(202)); + if (request === "/null") return null; +}; +const asyncProxy: NextProxy = async (request) => (request === "/a" ? new NextResponse(201) : undefined); + +async function handle(p: NextProxy, request: string): Promise { + const result = await p(request, 0); + return result ? String(result.status) : String(result); +} + +// The repro shape: Promise.resolve over the handler's result. +type Handler = (n: number) => NextResponse | null | Promise; +const h: Handler = () => new NextResponse(204); +Promise.resolve(h(1)).then((r) => console.log(r ? r.status : "none")); + +async function main(): Promise { + for (const request of ["/health", "/async", "/null", "/other"]) console.log(request, await handle(proxy, request)); + for (const request of ["/a", "/b"]) console.log(request, await handle(asyncProxy, request)); +} +main(); diff --git a/tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts b/tests/corpus/6248-tagged-template-any-rest.ts similarity index 100% rename from tests/diagnostics/retired-engine-2251-tagged-templates-dyn.ts rename to tests/corpus/6248-tagged-template-any-rest.ts diff --git a/tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts b/tests/corpus/6249-tagged-template-missing-substitutions.ts similarity index 100% rename from tests/diagnostics/retired-engine-2790-array-missing-tagged-substitutions.ts rename to tests/corpus/6249-tagged-template-missing-substitutions.ts diff --git a/tests/diagnostics/component-fences.ts b/tests/diagnostics/component-fences.ts index 267102086..6d88f9af5 100644 --- a/tests/diagnostics/component-fences.ts +++ b/tests/diagnostics/component-fences.ts @@ -5,9 +5,9 @@ const byFlag = new Map(); console.log(byFlag); -// Map values have no slot for functions. -const listeners = new Map void>(); -console.log(listeners); +// Map values have no slot for Dates. +const stamps = new Map(); +console.log(stamps); // Boolean Set elements remain outside the supported element domain. const flags = new Set(); diff --git a/tests/diagnostics/maps.ts b/tests/diagnostics/maps.ts index eaa2508d3..fcdef411d 100644 --- a/tests/diagnostics/maps.ts +++ b/tests/diagnostics/maps.ts @@ -4,7 +4,7 @@ // Boolean keys compile alongside the remaining value and method fences. const byFlag = new Map(); -// Values exclude functions (no closure story in the uniform value slot yet). +// Function values compile (closures keep their identity in the slot). const handlers = new Map void>(); // Typed nested Maps retain their identity and are supported. diff --git a/tests/diagnostics/maybe-promise-fences.ts b/tests/diagnostics/maybe-promise-fences.ts new file mode 100644 index 000000000..efe541c9e --- /dev/null +++ b/tests/diagnostics/maybe-promise-fences.ts @@ -0,0 +1,37 @@ +// Maybe-promise unions (`T | Promise`) compile when the union tag can +// answer what JS's await decides. These forms stay refused. + +// A data arm that declares `then` is a thenable: await would adopt it. +interface Thenish { + then(cb: (v: number) => void): void; +} +function thenable(): Thenish | Promise { + return Promise.resolve(1); +} +console.log(typeof thenable); + +// Two promise arms: the union tag cannot tell them apart by instanceof. +function twoPromises(): Promise | Promise | boolean { + return true; +} +console.log(typeof twoPromises); + +// Promises have no inspect lowering, so neither does a union holding one. +function maybe(b: boolean): string | Promise { + return b ? "s" : Promise.resolve("p"); +} +console.log(maybe(true)); + +// Promise.resolve hands the promise arm back as is, so the result type +// must be that arm's own promise type. +const widened = Promise.resolve(maybe(false)); +console.log(typeof widened); + +// `instanceof Promise` on a value that is never a promise. +const record = { v: 1 }; +console.log(record instanceof Promise); + +// A variadic `unknown[]` slot takes closures with the same variadic ABI. +type HandlerFn = (...args: unknown[]) => Promise; +const fixed: HandlerFn = async (first: unknown) => first; +fixed(1, 2).then((v) => console.log(v)); diff --git a/tests/harness/__snapshots__/component-fences.ts.txt b/tests/harness/__snapshots__/component-fences.ts.txt index 498846004..5ed6ad6b9 100644 --- a/tests/harness/__snapshots__/component-fences.ts.txt +++ b/tests/harness/__snapshots__/component-fences.ts.txt @@ -1,27 +1,27 @@ -component-fences.ts:9:7 - error SC2009: values of type 'Map void>' cannot be compiled: the Map shape is supported, but '() => void' values have no Map slot yet +component-fences.ts:9:7 - error SC2009: values of type 'Map' cannot be compiled: the Map shape is supported, but 'Date' values have no Map slot yet - 8 | // Map values have no slot for functions. - 9 | const listeners = new Map void>(); - | ^~~~~~~~~ - 10 | console.log(listeners); + 8 | // Map values have no slot for Dates. + 9 | const stamps = new Map(); + | ^~~~~~ + 10 | console.log(stamps); hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -component-fences.ts:9:19 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet +component-fences.ts:9:16 - error SC1090: Map values of type 'Date' (Map values must be number, string, boolean, records, class instances, functions, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet - 8 | // Map values have no slot for functions. - 9 | const listeners = new Map void>(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 10 | console.log(listeners); + 8 | // Map values have no slot for Dates. + 9 | const stamps = new Map(); + | ^~~~~~~~~~~~~~~~~~~~~~~ + 10 | console.log(stamps); -component-fences.ts:10:13 - error SC2004: uses of 'listeners' inherit the blocker on its declaration +component-fences.ts:10:13 - error SC2004: uses of 'stamps' inherit the blocker on its declaration - 9 | const listeners = new Map void>(); - 10 | console.log(listeners); - | ^~~~~~~~~ + 9 | const stamps = new Map(); + 10 | console.log(stamps); + | ^~~~~~ 11 | - hint: the declaration of 'listeners' did not compile — fix the diagnostic reported there and these sites clear with it + hint: the declaration of 'stamps' did not compile — fix the diagnostic reported there and these sites clear with it component-fences.ts:35:7 - error SC2009: values of type '() => WeakRef' cannot be compiled: the function shape is supported, but its return type 'WeakRef' does not compile diff --git a/tests/harness/__snapshots__/maps.ts.txt b/tests/harness/__snapshots__/maps.ts.txt index fe64f3787..7d02a345c 100644 --- a/tests/harness/__snapshots__/maps.ts.txt +++ b/tests/harness/__snapshots__/maps.ts.txt @@ -1,19 +1,3 @@ -maps.ts:8:7 - error SC2009: values of type 'Map void>' cannot be compiled: the Map shape is supported, but '() => void' values have no Map slot yet - - 7 | // Values exclude functions (no closure story in the uniform value slot yet). - 8 | const handlers = new Map void>(); - | ^~~~~~~~ - 9 | - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -maps.ts:8:18 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet - - 7 | // Values exclude functions (no closure story in the uniform value slot yet). - 8 | const handlers = new Map void>(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 9 | - maps.ts:29:28 - error SC1090: JSON.stringify of 'Map' values (only number, string, boolean, records, arrays, unions of those, and 'unknown' stringify) are not supported yet 28 | const m = new Map(); diff --git a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt new file mode 100644 index 000000000..58fb23517 --- /dev/null +++ b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt @@ -0,0 +1,65 @@ +maybe-promise-fences.ts:8:10 - error SC2009: values of type 'Promise | Thenish' cannot be compiled: the union shape is supported, but its arm 'Thenish' declares 'then' beside a promise arm, so 'await' would have to adopt it as a thenable (a maybe-promise union's data arms must not be thenable) + + 7 | } + 8 | function thenable(): Thenish | Promise { + | ^~~~~~~~ + 9 | return Promise.resolve(1); + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blocker on its declaration + + 10 | } + 11 | console.log(typeof thenable); + | ^~~~~~~~ + 12 | + + hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it + +maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise | Promise' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise' and 'Promise'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test) + + 13 | // Two promise arms: the union tag cannot tell them apart by instanceof. + 14 | function twoPromises(): Promise | Promise | boolean { + | ^~~~~~~~~~~ + 15 | return true; + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration + + 16 | } + 17 | console.log(typeof twoPromises); + | ^~~~~~~~~~~ + 18 | + + hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it + +maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise | string' values ('Promise' values have no inspect lowering) are not supported yet + + 22 | } + 23 | console.log(maybe(true)); + | ^~~~~~~~~~~ + 24 | + +maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise | string' at this result type' is part of the standard library types but has no scriptc lowering yet + + 26 | // must be that arm's own promise type. + 27 | const widened = Promise.resolve(maybe(false)); + | ^~~~~~~~~~~~ + 28 | console.log(typeof widened); + + hint: the promise arm passes through identically, so it must already be the result's promise type + +maybe-promise-fences.ts:32:13 - error SC1090: 'instanceof Promise' on '{ v: number }' values (only promises, promise-armed unions, and checked-dynamic values have a lowering) are not supported yet + + 31 | const record = { v: 1 }; + 32 | console.log(record instanceof Promise); + | ^~~~~~~~~~~~~~~~~~~~~~~~~ + 33 | + +maybe-promise-fences.ts:36:26 - error SC1090: '(unknown) => Promise' values where '() => Promise' is expected is not supported yet + + 35 | type HandlerFn = (...args: unknown[]) => Promise; + 36 | const fixed: HandlerFn = async (first: unknown) => first; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 37 | fixed(1, 2).then((v) => console.log(v)); \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt b/tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt deleted file mode 100644 index 8d2f13b3e..000000000 --- a/tests/harness/__snapshots__/retired-engine-2251-tagged-templates-dyn.ts.txt +++ /dev/null @@ -1,44 +0,0 @@ -retired-engine-2251-tagged-templates-dyn.ts:6:15 - error SC2009: values of type 'any[]' cannot be compiled: the array shape is supported, but its element type 'any' does not compile - - 5 | // non-function tag throws Node's catchable TypeError). - 6 | function f(...args: any[]): string { - | ^~~~ - 7 | return args.length > 1 - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -retired-engine-2251-tagged-templates-dyn.ts:11:13 - error SC2004: uses of 'f' inherit the blocker on its declaration - - 10 | } - 11 | console.log(f`a${1}b${2}c`); - | ^ - 12 | console.log(f`plain`); - - hint: the declaration of 'f' did not compile — fix the diagnostic reported there and these sites clear with it - -retired-engine-2251-tagged-templates-dyn.ts:12:13 - error SC2004: uses of 'f' inherit the blocker on its declaration - - 11 | console.log(f`a${1}b${2}c`); - 12 | console.log(f`plain`); - | ^ - 13 | - - hint: the declaration of 'f' did not compile — fix the diagnostic reported there and these sites clear with it - -retired-engine-2251-tagged-templates-dyn.ts:14:29 - error SC2009: values of type 'any[]' cannot be compiled: the array shape is supported, but its element type 'any' does not compile - - 13 | - 14 | function declare(x: any, ...ys: any[]): string { - | ^~ - 15 | return `${String(x)}|${ys.length}`; - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -retired-engine-2251-tagged-templates-dyn.ts:17:13 - error SC2004: uses of 'declare' inherit the blocker on its declaration - - 16 | } - 17 | console.log(declare`Hello ${0} world!`); - | ^~~~~~~ - 18 | - - hint: the declaration of 'declare' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt b/tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt deleted file mode 100644 index 6f03a0bfc..000000000 --- a/tests/harness/__snapshots__/retired-engine-2790-array-missing-tagged-substitutions.ts.txt +++ /dev/null @@ -1,17 +0,0 @@ -retired-engine-2790-array-missing-tagged-substitutions.ts:11:25 - error SC2009: values of type 'any[]' cannot be compiled: the array shape is supported, but its element type 'any' does not compile - - 10 | - 11 | function dynamicRest(...args: any[]): string { - | ^~~~ - 12 | return `${args.length}:${String(args[1])}`; - - hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') - -retired-engine-2790-array-missing-tagged-substitutions.ts:14:13 - error SC2004: uses of 'dynamicRest' inherit the blocker on its declaration - - 13 | } - 14 | console.log(dynamicRest`a${values[9]}b`); - | ^~~~~~~~~~~ - 15 | - - hint: the declaration of 'dynamicRest' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file From 52303baf4b01f7f0fb3946ca36a178782776a833 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 18:01:49 -0500 Subject: [PATCH 37/53] Keep the promise of async generic functions whose body reads a missing element The instance result of an async generic function whose body returns an element or index-signature read that can miss is Promise: the promise is always present and settles with undefined. The call site widened the whole promise instead, so the instance and its calls had different signatures and every call failed to compile. --- .../src/frontend/lowering/generic-functions.ts | 7 +++++-- tests/corpus/5954-generic-optional-returns.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 90f83ec2c..1502f7fc5 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -298,9 +298,12 @@ export function genericCallInstance( lowerer.badType(expr, retTs); } // A body that can return an indexed read that misses returns undefined - // there, as in JavaScript, whatever the declared result says. + // there, as in JavaScript, whatever the declared result says. An async + // body's promise is always present and settles with undefined there. const returnType = lowerer.runtimeOptionalGenericReturns.has(info.decl) - ? lowerer.runtimeOptionalType(mappedReturn) + ? mappedReturn.kind === "promise" + ? { kind: "promise" as const, inner: lowerer.runtimeOptionalType(mappedReturn.inner) } + : lowerer.runtimeOptionalType(mappedReturn) : mappedReturn; // keyof-constrained type parameters (`K extends keyof T`): the bound diff --git a/tests/corpus/5954-generic-optional-returns.ts b/tests/corpus/5954-generic-optional-returns.ts index 9aaec5889..3f8309c9d 100644 --- a/tests/corpus/5954-generic-optional-returns.ts +++ b/tests/corpus/5954-generic-optional-returns.ts @@ -49,3 +49,18 @@ function second(rows: T[][], row: number, column: number): T { return rows[row][column]; } console.log(second([[1, 2]], 0, 1), second([[1, 2]], 0, 9), second(pairs, 0, 1)); + +// An async generic function's promise is always present; its settled +// value is undefined for a missing element. +async function firstAsync(items: T[]): Promise { + return items[0]; +} +const lookupAsync = async (table: Record, key: string): Promise => table[key]; +console.log(await firstAsync([1, 2]), await firstAsync([]), (await firstAsync([])) === undefined); +console.log(await lookupAsync({ a: "A" }, "a"), await lookupAsync({ a: "A" }, "b")); +async function settle(): Promise { + const found = await firstAsync(["x"]); + const missing = await firstAsync([]); + console.log(found, missing, missing ?? "none"); +} +await settle(); From 550e00549c1e3bbec8127adf086a4ad89bd71236 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 18:40:52 -0500 Subject: [PATCH 38/53] Move callSiteReturnType into ir.ts for devirtualization Lowering may not import ir/validate.ts, and devirtualize.ts imported callSiteReturnType from it. The function only reads an IrFunction's types, so it moves next to typeEquals in ir.ts, which both the validator and lowering import. The Date formatter fence's hint now lists toUTCString(), which this branch lowers, so the date-formatters and stdlib-fence snapshots record the new hint. toString() and toLocaleString() still have no lowering, and the fixtures keep them. --- .../src/frontend/lowering/devirtualize.ts | 9 ++++-- packages/compiler/src/ir/ir.ts | 27 +++++++++++++++++ packages/compiler/src/ir/validate.ts | 29 +------------------ .../__snapshots__/date-formatters.ts.txt | 4 +-- .../harness/__snapshots__/stdlib-fence.ts.txt | 2 +- 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/devirtualize.ts b/packages/compiler/src/frontend/lowering/devirtualize.ts index 04a53aeb1..b71080a3f 100644 --- a/packages/compiler/src/frontend/lowering/devirtualize.ts +++ b/packages/compiler/src/frontend/lowering/devirtualize.ts @@ -1,7 +1,12 @@ import * as ts from "../ts7/adapter.js"; -import { type IrExpr, type IrFunction, type IrType, typeEquals } from "../../ir/ir.js"; +import { + type IrExpr, + type IrFunction, + type IrType, + callSiteReturnType, + typeEquals, +} from "../../ir/ir.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; -import { callSiteReturnType } from "../../ir/validate.js"; import type { ClassInfo } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 68a4776f5..12b02d139 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -857,6 +857,33 @@ export function typeKey(t: IrType): string { } } +/** The type a CALL SITE (or function value) receives from a module + * function: an async body's returnType is the promise's INNER type, so + * calls and closures over it see Promise; a generator body's is the + * TReturn channel, so call sites see the generator type. The one place + * the body/call-site split is spelled out, for the validator and lowering. */ +export function callSiteReturnType(fn: IrFunction): IrType { + if (fn.async && fn.generator !== undefined) { + return { + kind: "generator", + async: true, + yieldT: fn.generator.yieldT, + retT: fn.returnType, + nextT: fn.generator.nextT, + }; + } + if (fn.async) return { kind: "promise", inner: fn.returnType }; + if (fn.generator !== undefined) { + return { + kind: "generator", + yieldT: fn.generator.yieldT, + retT: fn.returnType, + nextT: fn.generator.nextT, + }; + } + return fn.returnType; +} + export function typeEquals(a: IrType, b: IrType): boolean { // Dispatch once: a chain of negative narrowing checks repeatedly retags // the remaining variants when this comparator runs in the native compiler. diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 979b70115..2526dafa1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -56,7 +56,7 @@ import { unionContainerArmsOk, VOID, } from "./ir.js"; -import { DYN_CLASS_PROPERTIES } from "./ir.js"; +import { DYN_CLASS_PROPERTIES, callSiteReturnType } from "./ir.js"; import { unionWideningTags } from "./analysis.js"; import { alwaysReturns } from "./control-flow.js"; import { dynamicOperationOf, functionTier } from "./tiers.js"; @@ -85,33 +85,6 @@ export interface IrValidationError { loc: SrcLoc; } -/** The type a CALL SITE (or function value) receives from a module - * function: an async body's returnType is the promise's INNER type, so - * calls and closures over it see Promise; a generator body's is the - * TReturn channel, so call sites see the generator type. The one place - * the body/call-site split is spelled out in the validator. */ -export function callSiteReturnType(fn: IrFunction): IrType { - if (fn.async && fn.generator !== undefined) { - return { - kind: "generator", - async: true, - yieldT: fn.generator.yieldT, - retT: fn.returnType, - nextT: fn.generator.nextT, - }; - } - if (fn.async) return { kind: "promise", inner: fn.returnType }; - if (fn.generator !== undefined) { - return { - kind: "generator", - yieldT: fn.generator.yieldT, - retT: fn.returnType, - nextT: fn.generator.nextT, - }; - } - return fn.returnType; -} - interface VirtualCallInfo { declared: boolean; hasOverride: boolean; diff --git a/tests/harness/__snapshots__/date-formatters.ts.txt b/tests/harness/__snapshots__/date-formatters.ts.txt index 66d98d861..90928dae7 100644 --- a/tests/harness/__snapshots__/date-formatters.ts.txt +++ b/tests/harness/__snapshots__/date-formatters.ts.txt @@ -5,7 +5,7 @@ date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toString' is part of the | ^~~~~~~~~~~~~~~ 5 | date.toLocaleString(); - hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet @@ -14,4 +14,4 @@ date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part o | ^~~~~~~~~~~~~~~~~~~~~ 6 | - hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering \ No newline at end of file + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 337354eec..95fbe1fff 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -66,7 +66,7 @@ stdlib-fence.ts:25:1 - error SC2020: 'Date.prototype.setUTCFullYear with argumen | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | const sameDate = mutableDate === mutableDate; // scalar storage must not fake object identity - hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering stdlib-fence.ts:26:18 - error SC1043: comparing non-number, non-string values are not supported yet From 3de2fb56a06fea1f28c37fbc53a134af4e347a42 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 19:28:55 -0500 Subject: [PATCH 39/53] Cancel running subtests when their parent test times out node:test changes, matching Node 24.22: - When a test's { timeout } elapses, its subtests that are still running are cancelled, the innermost first. Each reports its duration so far and fails with "test did not finish before its parent and was cancelled", before the parent's result. - A cancelled subtest's body keeps running unobserved: when it settles it reports nothing and leaves the runner's state alone. Before, it reported its result after the run's summary. The timeout-subtests fixture compares the results, counts and failing section with Node, plain and with SCRIPTC_SAN=1. --- .../limitations/builtin-apis/test-runner.mdx | 1 + packages/runtime/src/scr_test.c | 28 +++++++++++++++++++ .../node-test/cases/timeout-subtests.ts | 23 +++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 tests/fixtures/node-test/cases/timeout-subtests.ts diff --git a/docs/content/limitations/builtin-apis/test-runner.mdx b/docs/content/limitations/builtin-apis/test-runner.mdx index 6583a2873..77d3d9a84 100644 --- a/docs/content/limitations/builtin-apis/test-runner.mdx +++ b/docs/content/limitations/builtin-apis/test-runner.mdx @@ -3,3 +3,4 @@ - **TestContext values:** `t.filePath` is the entry file's path when the program was compiled. `t.attempt` is `0`, since a test runs once. `t.passed` is `false` while the body runs and the test's outcome in its `afterEach` and `t.after` hooks. `t.workerId` is `Number(process.env.NODE_TEST_WORKER_ID) || undefined`, as in Node. `t.tags` is `[]`, since the `tags` test option throws `SC2020`; the array is not frozen as Node's is. - **Suite and hook contexts:** a `describe` body may take its `SuiteContext`, which has `name`, `fullName`, `filePath`, `attempt`, `passed` and `diagnostic`. Hooks may take their context, as in Node: the suite's for `before` and `after`, the test's for `beforeEach`, `afterEach`, `t.before` and `t.after`. The `SuiteContext` `signal` and `log` members, and hook `done` callbacks, throw `SC2020`. - **Module-level hooks:** `before` hooks outside any suite run at their `before()` call, and the first top-level test waits for the async ones; `after` hooks outside any suite run after the last top-level test. Both get the root's context, named ``. As in Node, a throwing root `before` fails each top-level test with its error, without running its body, and cancels the tests and suites inside each top-level suite. A throwing root `after` reports the entry file as a failed result, and the exit code is unchanged. +- **Timeouts:** when a test's `{ timeout }` elapses, its running subtests are cancelled, as in Node. Subtests do not take their parent's `timeout` as their own, where Node's do, and the subtests of a timed-out test with `{ concurrency }` above 1 run to completion before the test reports. A body that keeps running after its timeout and calls `t.test` again runs that subtest, where Node reports it as not started. diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index cc52513a6..4217169b8 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -125,6 +125,7 @@ struct ScrTestCtx { struct ScrMockTimers *mock_timers; /* t.mock.timers, made on first use; freed at exit */ bool passed; /* t.passed: set when the body passes, as Node's pass() does */ bool listed; /* a suite listed in the failing section (a failing root before) */ + double start_ms; /* when the test started running; NaN before */ }; static SCR_TL ScrTestCtx *scr_test_root = NULL; @@ -230,6 +231,7 @@ static ScrTestCtx *scr_test_node_new(ScrStr *name /*borrowed*/, int mode, t->plan = -1; t->timeout = INFINITY; t->concurrency = 1; + t->start_ms = NAN; #ifdef SCR_RC_AUDIT scr_testctx_live++; #endif @@ -597,6 +599,23 @@ static void scr_test_cancel_unrun(ScrTestCtx *c) { scr_test_report_result(c); } +/* A test that timed out (Node): its subtests that are still running are + * cancelled, innermost first, with their durations so far. Their bodies + * keep running unobserved and report nothing when they settle. */ +static void scr_test_cancel_running(ScrTestCtx *t) { + for (size_t i = 0; i < t->nchildren; i++) { + ScrTestCtx *c = t->children[i]; + if (c->is_suite || c->state != SCR_TS_PENDING || isnan(c->start_ms)) continue; + scr_test_cancel_running(c); + static const char msg[] = "test did not finish before its parent and was cancelled"; + scr_throw_str(scr_str_new(msg, sizeof msg - 1)); + c->err = scr_exc_take(); + c->dur_ms = scr_now_ms() - c->start_ms; + c->state = SCR_TS_CANCELLED; + scr_test_report_result(c); + } +} + static void scr_test_fail_unrun(ScrTestCtx *c, ScrCaught *err) { double start = scr_now_ms(); c->err = scr_caught_retain(err); @@ -689,6 +708,7 @@ static void scr_test_run_node(ScrTestCtx *t) { * afterEach chain. */ ScrTestCtx *prev_parent = scr_test_parent_get(); scr_test_parent_set(t); + t->start_ms = start; if (t->concurrency > 1) scr_test_defer++; bool hooked = scr_test_run_each_hooks(t, SCR_HOOK_BEFORE_EACH, true); /* A concurrent subtest resumes after its beforeEach hooks as many turns @@ -703,10 +723,18 @@ static void scr_test_run_node(ScrTestCtx *t) { failed = true; } else if (t->cb) { scr_test_call(t->cb, t->flags, t, t); + if (t->state != SCR_TS_PENDING) { + /* Its parent timed out and cancelled it while the body ran: the + * result is reported, and the runner has moved on. */ + if (scr_exc_pending()) scr_exc_clear(); + if (t->concurrency > 1) scr_test_defer--; + return; + } if (scr_exc_pending()) { t->err = scr_exc_take(); failed = true; } + if (failed && t->timed_out) scr_test_cancel_running(t); } /* Children a passing body registered without running (bare test() in * the body): run them now, before the parent completes — Node holds diff --git a/tests/fixtures/node-test/cases/timeout-subtests.ts b/tests/fixtures/node-test/cases/timeout-subtests.ts new file mode 100644 index 000000000..91a5417e7 --- /dev/null +++ b/tests/fixtures/node-test/cases/timeout-subtests.ts @@ -0,0 +1,23 @@ +// @exit: 1 +// A test that times out cancels its subtests that are still running, the +// innermost first, with "test did not finish before its parent and was +// cancelled". Subtests that finished keep their results, and the +// cancelled bodies report nothing when they settle later. +import { test } from "node:test"; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +test("parent", { timeout: 40 }, async (t) => { + await t.test("finished first", () => {}); + await t.test("running", async (t) => { + await t.test("inner done", () => {}); + await t.test("inner running", async () => { + await sleep(300); + throw new Error("never reported"); + }); + }); +}); + +test("next", async () => { + await sleep(400); +}); From 6a2d43b462f8171f888c91f247981a919709d496 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:16:44 -0700 Subject: [PATCH 40/53] Keep declaration-file rest callbacks on their existing paths Library callback slots such as String#replace's (substring, ...args: any[]) have intrinsic lowerings that inspect the unmapped function type, so only rest parameters declared in program source take the checked-dynamic variadic ABI. Found by the zero-cost gate on corpus 6165 after merging main. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 236b2a6c0..1aa5b1d1b 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4317,11 +4317,16 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null if (!armed) return null; pt = armed; } - // `...args: unknown[]` / `...args: any[]`: the checked-dynamic - // variadic ABI (no typed slot; the callee's hidden trailing dyn - // array receives every surplus argument, as for JS rest params). + // `...args: unknown[]` / `...args: any[]` declared in program source: + // the checked-dynamic variadic ABI (no typed slot; the callee's hidden + // trailing dyn array receives every surplus argument, as for JS rest + // params). Declaration files keep their existing paths: library + // callback slots such as String#replace's `(substring, ...args: any[])` + // have intrinsic lowerings that inspect the unmapped type. if ( rest && + decl !== undefined && + !decl.getSourceFile().isDeclarationFile && (pt?.kind === "dyn" || (!pt && isAnyArrayType(checker.getTypeOfSymbol(p), checker))) ) { dynRest = true; From ec79cf27897af17e5ed035b9bee9c17ef6a0be9b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 19:42:27 -0500 Subject: [PATCH 41/53] Run iterator helpers on untyped iterators In JavaScript, a receiver without an inferred type, such as an untyped parameter's xs.values(), is the runtime's checked iterator. Its only method was next(), so xs.values().filter(f), .map(f) and the other iterator helpers threw "TypeError: ... is not a function" where Node runs them, and [].values().reduce(f) threw the same instead of Node's "Reduce of a done iterator with no initial value". The native array, string, Map and Set iterators now answer the Iterator.prototype methods. map, filter, take, drop and flatMap make helper objects (SCR_DYNH_ITERATOR_HELPER) that pull lazily and support next(), return(), for...of and spreads, print as "Object [Iterator Helper] {}" and tag as "[object Iterator Helper]". toArray, forEach, reduce, some, every and find drive the iterator. The helpers close the iterator they were made from where Node does: when a callback throws, when take's budget is spent, on return(), and before the error for an invalid argument. flatMap closes its inner iterator first. An error from a close that runs because of another error is dropped. Invalid arguments throw Node's messages, such as "number 5 is not a function" and "x must be positive". --- .../6070-iterator-helpers-untyped.js.json | 6 + packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_json.c | 691 +++++++++++++++++- tests/corpus/6070-iterator-helpers-untyped.js | 89 +++ tests/harness/corpus-costs.json | 2 + 5 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json create mode 100644 tests/corpus/6070-iterator-helpers-untyped.js diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json new file mode 100644 index 000000000..41ecbaab7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6070-iterator-helpers-untyped.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6070-iterator-helpers-untyped.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index ca21a0685..bccd8d55c 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -173,6 +173,7 @@ typedef enum { SCR_DYNH_BROADCAST_CHANNEL, /* worker_threads BroadcastChannel (scr_worker_events.c) */ SCR_DYNH_HASH, /* node:crypto Hash (scr_lib.c) */ SCR_DYNH_HMAC, /* node:crypto Hmac (scr_lib.c) */ + SCR_DYNH_ITERATOR_HELPER, /* an iterator helper object over a checked iterator (scr_json.c) */ SCR_DYNH_COUNT, } ScrDynHandleTag; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 0567d0b15..401933904 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -75,7 +75,7 @@ static bool scr_builtin_iterable(const ScrDyn *value) { return value->kind == SCR_DYN_ARR || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BYTES || (scr_dyn_generator(value) && value->v.typed_ref.type_key[0] != 'a') || (value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value)) || - (value->kind == SCR_DYN_HANDLE && (value->v.handle.tag == SCR_DYNH_ITERATOR || value->v.handle.tag == SCR_DYNH_MAP || value->v.handle.tag == SCR_DYNH_SET || + (value->kind == SCR_DYN_HANDLE && (value->v.handle.tag == SCR_DYNH_ITERATOR || value->v.handle.tag == SCR_DYNH_ITERATOR_HELPER || value->v.handle.tag == SCR_DYNH_MAP || value->v.handle.tag == SCR_DYNH_SET || scr_dyn_handle_ops_of(value)->iter_pack || scr_dyn_handle_ops_of(value)->iter_step)); } static bool scr_iterator_object(const ScrDyn *value) { @@ -4657,8 +4657,16 @@ bool scr_dyn_iterator_step_done(const ScrDyn *value) { return ((ScrNativeIterator *)value->v.handle.ptr)->step_done; } +static int scr_ih_method_index(const char *name, size_t len); +static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc); +static ScrDyn *scr_ih_method_function(int index); + static ScrDyn *scr_native_iterator_get(void *ptr, const char *key, size_t len) { - if (len != 4 || memcmp(key, "next", 4)) return NULL; + if (len != 4 || memcmp(key, "next", 4)) { + /* The Iterator.prototype helper methods. */ + int index = scr_ih_method_index(key, len); + return index >= 0 ? scr_ih_method_function(index) : NULL; + } unsigned kind = ((ScrNativeIterator *)ptr)->kind; if (kind == 4) return scr_iterator_function(10, &scr_native_handle_iterator_next_call, "next"); return kind == 3 ? scr_iterator_function(8, &scr_native_set_iterator_next_call, "next") @@ -4669,8 +4677,9 @@ static ScrDyn *scr_native_iterator_get(void *ptr, const char *key, size_t len) { static ScrDyn *scr_native_iterator_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { - (void)self; if (!strcmp(method, "next")) return scr_native_iterator_next(ptr); + int index = scr_ih_method_index(method, strlen(method)); + if (index >= 0) return scr_iterator_helper_method(self, index, args, argc); ScrDyn *missing = scr_dyn_undefined(); return scr_dyn_call(missing, args, argc, what); } @@ -4700,6 +4709,682 @@ static ScrDyn *scr_native_collection_iterator(ScrDyn *source, unsigned selection return result; } +/* ── iterator helpers on checked iterators (ES2025) ───────────────────────── + * The Iterator.prototype methods over a checked iterator object: a native + * array, string, Map or Set iterator, or a helper object made here. Typed + * code lowers its helpers statically; these answer untyped JavaScript + * receivers. The stages (map, filter, take, drop, flatMap) make helper + * objects that pull lazily; the consuming methods (toArray, forEach, reduce, + * some, every, find) drive the iterator to completion or to their answer. + * Closing follows IteratorClose: a helper closes the iterator it was made + * from when a callback throws, when its budget is spent, and on return(); + * an error from a close that runs because of another error is dropped. */ + +static ScrDyn *scr_iterator_call(const ScrDyn *method, const ScrDyn *receiver, const char *name); +static ScrDyn *scr_iterator_method(const ScrDyn *source); + +enum { SCR_IH_MAP, SCR_IH_FILTER, SCR_IH_TAKE, SCR_IH_DROP, SCR_IH_FLATMAP }; +enum { SCR_IH_START, SCR_IH_YIELDED, SCR_IH_RUNNING, SCR_IH_DONE }; + +typedef struct { + size_t rc; + unsigned kind; + unsigned state; + ScrDyn *iterated; /* the iterator the helper was made from */ + ScrDyn *next; /* its next method, read when the helper was made */ + ScrDyn *fn; /* the map, filter or flatMap callback */ + double remaining; /* the take or drop budget */ + double counter; + ScrDyn *inner; /* flatMap's current inner iterator */ + ScrDyn *inner_next; +} ScrIterHelper; + +static const char *const scr_ih_method_names[] = { + "map", "filter", "take", "drop", "flatMap", "reduce", "toArray", "forEach", "some", "every", "find", +}; +#define SCR_IH_METHOD_COUNT (sizeof scr_ih_method_names / sizeof scr_ih_method_names[0]) + +/* The index of an Iterator.prototype helper method name, or -1. */ +static int scr_ih_method_index(const char *name, size_t len) { + for (size_t i = 0; i < SCR_IH_METHOD_COUNT; i++) { + if (strlen(scr_ih_method_names[i]) == len && !memcmp(scr_ih_method_names[i], name, len)) return (int)i; + } + return -1; +} + +/* V8's description of a value in " is not a function": the type, + * with the value for primitives that print one. */ +static void scr_ih_describe_type(ScrJsonBuf *b, const ScrDyn *v) { + switch (v->kind) { + case SCR_DYN_UNDEF: scr_jb_puts(b, "undefined"); return; + case SCR_DYN_NULL: scr_jb_puts(b, "object null"); return; + case SCR_DYN_BOOL: scr_jb_puts(b, v->v.b ? "boolean true" : "boolean false"); return; + case SCR_DYN_NUM: { + scr_jb_puts(b, "number "); + ScrStr *n = scr_f64_to_scrstr(v->v.num); + scr_jb_write(b, n->data, n->len); + scr_str_release(n); + return; + } + case SCR_DYN_STR: + scr_jb_puts(b, "string \""); + scr_jb_write(b, v->v.str->data, v->v.str->len); + scr_jb_putc(b, '"'); + return; + case SCR_DYN_SYMBOL: scr_jb_puts(b, "symbol"); return; + case SCR_DYN_BIGINT: scr_jb_puts(b, "bigint"); return; + default: scr_jb_puts(b, "object"); return; + } +} + +static bool scr_ih_require_callable(const ScrDyn *fn) { + if (scr_dyn_is_callable(fn)) return true; + ScrJsonBuf b; + scr_jb_init(&b); + scr_ih_describe_type(&b, fn); + scr_jb_puts(&b, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return false; +} + +/* V8's text for the value in " must be positive": numbers, strings + * raw, undefined by name, + * arrays and objects by their default string or constructor name. */ +static void scr_ih_describe_value(ScrJsonBuf *b, const ScrDyn *v) { + if (v->kind == SCR_DYN_ARR) { + scr_jb_puts(b, "[object Array]"); + return; + } + if (v->kind == SCR_DYN_OBJ) { + scr_jb_puts(b, "#"); + return; + } + if (v->kind == SCR_DYN_HANDLE) { + scr_jb_puts(b, "#<"); + scr_jb_puts(b, scr_dyn_handle_ops_of(v)->cls); + scr_jb_putc(b, '>'); + return; + } + if (v->kind == SCR_DYN_STR) { + scr_jb_write(b, v->v.str->data, v->v.str->len); + return; + } + if (v->kind == SCR_DYN_NUM) { + ScrStr *n = scr_f64_to_scrstr(v->v.num); + scr_jb_write(b, n->data, n->len); + scr_str_release(n); + return; + } + scr_jb_puts(b, v->kind == SCR_DYN_UNDEF ? "undefined" : "NaN"); +} + +/* ToIntegerOrInfinity of a take/drop budget with Node's RangeError for NaN + * and negative budgets. Answers false with the exception pending. */ +static bool scr_ih_budget(const ScrDyn *limit, double *out) { + double n = scr_dyn_number_coerce(limit); + if (scr_exc_pending()) return false; + if (n != n) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_ih_describe_value(&b, limit); + scr_jb_puts(&b, " must be positive"); + scr_throw_error(SCR_ERR_RANGE, scr_jb_finish(&b)); + return false; + } + double t = n < 9007199254740992.0 && n > -9007199254740992.0 ? n - fmod(n, 1.0) : n; + if (t < 0) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_ih_describe_value(&b, limit); + scr_jb_puts(&b, " must be positive"); + scr_throw_error(SCR_ERR_RANGE, scr_jb_finish(&b)); + return false; + } + *out = t == 0 ? 0 : t; + return true; +} + +/* IteratorClose(iterator, completion). `abrupt` is a throw completion: the + * pending error stays and anything the return() call raises is dropped. + * Otherwise errors from return() propagate, and a non-object result is + * Node's TypeError. Answers false when an exception is pending after. */ +static bool scr_ih_close(ScrDyn *iterator, bool abrupt) { + ScrCaught *first = abrupt ? scr_exc_take() : NULL; + ScrDyn *method = scr_iterator_read(iterator, "return", 6); + bool ok = method != NULL; + if (ok && method->kind != SCR_DYN_UNDEF && method->kind != SCR_DYN_NULL) { + ScrDyn *result = scr_iterator_call(method, iterator, "return"); + if (!result) ok = false; + else { + if (!abrupt && !scr_iterator_object(result)) { + static const char message[] = "Iterator result is not an object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + ok = false; + } + scr_dyn_release(result); + } + } + scr_dyn_release(method); + if (first) { + if (scr_exc_pending()) { + ScrCaught *dropped = scr_exc_take(); + if (dropped) scr_caught_release(dropped); + } + scr_rethrow(first); + scr_caught_release(first); + return false; + } + return ok && !scr_exc_pending(); +} + +/* IteratorStepValue over (iterator, next): +1 value with *done false, or + * *done true (the answer is then undefined); NULL with the exception + * pending when next() throws or answers a non-object. */ +static ScrDyn *scr_ih_step(ScrDyn *iterator, ScrDyn *next, bool *done) { + *done = false; + if (scr_dyn_iterator_can_step(iterator, next)) { + ScrDyn *value = scr_dyn_iterator_step(iterator); + if (!value) return NULL; + *done = scr_dyn_iterator_step_done(iterator); + return value; + } + ScrDyn *raw = scr_iterator_call(next, iterator, "next"); + if (!raw) return NULL; + ScrDyn *result = scr_dyn_iterator_result(raw); + scr_dyn_release(raw); + if (!result) return NULL; + ScrDyn *finished = scr_iterator_read(result, "done", 4); + if (!finished) { + scr_dyn_release(result); + return NULL; + } + *done = scr_dyn_truthy(finished); + scr_dyn_release(finished); + ScrDyn *value = *done ? scr_dyn_retain(scr_dyn_undefined()) : scr_iterator_read(result, "value", 5); + scr_dyn_release(result); + return value; +} + +/* GetIteratorDirect: the receiver must be an object; its next method is + * read once. +1 next, or NULL with the TypeError pending. */ +static ScrDyn *scr_ih_direct(const ScrDyn *self, const char *method) { + if (!scr_iterator_object(self)) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Iterator.prototype."); + scr_jb_puts(&b, method); + scr_jb_puts(&b, " called on non-object"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return NULL; + } + return scr_iterator_read(self, "next", 4); +} + +static ScrDyn *scr_ih_result(ScrDyn *value, bool done) { + ScrDyn *result = scr_dyn_new_obj(); + scr_dyn_obj_set(result, "value", 5, value); + scr_dyn_obj_set(result, "done", 4, scr_dyn_new_bool(done)); + return result; +} + +static void *scr_ih_retain(void *ptr) { + ((ScrIterHelper *)ptr)->rc++; + return ptr; +} + +static void scr_ih_release(void *ptr) { + ScrIterHelper *h = ptr; + if (--h->rc) return; + scr_dyn_release(h->iterated); + scr_dyn_release(h->next); + if (h->fn) scr_dyn_release(h->fn); + if (h->inner) scr_dyn_release(h->inner); + if (h->inner_next) scr_dyn_release(h->inner_next); + free(h); +} + +static void scr_ih_clear_inner(ScrIterHelper *h) { + if (h->inner) scr_dyn_release(h->inner); + if (h->inner_next) scr_dyn_release(h->inner_next); + h->inner = NULL; + h->inner_next = NULL; +} + +/* GetIteratorFlattenable(value, reject-primitives) for flatMap's callback + * result: +1 iterator with *next_out +1, or NULL with the TypeError pending. */ +static ScrDyn *scr_ih_flattenable(ScrDyn *value, ScrDyn **next_out) { + if (!scr_iterator_object(value)) { + static const char message[] = "Iterator.prototype.flatMap called on non-object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + ScrDyn *method = scr_iterator_method(value); + if (!method) return NULL; + ScrDyn *iterator; + if (method->kind == SCR_DYN_UNDEF || method->kind == SCR_DYN_NULL) { + iterator = scr_dyn_retain(value); + } else { + iterator = scr_iterator_call(method, value, "value[Symbol.iterator]"); + if (iterator && !scr_iterator_object(iterator)) { + scr_dyn_release(iterator); + static const char message[] = "Iterator result is not an object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + iterator = NULL; + } + } + scr_dyn_release(method); + if (!iterator) return NULL; + ScrDyn *next = scr_iterator_read(iterator, "next", 4); + if (!next) { + scr_dyn_release(iterator); + return NULL; + } + *next_out = next; + return iterator; +} + +/* Calls a stage callback with (value, counter); +1 result or NULL. */ +static ScrDyn *scr_ih_callback(ScrDyn *fn, ScrDyn *value, double counter) { + ScrDyn *index = scr_dyn_new_num(counter); + ScrDyn *args[2] = { value, index }; + ScrDyn *undefined = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_this_push_dyn(undefined); + ScrDyn *result = scr_dyn_call(fn, args, 2, "callback"); + scr_dyn_this_pop(); + scr_dyn_release(undefined); + scr_dyn_release(index); + return result; +} + +/* The helper's next value: +1 value with *done false, *done true, or NULL + * with the exception pending (the helper is then done). */ +static ScrDyn *scr_ih_advance(ScrIterHelper *h, bool *done) { + *done = false; + for (;;) { + if (h->kind == SCR_IH_FLATMAP && h->inner) { + bool inner_done; + ScrDyn *value = scr_ih_step(h->inner, h->inner_next, &inner_done); + if (!value) { + scr_ih_close(h->iterated, true); + return NULL; + } + if (!inner_done) return value; + scr_dyn_release(value); + scr_ih_clear_inner(h); + continue; + } + if (h->kind == SCR_IH_TAKE) { + if (h->remaining == 0) { + *done = true; + if (!scr_ih_close(h->iterated, false)) return NULL; + return scr_dyn_retain(scr_dyn_undefined()); + } + if (h->remaining != INFINITY) h->remaining--; + } + if (h->kind == SCR_IH_DROP) { + while (h->remaining > 0) { + if (h->remaining != INFINITY) h->remaining--; + bool skipped_done; + ScrDyn *skipped = scr_ih_step(h->iterated, h->next, &skipped_done); + if (!skipped) return NULL; + scr_dyn_release(skipped); + if (skipped_done) { + *done = true; + return scr_dyn_retain(scr_dyn_undefined()); + } + } + } + bool source_done; + ScrDyn *value = scr_ih_step(h->iterated, h->next, &source_done); + if (!value) return NULL; + if (source_done) { + *done = true; + return value; + } + if (h->kind == SCR_IH_TAKE || h->kind == SCR_IH_DROP) return value; + ScrDyn *mapped = scr_ih_callback(h->fn, value, h->counter); + if (!mapped) { + scr_dyn_release(value); + scr_ih_close(h->iterated, true); + return NULL; + } + h->counter++; + if (h->kind == SCR_IH_MAP) { + scr_dyn_release(value); + return mapped; + } + if (h->kind == SCR_IH_FILTER) { + bool keep = scr_dyn_truthy(mapped); + scr_dyn_release(mapped); + if (keep) return value; + scr_dyn_release(value); + continue; + } + /* flatMap */ + scr_dyn_release(value); + ScrDyn *inner_next = NULL; + ScrDyn *inner = scr_ih_flattenable(mapped, &inner_next); + scr_dyn_release(mapped); + if (!inner) { + scr_ih_close(h->iterated, true); + return NULL; + } + h->inner = inner; + h->inner_next = inner_next; + } +} + +static void scr_ih_running_error(void) { + static const char message[] = "Generator is already running"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); +} + +static ScrDyn *scr_ih_next(ScrIterHelper *h) { + if (h->state == SCR_IH_RUNNING) { + scr_ih_running_error(); + return NULL; + } + if (h->state == SCR_IH_DONE) return scr_ih_result(scr_dyn_retain(scr_dyn_undefined()), true); + h->state = SCR_IH_RUNNING; + scr_ih_retain(h); + bool done; + ScrDyn *value = scr_ih_advance(h, &done); + ScrDyn *result = NULL; + if (!value || done) { + h->state = SCR_IH_DONE; + scr_ih_clear_inner(h); + if (value) { + scr_dyn_release(value); + result = scr_ih_result(scr_dyn_retain(scr_dyn_undefined()), true); + } + } else { + h->state = SCR_IH_YIELDED; + result = scr_ih_result(value, false); + } + scr_ih_release(h); + return result; +} + +/* %IteratorHelperPrototype%.return: an unstarted or suspended helper + * completes and closes the iterator it was made from (flatMap closes its + * inner iterator first). */ +static ScrDyn *scr_ih_return(ScrIterHelper *h) { + if (h->state == SCR_IH_RUNNING) { + scr_ih_running_error(); + return NULL; + } + if (h->state != SCR_IH_DONE) { + h->state = SCR_IH_DONE; + scr_ih_retain(h); + bool ok = true; + if (h->inner) { + ScrDyn *inner = scr_dyn_retain(h->inner); + scr_ih_clear_inner(h); + if (!scr_ih_close(inner, false)) { + scr_ih_close(h->iterated, true); + ok = false; + } + scr_dyn_release(inner); + } + if (ok) ok = scr_ih_close(h->iterated, false); + scr_ih_release(h); + if (!ok) return NULL; + } + return scr_ih_result(scr_dyn_retain(scr_dyn_undefined()), true); +} + +static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc); + +static ScrDyn *scr_ih_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (!strcmp(method, "next")) return scr_ih_next(ptr); + if (!strcmp(method, "return")) return scr_ih_return(ptr); + int index = scr_ih_method_index(method, strlen(method)); + if (index >= 0) return scr_iterator_helper_method(self, index, args, argc); + return scr_dyn_call(scr_dyn_undefined(), args, argc, what); +} + +static ScrDyn *scr_ih_method_function(int index); + +static ScrDyn *scr_ih_next_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)clo; (void)args; (void)argc; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == SCR_DYNH_ITERATOR_HELPER) { + result = scr_ih_next(self->v.handle.ptr); + } else { + static const char message[] = "Method Iterator Helper.prototype.next called on incompatible receiver"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(self); + return result; +} + +static ScrDyn *scr_ih_return_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)clo; (void)args; (void)argc; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == SCR_DYNH_ITERATOR_HELPER) { + result = scr_ih_return(self->v.handle.ptr); + } else { + static const char message[] = "Method Iterator Helper.prototype.return called on incompatible receiver"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(self); + return result; +} + +static SCR_TL ScrDyn *scr_ih_functions[SCR_IH_METHOD_COUNT + 2]; +static SCR_TL bool scr_ih_functions_registered; + +static void scr_ih_functions_cleanup(void) { + for (size_t i = 0; i < SCR_IH_METHOD_COUNT + 2; i++) { + scr_dyn_release(scr_ih_functions[i]); + scr_ih_functions[i] = NULL; + } +} + +static ScrDyn *scr_ih_function(size_t slot, ScrDynThunk thunk, const char *name) { + if (!scr_ih_functions_registered) { + scr_ih_functions_registered = true; + scr_atexit(scr_ih_functions_cleanup); + } + if (!scr_ih_functions[slot]) + scr_ih_functions[slot] = scr_dyn_new_func(scr_closure_new(NULL, 0), thunk, 0, "native:iterator", name); + return scr_dyn_retain(scr_ih_functions[slot]); +} + +static ScrDyn *scr_ih_get(void *ptr, const char *key, size_t len) { + (void)ptr; + if (len == 4 && !memcmp(key, "next", 4)) return scr_ih_function(SCR_IH_METHOD_COUNT, &scr_ih_next_call, "next"); + if (len == 6 && !memcmp(key, "return", 6)) + return scr_ih_function(SCR_IH_METHOD_COUNT + 1, &scr_ih_return_call, "return"); + int index = scr_ih_method_index(key, len); + return index >= 0 ? scr_ih_method_function(index) : NULL; +} + +static ScrDyn *scr_ih_inspect_view(void *ptr) { + (void)ptr; + return scr_dyn_new_obj(); +} + +static ScrDyn *scr_ih_new(unsigned kind, ScrDyn *iterated, ScrDyn *next, ScrDyn *fn, double remaining) { + static const ScrDynHandleOps ops = { + .cls = "Object", + .retain = &scr_ih_retain, + .release = &scr_ih_release, + .invoke = &scr_ih_invoke, + .get = &scr_ih_get, + .inspect_view = &scr_ih_inspect_view, + .to_string_tag = "Iterator Helper", + .inspect_view_empty = true, + }; + scr_dyn_handle_install(SCR_DYNH_ITERATOR_HELPER, &ops); + ScrIterHelper *h = malloc(sizeof *h); + if (!h) scr_json_oom(); + *h = (ScrIterHelper){ .rc = 1, .kind = kind, .state = SCR_IH_START, .iterated = scr_dyn_retain(iterated), + .next = next, .fn = fn ? scr_dyn_retain(fn) : NULL, .remaining = remaining }; + ScrDyn *result = scr_dyn_new_handle(h, SCR_DYNH_ITERATOR_HELPER); + scr_ih_release(h); + return result; +} + +/* The consuming methods: drive (self, next) with `fn`. */ +static ScrDyn *scr_ih_consume(ScrDyn *self, ScrDyn *next, int index, ScrDyn *fn, ScrDyn *const *args, + size_t argc) { + const char *name = scr_ih_method_names[index]; + bool reduce = !strcmp(name, "reduce"); + ScrDyn *out = !strcmp(name, "toArray") ? scr_dyn_new_arr() : NULL; + ScrDyn *acc = NULL; + double counter = 0; + if (reduce && argc >= 2) acc = scr_dyn_retain(args[1]); + else if (reduce) { + bool done; + ScrDyn *first = scr_ih_step(self, next, &done); + if (!first) return NULL; + if (done) { + scr_dyn_release(first); + static const char message[] = "Reduce of a done iterator with no initial value"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + acc = first; + counter = 1; + } + for (;;) { + bool done; + ScrDyn *value = scr_ih_step(self, next, &done); + if (!value) { + if (out) scr_dyn_release(out); + if (acc) scr_dyn_release(acc); + return NULL; + } + if (done) { + scr_dyn_release(value); + break; + } + if (out) { + scr_dyn_arr_push(out, value); + continue; + } + ScrDyn *answer; + if (reduce) { + ScrDyn *index_value = scr_dyn_new_num(counter); + ScrDyn *call_args[3] = { acc, value, index_value }; + ScrDyn *undefined = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_this_push_dyn(undefined); + answer = scr_dyn_call(fn, call_args, 3, "callback"); + scr_dyn_this_pop(); + scr_dyn_release(undefined); + scr_dyn_release(index_value); + } else { + answer = scr_ih_callback(fn, value, counter); + } + counter++; + if (!answer) { + scr_dyn_release(value); + if (acc) scr_dyn_release(acc); + scr_ih_close(self, true); + return NULL; + } + if (reduce) { + scr_dyn_release(acc); + acc = answer; + scr_dyn_release(value); + continue; + } + bool truthy = scr_dyn_truthy(answer); + scr_dyn_release(answer); + if (!strcmp(name, "forEach")) { + scr_dyn_release(value); + continue; + } + bool stop = !strcmp(name, "every") ? !truthy : truthy; + if (!stop) { + scr_dyn_release(value); + continue; + } + if (!scr_ih_close(self, false)) { + scr_dyn_release(value); + return NULL; + } + if (!strcmp(name, "find")) return value; + scr_dyn_release(value); + return scr_dyn_new_bool(!strcmp(name, "some")); + } + if (out) return out; + if (reduce) return acc; + if (!strcmp(name, "some") || !strcmp(name, "every")) return scr_dyn_new_bool(!strcmp(name, "every")); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* An Iterator.prototype helper method called on `self` (borrowed). */ +static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc) { + const char *name = scr_ih_method_names[index]; + ScrDyn *next = scr_ih_direct(self, name); + if (!next) return NULL; + ScrDyn *arg = argc > 0 ? args[0] : scr_dyn_undefined(); + if (!strcmp(name, "take") || !strcmp(name, "drop")) { + double budget; + if (!scr_ih_budget(arg, &budget)) { + scr_dyn_release(next); + scr_ih_close(self, true); + return NULL; + } + return scr_ih_new(!strcmp(name, "take") ? SCR_IH_TAKE : SCR_IH_DROP, self, next, NULL, budget); + } + if (!strcmp(name, "toArray")) { + ScrDyn *result = scr_ih_consume(self, next, index, NULL, args, argc); + scr_dyn_release(next); + return result; + } + if (!scr_ih_require_callable(arg)) { + scr_dyn_release(next); + scr_ih_close(self, true); + return NULL; + } + if (index < 5) { + unsigned kind = !strcmp(name, "map") ? SCR_IH_MAP : !strcmp(name, "filter") ? SCR_IH_FILTER : SCR_IH_FLATMAP; + return scr_ih_new(kind, self, next, arg, 0); + } + ScrDyn *result = scr_ih_consume(self, next, index, arg, args, argc); + scr_dyn_release(next); + return result; +} + +static ScrDyn *scr_ih_method_call(int index, ScrDyn *const *args, size_t argc) { + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = scr_iterator_helper_method(self, index, args, argc); + scr_dyn_release(self); + return result; +} + +#define SCR_IH_THUNK(i) \ + static ScrDyn *scr_ih_method_call_##i(ScrClosure *clo, ScrDyn *const *args, size_t argc) { \ + (void)clo; \ + return scr_ih_method_call(i, args, argc); \ + } +SCR_IH_THUNK(0) +SCR_IH_THUNK(1) +SCR_IH_THUNK(2) +SCR_IH_THUNK(3) +SCR_IH_THUNK(4) +SCR_IH_THUNK(5) +SCR_IH_THUNK(6) +SCR_IH_THUNK(7) +SCR_IH_THUNK(8) +SCR_IH_THUNK(9) +SCR_IH_THUNK(10) + +static ScrDyn *scr_ih_method_function(int index) { + static const ScrDynThunk thunks[SCR_IH_METHOD_COUNT] = { + &scr_ih_method_call_0, &scr_ih_method_call_1, &scr_ih_method_call_2, &scr_ih_method_call_3, + &scr_ih_method_call_4, &scr_ih_method_call_5, &scr_ih_method_call_6, &scr_ih_method_call_7, + &scr_ih_method_call_8, &scr_ih_method_call_9, &scr_ih_method_call_10, + }; + return scr_ih_function((size_t)index, thunks[index], scr_ih_method_names[index]); +} + ScrDyn *scr_dyn_native_handle_iterator(ScrDyn *source, unsigned selection) { return scr_native_collection_iterator(source, selection); } diff --git a/tests/corpus/6070-iterator-helpers-untyped.js b/tests/corpus/6070-iterator-helpers-untyped.js new file mode 100644 index 000000000..4f528f0d3 --- /dev/null +++ b/tests/corpus/6070-iterator-helpers-untyped.js @@ -0,0 +1,89 @@ +// Iterator helpers on untyped iterators in JavaScript: the receivers come +// from parameters without inferred types, so the helpers run on the +// runtime's iterator objects. +const log = []; +function flush(what) { + console.log(what, "|", log.join(" ")); + log.length = 0; +} +function values(xs) { + return xs.values(); +} +function keysOf(m) { + return m.keys(); +} +function charsOf(s) { + return s[Symbol.iterator](); +} + +// Stages and consuming methods. +const evens = values([1, 2, 3, 4, 6]).filter((x) => x % 2 === 0); +console.log(evens.next(), evens.take(1).toArray(), [...evens]); +console.log(values([1, 2, 3]).map((x, i) => x * 10 + i).toArray(), values([]).map((x) => x).toArray()); +console.log(values([1, 2, 3]).reduce((a, b) => a + b), values([1, 2]).reduce((a, b, i) => a + b * i, 100)); +console.log(values([5, 6, 7]).drop(1).toArray(), values([5, 6, 7]).take(Infinity).toArray()); +console.log(values(new Set([1, 3, 5])).find((x) => x > 2), values(new Set()).find(() => true)); +console.log(values([1, 2, 3]).some((x) => x === 2), values([1, 2, 3]).every((x) => x < 3)); +console.log(keysOf(new Map([["a", 1], ["b", 2]])).map((k) => k.toUpperCase()).toArray()); +console.log(charsOf("a-b-c").filter((c) => c !== "-").toArray().join("")); +const seen = []; +values(["p", "q"]).forEach((v, i) => seen.push(v + i)); +console.log(seen.join(",")); + +// Helper objects. +const doubled = values([1, 2, 3]).map((x) => x * 2); +console.log(typeof doubled.next, typeof doubled.return, typeof doubled.map, typeof doubled.throw); +console.log(doubled.next(), doubled.return(), doubled.next()); +for (const x of values([1, 2, 3]).drop(1)) log.push(`of${x}`); +flush("for...of"); +console.log([...values([4, 5]).flatMap((x) => [x, x])]); +console.log(Object.prototype.toString.call(doubled), String(doubled)); +console.log(doubled); +let self = null; +self = values([1, 2]).map((x) => { + if (x === 2) { + try { + self.next(); + } catch (e) { + log.push(`${e.name}: ${e.message}`); + } + } + return x; +}); +flush(self.toArray()); + +// flatMap closes an inner iterator it leaves early. +function inner(label) { + let n = 0; + return { + next() { + n++; + return { value: `${label}${n}`, done: n > 3 }; + }, + return() { + log.push(`${label}:closed`); + return { done: true }; + }, + }; +} +flush(values([1, 2]).flatMap((x) => inner(`i${x}`)).take(2).toArray()); +const stepping = values([1]).flatMap(() => inner("j")); +stepping.next(); +flush([stepping.return(), stepping.next()]); + +// Node's errors for invalid arguments. +function attempt(f) { + try { + console.log(f()); + } catch (e) { + console.log(`${e.name}: ${e.message}`); + } +} +attempt(() => values([1]).take("x")); +attempt(() => values([1]).drop(-1)); +attempt(() => values([1]).map(5)); +attempt(() => values([1]).filter(null)); +attempt(() => values([1]).reduce(undefined)); +attempt(() => values([1]).forEach("f")); +attempt(() => values([]).reduce((a, b) => a)); +attempt(() => values([1, 2]).flatMap((x) => x).toArray()); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 48b392237..8131c1cd2 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2222,6 +2222,7 @@ "6043-string-index-narrowed-union.ts": 2983, "605-closures-forward-capture-tdz.ts": 3277, "6051-generic-class-factory-results.ts": 4363, + "6070-iterator-helpers-untyped.js": 4050, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, "6103-typeof-builtin-bindings.cjs": 2202, @@ -5280,6 +5281,7 @@ "6043-string-index-narrowed-union.ts": 3627, "605-closures-forward-capture-tdz.ts": 4638, "6051-generic-class-factory-results.ts": 46581, + "6070-iterator-helpers-untyped.js": 6900, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, "6103-typeof-builtin-bindings.cjs": 1796, From b6637c882d60fc36a381d9c275b381319ea1a889 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:43:21 -0700 Subject: [PATCH 42/53] Check record, array and string fields that construction can read early A field without an initializer that the constructor assigns only after handing `this` to other code is undefined in Node until that assignment. Class-typed fields in that position already take the undefined-armed deferred-init slot whose reads check it. Record, array and string fields kept a plain pointer slot, so a method the constructor calls first could read null and crash with a segfault (`this.internal.n` read from a method before `this.internal = ...`). Those field kinds now take the same checked slot when a method or accessor the constructor calls through `this` (transitively, over the class and its bases) reads them. Fields only exposed in other ways keep their plain slot, so constructors whose early calls never touch the field compile as before. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 69 ++++++++++++++++++- ...class-field-read-before-assignment.ts.json | 6 ++ ...6191-class-field-read-before-assignment.ts | 44 ++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6191-class-field-read-before-assignment.ts.json create mode 100644 tests/corpus/6191-class-field-read-before-assignment.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 39b3242d6..6dadb029d 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -3608,11 +3608,26 @@ export function collectClassShapeInner( } if (escape.assigned && !inst) { + let earlyReads: Set | null = null; for (const f of fieldOrder) { if (f.redeclared || escape.assigned.has(f.name) || deferredInitFields.has(f.name)) continue; if (paramProps.some((pp) => pp.name === f.name)) continue; const declared = fields.get(f.name); - if (declared?.kind !== "object" || lowerer.classes.get(declared.className)?.def.runtime) + // Records, arrays and strings are pointer slots that start empty + // too, but only take the checked slot when a method the + // constructor calls actually reads them: their other early + // exposures keep the plain slot. + if ( + declared?.kind === "record" || + declared?.kind === "array" || + declared?.kind === "string" + ) { + earlyReads ??= constructorMethodReads(decl, base, ctor ?? undefined); + if (!earlyReads.has(f.name)) continue; + } else if ( + declared?.kind !== "object" || + lowerer.classes.get(declared.className)?.def.runtime + ) continue; const armed = lowerer.withUndefinedArm(declared); if (armed === null || armed.kind !== "union") continue; @@ -4838,6 +4853,58 @@ export function staticShadowBelow(info: ClassInfo, name: string): boolean { ); } +/** The `this` members read by the methods and accessors a constructor + * calls through `this` (transitively, over the class and its bases), + * outside nested functions. The constructor's own reads are not included: + * they run where the constructor's statements put them. */ +function constructorMethodReads( + decl: ts.ClassLikeDeclaration, + base: ClassInfo | null, + ctor: ts.ConstructorDeclaration | undefined, +): Set { + const reads = new Set(); + const visited = new Set(); + const bodies = (name: string): ts.Node[] => { + const out: ts.Node[] = []; + let owner: ts.ClassLikeDeclaration | undefined = decl; + for (let next = base; owner; owner = next?.decl ?? undefined, next = next?.base ?? null) { + for (const member of owner.members) { + if ( + (ts.isMethodDeclaration(member) || ts.isAccessor(member)) && + member.body && + (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) && + member.name.text === name + ) + out.push(member.body); + } + } + return out; + }; + const enter = (name: string): void => { + if (visited.has(name)) return; + visited.add(name); + for (const body of bodies(name)) scan(body, true); + }; + // Nested functions (callbacks handed elsewhere) are not followed: only + // the straight-line code of the constructor and the methods it calls. + const scan = (node: ts.Node, inMethod: boolean): void => { + ts.walkPreorder(node, (n) => { + if (n !== node && ts.isFunctionLike(n)) return "skip"; + if ( + ts.isPropertyAccessExpression(n) && + n.expression.kind === ts.SyntaxKind.ThisKeyword && + (ts.isIdentifier(n.name) || ts.isPrivateIdentifier(n.name)) + ) { + if (inMethod) reads.add(n.name.text); + enter(n.name.text); + } + return undefined; + }); + }; + if (ctor?.body) scan(ctor.body, false); + return reads; +} + /** The nearest GENERIC static declaration of `name` at/above `info` — * findStaticOn's twin over the genericStatics tables. */ export function findGenericStaticOn( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6191-class-field-read-before-assignment.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6191-class-field-read-before-assignment.ts.json new file mode 100644 index 000000000..3813f38bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6191-class-field-read-before-assignment.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6191-class-field-read-before-assignment.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6191-class-field-read-before-assignment.ts b/tests/corpus/6191-class-field-read-before-assignment.ts new file mode 100644 index 000000000..92cb75b02 --- /dev/null +++ b/tests/corpus/6191-class-field-read-before-assignment.ts @@ -0,0 +1,44 @@ +// Fields without initializers that a method reads while the constructor +// has not assigned them yet (the constructor calls the method first). +// Node reads undefined there, so member access on it throws a TypeError; +// after the assignment the same reads see the values. + +class Settings { + internal: { n: number }; + items: number[]; + label: string; + + constructor() { + console.log(this.describe()); + this.internal = { n: 1 }; + this.items = [1, 2]; + this.label = "ready"; + console.log(this.describe()); + } + + describe(): string { + let internal: string; + let items: string; + let label: string; + try { + internal = String(this.internal.n); + } catch (e) { + internal = e instanceof TypeError ? "TypeError" : "other"; + } + try { + items = String(this.items.length); + } catch (e) { + items = e instanceof TypeError ? "TypeError" : "other"; + } + try { + label = this.label.toUpperCase(); + } catch (e) { + label = e instanceof TypeError ? "TypeError" : "other"; + } + return `${internal} ${items} ${label}`; + } +} + +const settings = new Settings(); +settings.items.push(3); +console.log(settings.internal.n, settings.items.length, settings.label); From f8fa60afecbdf26a6a4154774a9b2e907b0ad0f6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:43:21 -0700 Subject: [PATCH 43/53] Link the promise adapter when a boxed class instance holds a promise Boxing a class instance as a dynamic value (Object.keys, spread, inspection) boxes its fields, and a promise field goes through scr_dyn_new_promise_adapting in scr_async_dyn.c. The link-unit scan followed promises through records, arrays, unions and functions but not class instances, so `Object.keys(job)` over a class with an optional `Promise` field failed to link. The scan now follows a class instance's fields. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/ir/runtime-features.ts | 15 +++++++++++++-- .../6192-optional-promise-field-keys.ts.json | 6 ++++++ tests/corpus/6192-optional-promise-field-keys.ts | 12 ++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6192-optional-promise-field-keys.ts.json create mode 100644 tests/corpus/6192-optional-promise-field-keys.ts diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index d16edfabd..57331d830 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1,4 +1,4 @@ -import type { IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } from "./ir.js"; +import type { IrClassDef, IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } from "./ir.js"; import type { IrLibFn } from "./builtin-signatures.js"; import { isDirentShape, @@ -132,10 +132,11 @@ export function moduleRuntimeFeatures(mod: IrModule): RuntimeFeatures { return scanRuntimeFeatures(mod); } -/** The module's record shapes and unions by id. */ +/** The module's record shapes, unions and class layouts by id. */ interface TypeTables { records: Map; unions: Map; + classes: Map; } /** Whether boxing a value of type `t` as dyn boxes a promise, which goes @@ -166,6 +167,15 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set): boolean const def = tables.unions.get(t.unionId); return def?.arms.some((a) => boxesPromise(a, tables, seen)) ?? false; } + case "object": { + // A boxed class instance exposes its fields as dyn values (Object.keys, + // spread, inspection), boxing a promise any field holds. + const key = `class:${t.className}`; + if (seen.has(key)) return false; + seen.add(key); + const def = tables.classes.get(t.className); + return def?.fields.some((f) => boxesPromise(f.type, tables, seen)) ?? false; + } default: return false; } @@ -222,6 +232,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run (tables ??= { records: new Map((mod.records ?? []).map((r) => [r.id, r])), unions: new Map((mod.unions ?? []).map((u) => [u.id, u])), + classes: new Map((mod.classes ?? []).map((c) => [c.name, c])), }); const expr = (node: IrExpr): boolean => { if (node.kind === "libCall") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6192-optional-promise-field-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6192-optional-promise-field-keys.ts.json new file mode 100644 index 000000000..dbae046c7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6192-optional-promise-field-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6192-optional-promise-field-keys.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6192-optional-promise-field-keys.ts b/tests/corpus/6192-optional-promise-field-keys.ts new file mode 100644 index 000000000..98cec870c --- /dev/null +++ b/tests/corpus/6192-optional-promise-field-keys.ts @@ -0,0 +1,12 @@ +// Object.keys over an instance whose optional field holds a promise: +// boxing the instance links the promise adapter of the runtime. + +class Job { + done?: Promise; + name = "job"; +} + +const job = new Job(); +console.log(Object.keys(job).join(",")); +job.done = Promise.resolve(); +console.log(Object.keys(job).join(","), typeof job.done); From 20f15f89fdbab54dc12342a87d38893bb738d139 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:43:22 -0700 Subject: [PATCH 44/53] Let implicit JS instances that fall through return undefined A JavaScript function specialized per call site takes its declared return type from JSDoc. bcryptjs's `hash` and `compare` document `@returns {!Promise}` but return nothing when a callback is passed, so their instances ended with the "the checker proved every path returns" trap and every callback-style call aborted with SC9002. Non-generic JavaScript functions already widen such a return with an undefined arm; implicit instances now do the same. The widened `Promise | undefined` result is then awaited by TypeScript callers, which was refused: an await of a promise-of-dynamic or unit union now goes through the dynamic await, which adopts the promise or hops once for the unit, as Node does. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/generic-functions.ts | 11 ++++++++ .../src/frontend/lowering/lower-exprs.ts | 12 +++++++++ .../main.ts.json | 6 +++++ .../main.ts | 9 +++++++ .../node_modules/callback-hash/index.d.ts | 6 +++++ .../node_modules/callback-hash/index.js | 26 +++++++++++++++++++ .../node_modules/callback-hash/package.json | 1 + 7 files changed, 71 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index bd785a256..11265be8b 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -13,6 +13,7 @@ import { } from "../../ir/ir.js"; import { isJsSourceFile, locOf } from "../program.js"; import { typeKey } from "../type-mapper.js"; +import { functionCanReturnUndefined } from "../function-completion.js"; import { PoisonError, dynFallbackType, neverTaintedJsType, newFnCtx } from "./lowerer.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { findGenericMethodOn, type ClassInfo } from "./lower-classes.js"; @@ -1428,6 +1429,16 @@ function implicitDeclaredReturn(lowerer: Lowerer, info: GenericFnInfo): IrType | return null; if (mapped !== null && promiseCarriesDyn(lowerer, mapped) && !hasExplicitJsDocReturn(info.decl)) return null; + // A JavaScript body that can complete without returning yields + // undefined there, whatever its JSDoc claims (bcryptjs's `hash` + // returns a promise only when no callback is passed), exactly as + // declaredReturnType widens non-generic JavaScript functions. + if ( + mapped !== null && + isJsSourceFile(info.decl.getSourceFile()) && + functionCanReturnUndefined(info.decl) + ) + return lowerer.withUndefinedArmOf(mapped) ?? mapped; return mapped; } catch (e) { if (!(e instanceof PoisonError)) throw e; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index c54341df8..6ec10e8d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1722,6 +1722,18 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const promiseArm = def.arms[promiseTag]!; const inner = promiseArm.kind === "promise" ? promiseArm.inner : VOID; const units = def.arms.filter(isUnitType); + // A checked-dynamic inner: the whole operand is a dyn value (the + // promise boxes by reference, the unit as itself), and the dyn + // await adopts the promise or hops once for the unit. + if (inner.kind === "dyn" && lowerer.dynConvertible(value.type)) { + return { + kind: "libCall", + fn: "async.awaitDyn", + args: [lowerer.coerceToExpected(value, DYN)], + type: DYN, + loc, + }; + } if (inner.kind === "union" || inner.kind === "dyn") { // A union inner would need an arm-wise re-tag into the result diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json new file mode 100644 index 000000000..39de1b842 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts new file mode 100644 index 000000000..fb61368f7 --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts @@ -0,0 +1,9 @@ +// A package function whose JSDoc promises a Promise but that returns +// nothing when a callback is passed (bcryptjs's hash and compare). The +// call that falls through completes normally, as in Node. +import { hash } from "callback-hash"; + +hash("pw", 4, (err, value) => { + console.log("callback", err, value); +}); +console.log("promise", await hash("pw", "salt")); diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts new file mode 100644 index 000000000..819294565 --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts @@ -0,0 +1,6 @@ +export declare function hash(input: string, salt: number | string): Promise; +export declare function hash( + input: string, + salt: number | string, + callback: (err: Error | null, value?: string) => void, +): void; diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js new file mode 100644 index 000000000..2661ea6ab --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js @@ -0,0 +1,26 @@ +/** + * @param {string} input + * @param {number|string} salt + * @param {function(Error, string=)=} callback + * @param {function(number)=} progress + * @returns {!Promise} + */ +export function hash(input, salt, callback, progress) { + function run(done) { + if (progress) progress(1); + done(null, `${input}:${salt}`); + } + if (callback) { + if (typeof callback !== "function") throw Error("Illegal callback: " + typeof callback); + run(callback); + } else + return new Promise(function (resolve, reject) { + run(function (err, res) { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); +} diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json new file mode 100644 index 000000000..d3553db20 --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json @@ -0,0 +1 @@ +{ "name": "callback-hash", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } From f761f7d75aa088881ddeccc5a7a9e2c551d5426c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 19:54:32 -0500 Subject: [PATCH 45/53] Count idle pooled sockets in agent.totalSocketCount agent.totalSocketCount counted only the requests an agent was serving, so the idle sockets of a keepAlive agent in agent.freeSockets were left out, and a second request that dialed a new socket while the first was idle counted 1 instead of 2. As in Node, the count is now the number of sockets the agent created that have not closed: a socket joins it when its dial starts and leaves it when it closes, or before the request's 'close' when it does not go back to the pool. A request that takes a free socket keeps the count. Corpus 5593 covers reuse, a second socket, agent.destroy() and an agent without keepAlive. --- ...5593-http-agent-total-socket-count.ts.json | 6 ++ packages/runtime/src/scr_http.c | 58 ++++++++++++++--- .../5593-http-agent-total-socket-count.ts | 63 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5593-http-agent-total-socket-count.ts.json create mode 100644 tests/corpus/5593-http-agent-total-socket-count.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5593-http-agent-total-socket-count.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5593-http-agent-total-socket-count.ts.json new file mode 100644 index 000000000..9e67f9bd5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5593-http-agent-total-socket-count.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5593-http-agent-total-socket-count.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index f4e44bf6e..f34cc18bb 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -2386,6 +2386,19 @@ typedef enum { SCR_HTTP_BODY_EOF, /* RESPONSE mode: no framing header — body until EOF */ } ScrHttpParseState; +/* agent.totalSocketCount: the sockets an agent dialed that have not + * closed yet, idle pooled ones included. The agent and each counted + * connection hold the cell (+1), so a socket that closes after the agent + * is gone still lowers a live count. */ +typedef struct ScrHttpSockCount { + size_t rc; + size_t n; +} ScrHttpSockCount; + +static void scr_http_sock_count_release(ScrHttpSockCount *k) { + if (k != NULL && --k->rc == 0) free(k); +} + typedef struct ScrHttpConn { ScrNetSocket *sock; /* BORROWED: the ctx lives and dies with the socket * (a retained ref here would be a cycle the RC @@ -2408,6 +2421,8 @@ typedef struct ScrHttpConn { bool pumping; /* inside scr_http_conn_pump */ bool keep_alive_timer; /* the socket's idle timer is the keep-alive timeout */ size_t max_header_size; /* maxHeaderSize option; 0 = SCR_HTTP_MAX_HEADER_SIZE */ + struct ScrHttpSockCount *sock_count; /* client mode: the agent count that includes + * this socket (+1), or NULL */ struct ScrHttpAgent *pool_agent; /* client mode: the agent whose free list holds * this idle socket (borrowed), or NULL */ bool header_overflow; /* client mode: the response head exceeded a bound */ @@ -2420,6 +2435,23 @@ typedef struct ScrHttpConn { struct ScrHttpClientReq *client; /* +1 in client mode; released at close */ } ScrHttpConn; +/* The connection's socket joins an agent's totalSocketCount when it starts + * to dial, and leaves it when it closes. */ +static void scr_http_conn_count(ScrHttpConn *conn, ScrHttpSockCount *k) { + if (conn == NULL || conn->sock_count != NULL) return; + k->rc++; + k->n++; + conn->sock_count = k; +} + +static void scr_http_conn_uncount(ScrHttpConn *conn) { + ScrHttpSockCount *k = conn != NULL ? conn->sock_count : NULL; + if (k == NULL) return; + conn->sock_count = NULL; + k->n--; + scr_http_sock_count_release(k); +} + /* Client-mode forward declarations (implementations live with the client * below the server parser). */ static bool scr_http_client_parse_head(ScrHttpConn *conn, size_t head_len); @@ -3878,6 +3910,10 @@ static void scr_http_client_settle(struct ScrHttpClientReq *c) { c->destroyed = true; scr_http_client_signal_detach(c); scr_http_client_agent_detach(c); /* usually already detached at socket close */ + /* a socket that does not go back to the pool leaves totalSocketCount + * before the request's 'close', as Node's agent removes it at the + * socket's 'close' (c->sock keeps c->conn alive) */ + if (c->sock != NULL && !c->sock_pooled) scr_http_conn_uncount(c->conn); scr_net_fire0(&c->close_ls); scr_net_ls_drop(&c->resp_ls); scr_net_ls_drop(&c->err_ls); @@ -4857,6 +4893,7 @@ static void scr_http_client_agent_detach(ScrHttpClientReq *c) { } static void scr_http_client_closed(ScrHttpConn *conn) { + scr_http_conn_uncount(conn); if (conn->pool_agent != NULL) scr_http_agent_free_remove(conn->pool_agent, conn); ScrHttpClientReq *c = conn->client; if (!c) return; @@ -4931,6 +4968,7 @@ static void scr_http_client_sock_established(void *ctx) { } static void scr_http_client_conn_free(ScrHttpConn *conn) { + scr_http_conn_uncount(conn); if (conn->pool_agent != NULL) scr_http_agent_free_remove(conn->pool_agent, conn); if (conn->req) { scr_http_req_finish(conn->req, false); @@ -5138,6 +5176,7 @@ typedef struct ScrHttpAgent { bool destroyed; ScrHttpAgentEnt *ents; /* append order — Node's FIFO queue */ struct ScrHttpAgentFree *free_socks; /* keepAlive: idle sockets, most recent first */ + ScrHttpSockCount *sock_count; /* totalSocketCount (+1) */ bool in_registry; struct ScrHttpAgent *next; } ScrHttpAgent; @@ -5183,6 +5222,7 @@ static void scr_http_agent_release(ScrHttpAgent *a) { free(e); e = next; } + scr_http_sock_count_release(a->sock_count); free(a); } @@ -5276,6 +5316,7 @@ static void scr_http_client_rebind(ScrHttpClientReq *c, ScrHttpAgentFree *f, boo scr_net_sock_set_native_established(old, NULL); scr_net_sock_move_writes(old, f->sock); if (oldconn != NULL) { + scr_http_conn_uncount(oldconn); if (oldconn->client) scr_http_client_release(oldconn->client); free(oldconn->buf); free(oldconn); @@ -5413,7 +5454,10 @@ static void scr_http_agent_client_done(struct ScrHttpAgent *ag, struct ScrHttpCl scr_http_client_rebind(e->client, f, false); } else if (scr_http_agent_active(ag, name) < ag->max_sockets) { e->queued = false; - if (e->client->sock) scr_net_sock_dial_start(e->client->sock); + if (e->client->sock) { + scr_http_conn_count(e->client->conn, ag->sock_count); + scr_net_sock_dial_start(e->client->sock); + } } break; } @@ -5463,6 +5507,9 @@ ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, ScrHttpAgent *a = calloc(1, sizeof *a); if (!a) scr_http_oom(); a->rc = 1; + a->sock_count = calloc(1, sizeof *a->sock_count); + if (!a->sock_count) scr_http_oom(); + a->sock_count->rc = 1; a->secure = secure; a->keep_alive = keep_alive; a->fifo = fifo; @@ -5601,6 +5648,7 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po scr_net_sock_set_timeout(c->sock, ag->timeout_ms); } if (reuse != NULL) scr_http_client_rebind(c, reuse, true); + else if (!queue) scr_http_conn_count(c->conn, ag->sock_count); /* dialed now */ c->agent = scr_http_agent_retain(ag); ScrHttpAgentEnt *e = calloc(1, sizeof *e); if (!e) scr_http_oom(); @@ -6772,13 +6820,7 @@ static ScrDyn *scr_http_dynh_agent_get(void *h, const char *key, size_t key_len) } return obj; } - if (strcmp(key, "totalSocketCount") == 0) { - size_t n = 0; - for (ScrHttpAgentEnt *e = a->ents; e; e = e->next) { - if (!e->queued) n++; - } - return scr_dyn_new_num((double)n); - } + if (strcmp(key, "totalSocketCount") == 0) return scr_dyn_new_num((double)a->sock_count->n); { static const char *const known[] = { "options", "maxTotalSockets", NULL }; if (scr_http_dynh_in(key, known)) { diff --git a/tests/corpus/5593-http-agent-total-socket-count.ts b/tests/corpus/5593-http-agent-total-socket-count.ts new file mode 100644 index 000000000..6e5f0fc18 --- /dev/null +++ b/tests/corpus/5593-http-agent-total-socket-count.ts @@ -0,0 +1,63 @@ +// agent.totalSocketCount counts the sockets an agent created that have not +// closed: the sockets serving requests and, for a keepAlive agent, the idle +// ones in agent.freeSockets. A request that reuses a free socket keeps the +// count, a request that dials a new one raises it, and agent.destroy() +// lowers it as each socket closes. Without keepAlive, a socket counts until +// it closes after its response. +import * as http from "node:http"; + +const server = http.createServer((req, res) => res.end("ok")); + +function freeCount(agent: http.Agent): number { + let n = 0; + for (const list of Object.values(agent.freeSockets)) n += list ? list.length : 0; + return n; +} + +// Logs the count when the request is created, at its response, at the +// response's end and once the socket is idle, unless `quiet` is set. +function get(agent: http.Agent, port: number, tag: string, quiet: boolean): Promise { + return new Promise((resolve) => { + const req = http.get({ host: "127.0.0.1", port, agent }, (res) => { + if (!quiet) console.log(tag, "response", agent.totalSocketCount); + res.resume(); + res.on("end", () => { + if (!quiet) console.log(tag, "end", agent.totalSocketCount); + setImmediate(() => { + if (!quiet) console.log(tag, "idle", agent.totalSocketCount, freeCount(agent)); + resolve(); + }); + }); + }); + console.log(tag, "created", agent.totalSocketCount, req.reusedSocket); + }); +} + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as { port: number }).port; + const keepAlive = new http.Agent({ keepAlive: true }); + await get(keepAlive, port, "first", false); + await get(keepAlive, port, "reuse", false); + // Two requests at once: one reuses the free socket, one dials a second. + await Promise.all([get(keepAlive, port, "pair a", true), get(keepAlive, port, "pair b", true)]); + console.log("pair idle", keepAlive.totalSocketCount, freeCount(keepAlive)); + keepAlive.destroy(); + setTimeout(() => { + console.log("after destroy", keepAlive.totalSocketCount, freeCount(keepAlive)); + // Without keepAlive the socket closes after the response, and leaves + // the count before the request's 'close'. + const plain = new http.Agent(); + const req = http.get({ host: "127.0.0.1", port, agent: plain }, (res) => { + res.resume(); + res.on("end", () => console.log("plain end", plain.totalSocketCount)); + }); + console.log("plain created", plain.totalSocketCount); + req.on("close", () => { + console.log("plain request close", plain.totalSocketCount); + setTimeout(() => { + console.log("plain closed", plain.totalSocketCount); + server.close(); + }, 50); + }); + }, 50); +}); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index a79e12d05..e74058abb 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1945,6 +1945,7 @@ "5570-fs-cp-async.ts": 3348, "5571-fs-cp-deep.ts": 4817, "5585-record-results-inlined.ts": 2922, + "5593-http-agent-total-socket-count.ts": 3212, "5594-http-agent-header-limit.ts": 2622, "5595-net-ref-unref.ts": 2593, "5596-http-agent-keep-alive.ts": 3194, @@ -4990,6 +4991,7 @@ "5570-fs-cp-async.ts": 4176, "5571-fs-cp-deep.ts": 5306, "5585-record-results-inlined.ts": 3681, + "5593-http-agent-total-socket-count.ts": 3075, "5594-http-agent-header-limit.ts": 2691, "5595-net-ref-unref.ts": 3036, "5596-http-agent-keep-alive.ts": 3568, From 18033dc42dbb72f30b8e6b5b9ea061e277152329 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:45:49 -0700 Subject: [PATCH 46/53] Compile derived constructors whose super() call leads a comma statement Minifiers join a derived constructor's statements into one comma expression (`super(message, options), this.name = ..., ...;`), which jose 6.2.12 ships in its error classes. The statement lowering already runs each operand of a statement-level comma as its own statement, so super() is accepted when only parentheses and comma operators separate it from its expression statement. Every other expression position keeps the SC1090 refusal. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 20 +++- .../corpus/super-comma-jose-errors.js.json | 6 ++ .../corpus/super-comma-sequence-js.js.json | 6 ++ .../tests/corpus/super-comma-sequence.ts.json | 6 ++ .../diagnostics/super-comma-nested.ts.json | 6 ++ tests/corpus/super-comma-jose-errors.js | 84 +++++++++++++++ tests/corpus/super-comma-sequence-js.js | 49 +++++++++ tests/corpus/super-comma-sequence.ts | 101 ++++++++++++++++++ tests/diagnostics/super-comma-nested.ts | 24 +++++ .../__snapshots__/super-comma-nested.ts.txt | 27 +++++ 10 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-jose-errors.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence-js.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/super-comma-nested.ts.json create mode 100644 tests/corpus/super-comma-jose-errors.js create mode 100644 tests/corpus/super-comma-sequence-js.js create mode 100644 tests/corpus/super-comma-sequence.ts create mode 100644 tests/diagnostics/super-comma-nested.ts create mode 100644 tests/harness/__snapshots__/super-comma-nested.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index cddc536c6..3838f067f 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -7705,6 +7705,24 @@ function paramPropInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrLoca return out; } +/** Whether a super() call's value is discarded by its statement: the call + * is the expression statement itself, or an operand of a comma chain that + * is (`super(m, o), this.code = c;`, as minifiers join statements). The + * statement lowering runs each comma operand as its own statement in + * source order, so the chain lowers exactly like `super(m, o); this.code = + * c;`. Parentheses are transparent to both. */ +function superCallInStatementPosition(call: ts.CallExpression): boolean { + let node: ts.Node = call; + for (;;) { + const parent = node.parent; + if (ts.isExpressionStatement(parent)) return true; + const comma = + ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.CommaToken; + if (!comma && !ts.isParenthesizedExpression(parent)) return false; + node = parent; + } +} + /** Super statements initialize the base and then the derived fields on * whichever control-flow path executes. Repeated calls are explicitly * refused until native layouts can allocate a second receiver. */ @@ -7722,7 +7740,7 @@ export function lowerDerivedCtorBody( const previousInitialized = context.superInitialized; context.superInitialized = initialized; context.superCall = (superCall) => { - if (!ts.isExpressionStatement(superCall.parent)) + if (!superCallInStatementPosition(superCall)) lowerer.unsupported("SC1090", superCall, "super() in an expression position"); superSeen = true; const loc = locOf(superCall); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-jose-errors.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-jose-errors.js.json new file mode 100644 index 000000000..0ed94bfd7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-jose-errors.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/super-comma-jose-errors.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence-js.js.json new file mode 100644 index 000000000..5698bfbed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/super-comma-sequence-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence.ts.json new file mode 100644 index 000000000..3954c5766 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/super-comma-sequence.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/super-comma-sequence.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/super-comma-nested.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/super-comma-nested.ts.json new file mode 100644 index 000000000..558b0ad78 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/super-comma-nested.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/super-comma-nested.ts" + ], + "diags": [] +} diff --git a/tests/corpus/super-comma-jose-errors.js b/tests/corpus/super-comma-jose-errors.js new file mode 100644 index 000000000..a194fca93 --- /dev/null +++ b/tests/corpus/super-comma-jose-errors.js @@ -0,0 +1,84 @@ +// jose 6.2.12's error classes (dist/webapi/util/errors.js), verbatim in +// shape: minifiers join a derived constructor's statements into one comma +// expression that opens with super(...). Each comma operand runs as its +// own statement, so the base constructor and the class field initializers +// finish before the trailing assignments. +class JOSEError extends Error { + static code = "ERR_JOSE_GENERIC"; + code = "ERR_JOSE_GENERIC"; + constructor(message, options) { + super(message, options), this.name = this.constructor.name, Error.captureStackTrace?.(this, this.constructor); + } +} +class JWTClaimValidationFailed extends JOSEError { + static code = "ERR_JWT_CLAIM_VALIDATION_FAILED"; + code = "ERR_JWT_CLAIM_VALIDATION_FAILED"; + claim; + reason; + payload; + constructor(message, payload, claim = "unspecified", reason = "unspecified") { + super(message, { cause: { claim, reason, payload } }), this.claim = claim, this.reason = reason, this.payload = payload; + } +} +class JWTExpired extends JOSEError { + static code = "ERR_JWT_EXPIRED"; + code = "ERR_JWT_EXPIRED"; + claim; + reason; + payload; + constructor(message, payload, claim = "unspecified", reason = "unspecified") { + super(message, { cause: { claim, reason, payload } }), this.claim = claim, this.reason = reason, this.payload = payload; + } +} +class JOSEAlgNotAllowed extends JOSEError { + static code = "ERR_JOSE_ALG_NOT_ALLOWED"; + code = "ERR_JOSE_ALG_NOT_ALLOWED"; +} +class JWSSignatureVerificationFailed extends JOSEError { + static code = "ERR_JWS_SIGNATURE_VERIFICATION_FAILED"; + code = "ERR_JWS_SIGNATURE_VERIFICATION_FAILED"; + constructor(message = "signature verification failed", options) { + super(message, options); + } +} + +// The stack is read where the receiver's class is known: a read through an +// untyped parameter does not see the stack (a documented scriptc gap). +function describe(label, error, stack) { + console.log(label, error.name, error.code, JSON.stringify(error.message)); + console.log(" instanceof", error instanceof JOSEError, error instanceof Error); + console.log(" stack head", String(stack).split("\n")[0]); +} + +const generic = new JOSEError("generic failure"); +describe("generic", generic, generic.stack); +console.log(" static", JOSEError.code, "cause" in generic); + +const withCause = new JOSEError("wrapped", { cause: "root" }); +describe("wrapped", withCause, withCause.stack); +console.log(" cause", withCause.cause); + +const claims = { sub: "user", exp: 1 }; +const failed = new JWTClaimValidationFailed('"iss" claim mismatch', claims, "iss", "check_failed"); +describe("claim", failed, failed.stack); +console.log(" fields", failed.claim, failed.reason, failed.payload === claims); +console.log(" cause", failed.cause.claim, failed.cause.reason, failed.cause.payload === claims); +console.log(" chain", failed instanceof JWTClaimValidationFailed, failed instanceof JWTExpired); + +const defaults = new JWTClaimValidationFailed("defaults", claims); +console.log("defaults", defaults.claim, defaults.reason); + +const expired = new JWTExpired('"exp" claim timestamp check failed', claims, "exp"); +describe("expired", expired, expired.stack); +console.log(" static", JWTExpired.code, expired.reason); + +const alg = new JOSEAlgNotAllowed('"alg" (Algorithm) Header Parameter value not allowed'); +describe("alg", alg, alg.stack); +const sig = new JWSSignatureVerificationFailed(); +describe("sig", sig, sig.stack); + +try { + throw new JWTExpired("thrown", claims, "exp", "check_failed"); +} catch (error) { + if (error instanceof JOSEError) console.log("caught", error.name, error.code, error.claim); +} diff --git a/tests/corpus/super-comma-sequence-js.js b/tests/corpus/super-comma-sequence-js.js new file mode 100644 index 000000000..073bcd97d --- /dev/null +++ b/tests/corpus/super-comma-sequence-js.js @@ -0,0 +1,49 @@ +// The JavaScript side of super-comma-sequence.ts: a statement-level comma +// expression containing super(...) runs its operands in source order, with +// the base constructor and the derived field initializers at the super() +// call, as the separate statements a minifier joined would. +function log(label, value) { + console.log(" " + label); + return value; +} + +class Base { + constructor(origin) { + console.log(" Base body", origin); + this.origin = origin; + } +} + +class Joined extends Base { + first = log("field first", 1); + second = this.first + 1; + constructor(origin) { + super(log("super argument", origin)), log("after super", 0), this.tag = `${this.origin}:${this.second}`; + } +} + +class Leading extends Base { + value = log("field value", "v"); + constructor(origin) { + log("before super", 0), super(origin), log("after super " + this.value, 0); + } +} + +class Again extends Joined { + extra = log("field extra", "e"); + constructor() { + super("again"), this.tag = this.tag + "!", this.extra = this.extra + this.tag; + } +} + +console.log("joined:"); +const joined = new Joined("j"); +console.log(joined.origin, joined.first, joined.second, joined.tag); + +console.log("leading:"); +const leading = new Leading("l"); +console.log(leading.origin, leading.value); + +console.log("again:"); +const again = new Again(); +console.log(again.origin, again.tag, again.extra, again instanceof Joined, again instanceof Base); diff --git a/tests/corpus/super-comma-sequence.ts b/tests/corpus/super-comma-sequence.ts new file mode 100644 index 000000000..46e23a509 --- /dev/null +++ b/tests/corpus/super-comma-sequence.ts @@ -0,0 +1,101 @@ +// @transform-types +// super(...) as an operand of a statement-level comma expression lowers +// like the separate statements a minifier joined: operands run in source +// order, the base constructor and then the derived field initializers run +// at the super() call, and the trailing operands see the initialized +// instance. +function log(label: string, value: T): T { + console.log(" " + label); + return value; +} + +class Base { + readonly origin: string; + constructor(origin: string) { + console.log(" Base body", origin); + this.origin = origin; + } +} + +class Joined extends Base { + first = log("field first", 1); + second: number = this.first + 1; + tag: string; + constructor(origin: string) { + super(log("super argument", origin)), log("after super", 0), this.tag = `${this.origin}:${this.second}`; + } +} + +class Leading extends Base { + value = log("field value", "v"); + constructor(origin: string) { + log("before super", 0), super(origin), log("after super " + this.value, 0); + } +} + +class Parenthesized extends Base { + count = 0; + constructor() { + (super("paren"), this.count++, this.count++); + } +} + +class Nested extends Base { + steps: string[] = []; + constructor() { + super("nested"), (this.steps.push("a"), this.steps.push("b")), this.steps.push("c"); + } +} + +class Branching extends Base { + side: string; + constructor(left: boolean) { + if (left) super("left"), this.side = "L"; + else super("right"), this.side = "R"; + } +} + +class Point { + constructor( + public x: number, + public y: number, + ) {} +} + +class Point3 extends Point { + label = "p3"; + constructor( + x: number, + y: number, + public z: number, + ) { + super(x, y), console.log(" Point3", this.x, this.y, this.z, this.label); + } +} + +class Point4 extends Point3 { + w: number; + constructor(x: number, y: number, z: number, w: number) { + super(x, y, z), this.w = w, this.label = "p4"; + } +} + +console.log("joined:"); +const joined = new Joined("j"); +console.log(joined.origin, joined.first, joined.second, joined.tag); + +console.log("leading:"); +const leading = new Leading("l"); +console.log(leading.origin, leading.value); + +const paren = new Parenthesized(); +console.log("parenthesized:", paren.origin, paren.count); + +const nested = new Nested(); +console.log("nested:", nested.origin, nested.steps.join(",")); + +console.log("branching:", new Branching(true).side, new Branching(false).side); + +console.log("chain:"); +const p4 = new Point4(1, 2, 3, 4); +console.log(p4.x, p4.y, p4.z, p4.w, p4.label, p4 instanceof Point3, p4 instanceof Point); diff --git a/tests/diagnostics/super-comma-nested.ts b/tests/diagnostics/super-comma-nested.ts new file mode 100644 index 000000000..799436ff9 --- /dev/null +++ b/tests/diagnostics/super-comma-nested.ts @@ -0,0 +1,24 @@ +// super(...) as an operand of a statement-level comma expression compiles +// (super-comma-sequence.ts). Any other expression position still refuses: +// the field initializers that run at super() would be misordered. +class Base { + constructor(readonly origin: string) {} +} + +class Selected extends Base { + constructor(left: boolean) { + left ? super("left") : super("right"); + } +} + +function keep(value: T): T { + return value; +} + +class Argument extends Base { + constructor() { + keep((super("argument"), 1)); + } +} + +console.log(new Selected(true).origin, new Argument().origin); diff --git a/tests/harness/__snapshots__/super-comma-nested.ts.txt b/tests/harness/__snapshots__/super-comma-nested.ts.txt new file mode 100644 index 000000000..e7b7f275a --- /dev/null +++ b/tests/harness/__snapshots__/super-comma-nested.ts.txt @@ -0,0 +1,27 @@ +super-comma-nested.ts:9:3 - error SC1090: derived constructors without a lowered super() call is not supported yet + + 8 | class Selected extends Base { + 9 | constructor(left: boolean) { + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 10 | left ? super("left") : super("right"); + +super-comma-nested.ts:10:12 - error SC1090: super() in an expression position is not supported yet + + 9 | constructor(left: boolean) { + 10 | left ? super("left") : super("right"); + | ^~~~~~~~~~~~~ + 11 | } + +super-comma-nested.ts:19:3 - error SC1090: derived constructors without a lowered super() call is not supported yet + + 18 | class Argument extends Base { + 19 | constructor() { + | ^~~~~~~~~~~~~~~ + 20 | keep((super("argument"), 1)); + +super-comma-nested.ts:20:11 - error SC1090: super() in an expression position is not supported yet + + 19 | constructor() { + 20 | keep((super("argument"), 1)); + | ^~~~~~~~~~~~~~~~~ + 21 | } \ No newline at end of file From c48983889c5f509b7c35bda0b656e2b251fceaf0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 20:35:23 -0500 Subject: [PATCH 47/53] Clear timers through handles that may be undefined or null clearTimeout, clearInterval and clearImmediate refused a handle typed `Timeout | undefined` or `Timeout | null`, the type of a timer variable that starts empty (`let timer: ReturnType | undefined`) and of `handles[i]!` reads. Such a handle now passes id 0 when it is absent, which matches no timer, so the clear does nothing, as in Node. --- .../src/frontend/lowering/lower-timers.ts | 28 ++++-- .../5684-clear-optional-timer-handles.ts.json | 6 ++ .../5684-clear-optional-timer-handles.ts | 89 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5684-clear-optional-timer-handles.ts.json create mode 100644 tests/corpus/5684-clear-optional-timer-handles.ts diff --git a/packages/compiler/src/frontend/lowering/lower-timers.ts b/packages/compiler/src/frontend/lowering/lower-timers.ts index 44b2b6a1d..c2a54dfc0 100644 --- a/packages/compiler/src/frontend/lowering/lower-timers.ts +++ b/packages/compiler/src/frontend/lowering/lower-timers.ts @@ -6,6 +6,7 @@ import { canBoxFuncIntoDyn, DYN, F64, + isUnitType, VOID, type IrExpr, type IrType, @@ -299,6 +300,19 @@ function tolerantClearNoop(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLo return null; } +/** A clear's handle as the f64 id it passes to the runtime. A handle that + * may be undefined or null (`let timer: Timeout | undefined`) passes 0 + * when absent: no timer has id 0, so the clear does nothing, as Node's + * clearTimeout(undefined) does. Other handle types are returned as they + * are. */ +function optionalTimerHandle(lowerer: Lowerer, handle: IrExpr, loc: SrcLoc): IrExpr { + if (handle.type.kind !== "union") return handle; + const arms = lowerer.unions.get(handle.type.unionId)?.arms ?? []; + const present = arms.filter((arm) => !isUnitType(arm)); + if (present.length !== 1 || present[0]!.kind !== "f64") return handle; + return { kind: "nullish", left: handle, right: numLit(0, loc), type: F64, loc }; +} + const MOCKABLE_TIMER_FNS: ReadonlySet = new Set([ "timers.setTimeout", "timers.setTimeoutHandle", @@ -392,8 +406,7 @@ function lowerTimerCall( return { kind: "libCall", fn: "timers.setTimeout", args: [cb, ms], type: VOID, loc }; } // clearTimeout(handle): shares the interval clear (the handle ids - // share one space). A `Timeout | null` handle narrows first, like - // clearInterval. + // share one space). An optional handle clears nothing when absent. if (member === "clearTimeout") { const noop = tolerantClearNoop(lowerer, expr, loc); if (noop) return noop; @@ -419,11 +432,12 @@ function lowerTimerCall( loc, }; } + handle = optionalTimerHandle(lowerer, handle, loc); if (handle.type.kind !== "f64") { lowerer.noLowering( `clearTimeout of '${lowerer.fmt(handle.type)}' handles`, expr.arguments[0]!, - "the handle is the Timeout setTimeout returned (narrow 'Timeout | null' first)", + "the handle is the Timeout setTimeout returned, or undefined or null", ); } return { kind: "libCall", fn: "timers.clearTimeout", args: [handle], type: VOID, loc }; @@ -434,12 +448,12 @@ function lowerTimerCall( if (expr.arguments.length !== 1) { lowerer.noLowering(`clearInterval with ${expr.arguments.length} arguments`, expr); } - const handle = lowerer.lowerExpr(expr.arguments[0]!); + const handle = optionalTimerHandle(lowerer, lowerer.lowerExpr(expr.arguments[0]!), loc); if (handle.type.kind !== "f64") { lowerer.noLowering( `clearInterval of '${lowerer.fmt(handle.type)}' handles`, expr.arguments[0]!, - "the handle is the number setInterval returned (narrow `number | null` first)", + "the handle is the Timeout setInterval returned, or undefined or null", ); } return { kind: "libCall", fn: "timers.clearInterval", args: [handle], type: VOID, loc }; @@ -478,12 +492,12 @@ function lowerTimerCall( if (expr.arguments.length !== 1) { lowerer.noLowering(`clearImmediate with ${expr.arguments.length} arguments`, expr); } - const handle = lowerer.lowerExpr(expr.arguments[0]!); + const handle = optionalTimerHandle(lowerer, lowerer.lowerExpr(expr.arguments[0]!), loc); if (handle.type.kind !== "f64") { lowerer.noLowering( `clearImmediate of '${lowerer.fmt(handle.type)}' handles`, expr.arguments[0]!, - "the handle is the Immediate setImmediate returned (narrow 'Immediate | undefined' first)", + "the handle is the Immediate setImmediate returned, or undefined or null", ); } return { kind: "libCall", fn: "timers.clearImmediate", args: [handle], type: VOID, loc }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5684-clear-optional-timer-handles.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5684-clear-optional-timer-handles.ts.json new file mode 100644 index 000000000..307a74fa9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5684-clear-optional-timer-handles.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5684-clear-optional-timer-handles.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5684-clear-optional-timer-handles.ts b/tests/corpus/5684-clear-optional-timer-handles.ts new file mode 100644 index 000000000..45a2f48aa --- /dev/null +++ b/tests/corpus/5684-clear-optional-timer-handles.ts @@ -0,0 +1,89 @@ +// clearTimeout, clearInterval and clearImmediate on handles that may be +// undefined or null: an absent handle clears nothing, a present one clears +// its timer, as in Node. Covers a debounce that re-arms through +// `Timeout | undefined`, array elements read with `!`, intervals that +// clear themselves through an optional handle, and immediates. + +const log: string[] = []; + +// A debounce: each call clears the pending timer, which is undefined at first. +let pending: ReturnType | undefined; +function debounce(label: string, ms: number): void { + clearTimeout(pending); + pending = setTimeout(() => { + log.push(`debounced ${label}`); + pending = undefined; + }, ms); +} +debounce("a", 20); +debounce("b", 20); +debounce("c", 5); + +// Null-initialized handles, cleared before and after they are set. +let retry: ReturnType | null = null; +clearTimeout(retry); +retry = setTimeout(() => log.push("retry fired"), 1); +let cancelled: ReturnType | null = setTimeout(() => log.push("cancelled fired"), 1); +clearTimeout(cancelled); +cancelled = null; +clearTimeout(cancelled); + +// Handles kept in an array, cleared through `!` element reads. +const handles = [setTimeout(() => log.push("kept 0"), 2)]; +for (let i = 1; i < 6; i++) handles.push(setTimeout(() => log.push(`kept ${i}`), 2 + i)); +for (let i = 1; i < handles.length; i += 2) clearTimeout(handles[i]!); + +// An interval that clears itself through its optional handle. +let ticks = 0; +let ticker: ReturnType | undefined; +clearInterval(ticker); +ticker = setInterval(() => { + ticks++; + if (ticks === 3) { + clearInterval(ticker); + ticker = undefined; + log.push(`ticker stopped at ${ticks}`); + } +}, 1); +let idle: ReturnType | null = null; +clearInterval(idle); + +// Immediates: an optional handle, cleared while pending and when absent. +let soon: ReturnType | undefined = setImmediate(() => log.push("soon fired")); +clearImmediate(soon); +soon = undefined; +clearImmediate(soon); +let later: ReturnType | null = setImmediate(() => log.push("later fired")); +const keep = later; +later = null; +clearImmediate(later); +console.log("kept immediate", keep !== null); + +// A class that owns an optional timer. +class Poller { + timer: ReturnType | undefined = undefined; + runs = 0; + start(): void { + this.stop(); + this.timer = setTimeout(() => { + this.runs++; + log.push(`poller run ${this.runs}`); + this.timer = undefined; + }, 3); + } + stop(): void { + clearTimeout(this.timer); + this.timer = undefined; + } +} +const poller = new Poller(); +poller.stop(); +poller.start(); +poller.start(); + +setTimeout(() => { + clearTimeout(pending); + clearInterval(ticker); + console.log(log.sort().join("\n")); + console.log("pending", pending === undefined, "ticker", ticker === undefined, "runs", poller.runs); +}, 60); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index a3ee81f9b..979c6ace1 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2004,6 +2004,7 @@ "5681-array-pop-and-length.ts": 3754, "5682-publish-wide-objects.ts": 3326, "5683-date-local-fields-tz.ts": 3848, + "5684-clear-optional-timer-handles.ts": 3989, "5700-cycle-release-retained.ts": 2156, "5701-cycle-release-old-edges.ts": 2601, "5702-publish-candidate-buffers.ts": 3469, @@ -5068,6 +5069,7 @@ "5681-array-pop-and-length.ts": 3216, "5682-publish-wide-objects.ts": 3376, "5683-date-local-fields-tz.ts": 3911, + "5684-clear-optional-timer-handles.ts": 1757, "5700-cycle-release-retained.ts": 3414, "5701-cycle-release-old-edges.ts": 4210, "5702-publish-candidate-buffers.ts": 3848, From 2ed5136923f5b79899298112a9e3d7b79220cc67 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 20:55:25 -0500 Subject: [PATCH 48/53] Convert class instances to strings and numbers through their own methods String(x), template literals, `+` with a string and Number(x) or unary plus on a program class instance failed with SC2001 ("values of type 'Money' cannot be compiled yet"), even when the class defines toString. They now run Node's ToPrimitive protocol through the class's methods: - `[Symbol.toPrimitive](hint)` when the class defines it, with the hint "string" for String(x) and template literals, "default" for `+`, and "number" for Number(x) and unary plus. - Otherwise toString (string hint) or valueOf and then toString (default and number hints), and "[object Object]" (NaN as a number) when the class defines neither. - The method dispatches like a call of it. Subclasses that convert through a different method are tested first with instanceof, so an array of base-class values converts each element by its own class. - Unions of class instances and primitives (`Box | undefined`) convert each arm as it would alone. Conversions the program can change at run time keep a named refusal: a toString or valueOf instance property or prototype write, a Symbol.toStringTag, and conversion methods that can return an object. Builtin classes and their subclasses keep their existing conversions. --- .../frontend/lowering/class-to-primitive.ts | 325 ++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 4 + .../src/frontend/lowering/lower-exprs.ts | 34 +- .../corpus/6053-class-to-primitive.ts.json | 6 + .../corpus/6054-js-class-to-primitive.js.json | 6 + tests/corpus/6053-class-to-primitive.ts | 125 +++++++ tests/corpus/6054-js-class-to-primitive.js | 15 + tests/harness/corpus-costs.json | 4 + 8 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-to-primitive.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6053-class-to-primitive.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6054-js-class-to-primitive.js.json create mode 100644 tests/corpus/6053-class-to-primitive.ts create mode 100644 tests/corpus/6054-js-class-to-primitive.js diff --git a/packages/compiler/src/frontend/lowering/class-to-primitive.ts b/packages/compiler/src/frontend/lowering/class-to-primitive.ts new file mode 100644 index 000000000..516a62f3c --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-to-primitive.ts @@ -0,0 +1,325 @@ +import { varRef } from "../../ir/build.js"; +import { BOOL, DYN, F64, STRING, type IrExpr, type IrStmt, type IrType } from "../../ir/ir.js"; +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { accessorCall, findMethodOn, type ClassInfo } from "./lower-classes.js"; +import { isClassCallback } from "./class-callbacks.js"; +import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; + +/** The ToPrimitive hint of a conversion: `String(x)` and template literals + * take "string", `+` "default", `Number(x)` "number". */ +export type ConversionHint = "string" | "default" | "number"; +export type StringConversionHint = "string" | "default"; + +type ConversionMethod = "sym:toPrimitive" | "valueOf" | "toString"; + +/** How an instance of one class converts: through one of its methods, or + * through Object.prototype.toString ("[object Object]"). */ +type Plan = { method: ConversionMethod; ret: IrType } | { method: null }; + +const PRIMITIVE_KINDS = new Set(["f64", "string", "bool", "bigint", "undefinedT", "nullT"]); + +/** ToString(ToPrimitive(value, hint)) of a program class instance, as Node + * computes it: `[Symbol.toPrimitive](hint)` when the class defines it, then + * `toString` (hint "string") or `valueOf` and then `toString` (hint + * "default"), and "[object Object]" when the class defines neither. The + * methods dispatch like calls of them; subclasses that convert through a + * different method are tested first. Null for values whose class is not a + * plain program class (builtin classes and their subclasses keep their own + * conversions). */ +export function classToStringConversion( + lowerer: Lowerer, + value: IrExpr, + hint: StringConversionHint, + node: ts.Node, +): IrExpr | null { + return classConversion(lowerer, value, hint, node, STRING, (primitive) => + lowerer.ensureString(primitive, node), + ); +} + +/** ToNumber(ToPrimitive(value, "number")) of a program class instance + * (`Number(x)`): `[Symbol.toPrimitive]("number")`, then `valueOf` and then + * `toString`, and NaN for "[object Object]". Null as for + * classToStringConversion. */ +export function classToNumberConversion( + lowerer: Lowerer, + value: IrExpr, + node: ts.Node, + operator: "Number" | "unary plus" = "Number", +): IrExpr | null { + return classConversion(lowerer, value, "number", node, F64, (primitive) => + numberOfPrimitive(lowerer, primitive, node, operator), + ); +} + +/** ToNumber of a primitive value: `Number(primitive)`, or unary plus, + * which throws for a BigInt instead of converting it. */ +function numberOfPrimitive( + lowerer: Lowerer, + value: IrExpr, + node: ts.Node, + operator: "Number" | "unary plus", +): IrExpr { + const loc = value.loc; + if (operator === "unary plus" && mayBeBigint(lowerer, value.type)) + lowerer.unsupported( + "SC1090", + node, + "unary plus of class instances whose conversion can return a bigint", + ); + switch (value.type.kind) { + case "f64": + return value; + case "string": + return { kind: "libCall", fn: "num.fromString", args: [value], type: F64, loc }; + case "bool": + return { + kind: "ternary", + cond: value, + then: { kind: "numLit", value: 1, type: F64, loc }, + else_: { kind: "numLit", value: 0, type: F64, loc }, + type: F64, + loc, + }; + case "bigint": + return { kind: "libCall", fn: "bigint.toF64", args: [value], type: F64, loc }; + default: + if (!lowerer.dynConvertible(value.type)) + lowerer.unsupported("SC1090", node, "number conversions of this class's primitive value"); + return { + kind: "libCall", + fn: "dyn.numberConstructor", + args: [lowerer.coerceToExpected(value, DYN)], + type: F64, + loc, + }; + } +} + +function mayBeBigint(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "bigint") return true; + return ( + type.kind === "union" && + lowerer.unions.get(type.unionId)?.arms.some((arm) => arm.kind === "bigint") === true + ); +} + +function classConversion( + lowerer: Lowerer, + value: IrExpr, + hint: ConversionHint, + node: ts.Node, + resultType: IrType, + finish: (primitive: IrExpr) => IrExpr, +): IrExpr | null { + if (value.type.kind !== "object") return null; + const info = lowerer.classes.get(value.type.className); + if (!info || !plainProgramClass(info)) return null; + const classes = subtree(lowerer, info); + for (const cls of classes) refuseReplacedConversions(lowerer, cls, node); + const plans = new Map(classes.map((cls) => [cls, planOf(lowerer, cls, hint, node)])); + // A class whose plan differs from its base's starts a group; the deepest + // groups are tested first, so each instance takes its own class's plan. + const tops = classes + .filter((cls) => { + if (cls === info) return false; + const base = cls.base; + return base !== null && plans.has(base) && !samePlan(plans.get(base)!, plans.get(cls)!); + }) + .sort((a, b) => depth(b) - depth(a)); + const loc = value.loc; + const prefix: IrStmt[] = []; + let receiver = value; + if (tops.length > 0 && !isSafeToRepeat(value)) { + const local = lowerer.declareHiddenLocal("%converted", value.type); + prefix.push({ kind: "varDecl", localId: local.id, init: value, loc }); + receiver = varRef(local.id, value.type, loc); + } + const convert = (cls: ClassInfo, object: IrExpr): IrExpr => { + const plan = plans.get(cls)!; + if (plan.method === null) + return finish({ kind: "strLit", value: "[object Object]", type: STRING, loc }); + const args: IrExpr[] = + plan.method === "sym:toPrimitive" ? [{ kind: "strLit", value: hint, type: STRING, loc }] : []; + return finish(accessorCall(lowerer, cls.def.name, plan.method, object, args, plan.ret, loc)); + }; + let result = convert(info, receiver); + for (let i = tops.length - 1; i >= 0; i--) { + const top = tops[i]!; + const narrowed: IrExpr = { + kind: "downcast", + value: receiver, + type: { kind: "object", className: top.def.name }, + loc, + }; + result = { + kind: "ternary", + cond: { kind: "instanceOf", value: receiver, className: top.def.name, type: BOOL, loc }, + then: convert(top, narrowed), + else_: result, + type: resultType, + loc, + }; + } + return prefix.length > 0 + ? { kind: "seqExpr", stmts: prefix, result, type: resultType, loc } + : result; +} + +/** A class of the program whose chain reaches no builtin base: its + * instances inherit Object.prototype's conversions. */ +function plainProgramClass(info: ClassInfo): boolean { + for (let cls: ClassInfo | null = info; cls; cls = cls.base) { + if ( + cls.def.runtime || + cls.builtinError || + cls.builtinEmitter || + cls.builtinStream || + cls.callableBase !== undefined + ) + return false; + } + return true; +} + +function subtree(lowerer: Lowerer, info: ClassInfo): ClassInfo[] { + const out: ClassInfo[] = []; + const visit = (cls: ClassInfo): void => { + out.push(cls); + for (const child of cls.subclasses) visit(child); + }; + visit(info); + return out.filter( + (cls) => cls === info || lowerer.classCanBeConstructed(cls) || cls.subclasses.length > 0, + ); +} + +function depth(info: ClassInfo): number { + let n = 0; + for (let cls = info.base; cls; cls = cls.base) n++; + return n; +} + +function samePlan(a: Plan, b: Plan): boolean { + return a.method === b.method; +} + +/** A conversion method the class declares or inherits (a field of that + * name is an instance property the program sets, refused separately). */ +function declared( + lowerer: Lowerer, + info: ClassInfo, + method: ConversionMethod, +): { ret: IrType } | null { + if (info.fields.has(method)) return null; + const found = findMethodOn(lowerer, info, method); + return found ? { ret: found.sig.ret } : null; +} + +function planOf(lowerer: Lowerer, info: ClassInfo, hint: ConversionHint, node: ts.Node): Plan { + const exotic = declared(lowerer, info, "sym:toPrimitive"); + if (exotic) { + if (!primitive(lowerer, exotic.ret)) + refuse(lowerer, node, info, "a [Symbol.toPrimitive] method that can return an object"); + return { method: "sym:toPrimitive", ret: exotic.ret }; + } + // OrdinaryToPrimitive: "string" tries toString first, which every object + // has (Object.prototype.toString answers a string); "default" and + // "number" try valueOf first, whose Object.prototype version answers the + // object itself. + const order: ConversionMethod[] = hint === "string" ? ["toString"] : ["valueOf", "toString"]; + for (const method of order) { + const found = declared(lowerer, info, method); + if (!found) continue; + if (!primitive(lowerer, found.ret)) + refuse(lowerer, node, info, `a '${method}' method that can return an object`); + return { method, ret: found.ret }; + } + return { method: null }; +} + +function primitive(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "union") + return ( + lowerer.unions.get(type.unionId)?.arms.every((arm) => PRIMITIVE_KINDS.has(arm.kind)) === true + ); + return PRIMITIVE_KINDS.has(type.kind); +} + +/** Conversions the program can change at run time (an instance property + * of a method's name, a prototype write, a Symbol.toStringTag) would not be + * the class's declared methods. */ +function refuseReplacedConversions(lowerer: Lowerer, info: ClassInfo, node: ts.Node): void { + for (const method of ["toString", "valueOf"] as const) { + if (info.fields.has(method) || isClassCallback(lowerer, info, method)) + refuse(lowerer, node, info, `a '${method}' instance property`); + if (lowerer.prototypeMethodAccesses.has(method)) + refuse(lowerer, node, info, `a '${method}' the program reaches through a prototype`); + } + if (findMethodOn(lowerer, info, "get:sym:toStringTag") || info.fields.has("sym:toStringTag")) + refuse(lowerer, node, info, "a Symbol.toStringTag"); +} + +function refuse(lowerer: Lowerer, node: ts.Node, info: ClassInfo, what: string): never { + lowerer.unsupported( + "SC1090", + node, + `conversions of '${info.def.jsName ?? info.def.name}' instances to primitives (the class has ${what})`, + ); +} + +/** ToString(ToPrimitive(value, hint)) of a union whose arms are program + * class instances and primitives (`Box | undefined`): each arm converts as + * it would alone. Null for other unions. */ +export function classUnionToString( + lowerer: Lowerer, + value: IrExpr, + hint: StringConversionHint, + node: ts.Node, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const unionId = value.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + if (!arms || !arms.some((arm) => arm.kind === "object")) return null; + const convertible = (arm: IrType): boolean => { + if (PRIMITIVE_KINDS.has(arm.kind)) return true; + if (arm.kind !== "object") return false; + const info = lowerer.classes.get(arm.className); + return info !== undefined && plainProgramClass(info); + }; + if (!arms.every(convertible)) return null; + const loc = value.loc; + const prefix: IrStmt[] = []; + let stable = value; + if (!isSafeToRepeat(value)) { + const local = lowerer.declareHiddenLocal("%converted", value.type); + prefix.push({ kind: "varDecl", localId: local.id, init: value, loc }); + stable = varRef(local.id, value.type, loc); + } + const convertArm = (tag: number): IrExpr => { + const arm = arms[tag]!; + if (arm.kind === "undefinedT" || arm.kind === "nullT") + return { + kind: "strLit", + value: arm.kind === "nullT" ? "null" : "undefined", + type: STRING, + loc, + }; + const narrowed: IrExpr = { kind: "unionNarrow", unionId, tag, value: stable, type: arm, loc }; + if (arm.kind === "object") return classToStringConversion(lowerer, narrowed, hint, node)!; + return lowerer.ensureString(narrowed, node); + }; + let result = convertArm(arms.length - 1); + for (let tag = arms.length - 2; tag >= 0; tag--) { + result = { + kind: "ternary", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: stable, type: BOOL, loc }, + then: convertArm(tag), + else_: result, + type: STRING, + loc, + }; + } + return prefix.length > 0 ? { kind: "seqExpr", stmts: prefix, result, type: STRING, loc } : result; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 303bdf517..f431d4e54 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -157,6 +157,7 @@ import { } from "./lower-exprs.js"; import { isSafeToDiscard } from "./expressions/evaluation-safety.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; +import { classToNumberConversion } from "./class-to-primitive.js"; import { hasRuntimeStatics } from "./class-runtime-statics.js"; import { httpClientFnBindingOf, @@ -2134,6 +2135,9 @@ function lowerNumberConstructorValue( }; const optionalNumber = lowerOptionalStringNumber(lowerer, arg, loc); if (optionalNumber) return optionalNumber; + // A program class instance converts through its own methods. + const classNumber = classToNumberConversion(lowerer, arg, argNode); + if (classNumber) return classNumber; const scalarUnionNumber = lowerScalarUnionNumber(lowerer, arg, argNode, loc); if (scalarUnionNumber) return scalarUnionNumber; return lowerer.noLowering( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 558838c1f..fc820b4d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -221,6 +221,11 @@ import { } from "./expressions/optional-chains.js"; import { foldedStringKeyOf } from "./expressions/object-literals.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; +import { + classToNumberConversion, + classToStringConversion, + classUnionToString, +} from "./class-to-primitive.js"; import { fenceNodeModuleMutation, isNodeModuleValue, @@ -8107,7 +8112,7 @@ function lowerElementCompoundParts( const appended: IrExpr = { kind: "strConcat", left: oldValue, - right: lowerer.ensureString(rhs, expr.right), + right: concatOperand(lowerer, rhs, expr.right), type: STRING, loc, }; @@ -8125,7 +8130,7 @@ function lowerElementCompoundParts( computed = { kind: "strConcat", left: lowerer.ensureString(oldRef(), target), - right: lowerer.ensureString(rhs, expr.right), + right: concatOperand(lowerer, rhs, expr.right), type: STRING, loc, }; @@ -8876,6 +8881,16 @@ export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrEx return lowerRecordPropertyKey(lowerer, key, node); } +/** An operand of `+` with a string: ToString of ToPrimitive with the + * default hint, which tries a class instance's valueOf before toString. */ +function concatOperand(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { + return ( + classToStringConversion(lowerer, e, "default", node) ?? + classUnionToString(lowerer, e, "default", node) ?? + lowerer.ensureString(e, node) + ); +} + export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { if (e.type.kind === "string") return e; if (e.type.kind === "url") @@ -8919,6 +8934,9 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr return errorToStringCall(lowerer, e); } } + // A program class instance converts through its own methods. + const converted = classToStringConversion(lowerer, e, "string", node); + if (converted) return converted; } if (e.type.kind === "union") { // `${u}` — the arm's ToString via a per-union interned helper @@ -8977,6 +8995,8 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr node, ); } + const viaClasses = classUnionToString(lowerer, e, "string", node); + if (viaClasses) return viaClasses; lowerer.unsupported( "SC1090", node, @@ -9450,6 +9470,8 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio if (raw.type.kind === "string") { return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc }; } + const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus"); + if (classNumber) return classNumber; const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]); return operand; @@ -9918,7 +9940,7 @@ export function lowerCompoundValueToTarget( { kind: "strConcat", left: lowerer.ensureString(read, expr.left), - right: lowerer.ensureString(rhs, expr.right), + right: concatOperand(lowerer, rhs, expr.right), type: STRING, loc, }, @@ -10981,8 +11003,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (left.type.kind === "string" || right.type.kind === "string") { return { kind: "strConcat", - left: lowerer.ensureString(left, expr.left), - right: lowerer.ensureString(right, expr.right), + left: concatOperand(lowerer, left, expr.left), + right: concatOperand(lowerer, right, expr.right), type: STRING, loc, }; @@ -16310,7 +16332,7 @@ function lowerFieldCompoundValue( const numericRead = lowerOptionalNumber(lowerer, read, loc, access); const bigintValue = rhsNode ? bigintCompoundValue(op, read, rhs, loc) : null; if (op === "+" && read.type.kind === "string") { - appended = lowerer.ensureString(rhs, rhsNode ?? access); + appended = concatOperand(lowerer, rhs, rhsNode ?? access); value = { kind: "strConcat", left: read, right: appended, type: STRING, loc }; } else if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") { value = { kind: "bin", op, left: numericRead, right: numericRhs, type: F64, loc }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6053-class-to-primitive.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6053-class-to-primitive.ts.json new file mode 100644 index 000000000..6c4f1aaea --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6053-class-to-primitive.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6053-class-to-primitive.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6054-js-class-to-primitive.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6054-js-class-to-primitive.js.json new file mode 100644 index 000000000..50591191a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6054-js-class-to-primitive.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6054-js-class-to-primitive.js" + ], + "diags": [] +} diff --git a/tests/corpus/6053-class-to-primitive.ts b/tests/corpus/6053-class-to-primitive.ts new file mode 100644 index 000000000..c79a5ddcb --- /dev/null +++ b/tests/corpus/6053-class-to-primitive.ts @@ -0,0 +1,125 @@ +// String and number conversions of class instances run Node's ToPrimitive +// protocol through the class's own methods: String(x) and template +// literals use the string hint, `+` with a string the default hint, and +// Number(x) and unary plus the number hint. Classes without conversion +// methods convert to "[object Object]" and NaN. +class Money { + cents: number; + constructor(cents: number) { + this.cents = cents; + } + toString(): string { + return `$${(this.cents / 100).toFixed(2)}`; + } + valueOf(): number { + return this.cents; + } +} +const m = new Money(1250); +console.log(String(m), `${m}`, "total " + m, m + "!"); +let s = "sum:"; +s += m; +console.log(s); +class Plain { + x = 1; +} +console.log(String(new Plain()), `${new Plain()}`, "p" + new Plain()); +class Base { + name(): string { + return "base"; + } +} +class Named extends Base { + toString(): string { + return "named " + this.name(); + } +} +class Deeper extends Named { + valueOf(): number { + return 7; + } +} +const items: Base[] = [new Base(), new Named(), new Deeper()]; +for (const item of items) console.log(String(item), `${item}`, "+" + item); +class Hinted { + [Symbol.toPrimitive](hint: string): string | number { + return hint === "number" ? 42 : `hint:${hint}`; + } +} +const h = new Hinted(); +console.log(String(h), `${h}`, "x" + h); +let calls = 0; +class Counted { + toString(): string { + calls++; + return "c" + calls; + } +} +const c = new Counted(); +console.log(`${c}-${c}`, calls); + +class Amount { + cents: number; + constructor(cents: number) { + this.cents = cents; + } + valueOf(): number { + return this.cents; + } +} +const amount = new Amount(1250); +console.log(Number(amount), +amount); +class Label { + toString(): string { + return "12.5"; + } +} +class Flag { + [Symbol.toPrimitive](hint: string): number | string { + return hint === "number" ? 3 : "flag"; + } +} +class Empty {} +console.log(Number(new Label()), +new Label(), Number(new Flag()), +new Flag(), `${new Flag()}`, Number(new Empty())); + +class Box { + value: T; + constructor(value: T) { + this.value = value; + } + toString(): string { + return `Box(${String(this.value)})`; + } + describe(): string { + return `I am ${this}`; + } +} +console.log(`${new Box(1)}`, String(new Box("s")), new Box(true).describe()); +abstract class Shape { + abstract area(): number; + toString(): string { + return `shape:${this.area()}`; + } +} +class Square extends Shape { + side: number; + constructor(side: number) { + super(); + this.side = side; + } + area(): number { + return this.side * this.side; + } +} +const shapes: Shape[] = [new Square(3)]; +console.log(shapes.map((shape) => `${shape}`).join(",")); +class Code { + valueOf(): string { + return "code-value"; + } +} +console.log(String(new Code()), "" + new Code()); +const maybe: Box | undefined = process.argv.length > 99 ? undefined : new Box(5); +console.log(`${maybe}`); +const pair: Code | number | null = process.argv.length > 99 ? null : new Code(); +console.log(`${pair}`, "p:" + pair); diff --git a/tests/corpus/6054-js-class-to-primitive.js b/tests/corpus/6054-js-class-to-primitive.js new file mode 100644 index 000000000..e5f47b233 --- /dev/null +++ b/tests/corpus/6054-js-class-to-primitive.js @@ -0,0 +1,15 @@ +// JavaScript class instances convert to strings through their own toString, +// and to "[object Object]" without one, as in Node. +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + toString() { + return `(${this.x}, ${this.y})`; + } +} +const p = new Point(1, 2); +console.log(`${p}`, String(p), "p=" + p); +class Bare {} +console.log(`${new Bare()}`); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index b1e77984a..8c6307926 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2230,6 +2230,8 @@ "6043-string-index-narrowed-union.ts": 2983, "605-closures-forward-capture-tdz.ts": 3277, "6051-generic-class-factory-results.ts": 4363, + "6053-class-to-primitive.ts": 4385, + "6054-js-class-to-primitive.js": 2181, "6070-iterator-helpers-untyped.js": 4050, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, @@ -5297,6 +5299,8 @@ "6043-string-index-narrowed-union.ts": 3627, "605-closures-forward-capture-tdz.ts": 4638, "6051-generic-class-factory-results.ts": 46581, + "6053-class-to-primitive.ts": 36270, + "6054-js-class-to-primitive.js": 2958, "6070-iterator-helpers-untyped.js": 6900, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, From e8e432b6ddc0791f56c2ff84d92c8afb028e0c7e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 20:58:20 -0500 Subject: [PATCH 49/53] Clean up subtests cancelled by their parent's timeout node:test changes, matching Node 24.22: - A subtest cancelled because its parent timed out has its mocks restored and its mock timers reset when it is cancelled, as Node's postRun resets the test's mock tracker. Before, a mocked method stayed mocked for the tests that ran after it. - Its afterEach chain and t.after hooks run at the next turn, once the runner has moved on to the next test, as Node runs them when the cancelled test's own run resumes. Before, they never ran. The chain is copied when the test is cancelled, since its ancestors drop their hooks when they finish. - Its hooks are then dropped, so closures that capture its context do not keep it alive. The registration parent is still not restored when the cancelled body settles: it belongs to the test that runs then. The timeout-cancel-cleanup fixture records the mocked method and the hook order against the next test's body and compares them with Node, plain and with SCRIPTC_SAN=1. It fails without this change. --- packages/runtime/src/scr_test.c | 62 ++++++++++++++++++- .../node-test/cases/timeout-cancel-cleanup.ts | 44 +++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/node-test/cases/timeout-cancel-cleanup.ts diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index 4217169b8..00e609129 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -599,9 +599,59 @@ static void scr_test_cancel_unrun(ScrTestCtx *c) { scr_test_report_result(c); } +/* A cancelled subtest's afterEach chain and t.after hooks, on their own + * fiber: they start at the next turn, once the runner has moved on, as + * Node runs them when the cancelled test's own run resumes. The afterEach + * chain is copied when the test is cancelled, because its ancestors drop + * their hooks when they finish. Then the test's hooks are dropped, + * releasing closures that capture its context. */ +typedef struct { + ScrTestCtx *test; /* owned ref */ + ScrTestHooks each; /* the afterEach chain, innermost first; owned closures */ +} ScrTestCancelled; + +static void scr_test_cancelled_hooks_entry(ScrFiber *self, void *arg) { + (void)self; + ScrTestCancelled *job = arg; + ScrTestCtx *c = job->test; + scr_await_hop(); /* the spawn runs the fiber at once; start at the next turn */ + for (size_t i = 0; i < job->each.n && !scr_exc_pending(); i++) + scr_test_call(job->each.v[i].cb, job->each.v[i].flags, c, NULL); + if (scr_exc_pending()) scr_exc_clear(); + scr_test_run_hooks_of(c, SCR_HOOK_AFTER, c); + if (scr_exc_pending()) scr_exc_clear(); + for (int k = 0; k < 4; k++) scr_test_hooks_free(&c->hooks[k]); + scr_test_hooks_free(&job->each); + scr_testctx_release(c); + free(job); +} + +static void scr_test_cancelled_hooks(ScrTestCtx *c) { + ScrTestCancelled *job = calloc(1, sizeof *job); + if (!job) scr_test_oom(); + job->test = scr_testctx_retain(c); + for (ScrTestCtx *p = c->parent; p; p = p->parent) { + ScrTestHooks *h = &p->hooks[SCR_HOOK_AFTER_EACH]; + for (size_t i = 0; i < h->n; i++) { + if (job->each.n == job->each.cap) { + job->each.cap = job->each.cap ? job->each.cap * 2 : 4; + job->each.v = realloc(job->each.v, job->each.cap * sizeof *job->each.v); + if (!job->each.v) scr_test_oom(); + } + job->each.v[job->each.n].cb = scr_closure_retain(h->v[i].cb); + job->each.v[job->each.n].flags = h->v[i].flags; + job->each.v[job->each.n].at = NULL; + job->each.n++; + } + } + scr_promise_release(scr_async_spawn(scr_test_cancelled_hooks_entry, job)); +} + /* A test that timed out (Node): its subtests that are still running are - * cancelled, innermost first, with their durations so far. Their bodies - * keep running unobserved and report nothing when they settle. */ + * cancelled, innermost first, with their durations so far. Each one's + * mocks are restored and its mock timers reset at once, and its afterEach + * chain and t.after hooks run once the runner yields. Their bodies keep + * running unobserved and report nothing when they settle. */ static void scr_test_cancel_running(ScrTestCtx *t) { for (size_t i = 0; i < t->nchildren; i++) { ScrTestCtx *c = t->children[i]; @@ -613,6 +663,9 @@ static void scr_test_cancel_running(ScrTestCtx *t) { c->dur_ms = scr_now_ms() - c->start_ms; c->state = SCR_TS_CANCELLED; scr_test_report_result(c); + scr_test_mocks_restore(&c->mocks, true); + scr_mt_reset(c->mock_timers); + scr_test_cancelled_hooks(c); } } @@ -725,7 +778,10 @@ static void scr_test_run_node(ScrTestCtx *t) { scr_test_call(t->cb, t->flags, t, t); if (t->state != SCR_TS_PENDING) { /* Its parent timed out and cancelled it while the body ran: the - * result is reported, and the runner has moved on. */ + * result is reported, its cleanup ran at the cancellation + * (scr_test_cancel_running), and the runner has moved on, so the + * registration parent is not restored either: it belongs to the + * test that runs now. */ if (scr_exc_pending()) scr_exc_clear(); if (t->concurrency > 1) scr_test_defer--; return; diff --git a/tests/fixtures/node-test/cases/timeout-cancel-cleanup.ts b/tests/fixtures/node-test/cases/timeout-cancel-cleanup.ts new file mode 100644 index 000000000..f9c0e726c --- /dev/null +++ b/tests/fixtures/node-test/cases/timeout-cancel-cleanup.ts @@ -0,0 +1,44 @@ +// @exit: 1 +// A subtest cancelled by its parent's timeout is cleaned up as Node does: +// its mocks are restored at once, and its afterEach chain and t.after +// hooks run once the runner has moved on to the next test. Its body keeps +// running and sees the restored method when it settles. +import { afterEach, test } from "node:test"; +import * as assert from "node:assert"; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +const api = { greet: (): string => "real" }; +const seen: string[] = []; + +afterEach((c) => { + seen.push(`root afterEach for ${c.name}`); +}); + +test("parent", { timeout: 30 }, async (t) => { + t.afterEach((c) => { + seen.push(`parent afterEach for ${c.name}`); + }); + await t.test("sub", async (t) => { + t.mock.method(api, "greet", () => "mocked"); + t.after(() => { + seen.push(`after hook of ${t.name}`); + }); + seen.push(`sub runs: ${api.greet()}`); + await sleep(200); + seen.push(`sub settles: ${api.greet()}`); + }); +}); + +test("next", async () => { + seen.push(`next starts: ${api.greet()}`); + await sleep(300); + assert.deepStrictEqual(seen, [ + "sub runs: mocked", + "root afterEach for parent", + "next starts: real", + "parent afterEach for sub", + "root afterEach for sub", + "after hook of sub", + "sub settles: real", + ]); +}); From 96f59b0f3f782d7f14aaf334648034dc23514017 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 21:53:14 -0500 Subject: [PATCH 50/53] Free errors that crossed into checked-dynamic code and attach Node's error properties The error identity cache (scr_dyn_from_error) keeps one checked-dynamic node per error so a crossing error compares reference-equal and keeps the properties set on it. It retained both sides for the whole process, so every error that crossed a checked-dynamic boundary (a caught error read through a cast, an error delivered to an untyped callback, a dns failure, a warning) stayed alive until exit: 100,000 caught AssertionErrors read through a cast grew the resident set by about 210 MiB. The cache now sweeps itself. An entry whose error and node are referenced only by the cache is unreachable (a node holds no reference to its error; a registered subclass box holds one, and a spawnSync error's `error` property refers to its own node), so a sweep releases both sides. Sweeps run when the cache doubles past the entries the last sweep kept, so they cost amortized constant time per crossing. The cache is per thread, and objects shared across threads are published (immortal), so a sweep never races another thread's reference. Live errors and live nodes keep their entries: identity, rethrowing the same instance, isNativeError and kind checks are unchanged. scr_error_set_prop sets an own property on an error's node; dns, assert, cipher, crypto, util.callbackify and warning errors use it for the properties they attach. util.inspect of a runtime error renders through its node: Node's own properties follow the bracket form in key order (a system error's errno, code, syscall and path, and the extra properties a throw site attached), and an AssertionError renders at depth 0 with long string actual/expected values shortened, as its custom inspect does. A node that contains itself renders as Node's /[Circular *1]. child_process errors carry Node's own properties: - execSync and execFileSync: status, signal, output, pid, stdout and stderr (Buffers, or strings with an encoding option); a spawn failure or timeout adds errno, syscall, path, spawnargs and error first. - execFile's callback and the promisified execFile/exec: code (the exit status, or null after a signal), killed, signal and cmd, with stdout and stderr when promisified; a spawn failure has errno, code, syscall, path, spawnargs and cmd. The sync forms echo the captured stderr before a timeout throw too, as Node's inheritStderr does. The cp.execSync libCall takes a flag for Buffer outputs. An error's own `stack` stays non-enumerable when assigned through its node. A worker's record-source table is freed when the worker exits. --- .../process-arguments-and-errors.mdx | 2 +- .../lowering/builtins/child-process-sync.ts | 6 + .../compiler/src/ir/builtin-signatures.ts | 9 +- .../5634-error-extra-props-inspect.ts.json | 6 + .../5635-error-extra-props-retry-loop.ts.json | 6 + .../corpus/5636-child-error-props.ts.json | 6 + packages/runtime/src/scr_assert.c | 17 +- packages/runtime/src/scr_async_dyn.c | 4 +- packages/runtime/src/scr_child.c | 377 +++++++++++++++--- packages/runtime/src/scr_child.h | 12 +- packages/runtime/src/scr_cipher.c | 10 +- packages/runtime/src/scr_dgram.c | 2 +- packages/runtime/src/scr_dyn.h | 4 + packages/runtime/src/scr_inspect.c | 77 +++- packages/runtime/src/scr_inspect.h | 11 +- packages/runtime/src/scr_json.c | 137 ++++++- packages/runtime/src/scr_lib.c | 8 +- packages/runtime/src/scr_util_callable.c | 2 +- .../corpus/5634-error-extra-props-inspect.ts | 64 +++ .../5635-error-extra-props-retry-loop.ts | 31 ++ tests/corpus/5636-child-error-props.ts | 95 +++++ .../fixtures/error-cache-sweep/retry-loop.ts | 34 ++ tests/harness/corpus-costs.json | 6 + tests/harness/error-cache-sweep.test.ts | 38 ++ 24 files changed, 856 insertions(+), 108 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5634-error-extra-props-inspect.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5635-error-extra-props-retry-loop.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5636-child-error-props.ts.json create mode 100644 tests/corpus/5634-error-extra-props-inspect.ts create mode 100644 tests/corpus/5635-error-extra-props-retry-loop.ts create mode 100644 tests/corpus/5636-child-error-props.ts create mode 100644 tests/fixtures/error-cache-sweep/retry-loop.ts create mode 100644 tests/harness/error-cache-sweep.test.ts diff --git a/docs/content/limitations/process-arguments-and-errors.mdx b/docs/content/limitations/process-arguments-and-errors.mdx index d048b4154..69d7d9dc1 100644 --- a/docs/content/limitations/process-arguments-and-errors.mdx +++ b/docs/content/limitations/process-arguments-and-errors.mdx @@ -1,5 +1,5 @@ `process.argv[0]` is `"scriptc"`, `process.argv[1]` is the binary's path, and program arguments begin at index 2. In programs that use `node:test`, a leading `--test-update-snapshots` argument is Node's snapshot update flag: it is kept out of `process.argv` and appears in `process.execArgv`, which is otherwise `[]`. `process.version` and `process.versions.node` report the Node.js release the runtime is verified against. -Uncaught exceptions print `Uncaught ` to stderr instead of Node.js's source excerpt and stack-trace block; thrown strings and numbers print their value. Exit codes and output written before the exception are preserved. Runtime errors expose `message` and Node's `code`, but not `errno`, `syscall`, or `path`. +Uncaught exceptions print `Uncaught ` to stderr instead of Node.js's source excerpt and stack-trace block; thrown strings and numbers print their value. Exit codes and output written before the exception are preserved. Runtime errors expose `message` and Node's `code` as typed members. The other own properties Node gives them, such as a system error's `errno`, `syscall`, and `path`, a child process error's `status`, `stdout`, and `stderr`, or an assertion error's `actual` and `expected`, are readable through an untyped view of the error (for example a cast of a caught value) and appear in `util.inspect` output. Errors carry no stack frames. `console.log` and `util.inspect` print an Error in Node's stackless form, `[TypeError: message]`, where Node prints `TypeError: message` followed by stack frames; the two agree when `Error.stackTraceLimit` is `0`. diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts index a8dea8001..bfa2bc99a 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-sync.ts @@ -102,6 +102,8 @@ export function lowerExecSyncCall( let stdoutMode = 1; let stderrMode = 0; let stdinInherit = false; + // Without an encoding option, a failure's error carries Buffer outputs. + let encodingGiven = false; // A conditional env spread (`...(c ? { env: ... } : {})`): the call // itself splits into a ternary of the two env-nesses at the tail. let condEnvSpread: { cond: IrExpr; pairs: IrExpr; whenTrue: boolean } | null = null; @@ -134,6 +136,8 @@ export function lowerExecSyncCall( runtime.timeout, runtime.stdoutMode, runtime.stderrMode, + // The runtime options carry a utf8 encoding (others throw). + boolLit(false, loc), ], type: STRING, loc, @@ -251,6 +255,7 @@ export function lowerExecSyncCall( 'outputs are captured as utf8 — pass { encoding: "utf8" }', ); } + encodingGiven = true; break; } case "cwd": @@ -395,6 +400,7 @@ export function lowerExecSyncCall( timeout, numLit(stdoutMode + (stdinInherit ? 4 : 0), loc), numLit(stderrMode, loc), + boolLit(!encodingGiven, loc), ], type: STRING, loc, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index d2c503214..9467a2458 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2285,11 +2285,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ result: VOID, }, /** execFileSync/execSync as ONE entry (args: cmd, argv, shell, input, - * cwd, hasEnv, envPairs, timeoutMs, stdoutMode, stderrMode — see - * scr_runtime.h). Throws Node's exact errors: "Command failed: " + * cwd, hasEnv, envPairs, timeoutMs, stdoutMode, stderrMode, buffers — + * see scr_child.h). Throws Node's exact errors: "Command failed: " * (+ captured stderr) on non-zero exit or signal death, "spawnSync * ENOENT" on spawn failure, "spawnSync ETIMEDOUT" after - * the SIGTERM timeout. Result is the captured utf8 stdout (+1). */ + * the SIGTERM timeout, with Node's status, signal, output, pid, stdout + * and stderr properties (Buffer outputs when buffers is set: no + * encoding option). Result is the captured utf8 stdout (+1). */ "cp.execSync": { argTypes: [ STRING, @@ -2303,6 +2305,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ F64, F64, F64, + BOOL, ], result: STRING, }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5634-error-extra-props-inspect.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5634-error-extra-props-inspect.ts.json new file mode 100644 index 000000000..418b206b1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5634-error-extra-props-inspect.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5634-error-extra-props-inspect.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5635-error-extra-props-retry-loop.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5635-error-extra-props-retry-loop.ts.json new file mode 100644 index 000000000..9fbf4c473 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5635-error-extra-props-retry-loop.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5635-error-extra-props-retry-loop.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5636-child-error-props.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5636-child-error-props.ts.json new file mode 100644 index 000000000..bf6db4e51 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5636-child-error-props.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5636-child-error-props.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c index 5a4769038..4917b1291 100644 --- a/packages/runtime/src/scr_assert.c +++ b/packages/runtime/src/scr_assert.c @@ -167,19 +167,16 @@ static ScrDyn *scr_assert_dyn_text(const char *text) { return d; } -/* Sets the properties on the error's view in Node's order. The values - * move in. */ +/* Sets the error's own properties in Node's order. The values move in. */ static void scr_assert_attach_values(ScrError *e, bool generated, ScrDyn *actual, ScrDyn *expected, ScrDyn *op, ScrDyn *diff) { - ScrDyn *view = scr_dyn_from_error(e); - scr_dyn_obj_set(view, "generatedMessage", 16, scr_dyn_new_bool(generated)); + scr_error_set_prop(e, "generatedMessage", 16, scr_dyn_new_bool(generated)); scr_error_set_code(e, "ERR_ASSERTION"); - scr_dyn_obj_set(view, "code", 4, scr_assert_dyn_text("ERR_ASSERTION")); - scr_dyn_obj_set(view, "actual", 6, actual); - scr_dyn_obj_set(view, "expected", 8, expected); - scr_dyn_obj_set(view, "operator", 8, op); - scr_dyn_obj_set(view, "diff", 4, diff); - scr_dyn_release(view); + scr_error_set_prop(e, "code", 4, scr_assert_dyn_text("ERR_ASSERTION")); + scr_error_set_prop(e, "actual", 6, actual); + scr_error_set_prop(e, "expected", 8, expected); + scr_error_set_prop(e, "operator", 8, op); + scr_error_set_prop(e, "diff", 4, diff); } static ScrError *scr_assert_error(ScrStr *message, bool generated, const char *diff) { diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 95771d457..ffcb85050 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -1036,11 +1036,9 @@ void scr_process_emit_warning(ScrDyn *args) { e->name = type ? scr_str_retain((ScrStr *)type) : scr_str_new("Warning", 7); e->name_present = true; if (code) e->code = scr_str_retain((ScrStr *)code); + if (detail) scr_error_set_prop(e, "detail", 6, scr_dyn_new_str((ScrStr *)detail)); /* retains */ ScrDyn *w = scr_dyn_from_error(e); scr_error_release(e); - if (detail) { - scr_dyn_obj_set(w, "detail", 6, scr_dyn_new_str((ScrStr *)detail)); /* retains */ - } scr_warning_emit_deferred(w); scr_dyn_release(w); } diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 1235001ba..87ead5755 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -62,6 +62,118 @@ enum { SCR_CHILD_DATA_STRING = 2, }; +/* ── error properties (lib/child_process.js) ────────────────────────── + * The own properties Node attaches to child_process errors, set on the + * error's checked-dynamic node (scr_error_set_prop) in Node's key order. */ + +static ScrDyn *scr_child_dyn_cstr(const char *text) { + ScrStr *s = scr_str_new(text, strlen(text)); + ScrDyn *d = scr_dyn_new_str(s); + scr_str_release(s); + return d; +} + +static ScrDyn *scr_child_dyn_text_or_null(const char *text) { + return text != NULL ? scr_child_dyn_cstr(text) : scr_dyn_new_null(); +} + +/* A spawn failure's errnoException fields: errno, code (already stamped), + * syscall " " and path come from the native system fields, + * then spawnargs (the arguments after the file). Set before any other + * property, so the node orders them first. */ +static void scr_child_error_spawn_fields(ScrError *e, double uv_errno, const char *verb, + ScrStr *file, ScrArr *args) { + size_t cap = strlen(verb) + 1 + file->len + 1; + char *call = malloc(cap); + if (!call) scr_trap("scriptc: out of memory\n"); + int len = snprintf(call, cap, "%s %s", verb, file->data); + e->system_errno = uv_errno; + e->system_call = scr_str_new(call, (size_t)len); + free(call); + e->system_path = scr_str_retain(file); + ScrDyn *spawnargs = scr_dyn_new_arr(); + size_t n = args != NULL ? (size_t)scr_arr_len(args) : 0; + for (size_t i = 0; i < n; i++) { + ScrStr *arg = (ScrStr *)scr_arr_get_ref(args, (double)i); + scr_dyn_arr_push(spawnargs, scr_dyn_new_str(arg)); + scr_str_release(arg); + } + scr_error_set_prop(e, "spawnargs", 9, spawnargs); +} + +/* exec/execFile's command failure: code (the exit status, or null after a + * signal), killed and signal, before cmd. */ +static void scr_child_error_exit_fields(ScrError *e, bool has_code, double code, + const char *signal, bool killed) { + scr_error_set_prop(e, "code", 4, has_code ? scr_dyn_new_num(code) : scr_dyn_new_null()); + scr_error_set_prop(e, "killed", 6, scr_dyn_new_bool(killed)); + scr_error_set_prop(e, "signal", 6, scr_child_dyn_text_or_null(signal)); +} + +/* The sync forms' spawn failure and timeout carry the error itself as + * `error` (Node assigns the spawnSync result, whose error it is). */ +static void scr_child_error_self(ScrError *e) { + scr_error_set_prop(e, "error", 5, scr_dyn_from_error(e)); +} + +static void scr_child_error_cmd(ScrError *e, const char *cmd) { + scr_error_set_prop(e, "cmd", 3, scr_child_dyn_cstr(cmd)); +} + +/* The promisified forms add the captured outputs. Borrows. */ +static void scr_child_error_outputs(ScrError *e, ScrStr *out, ScrStr *err) { + scr_error_set_prop(e, "stdout", 6, scr_dyn_new_str(out)); + scr_error_set_prop(e, "stderr", 6, scr_dyn_new_str(err)); +} + +/* A captured sync output as Node's result holds it: a Buffer, the utf8 + * string with an encoding option, or null for a stream that was not + * piped. +1. */ +static ScrDyn *scr_child_sync_output(const char *data, size_t len, bool piped, bool buffers) { + if (!piped) return scr_dyn_new_null(); + if (!buffers) { + ScrStr *s = scr_str_new(data, len); + ScrDyn *d = scr_dyn_new_str(s); + scr_str_release(s); + return d; + } + ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)len); + b->is_buffer = true; + if (len) memcpy(b->data, data, len); + ScrDyn *d = scr_dyn_new_buffer(b); + scr_bytes_release(b); + return d; +} + +/* execSync/execFileSync: the spawnSync result's status, signal, output, + * pid, stdout and stderr. out/err move in (null for a stream that was not + * piped); output is [null, stdout, stderr]. A child that never started + * has a null output and undefined outputs. */ +static void scr_child_error_sync_fields(ScrError *e, bool has_status, double status, + const char *signal, double pid, bool started, + ScrDyn *out, ScrDyn *err) { + if (!started) { + scr_dyn_release(out); + scr_dyn_release(err); + out = scr_dyn_retain(scr_dyn_undefined()); + err = scr_dyn_retain(scr_dyn_undefined()); + } + scr_error_set_prop(e, "status", 6, has_status ? scr_dyn_new_num(status) : scr_dyn_new_null()); + scr_error_set_prop(e, "signal", 6, scr_child_dyn_text_or_null(signal)); + if (started) { + ScrDyn *output = scr_dyn_new_arr(); + scr_dyn_arr_push(output, scr_dyn_new_null()); + scr_dyn_arr_push(output, scr_dyn_retain(out)); + scr_dyn_arr_push(output, scr_dyn_retain(err)); + scr_error_set_prop(e, "output", 6, output); + } else { + scr_error_set_prop(e, "output", 6, scr_dyn_new_null()); + } + scr_error_set_prop(e, "pid", 3, scr_dyn_new_num(pid)); + scr_error_set_prop(e, "stdout", 6, out); + scr_error_set_prop(e, "stderr", 6, err); +} + #ifdef _WIN32 /* ── Windows arm: real children via CreateProcessW ──────────────────── * The POSIX arm below owns the corresponding implementation; this arm @@ -766,6 +878,7 @@ typedef struct { const char *spawn_errname; /* NULL = the child ran (and was reaped) */ bool timed_out; /* the deadline TerminateProcess fired */ DWORD exit_code; + DWORD pid; /* the child's process id (0 when it never ran) */ ScrCapBuf out, err; /* always initialized; caller frees */ } ScrWinSyncRes; @@ -780,6 +893,7 @@ static void scr_win_run_sync(ScrStr *cmd, ScrArr *args, const ScrStr *input, res->spawn_errname = NULL; res->timed_out = false; res->exit_code = 0; + res->pid = 0; scr_cap_init(&res->out); scr_cap_init(&res->err); @@ -842,6 +956,7 @@ static void scr_win_run_sync(ScrStr *cmd, ScrArr *args, const ScrStr *input, res->spawn_errname = sp.errname; return; } + res->pid = sp.pid; /* Pump: drain both captures, feed stdin (PIPE_NOWAIT — a full pipe * writes short, never blocks), watch the deadline. The 1ms wait rides @@ -1082,8 +1197,8 @@ static char *scr_exec_display(ScrStr *cmd, ScrArr *args, bool shell) { return out; } -static void scr_exec_throw_failed(const char *display, const ScrCapBuf *err_cap, - bool with_stderr, bool always_nl) { +static ScrError *scr_exec_failed_error(const char *display, const ScrCapBuf *err_cap, + bool with_stderr, bool always_nl) { size_t dlen = strlen(display); size_t elen = with_stderr ? err_cap->len : 0; size_t cap = 16 + dlen + 1 + elen + 1; @@ -1095,18 +1210,33 @@ static void scr_exec_throw_failed(const char *display, const ScrCapBuf *err_cap, memcpy(msg + at, err_cap->data, elen); at += elen; } - scr_throw_error_msg(SCR_ERR_ERROR, msg, at); + ScrStr *message = scr_str_new(msg, at); free(msg); + ScrError *e = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + return e; } -static void scr_exec_throw_spawn(ScrStr *cmd, const char *errname, bool async_shape) { +/* Node's spawn-failure/timeout error with its errnoException fields + * (errno is libuv's code for the name). +1. */ +static ScrError *scr_exec_spawn_error(ScrStr *cmd, ScrArr *args, const char *errname, + bool async_shape) { + const char *verb = async_shape ? "spawn" : "spawnSync"; size_t cap = 10 + cmd->len + 1 + strlen(errname) + 1; char *msg = malloc(cap); if (!msg) scr_child_oom(); - int len = snprintf(msg, cap, "%s %s %s", async_shape ? "spawn" : "spawnSync", - cmd->data, errname); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, errname); + int len = snprintf(msg, cap, "%s %s %s", verb, cmd->data, errname); + ScrStr *message = scr_str_new(msg, (size_t)len); free(msg); + ScrError *e = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + scr_error_set_code(e, errname); + scr_child_error_spawn_fields(e, (double)scr_win_uv_errno(errname), verb, cmd, args); + return e; +} + +static void scr_exec_throw(ScrError *e /* moves */) { + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } /* The shared exec body (sync + promisified shapes — the POSIX arm's @@ -1115,7 +1245,7 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, const ScrStr *input, const ScrStr *cwd, ScrArr *env_pairs, double timeout_ms, int stdout_mode, int stderr_mode, bool async_shape, - ScrStr **stderr_out) { + bool buffers, ScrStr **stderr_out) { /* stdio "inherit"'s stdin rides bit 4 of stdout_mode (scr_runtime.h). */ bool stdin_inherit = (stdout_mode & 4) != 0; stdout_mode &= 3; @@ -1129,31 +1259,67 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, if (w.spawn_errname != NULL) { free(w.out.data); free(w.err.data); - scr_exec_throw_spawn(cmd, w.spawn_errname, async_shape); + ScrError *e = scr_exec_spawn_error(cmd, args, w.spawn_errname, async_shape); + if (async_shape) { + char *display = scr_exec_display(cmd, args, shell); + scr_child_error_cmd(e, display); + free(display); + ScrStr *empty = scr_str_new("", 0); + scr_child_error_outputs(e, empty, empty); + scr_str_release(empty); + } else { + scr_child_error_self(e); + scr_child_error_sync_fields(e, false, 0, NULL, 0, false, NULL, NULL); + } + scr_exec_throw(e); return NULL; } - if (w.timed_out && !async_shape) { - /* The SYNC forms throw Node's spawnSync ETIMEDOUT; the async shape - * reports the kill's exit-1 death as an ordinary command failure - * below, exactly Node's promisified execFile. */ - free(w.out.data); - free(w.err.data); - scr_exec_throw_spawn(cmd, "ETIMEDOUT", false); - return NULL; - } - bool failed = w.exit_code != 0; /* Node's inheritStderr: no stdio option given → the captured stderr - * echoes to the parent's stderr AFTER completion, success or failure. */ + * echoes to the parent's stderr AFTER completion, success, failure or + * timeout. */ if (stderr_mode == 0 && w.err.len > 0) { fflush(stdout); fwrite(w.err.data, 1, w.err.len, stderr); } + if (w.timed_out && !async_shape) { + /* The SYNC forms throw Node's spawnSync ETIMEDOUT; the async shape + * reports the kill's death as an ordinary command failure below, + * exactly Node's promisified execFile. */ + ScrError *e = scr_exec_spawn_error(cmd, args, "ETIMEDOUT", false); + scr_child_error_self(e); + scr_child_error_sync_fields(e, false, 0, "SIGTERM", (double)w.pid, true, + scr_child_sync_output(w.out.data, w.out.len, cap_out, buffers), + scr_child_sync_output(w.err.data, w.err.len, cap_err, buffers)); + free(w.out.data); + free(w.err.data); + scr_exec_throw(e); + return NULL; + } + bool failed = w.exit_code != 0 || w.timed_out; if (failed) { char *display = scr_exec_display(cmd, args, shell); - scr_exec_throw_failed(display, &w.err, cap_err, async_shape); + ScrError *e = scr_exec_failed_error(display, &w.err, cap_err, async_shape); + /* libuv's kill emulation: a timed-out child died to the kill signal. */ + bool exited = !w.timed_out; + if (async_shape) { + scr_child_error_exit_fields(e, exited, (double)w.exit_code, exited ? NULL : "SIGTERM", + w.timed_out); + scr_child_error_cmd(e, display); + ScrStr *out_s = scr_str_new(w.out.data, w.out.len); + ScrStr *err_s = scr_str_new(w.err.data, w.err.len); + scr_child_error_outputs(e, out_s, err_s); + scr_str_release(out_s); + scr_str_release(err_s); + } else { + scr_child_error_sync_fields(e, exited, (double)w.exit_code, exited ? NULL : "SIGTERM", + (double)w.pid, true, + scr_child_sync_output(w.out.data, w.out.len, cap_out, buffers), + scr_child_sync_output(w.err.data, w.err.len, cap_err, buffers)); + } free(display); free(w.out.data); free(w.err.data); + scr_exec_throw(e); return NULL; } if (async_shape && stderr_out != NULL) { @@ -1167,10 +1333,11 @@ static ScrStr *scr_exec_run(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, bool has_input, ScrStr *cwd, bool has_env, ScrArr *env_pairs, - double timeout_ms, double stdout_mode, double stderr_mode) { + double timeout_ms, double stdout_mode, double stderr_mode, + bool buffers) { return scr_exec_run(cmd, args, shell, has_input ? input : NULL, cwd->len > 0 ? cwd : NULL, has_env ? env_pairs : NULL, - timeout_ms, (int)stdout_mode, (int)stderr_mode, false, NULL); + timeout_ms, (int)stdout_mode, (int)stderr_mode, false, buffers, NULL); } ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd, @@ -1178,7 +1345,7 @@ ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd ScrStr *err_s = NULL; ScrStr *out_s = scr_exec_run(cmd, args, shell, NULL, cwd->len > 0 ? cwd : NULL, has_env ? env_pairs : NULL, timeout_ms, 1, 1, - true, &err_s); + true, false, &err_s); if (out_s == NULL) return NULL; /* exception pending; dummy result */ return scr_spawn_res_new(true, 0, out_s, err_s); } @@ -1302,6 +1469,9 @@ struct ScrChild { ScrClosure *exec_cb; ScrExecFileFn exec_fn; ScrStr *exec_display; + ScrStr *exec_file; /* the spawned file and its arguments (+1 each), for */ + ScrArr *exec_args; /* a spawn failure's path and spawnargs */ + void (*exec_props)(struct ScrChild *c, ScrError *error); /* Node's error properties */ ScrChildStream *out_stream; ScrChildStream *err_stream; ScrChildWriter *in_stream; @@ -1356,6 +1526,8 @@ void scr_child_release(ScrChild *c) { scr_child_drop_listeners(c); /* only reachable pre-settle via leaks */ scr_str_release(c->err_msg); scr_str_release(c->exec_display); + scr_str_release(c->exec_file); + if (c->exec_args) scr_arr_release(c->exec_args); scr_dyn_release(c->out_dyn); scr_dyn_release(c->err_dyn); scr_dyn_release(c->in_dyn); @@ -2236,10 +2408,24 @@ ScrChild *scr_fork(double target, ScrArr *args, double in_mode, return child; } +/* Node's properties on the callback form's error. Reached only through + * the hook scr_exec_file installs, so programs without the callback form + * link none of the property code (the settle path is shared by every + * child). */ +static void scr_exec_file_error_props(ScrChild *c, ScrError *error) { + if (c->state == SCR_CHILD_SPAWN_FAILED) { + scr_child_error_spawn_fields(error, (double)c->spawn_uv_errno, "spawn", c->exec_file, c->exec_args); + } else { + scr_child_error_exit_fields(error, c->has_code, c->code, c->exit_signal, c->killed); + } + scr_child_error_cmd(error, c->exec_display->data); +} + static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { if (c->state == SCR_CHILD_SPAWN_FAILED) { ScrError *error = scr_error_new(0, c->err_msg); scr_error_set_code(error, c->spawn_errname); + if (c->exec_props) c->exec_props(c, error); return error; } if (c->has_code && c->code == 0) return NULL; @@ -2254,6 +2440,7 @@ static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { free(msg); ScrError *error = scr_error_new(0, message); scr_str_release(message); + if (c->exec_props) c->exec_props(c, error); return error; } @@ -2273,6 +2460,9 @@ ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, scr_str_release(empty); c->exec_cb = cb; c->exec_fn = fn; + c->exec_file = scr_str_retain(cmd); + c->exec_args = scr_arr_retain(args); + c->exec_props = &scr_exec_file_error_props; char *display = scr_exec_display(cmd, args, shell); c->exec_display = scr_str_new(display, strlen(display)); free(display); @@ -3099,9 +3289,10 @@ ScrSpawnRes *scr_spawn_sync_stdio_str(ScrStr *cmd, ScrArr *args, double timeout_ * the child completes (Node's inheritStderr flag — the capture is * unconditional); an explicit stdio option turns that off, and an * "ignore" stderr slot discards it entirely (nothing in the message). - * - The thrown error carries the MESSAGE only: Node's status/stdout/ - * stderr properties don't exist here (SEMANTICS.md — nothing the - * supported catch forms can read anyway). + * - The thrown error carries Node's own properties: status, signal, + * output, pid, stdout and stderr (Buffers, or strings with an encoding + * option), after the errnoException fields on a spawn failure or a + * timeout. * The result is the captured utf8 stdout (+1). */ typedef struct { @@ -3121,6 +3312,8 @@ typedef struct { * lands and the death reports as an ordinary command failure, exactly * Node's promisified behavior. */ bool async_shape; + /* The sync forms' error outputs are Buffers (no encoding option). */ + bool buffers; /* async_shape success: the captured stderr string (+1) lands here. */ ScrStr **stderr_out; } ScrExecOpts; @@ -3187,12 +3380,12 @@ static void scr_exec_envp_free(char **envp) { free(envp); } -/* The Command-failed thrower (message only — see the block comment). +/* The Command-failed error (+1; the caller adds its properties). * always_nl is the async shape: Node's promisified execFile appends * "\n" unconditionally (a trailing newline on empty stderr), * where the sync form appends only when stderr is non-empty. */ -static void scr_exec_throw_failed(const char *display, const ScrCapBuf *err_cap, - bool with_stderr, bool always_nl) { +static ScrError *scr_exec_failed_error(const char *display, const ScrCapBuf *err_cap, + bool with_stderr, bool always_nl) { size_t dlen = strlen(display); size_t elen = with_stderr ? err_cap->len : 0; size_t cap = 16 + dlen + 1 + elen + 1; @@ -3204,22 +3397,34 @@ static void scr_exec_throw_failed(const char *display, const ScrCapBuf *err_cap, memcpy(msg + at, err_cap->data, elen); at += elen; } - scr_throw_error_msg(SCR_ERR_ERROR, msg, at); + ScrStr *message = scr_str_new(msg, at); free(msg); + ScrError *e = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + return e; } -/* "spawnSync " — Node's uv-error message for spawn - * failures and timeouts, with `code` = the errno name (Node's - * errnoException shape; the Command-failed error stays code-less — Node - * puts the exit STATUS there, in properties this surface doesn't carry). */ -static void scr_exec_throw_spawn(ScrStr *cmd, const char *errname, bool async_shape) { +/* "spawnSync " — Node's uv-error for spawn failures and + * timeouts (+1): `code` is the errno name, and the errnoException fields + * (errno, syscall, path, spawnargs) follow. */ +static ScrError *scr_exec_spawn_error(ScrStr *cmd, ScrArr *args, int errnum, bool async_shape) { + const char *errname = scr_errname(errnum); + const char *verb = async_shape ? "spawn" : "spawnSync"; size_t cap = 10 + cmd->len + 1 + strlen(errname) + 1; char *msg = malloc(cap); if (!msg) scr_child_oom(); - int len = snprintf(msg, cap, "%s %s %s", async_shape ? "spawn" : "spawnSync", - cmd->data, errname); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, errname); + int len = snprintf(msg, cap, "%s %s %s", verb, cmd->data, errname); + ScrStr *message = scr_str_new(msg, (size_t)len); free(msg); + ScrError *e = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + scr_error_set_code(e, errname); + scr_child_error_spawn_fields(e, -(double)errnum, verb, cmd, args); + return e; +} + +static void scr_exec_throw(ScrError *e /* moves */) { + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { @@ -3286,7 +3491,19 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { if (has_input) close(infd[1]); /* execSync's ENOENT is the SHELL missing — can't happen; the file * form reports Node's spawnSync error. */ - scr_exec_throw_spawn(cmd, scr_errname(spawn_err), o->async_shape); + ScrError *e = scr_exec_spawn_error(cmd, args, spawn_err, o->async_shape); + if (o->async_shape) { + char *display = scr_exec_display(cmd, args, o->shell); + scr_child_error_cmd(e, display); + free(display); + ScrStr *empty = scr_str_new("", 0); + scr_child_error_outputs(e, empty, empty); + scr_str_release(empty); + } else { + scr_child_error_self(e); + scr_child_error_sync_fields(e, false, 0, NULL, 0, false, NULL, NULL); + } + scr_exec_throw(e); return NULL; } @@ -3379,29 +3596,53 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { } while (reaped < 0 && errno == EINTR); } - char *display = NULL; - if (timed_out && !o->async_shape) { - /* The SYNC forms throw Node's spawnSync ETIMEDOUT; the async shape - * reports the SIGTERM death as an ordinary command failure below, - * exactly Node's promisified execFile. */ - free(out.data); - free(err.data); - scr_exec_throw_spawn(cmd, "ETIMEDOUT", false); - return NULL; - } - bool failed = !WIFEXITED(st) || WEXITSTATUS(st) != 0; /* Node's inheritStderr: no stdio option given → the captured stderr - * echoes to the parent's stderr AFTER completion, success or failure. */ + * echoes to the parent's stderr AFTER completion, success, failure or + * timeout. */ if (o->stderr_mode == 0 && err.len > 0) { fflush(stdout); fwrite(err.data, 1, err.len, stderr); } + bool exited = WIFEXITED(st) && !(timed_out && !o->async_shape); + const char *signal = WIFSIGNALED(st) ? scr_signal_name(WTERMSIG(st)) : NULL; + if (timed_out && !o->async_shape) { + /* The SYNC forms throw Node's spawnSync ETIMEDOUT (signal = the kill + * signal even when the child raced it); the async shape reports the + * SIGTERM death as an ordinary command failure below, exactly Node's + * promisified execFile. */ + ScrError *e = scr_exec_spawn_error(cmd, args, ETIMEDOUT, false); + scr_child_error_self(e); + scr_child_error_sync_fields(e, false, 0, signal != NULL ? signal : "SIGTERM", (double)pid, + true, scr_child_sync_output(out.data, out.len, cap_out, o->buffers), + scr_child_sync_output(err.data, err.len, cap_err, o->buffers)); + free(out.data); + free(err.data); + scr_exec_throw(e); + return NULL; + } + bool failed = !exited || WEXITSTATUS(st) != 0; if (failed) { - display = scr_exec_display(cmd, args, o->shell); - scr_exec_throw_failed(display, &err, cap_err, o->async_shape); + char *display = scr_exec_display(cmd, args, o->shell); + ScrError *e = scr_exec_failed_error(display, &err, cap_err, o->async_shape); + if (o->async_shape) { + scr_child_error_exit_fields(e, exited, exited ? (double)WEXITSTATUS(st) : 0, signal, + timed_out); + scr_child_error_cmd(e, display); + ScrStr *out_s = scr_str_new(out.data, out.len); + ScrStr *err_s = scr_str_new(err.data, err.len); + scr_child_error_outputs(e, out_s, err_s); + scr_str_release(out_s); + scr_str_release(err_s); + } else { + scr_child_error_sync_fields(e, exited, exited ? (double)WEXITSTATUS(st) : 0, signal, + (double)pid, true, + scr_child_sync_output(out.data, out.len, cap_out, o->buffers), + scr_child_sync_output(err.data, err.len, cap_err, o->buffers)); + } free(display); free(out.data); free(err.data); + scr_exec_throw(e); return NULL; } if (o->async_shape && o->stderr_out != NULL) { @@ -3421,7 +3662,8 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) { * it means an empty environment). */ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, bool has_input, ScrStr *cwd, bool has_env, ScrArr *env_pairs, - double timeout_ms, double stdout_mode, double stderr_mode) { + double timeout_ms, double stdout_mode, double stderr_mode, + bool buffers) { ScrExecOpts o = { .shell = shell, .input = has_input ? input : NULL, @@ -3433,6 +3675,7 @@ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, .stdout_mode = (int)stdout_mode & 3, .stderr_mode = (int)stderr_mode, .stdin_inherit = ((int)stdout_mode & 4) != 0, + .buffers = buffers, }; return scr_exec_sync_core(cmd, args, &o); } @@ -3634,6 +3877,9 @@ struct ScrChild { ScrClosure *exec_cb; ScrExecFileFn exec_fn; ScrStr *exec_display; + ScrStr *exec_file; /* the spawned file and its arguments (+1 each), for */ + ScrArr *exec_args; /* a spawn failure's path and spawnargs */ + void (*exec_props)(struct ScrChild *c, ScrError *error); /* Node's error properties */ /* Piped stdio (stdio mode 3): the stream handles child.stdout / * child.stderr answer — owned (+1 each), NULL when not piped. */ ScrChildStream *out_stream; @@ -3828,6 +4074,8 @@ void scr_child_release(ScrChild *c) { scr_child_drop_listeners(c); /* only reachable pre-settle via leaks */ scr_str_release(c->err_msg); scr_str_release(c->exec_display); + scr_str_release(c->exec_file); + if (c->exec_args) scr_arr_release(c->exec_args); scr_dyn_release(c->out_dyn); scr_dyn_release(c->err_dyn); scr_dyn_release(c->in_dyn); @@ -3858,6 +4106,7 @@ static const char *scr_errname(int err) { case EAGAIN: return "EAGAIN"; case EIO: return "EIO"; case EINVAL: return "EINVAL"; + case ETIMEDOUT: return "ETIMEDOUT"; default: return "EUNKNOWN"; } } @@ -4881,10 +5130,24 @@ ScrChild *scr_fork(double target, ScrArr *args, double in_mode, return child; } +/* Node's properties on the callback form's error. Reached only through + * the hook scr_exec_file installs, so programs without the callback form + * link none of the property code (the settle path is shared by every + * child). */ +static void scr_exec_file_error_props(ScrChild *c, ScrError *error) { + if (c->state == SCR_CHILD_SPAWN_FAILED) { + scr_child_error_spawn_fields(error, -(double)c->spawn_errno, "spawn", c->exec_file, c->exec_args); + } else { + scr_child_error_exit_fields(error, c->has_code, c->code, c->exit_signal, c->killed); + } + scr_child_error_cmd(error, c->exec_display->data); +} + static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { if (c->state == SCR_CHILD_SPAWN_FAILED) { ScrError *error = scr_error_new(0, c->err_msg); scr_error_set_code(error, scr_errname(c->spawn_errno)); + if (c->exec_props) c->exec_props(c, error); return error; } if (c->has_code && c->code == 0) return NULL; @@ -4899,6 +5162,7 @@ static ScrError *scr_exec_file_error(ScrChild *c, ScrStr *stderr_value) { free(msg); ScrError *error = scr_error_new(0, message); scr_str_release(message); + if (c->exec_props) c->exec_props(c, error); return error; } @@ -4918,6 +5182,9 @@ ScrChild *scr_exec_file(ScrStr *cmd, ScrArr *args, ScrClosure *cb, scr_str_release(empty); c->exec_cb = cb; c->exec_fn = fn; + c->exec_file = scr_str_retain(cmd); + c->exec_args = scr_arr_retain(args); + c->exec_props = &scr_exec_file_error_props; char *display = scr_exec_display(cmd, args, shell); c->exec_display = scr_str_new(display, strlen(display)); free(display); diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index 1819b9c53..1187985c8 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -44,18 +44,22 @@ ScrSpawnRes *scr_spawn_sync_stdio_str(ScrStr *cmd, ScrArr *args, double timeout_ * 0 captures AND echoes to the parent stderr after completion (Node's * no-stdio-option default), 1 captures only, 2 ignores, 3 inherits. * Result is the captured utf8 stdout (+1) — "" when nothing captures - * (Node answers null there; discarded results cannot tell). All args + * (Node answers null there; discarded results cannot tell). A failure's + * error carries Node's status, signal, output, pid, stdout and stderr; + * `buffers` (no encoding option) makes those outputs Buffers. All args * borrowed. */ ScrStr *scr_exec_sync(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *input, bool has_input, ScrStr *cwd, bool has_env, ScrArr *env_pairs, - double timeout_ms, double stdout_mode, double stderr_mode); + double timeout_ms, double stdout_mode, double stderr_mode, + bool buffers); /* The promisified-execFile/exec capture (cp.execCapture; shell as for * scr_exec_sync — the /bin/sh -c argv): the same core in the * async shape — both streams captured (no echo), Node's ASYNC error * messages on the throw paths ("Command failed: \n" with the * unconditional newline, "spawn " with .code, timeouts as - * ordinary SIGTERM command failures). +1 ScrSpawnRes on success (stdout + - * stderr; status unused). All args borrowed. */ + * ordinary SIGTERM command failures), each error with Node's code, killed, + * signal, cmd, stdout and stderr properties. +1 ScrSpawnRes on success + * (stdout + stderr; status unused). All args borrowed. */ ScrSpawnRes *scr_exec_capture(ScrStr *cmd, ScrArr *args, bool shell, ScrStr *cwd, bool has_env, ScrArr *env_pairs, double timeout_ms); ScrSpawnRes *scr_spawn_res_retain(ScrSpawnRes *r); diff --git a/packages/runtime/src/scr_cipher.c b/packages/runtime/src/scr_cipher.c index 97b7a3e97..df62a79a3 100644 --- a/packages/runtime/src/scr_cipher.c +++ b/packages/runtime/src/scr_cipher.c @@ -159,22 +159,20 @@ static void sc_throw_openssl(const char *packed, const char *reason, const char snprintf(message, sizeof message, "error:%s:Provider routines::%s", packed, reason); ScrStr *text = scr_str_new(message, strlen(message)); ScrError *e = scr_error_new(SCR_ERR_ERROR, text); - ScrDyn *view = scr_dyn_from_error(e); ScrDyn *stack = scr_dyn_new_arr(); scr_dyn_arr_push(stack, scr_dyn_new_str(text)); scr_str_release(text); - scr_dyn_obj_set(view, "opensslErrorStack", 17, stack); + scr_error_set_prop(e, "opensslErrorStack", 17, stack); ScrStr *library = scr_str_new("Provider routines", 17); - scr_dyn_obj_set(view, "library", 7, scr_dyn_new_str(library)); + scr_error_set_prop(e, "library", 7, scr_dyn_new_str(library)); scr_str_release(library); ScrStr *reason_text = scr_str_new(reason, strlen(reason)); - scr_dyn_obj_set(view, "reason", 6, scr_dyn_new_str(reason_text)); + scr_error_set_prop(e, "reason", 6, scr_dyn_new_str(reason_text)); scr_str_release(reason_text); scr_error_set_code(e, code); ScrStr *code_text = scr_str_new(code, strlen(code)); - scr_dyn_obj_set(view, "code", 4, scr_dyn_new_str(code_text)); + scr_error_set_prop(e, "code", 4, scr_dyn_new_str(code_text)); scr_str_release(code_text); - scr_dyn_release(view); scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } diff --git a/packages/runtime/src/scr_dgram.c b/packages/runtime/src/scr_dgram.c index b02c41c03..c34923607 100644 --- a/packages/runtime/src/scr_dgram.c +++ b/packages/runtime/src/scr_dgram.c @@ -1297,9 +1297,9 @@ static ScrDyn *scr_dns_exception(int rc, const char *syscall, const ScrStr *host scr_error_set_code(e, code); e->system_call = scr_str_new(syscall, strlen(syscall)); e->system_errno = errnum; + scr_error_set_prop(e, "hostname", 8, scr_dyn_new_str((ScrStr *)host)); ScrDyn *d = scr_dyn_from_error(e); scr_error_release(e); - scr_dyn_obj_set(d, "hostname", 8, scr_dyn_new_str((ScrStr *)host)); return d; } diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index bccd8d55c..86fcc7709 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -998,6 +998,10 @@ ScrError *scr_error_from_dyn(const ScrDyn *d); /* scr_async_dyn.c (gated) */ * the gated extraction reads/writes through these). */ ScrError *scr_errdyn_err_of(const ScrDyn *d); /* +1 or NULL */ void scr_errdyn_put(ScrError *e, ScrDyn *d); /* retains both sides */ +/* Sets an own property on an error: its checked-dynamic node, which casts + * and util.inspect read. Moves value. */ +void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *value); +ScrDyn *scr_errdyn_view_of(const ScrError *e); /* borrowed or NULL */ /* ToBoolean over the dyn kind (JS-exact); borrowed, never throws. */ bool scr_dyn_truthy(const ScrDyn *d); /* JS String() over a dyn value (arrays join, [object Object], the error diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 34438353f..4aa703711 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -801,14 +801,32 @@ static ScrStr *scr_insp_array_buffer(const ScrBytes *h, const char *cls, double return out; } +static ScrStr *scr_insp_dyn_error(ScrDyn *d, double recurse, double depth); + +/* A runtime error through its node: the cached one, or one built from its + * fields. */ +ScrStr *scr_insp_error_node(ScrError *e, double recurse, double depth) { + ScrDyn *view = scr_errdyn_view_of(e); + if (view != NULL && view->kind == SCR_DYN_OBJ) return scr_insp_dyn_error(view, recurse, depth); + ScrDyn *fields = scr_error_dyn_fields(e); + ScrStr *out = scr_insp_dyn_error(fields, recurse, depth); + scr_dyn_release(fields); + return out; +} + /* Errors: the STACKLESS rendering — compiled binaries carry no stack, so * the base is formatError's bracket form `[Name: message]` / `[Name]` * (Node prints these exact forms for an error whose stack is empty; - * stack-carrying Node output is a documented divergence). A stamped - * `code` slot renders as the one extra own property Node would show: - * `[Error: m] { code: 'X' }`, `[Error]` beyond depth. Message newlines - * indent like Node's formatError tail. */ + * stack-carrying Node output is a documented divergence). A runtime error + * renders through its checked-dynamic node, so the own properties Node + * shows follow in key order: the system-error fields, code, and the extra + * properties a throw site attached (`[Error: m] { errno: -2, code: + * 'ENOENT', ... }`). Other errors render a stamped `code` slot as their + * one extra property: `[Error: m] { code: 'X' }`, `[Error]` beyond depth. + * Message newlines indent like Node's formatError tail. */ ScrStr *scr_insp_error(ScrError *e, double recurse, double depth) { + if (e->vt >= &scr_error_vts[0] && e->vt < &scr_error_vts[SCR_ERR_COUNT]) + return scr_insp_error_node(e, recurse, depth); bool has_code = e->code != NULL; /* the stamped slot, read directly (borrowed) */ if (has_code && recurse > depth) { /* `[${getCtxStyle(...)}]` with the trailing space sliced: [Name] */ @@ -897,13 +915,53 @@ static const ScrDyn *insp_error_slot(const ScrDyn *d, const char *key, size_t le return v != NULL && v->kind == SCR_DYN_STR ? v : NULL; } +/* AssertionError's custom inspect (lib/internal/assert/assertion_error.js) + * renders the error at depth 0 and shortens string actual/expected values + * with addEllipsis: past 10 lines, the first 10 and "\n..."; otherwise past + * 512 characters, the text from offset 512 on and "...". +1. */ +static ScrDyn *insp_assertion_ellipsis(ScrDyn *value) { + if (value->kind != SCR_DYN_STR) return scr_dyn_retain(value); + ScrStr *text = value->v.str; + size_t lines = 1, cut = 0; + for (size_t i = 0; i < text->len; i++) { + if (text->data[i] != '\n') continue; + if (++lines == 11) { + cut = i; + break; + } + } + InspBuf b = {0}; + if (lines > 10) { + ib_bytes(&b, text->data, cut); + ib_cstr(&b, "\n..."); + } else if (scr_str_utf16_len(text) > 512) { + ScrStr *tail = scr_str_slice(text, 512, scr_str_utf16_len(text)); + ib_str(&b, tail); + ib_cstr(&b, "..."); + scr_str_release(tail); + } else { + return scr_dyn_retain(value); + } + ScrStr *shortened = ib_take(&b); + ScrDyn *out = scr_dyn_new_str(shortened); + scr_str_release(shortened); + return out; +} + /* The checked-dynamic Error encoding (scr_error_dyn_fields) rendered like * scr_insp_error: the stackless bracket form `[Name: message]`, then the * remaining enumerable own properties (code, errno, syscall, ...) in * Node's `{ ... }` tail. */ static ScrStr *scr_insp_dyn_error(ScrDyn *d, double recurse, double depth) { + /* An error already on the traversal stack (a spawnSync error's own + * `error`): Node's [Circular *N], and on the outer rendering. */ + double circular = scr_insp_circ_check(d); + if (circular != 0) return scr_insp_circular(circular); const ScrDyn *name = insp_error_slot(d, "name", 4); const ScrDyn *message = insp_error_slot(d, "message", 7); + bool assertion = name != NULL && name->v.str->len == 14 && + memcmp(name->v.str->data, "AssertionError", 14) == 0; + double value_depth = assertion ? recurse : depth; size_t extra = 0; for (size_t i = 0; i < d->v.obj.len; i++) { ScrDynEntry *ent = &d->v.obj.entries[i]; @@ -934,6 +992,7 @@ static ScrStr *scr_insp_dyn_error(ScrDyn *d, double recurse, double depth) { ScrStr *base_str = ib_take(&base); if (extra == 0) return base_str; scr_insp_begin(recurse + 1); + scr_insp_seen_push(d); for (size_t i = 0; i < d->v.obj.len; i++) { ScrDynEntry *ent = &d->v.obj.entries[i]; if (!ent->enumerable) continue; @@ -941,7 +1000,11 @@ static ScrStr *scr_insp_dyn_error(ScrDyn *d, double recurse, double depth) { (ent->key_len == 7 && memcmp(ent->key, "message", 7) == 0) || (ent->key_len == 5 && memcmp(ent->key, "stack", 5) == 0)) continue; - ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); + bool shortened = assertion && ((ent->key_len == 6 && memcmp(ent->key, "actual", 6) == 0) || + (ent->key_len == 8 && memcmp(ent->key, "expected", 8) == 0)); + ScrDyn *shown = shortened ? insp_assertion_ellipsis(ent->value) : scr_dyn_retain(ent->value); + ScrStr *val = scr_insp_dyn(shown, recurse + 1, value_depth); + scr_dyn_release(shown); InspBuf eb = {0}; insp_key_into(&eb, ent->key, ent->key_len); ib_cstr(&eb, ": "); @@ -953,10 +1016,12 @@ static ScrStr *scr_insp_dyn_error(ScrDyn *d, double recurse, double depth) { } ScrStr *b0 = scr_str_new("{", 1); ScrStr *b1 = scr_str_new("}", 1); - ScrStr *out = scr_insp_end(base_str, b0, b1, recurse + 1, false, false); + ScrStr *body = scr_insp_end(base_str, b0, b1, recurse + 1, false, false); scr_str_release(b0); scr_str_release(b1); scr_str_release(base_str); + ScrStr *out = scr_insp_ref_wrap(d, body); + scr_str_release(body); return out; } diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index f8d28b4f0..4414330b5 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -26,10 +26,15 @@ ScrStr *scr_insp_regex(ScrRegex *re); /* /source/flags */ /* A Date (its time value): toISOString(), or "Invalid Date" for NaN (+1). */ ScrStr *scr_insp_date(double ms); ScrStr *scr_insp_buffer(ScrBytes *b); /* , 50-byte cap */ -/* The stackless error form: [Name: message] / [Name], plus the stamped - * code slot as its one extra property ({ code: 'X' }); [Name] beyond - * depth when a code makes it composite. */ +/* The stackless error form: [Name: message] / [Name], then a runtime + * error's own properties through its checked-dynamic node, or another + * error's stamped code slot as its one extra property ({ code: 'X' }); + * [Name] beyond depth when a code makes it composite. */ ScrStr *scr_insp_error(ScrError *e, double recurse, double depth); +/* A runtime error rendered through its checked-dynamic node (the cached + * one, or one built from its fields), so Node's own properties follow the + * bracket form. */ +ScrStr *scr_insp_error_node(ScrError *e, double recurse, double depth); /* The checked-dynamic tree: shape lives in the value, so the whole * traversal is here. dyn-boxed bytes render in the checked-dynamic tree's documented * Uint8Array identity. */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 401933904..0376ece2c 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2568,10 +2568,26 @@ static bool scr_record_source_declared(const ScrRecordSource *source, const char return scr_record_source_index(source, key, key_len) < source->count; } +/* Frees the table at thread exit (a worker's table dies with its thread): + * the storage is detached before any remaining view is released. */ +static void scr_record_source_teardown(void) { + ScrRecordSource *table = scr_record_sources; + size_t capacity = scr_record_source_capacity; + scr_record_sources = NULL; + scr_record_source_capacity = scr_record_source_count = scr_record_source_deleted = 0; + free(scr_record_source_filter); + scr_record_source_filter = NULL; + for (size_t i = 0; i < capacity; i++) { + if (table[i].rec && table[i].rec != SCR_RECORD_SOURCE_DELETED) scr_dyn_release(table[i].view); + } + free(table); +} + /* Inserts the entry of a record the caller just built; the table takes * the entry's view. A fresh record has no entry: the previous owner of its * address left the table when it was freed (the dispose hook). */ static void scr_record_source_put(const ScrRecordSource *entry) { + if (!scr_record_sources) scr_atexit(scr_record_source_teardown); scr_record_source_reserve(); if (!scr_record_source_filter) { scr_record_source_filter = calloc(((size_t)1 << SCR_RECORD_SOURCE_FILTER_BITS) / 64, @@ -6392,10 +6408,13 @@ ScrStr *scr_dyn_format_j(const ScrDyn *d) { * IDENTITY-CACHED: one error instance boxes to ONE dyn node, however many * times it crosses (Node passes the error OBJECT through, so `found.error * === thrown` and re-crossings compare reference-equal — the tracing - * suite's shape). The cache retains both sides for the process (like the - * dc registry; released atexit before the RC audit) and the node - * SNAPSHOTS name/message/code at first crossing — a later mutation of the - * error is invisible through it (SEMANTICS.md). Two pointer-keyed hash + * suite's shape). The cache retains both sides; an entry lives while + * either side is referenced elsewhere, and a sweep releases the entries + * whose error and node only the cache still references (the rest are + * released atexit before the RC audit). The node also holds the error's + * extra own properties (scr_error_set_prop) and SNAPSHOTS name/message/ + * code at first crossing — a later mutation of the error is invisible + * through it (SEMANTICS.md). Two pointer-keyed hash * indexes find an entry by either side: Promise.allSettled and * Promise.any cross every rejection reason. Both sides are unique per * entry (an error boxes once; an alien view enters once). */ @@ -6410,6 +6429,9 @@ static SCR_TL bool scr_errdyn_teardown_registered = false; /* Open-addressed indexes holding entry index + 1 (0 = empty slot). */ static SCR_TL size_t *scr_errdyn_by_err = NULL, *scr_errdyn_by_dyn = NULL; static SCR_TL size_t scr_errdyn_slots = 0; /* a power of two */ +/* The entry count that triggers the next sweep: twice the entries the + * previous sweep kept, so sweeping costs amortized O(1) per entry. */ +static SCR_TL size_t scr_errdyn_sweep_at = 64; static size_t scr_errdyn_hash(const void *p) { size_t x = (size_t)(uintptr_t)p >> 4; @@ -6445,8 +6467,70 @@ static size_t scr_errdyn_find(const void *key, bool by_err) { static void scr_errdyn_teardown(void); +/* The entries of a node that hold the node itself (a spawnSync error's + * `error`): references the node makes to itself, not reachability. */ +static size_t scr_errdyn_self_refs(const ScrDyn *d) { + if (d->kind != SCR_DYN_OBJ) return 0; + size_t n = 0; + for (size_t i = 0; i < d->v.obj.len; i++) n += d->v.obj.entries[i].value == d; + return n; +} + +/* Releases a node the cache owned, dropping its references to itself + * first so the release frees it. */ +static void scr_errdyn_release_node(ScrDyn *d) { + for (size_t i = 0; d->kind == SCR_DYN_OBJ && i < d->v.obj.len; i++) { + ScrDynEntry *entry = &d->v.obj.entries[i]; + if (entry->value != d) continue; + entry->value = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_release(d); + } + scr_dyn_release(d); +} + +/* Releases the entries nothing but the cache references. A field node + * holds no reference to its error and a registered subclass box holds + * one, so an entry whose node has only the cache's reference and whose + * error has no reference beyond the cache's and its box's is + * unreachable. The indexes are rebuilt over the + * kept entries before anything is released (a teardown never sees a + * stale entry); an entry a released node kept alive goes in a later + * sweep. */ +static void scr_errdyn_sweep(void) { + ScrErrDynEnt *dead = NULL; + size_t n_dead = 0, kept = 0; + for (size_t i = 0; i < scr_errdyn_n; i++) { + ScrErrDynEnt ent = scr_errdyn_cache[i]; + size_t boxed = ent.dyn->kind == SCR_DYN_TYPED_REF && ent.dyn->v.typed_ref.ptr == (void *)ent.err; + if (ent.dyn->rc == 1 + scr_errdyn_self_refs(ent.dyn) && ent.err->rc == 1 + boxed) { + if (dead == NULL) { + dead = malloc((scr_errdyn_n - i) * sizeof *dead); + if (!dead) scr_trap("scriptc: out of memory\n"); + } + dead[n_dead++] = ent; + } else { + scr_errdyn_cache[kept++] = ent; + } + } + scr_errdyn_n = kept; + scr_errdyn_sweep_at = kept * 2 > 64 ? kept * 2 : 64; + if (n_dead == 0) return; + memset(scr_errdyn_by_err, 0, scr_errdyn_slots * sizeof *scr_errdyn_by_err); + memset(scr_errdyn_by_dyn, 0, scr_errdyn_slots * sizeof *scr_errdyn_by_dyn); + for (size_t i = 0; i < scr_errdyn_n; i++) { + scr_errdyn_index_put(scr_errdyn_by_err, scr_errdyn_cache[i].err, i, true); + scr_errdyn_index_put(scr_errdyn_by_dyn, scr_errdyn_cache[i].dyn, i, false); + } + for (size_t i = 0; i < n_dead; i++) { + scr_error_release(dead[i].err); + scr_errdyn_release_node(dead[i].dyn); + } + free(dead); +} + /* Appends an entry (both sides retained) and indexes it. */ static void scr_errdyn_append(ScrError *e, ScrDyn *d) { + if (scr_errdyn_n >= scr_errdyn_sweep_at) scr_errdyn_sweep(); if (scr_errdyn_n == scr_errdyn_cap) { scr_errdyn_cap = scr_errdyn_cap ? scr_errdyn_cap * 2 : 8; scr_errdyn_cache = realloc(scr_errdyn_cache, scr_errdyn_cap * sizeof *scr_errdyn_cache); @@ -6502,17 +6586,21 @@ void scr_error_register_dyn(const ScrVt *vt, ScrDyn *(*box)(void *)) { } static void scr_errdyn_teardown(void) { - for (size_t i = 0; i < scr_errdyn_n; i++) { - scr_error_release(scr_errdyn_cache[i].err); - scr_dyn_release(scr_errdyn_cache[i].dyn); - } - free(scr_errdyn_cache); + /* Detach the storage first: no release below sees a half-torn cache. */ + ScrErrDynEnt *cache = scr_errdyn_cache; + size_t n = scr_errdyn_n; scr_errdyn_cache = NULL; scr_errdyn_n = scr_errdyn_cap = 0; free(scr_errdyn_by_err); free(scr_errdyn_by_dyn); scr_errdyn_by_err = scr_errdyn_by_dyn = NULL; scr_errdyn_slots = 0; + scr_errdyn_sweep_at = 64; + for (size_t i = 0; i < n; i++) { + scr_error_release(cache[i].err); + scr_errdyn_release_node(cache[i].dyn); + } + free(cache); } ScrDyn *scr_error_dyn_fields(const ScrError *e) { @@ -6540,7 +6628,11 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e) { scr_dyn_obj_set(d, "message", 7, scr_dyn_new_str(e->message)); d->v.obj.entries[d->v.obj.len - 1].enumerable = e->message_enumerable; } - if (e->stack) scr_dyn_obj_set(d, "stack", 5, scr_dyn_new_str(e->stack)); + if (e->stack) { + /* Node's own `stack` is non-enumerable. */ + scr_dyn_obj_set(d, "stack", 5, scr_dyn_new_str(e->stack)); + d->v.obj.entries[d->v.obj.len - 1].enumerable = false; + } if (e->system_message) { /* SystemError: code, the read-only `info` context ({ message, path, * syscall, errno, code }), then errno, syscall, path and dest. */ @@ -6617,6 +6709,23 @@ ScrDyn *scr_dyn_from_error(const ScrError *e) { return d; } +/* An own property on an error, as a throw site attaches Node's extra + * fields (a dns error's hostname, an exec error's status): set on the + * error's checked-dynamic node, which casts, the checked-dynamic surface + * and util.inspect read. Moves value. */ +void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *value) { + ScrDyn *view = scr_dyn_from_error(e); + scr_dyn_obj_set(view, key, key_len, value); + scr_dyn_release(view); +} + +/* The error's checked-dynamic node when it has one (borrowed), else NULL: + * util.inspect reads its extra own properties. */ +ScrDyn *scr_errdyn_view_of(const ScrError *e) { + size_t hit = scr_errdyn_find(e, true); + return hit == SIZE_MAX ? NULL : scr_errdyn_cache[hit].dyn; +} + /* The %Error EXTRACTION (dynCheck of `u as Error` / an instanceof-Error * narrow, and the dyn-boxed thunk's Error-typed parameters): the REVERSE * of scr_dyn_from_error, riding the same identity cache — a dyn error @@ -7741,7 +7850,15 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); return; } + bool created = scr_dyn_entry(recv, key) == NULL; scr_dyn_obj_set(recv, key->data, key->len, scr_dyn_retain(value)); + if (created && key->len == 5 && memcmp(key->data, "stack", 5) == 0 && + scr_errdyn_find(recv, false) != SIZE_MAX) { + /* An error's own `stack` exists from construction as a non-enumerable + * data property, so assigning it adds no enumerable key. */ + ScrDynEntry *entry = scr_dyn_entry(recv, key); + if (entry) entry->enumerable = false; + } scr_error_sync_cause(recv, key); return; } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index af57ab057..6493494f6 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -8738,12 +8738,10 @@ static void scr_crypto_not_xof(void) { ScrStr *text = scr_str_new(message, sizeof message - 1); ScrError *e = scr_error_new(SCR_ERR_ERROR, text); scr_str_release(text); - ScrDyn *view = scr_dyn_from_error(e); - scr_dyn_obj_set(view, "library", 7, scr_crypto_dyn_str(scr_str_new("digital envelope routines", 25))); - scr_dyn_obj_set(view, "reason", 6, scr_crypto_dyn_str(scr_str_new("not XOF or invalid length", 25))); + scr_error_set_prop(e, "library", 7, scr_crypto_dyn_str(scr_str_new("digital envelope routines", 25))); + scr_error_set_prop(e, "reason", 6, scr_crypto_dyn_str(scr_str_new("not XOF or invalid length", 25))); scr_error_set_code(e, code); - scr_dyn_obj_set(view, "code", 4, scr_crypto_dyn_str(scr_str_new(code, sizeof code - 1))); - scr_dyn_release(view); + scr_error_set_prop(e, "code", 4, scr_crypto_dyn_str(scr_str_new(code, sizeof code - 1))); scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); } diff --git a/packages/runtime/src/scr_util_callable.c b/packages/runtime/src/scr_util_callable.c index f5ef488dc..c905ea8ae 100644 --- a/packages/runtime/src/scr_util_callable.c +++ b/packages/runtime/src/scr_util_callable.c @@ -395,9 +395,9 @@ static ScrDyn *uc_callbackify_rejected(ScrClosure *closure, ScrDyn *const *args, ScrError *e = scr_error_new(SCR_ERR_ERROR, text); scr_str_release(text); scr_error_set_code(e, "ERR_FALSY_VALUE_REJECTION"); + scr_error_set_prop(e, "reason", 6, scr_dyn_retain(reason)); err = scr_dyn_from_error(e); scr_error_release(e); - scr_dyn_obj_set(err, "reason", 6, scr_dyn_retain(reason)); } uc_schedule_callback(cb, err, scr_dyn_retain(scr_dyn_undefined()), 1); scr_dyn_release(cb); diff --git a/tests/corpus/5634-error-extra-props-inspect.ts b/tests/corpus/5634-error-extra-props-inspect.ts new file mode 100644 index 000000000..b5d16ef97 --- /dev/null +++ b/tests/corpus/5634-error-extra-props-inspect.ts @@ -0,0 +1,64 @@ +// util.inspect of errors that carry Node's extra own properties: the +// AssertionError fields (rendered at depth 0, with long string values +// shortened), the falsy-rejection reason util.callbackify attaches, an +// OpenSSL-style crypto error's library and reason, and a system error's +// errno, syscall and path. Each error's stack is cleared first, so Node +// prints the same stackless form. +import * as assert from "node:assert"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import { callbackify, inspect } from "node:util"; + +function show(e: unknown): void { + if (e instanceof Error) { + e.stack = ""; + console.log(inspect(e)); + } +} + +try { + assert.strictEqual(1, 2); +} catch (e) { + show(e); +} +type Doc = { a: number; nested?: { b: number[] } }; +const left: Doc = { a: 1, nested: { b: [1, 2] } }; +const right: Doc = { a: 2 }; +try { + assert.deepStrictEqual(left, right, "custom message"); +} catch (e) { + show(e); +} +try { + assert.notStrictEqual("x".repeat(600), "x".repeat(600)); +} catch (e) { + show(e); +} +try { + assert.ok(false); +} catch (e) { + show(e); +} +try { + throw new RangeError("no extra properties"); +} catch (e) { + show(e); +} +try { + crypto.createHash("sha256", { outputLength: 5 }); +} catch (e) { + show(e); +} +try { + fs.readFileSync("/no/such/dir/file.txt"); +} catch (e) { + show(e); + console.log(inspect({ error: e }, { depth: 0 })); +} + +async function rejectsNull(): Promise { + return Promise.reject(null); +} +callbackify(rejectsNull)((err: NodeJS.ErrnoException | null) => { + show(err); +}); diff --git a/tests/corpus/5635-error-extra-props-retry-loop.ts b/tests/corpus/5635-error-extra-props-retry-loop.ts new file mode 100644 index 000000000..8cba3b984 --- /dev/null +++ b/tests/corpus/5635-error-extra-props-retry-loop.ts @@ -0,0 +1,31 @@ +// Errors that cross into checked-dynamic code (a cast of a caught error) +// in a retry loop, on the main thread and in a worker at the same time: +// every crossing reads the properties of its own error. +import * as assert from "node:assert"; +import { Worker, isMainThread, parentPort } from "node:worker_threads"; + +function round(n: number, offset: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + try { + assert.strictEqual(i + offset, -1); + } catch (e) { + const err = e as { code?: string; operator?: string; actual?: number }; + total += (err.code ?? "").length + (err.operator ?? "").length + (err.actual === i + offset ? 1 : 0); + } + } + return total; +} + +if (isMainThread) { + const worker = new Worker(new URL(import.meta.url)); + const fromWorker = new Promise((resolve) => worker.once("message", resolve)); + let sum = 0; + for (let r = 0; r < 5; r++) sum += round(5000, r); + console.log("main", sum); + console.log("worker", await fromWorker); +} else { + let sum = 0; + for (let r = 0; r < 5; r++) sum += round(5000, 100 + r); + parentPort?.postMessage(sum); +} diff --git a/tests/corpus/5636-child-error-props.ts b/tests/corpus/5636-child-error-props.ts new file mode 100644 index 000000000..b62f4385c --- /dev/null +++ b/tests/corpus/5636-child-error-props.ts @@ -0,0 +1,95 @@ +// child_process errors carry Node's own properties: the sync forms' +// status, signal, output, pid, stdout and stderr (Buffers, or strings with +// an encoding option), the async forms' code, killed, signal and cmd (with +// stdout and stderr when promisified), and a spawn failure's errno, +// syscall, path and spawnargs. Pids vary, so only their type prints. +import { exec, execFile, execFileSync, execSync } from "node:child_process"; +import { inspect, promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +function keys(e: unknown): string { + return Object.keys(e as object).join(","); +} + +function show(label: string, e: unknown): void { + if (!(e instanceof Error)) return; + const value: unknown = e; + const pid = typeof (value as { pid?: number }).pid; + e.stack = ""; + const text = inspect(e).replace(/pid: \d+/, "pid: "); + console.log(label, pid, keys(e)); + console.log(text); +} + +try { + execFileSync("/bin/sh", ["-c", "echo out; echo err 1>&2; exit 3"], { stdio: "pipe" }); +} catch (e) { + const err = e as { status?: number | null; signal?: string | null; stdout?: Buffer; stderr?: Buffer }; + console.log("status", err.status, err.signal, err.stdout?.toString(), err.stderr?.toString()); + show("execFileSync", e); +} +try { + execSync("echo text; exit 4", { encoding: "utf8", stdio: "pipe" }); +} catch (e) { + const err = e as { status?: number | null; stdout?: string; output?: (string | null)[] }; + console.log("encoding", err.status, JSON.stringify(err.stdout), inspect(err.output)); +} +try { + execSync("exit 5", { stdio: "ignore" }); +} catch (e) { + const err = e as { output?: unknown[]; stdout?: unknown }; + console.log("ignored", inspect(err.output), err.stdout); +} +try { + execFileSync("/bin/sh", ["-c", "kill -TERM $$"], { stdio: "pipe" }); +} catch (e) { + const err = e as { status?: number | null; signal?: string | null }; + console.log("signal", err.status, err.signal); +} +try { + execFileSync("/no/such/binary", ["a", "b"]); +} catch (e) { + show("spawn failure", e); + const err = e as { errno?: number; syscall?: string; path?: string; spawnargs?: string[] }; + console.log(err.errno, err.syscall, err.path, inspect(err.spawnargs)); +} +try { + execFileSync("/bin/sh", ["-c", "echo partial; exec sleep 5"], { timeout: 100, stdio: "pipe" }); +} catch (e) { + const err = e as { code?: string; status?: number | null; signal?: string | null; stdout?: Buffer }; + console.log("timeout", err.code, err.status, err.signal, err.stdout?.toString()); +} + +execFile("/bin/sh", ["-c", "echo o; exit 6"], (err, stdout) => { + const fields: unknown = err; + const e = fields as { code?: number | null; killed?: boolean; signal?: string | null; cmd?: string }; + console.log("callback", e.code, e.killed, e.signal, e.cmd, JSON.stringify(stdout)); + show("callback error", err); + execFile("/no/such/binary", ["x"], (missing) => { + show("callback spawn failure", missing); + exec("echo shell; exit 4", (shellError) => { + show("exec callback", shellError); + void promised(); + }); + }); +}); + +async function promised(): Promise { + try { + await execFileAsync("/bin/sh", ["-c", "echo po; echo pe 1>&2; exit 7"]); + } catch (e) { + show("promisified", e); + } + try { + await execFileAsync("/bin/sh", ["-c", "exec sleep 5"], { timeout: 100 }); + } catch (e) { + const err = e as { code?: number | null; killed?: boolean; signal?: string | null }; + console.log("promisified timeout", err.code, err.killed, err.signal); + } + try { + await execFileAsync("/no/such/binary"); + } catch (e) { + show("promisified spawn failure", e); + } +} diff --git a/tests/fixtures/error-cache-sweep/retry-loop.ts b/tests/fixtures/error-cache-sweep/retry-loop.ts new file mode 100644 index 000000000..3c58b0ba6 --- /dev/null +++ b/tests/fixtures/error-cache-sweep/retry-loop.ts @@ -0,0 +1,34 @@ +// 100,000 caught AssertionErrors read through a cast, on the main thread +// or (with the "worker" argument) in a worker; prints the resident-set +// growth in MiB after a warm-up round. +import * as assert from "node:assert"; +import { Worker, isMainThread, parentPort } from "node:worker_threads"; + +function round(n: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + try { + assert.strictEqual(i, -1); + } catch (e) { + const err = e as { code?: string; operator?: string }; + total += (err.code ?? "").length + (err.operator ?? "").length; + } + } + return total; +} + +function measure(): number { + round(20000); + const before = process.memoryUsage().rss; + for (let r = 0; r < 5; r++) round(20000); + return Math.round((process.memoryUsage().rss - before) / 1048576); +} + +if (!isMainThread) { + parentPort?.postMessage(measure()); +} else if (process.argv[2] === "worker") { + const worker = new Worker(new URL(import.meta.url)); + worker.once("message", (growth: number) => console.log(growth)); +} else { + console.log(measure()); +} diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 000efcdfc..1a4ad1180 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1982,6 +1982,9 @@ "5631-promisify-crypto.ts": 2200, "5632-promisify-dns.ts": 1700, "5633-zlib-typed-options.ts": 1800, + "5634-error-extra-props-inspect.ts": 2300, + "5635-error-extra-props-retry-loop.ts": 3300, + "5636-child-error-props.ts": 7600, "5644-net-socket-addresses.ts": 2220, "5645-net-socket-callbacks.ts": 2442, "5646-net-server-connections.ts": 2389, @@ -5052,6 +5055,9 @@ "5631-promisify-crypto.ts": 2600, "5632-promisify-dns.ts": 2000, "5633-zlib-typed-options.ts": 2200, + "5634-error-extra-props-inspect.ts": 2000, + "5635-error-extra-props-retry-loop.ts": 6100, + "5636-child-error-props.ts": 9800, "5644-net-socket-addresses.ts": 2393, "5645-net-socket-callbacks.ts": 2060, "5646-net-server-connections.ts": 2755, diff --git a/tests/harness/error-cache-sweep.test.ts b/tests/harness/error-cache-sweep.test.ts new file mode 100644 index 000000000..821a0aae2 --- /dev/null +++ b/tests/harness/error-cache-sweep.test.ts @@ -0,0 +1,38 @@ +/* Errors that cross into checked-dynamic code stay in the runtime's error + * identity cache only while something references them: a retry loop that + * reads 100,000 caught errors through a cast keeps the resident set flat, + * on the main thread and in a worker (each thread sweeps its own cache). + * Plain lane only: the sanitizer's allocator quarantine holds freed + * memory, so resident size says nothing there. */ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/error-cache-sweep"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +describe.skipIf(sanitize)("error identity cache", () => { + test("a retry loop of crossing errors keeps memory flat", { timeout: 300_000 }, async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(join(repoRoot, "tests/fixtures/error-cache-sweep/retry-loop.ts"), { + outPath: join(outDir, "retry-loop"), + outDir, + backend: "llvm", + }); + if (!result.ok) { + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + } + for (const args of [[], ["worker"]]) { + const run = await execFileAsync(result.binaryPath, args); + expect(run.stderr).toBe(""); + expect(Number(run.stdout.trim()), `growth in MiB (${args.join(" ") || "main"})`).toBeLessThan( + 16, + ); + } + }); +}); From b6e9e42308d9c2565ee02c0cd4072838389e0fe2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 21:56:43 -0500 Subject: [PATCH 51/53] Derive the docs compatibility metadata at build time node-v24-compatibility-meta.json held two values derived from the public compatibility artifact: a content hash for the data URL and its row count. Every compatibility change rewrote the hash line, so concurrent changes conflicted on it. next.config.mjs now computes both from the committed artifact and passes them to the page through Next's build-time env, with the same values. The generator no longer writes the file, and its local check compares the public artifact itself. --- AGENTS.md | 1 - docs/next.config.mjs | 11 +++++++++ .../components/node-compatibility-matrix.tsx | 13 +++++++---- .../node-v24-compatibility-meta.json | 7 ------ docs/test/routes.test.mjs | 7 ++++-- internal/compatibility/src/generate.mjs | 23 ++----------------- 6 files changed, 26 insertions(+), 36 deletions(-) delete mode 100644 docs/src/generated/node-v24-compatibility-meta.json diff --git a/AGENTS.md b/AGENTS.md index b94c90067..c2bdbea2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,6 @@ Never hand-edit these files: - `internal/compatibility/generated/node-v24-internal.json` - `internal/compatibility/generated/node-v24-backlog.json` - `docs/src/generated/node-v24-compatibility.json` -- `docs/src/generated/node-v24-compatibility-meta.json` Change compiler decision sources or the implementation-owned compatibility manifests, then regenerate. Use `pnpm manifest` when compiler decision tables change and `pnpm node-compat` whenever the Node pin, compatibility manifests, compiler surface manifest, changes. diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 36c0ea2cb..7079a6844 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -1,10 +1,21 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { createGeistdocs } from "@vercel/geistdocs/next"; const withGeistdocs = createGeistdocs(); const legacySlugs = ["quickstart", "coverage", "dependencies", "ffi", "native-objects", "wasm", "platforms", "cli", "how-it-works", "limitations"]; +// The compatibility page fetches its data under a content hash of the +// committed artifact, so a deploy never serves a cached older copy, and +// checks the row count it expects. Both are derived here at build time. +const compatibilityArtifact = readFileSync(new URL("./src/generated/node-v24-compatibility.json", import.meta.url), "utf8"); + /** @type {import('next').NextConfig} */ const nextConfig = { + env: { + COMPATIBILITY_ARTIFACT_VERSION: createHash("sha256").update(compatibilityArtifact).digest("hex").slice(0, 20), + COMPATIBILITY_ROW_COUNT: String(JSON.parse(compatibilityArtifact).rows.length), + }, agentRules: false, allowedDevOrigins: ["127.0.0.1"], distDir: process.env.NEXT_DIST_DIR || ".next", diff --git a/docs/src/components/node-compatibility-matrix.tsx b/docs/src/components/node-compatibility-matrix.tsx index f0a817a2f..095bcdaf2 100644 --- a/docs/src/components/node-compatibility-matrix.tsx +++ b/docs/src/components/node-compatibility-matrix.tsx @@ -5,7 +5,10 @@ // @refresh reset import { useEffect, useMemo, useState } from "react"; -import compatibilityMeta from "@/generated/node-v24-compatibility-meta.json"; + +// Set by next.config.mjs from the committed artifact. +const artifactVersion = process.env.COMPATIBILITY_ARTIFACT_VERSION; +const rowCount = Number(process.env.COMPATIBILITY_ROW_COUNT); type Status = | "supported" @@ -211,14 +214,14 @@ export function NodeCompatibilityMatrix() { useEffect(() => { const controller = new AbortController(); - fetch(`/compatibility/data?v=${compatibilityMeta.artifactVersion}`, { cache: "no-store", signal: controller.signal }) + fetch(`/compatibility/data?v=${artifactVersion}`, { cache: "no-store", signal: controller.signal }) .then((response) => { if (!response.ok) throw new Error(`compatibility data: ${response.status}`); return response.json() as Promise; }) .then((data) => { - if (!Array.isArray(data.rows) || data.rows.length !== compatibilityMeta.rowCount) { - throw new Error(`compatibility data is stale or malformed (expected ${compatibilityMeta.rowCount} rows)`); + if (!Array.isArray(data.rows) || data.rows.length !== rowCount) { + throw new Error(`compatibility data is stale or malformed (expected ${rowCount} rows)`); } setSnapshot(data); }) @@ -281,7 +284,7 @@ export function NodeCompatibilityMatrix() { return
The compatibility dataset could not be loaded.
; } if (!snapshot) { - return
Loading {compatibilityMeta.rowCount.toLocaleString()} Node.js API rows…
; + return
Loading {rowCount.toLocaleString()} Node.js API rows…
; } return ( diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json deleted file mode 100644 index 1cf76d3e4..000000000 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schemaVersion": 3, - "nodeVersion": "24.22.0", - "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "efc18bcd8a7a03fd8622", - "rowCount": 3880 -} diff --git a/docs/test/routes.test.mjs b/docs/test/routes.test.mjs index bd4b27bde..1520a5aa3 100644 --- a/docs/test/routes.test.mjs +++ b/docs/test/routes.test.mjs @@ -151,8 +151,11 @@ test("compatibility page and data endpoints keep their existing contracts", asyn const response = await get("/compatibility/data"); assert.equal(response.status, 200); const dataset = await response.json(); - const metadata = JSON.parse(await readFile(new URL("../src/generated/node-v24-compatibility-meta.json", import.meta.url), "utf8")); - assert.equal(dataset.rows.length, metadata.rowCount); + const artifact = JSON.parse(await readFile(new URL("../src/generated/node-v24-compatibility.json", import.meta.url), "utf8")); + assert.equal(dataset.rows.length, artifact.rows.length); + // The row count next.config.mjs derives reaches the page (React separates + // adjacent text nodes with empty comments). + assert.ok(html.replaceAll("", "").includes(`Loading ${artifact.rows.length.toLocaleString()} Node.js API rows`)); assert.equal((await get("/node-compatibility")).status, 308); assert.equal((await get("/node-compatibility/data")).status, 308); }); diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs index 3433e14f9..8c7f136cf 100644 --- a/internal/compatibility/src/generate.mjs +++ b/internal/compatibility/src/generate.mjs @@ -24,7 +24,6 @@ const root = fileURLToPath(new URL("../../../", import.meta.url)); const internalOutputPath = `${packageRoot}generated/node-v24-internal.json`; const backlogOutputPath = `${packageRoot}generated/node-v24-backlog.json`; const publicOutputPath = `${root}docs/src/generated/node-v24-compatibility.json`; -const publicMetaOutputPath = `${root}docs/src/generated/node-v24-compatibility-meta.json`; const compilerManifestPath = `${root}packages/compiler/surface-manifest.json`; const compilerCompatPath = `${packageRoot}static-support.json`; const pinPath = `${packageRoot}node-v24.json`; @@ -1009,17 +1008,6 @@ function publicSnapshot(snapshot) { }; } -function publicMetadata(publicRendered, snapshot) { - const matrix = projectPublicMatrix(snapshot); - return { - schemaVersion: 3, - nodeVersion: snapshot.nodeVersion, - nodeCommit: snapshot.nodeCommit, - artifactVersion: sha(publicRendered).slice(0, 20), - rowCount: matrix.rows.length, - }; -} - function checkLocal() { const snapshot = JSON.parse(readFileSync(internalOutputPath, "utf8")); if (snapshot.nodeVersion !== pin.version || snapshot.nodeCommit !== pin.commit) { @@ -1071,11 +1059,8 @@ function checkLocal() { if (readFileSync(backlogOutputPath, "utf8") !== render(buildBacklog(snapshot))) { throw new Error("the Node compatibility backlog is stale; run 'pnpm node-compat'"); } - const publicRendered = render(publicSnapshot(snapshot)); - if ( - readFileSync(publicMetaOutputPath, "utf8") !== render(publicMetadata(publicRendered, snapshot)) - ) { - throw new Error("the docs compatibility metadata is stale; run 'pnpm node-compat'"); + if (readFileSync(publicOutputPath, "utf8") !== render(publicSnapshot(snapshot))) { + throw new Error("the docs compatibility artifact is stale; run 'pnpm node-compat'"); } for (const row of snapshot.rows) { for (const key of ["static"]) { @@ -1141,7 +1126,6 @@ async function main() { const internalRendered = render(snapshot); const backlogRendered = render(buildBacklog(snapshot)); const publicRendered = render(publicSnapshot(snapshot)); - const publicMetaRendered = render(publicMetadata(publicRendered, snapshot)); if (args.has("--check")) { if (readFileSync(internalOutputPath, "utf8") !== internalRendered) @@ -1150,8 +1134,6 @@ async function main() { throw new Error("Node compatibility backlog is stale; run 'pnpm node-compat'"); if (readFileSync(publicOutputPath, "utf8") !== publicRendered) throw new Error("public Node compatibility snapshot is stale; run 'pnpm node-compat'"); - if (readFileSync(publicMetaOutputPath, "utf8") !== publicMetaRendered) - throw new Error("public Node compatibility metadata is stale; run 'pnpm node-compat'"); console.log( `Node compatibility snapshot matches ${pin.tag} (${snapshot.rows.length} API rows, commit ${pin.commit.slice(0, 12)})`, ); @@ -1160,7 +1142,6 @@ async function main() { writeFileSync(internalOutputPath, internalRendered); writeFileSync(backlogOutputPath, backlogRendered); writeFileSync(publicOutputPath, publicRendered); - writeFileSync(publicMetaOutputPath, publicMetaRendered); console.log( `wrote compatibility ledger, backlog, and public artifact (${snapshot.rows.length} source rows, ${projectPublicMatrix(snapshot).rows.length} public matrix rows, ${pin.tag})`, ); From b9cc0ed82027d27f1b00b8af26635e54b26960f0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 22:06:48 -0500 Subject: [PATCH 52/53] Sort the handle tags by name with explicit values Every change that added a checked-dynamic handle kind appended its tag just before SCR_DYNH_COUNT in scr_dyn.h, so any two such changes in flight conflicted there. The tags are now sorted by name, each with its current value written out, so a new tag goes in its sorted place with the next free value, and changes adding different tags edit different lines. SCR_DYNH_COUNT, used only by the capacity check, is gone. The values are unchanged and the runtime artifacts are identical. A new test checks what the enum's order used to guarantee: values are unique and below SCR_DYNH_CAPACITY, and the LLVM backend's hardcoded tag numbers (DYN_HANDLE_TAG_NUM) match the header. --- .../src/backend/llvm/dyn-handle-tags.test.ts | 39 +++++ packages/compiler/src/backend/llvm/dyn.ts | 4 +- packages/runtime/src/scr_dyn.h | 135 +++++++++--------- packages/runtime/src/scr_message.test.c | 2 +- 4 files changed, 112 insertions(+), 68 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts diff --git a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts new file mode 100644 index 000000000..8aa69b386 --- /dev/null +++ b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { expect, test } from "vitest"; +import { DYN_HANDLE_KINDS } from "../../ir/ir.js"; +import { DYN_HANDLE_TAG_NUM } from "./dyn.js"; + +// ScrDynHandleTag in scr_dyn.h lists its tags by name, each with an explicit +// value, so changes that add tags edit different lines. These checks stand +// in for what the enum's order used to guarantee. +const header = readFileSync(new URL("../../../../runtime/src/scr_dyn.h", import.meta.url), "utf8"); +const body = /typedef enum \{\n([\s\S]*?)\} ScrDynHandleTag;/.exec(header)![1]!; +const tags = [...body.matchAll(/^\s*(SCR_DYNH_[A-Z0-9_]+) = (\d+),/gm)].map(([, name, value]) => ({ + name: name!, + value: Number(value), +})); +const capacity = Number(/#define SCR_DYNH_CAPACITY (\d+)/.exec(header)![1]); + +test("every ScrDynHandleTag has an explicit value, in name order", () => { + const lines = body.split("\n").filter((line) => line.trim().startsWith("SCR_DYNH_")); + expect(tags.length).toBe(lines.length); + expect(tags.map((tag) => tag.name)).toEqual(tags.map((tag) => tag.name).toSorted()); +}); + +test("ScrDynHandleTag values are unique and below SCR_DYNH_CAPACITY", () => { + const owners = new Map(); + for (const { name, value } of tags) { + expect(owners.get(value), `${name} = ${value}`).toBeUndefined(); + owners.set(value, name); + expect(value, name).toBeLessThan(capacity); + } +}); + +test("the backend's handle tag numbers match scr_dyn.h", () => { + const values = new Map(tags.map((tag) => [tag.name, tag.value])); + for (const [kind, num] of Object.entries(DYN_HANDLE_TAG_NUM)) { + const tag = DYN_HANDLE_KINDS.get(kind)?.tag; + expect(tag, kind).toBeDefined(); + expect(values.get(tag!), `${kind} (${tag})`).toBe(num); + } +}); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 9b11d6240..154b80c02 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -72,8 +72,8 @@ export function recordDynKeepsIdentity(shape: IrRecordShape): boolean { } /** ScrDynKind values (scr_runtime.h). */ -/** ScrDynHandleTag numeric values (scr_runtime.h's enum order). */ -const DYN_HANDLE_TAG_NUM: Record = { +/** ScrDynHandleTag values (scr_dyn.h's explicit values; dyn-handle-tags.test.ts checks them). */ +export const DYN_HANDLE_TAG_NUM: Record = { httpReq: 0, httpRes: 1, netSocket: 2, diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index bccd8d55c..b28dfb906 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -117,76 +117,81 @@ typedef enum { /* The handle-type tags the checked-dynamic tree can carry. The set is deliberately the * HANDLE kinds whose member surfaces already have complete static * lowerings (the http/net receiver surface); new kinds join by adding a - * tag + an ops registration in their owning unit. */ + * tag + an ops registration in their owning unit. + * + * Tags are sorted by name and every value is explicit and permanent: the + * LLVM backend hardcodes some of them (DYN_HANDLE_TAG_NUM in + * packages/compiler/src/backend/llvm/dyn.ts). A new tag goes in its sorted + * place with the next value after the largest one in use, below + * SCR_DYNH_CAPACITY. packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts + * checks that values are unique and in range and that the backend's + * numbers match. */ typedef enum { - SCR_DYNH_HTTP_REQ, /* ScrHttpReq — IncomingMessage (server req & client res) */ - SCR_DYNH_HTTP_RES, /* ScrHttpRes — ServerResponse */ - SCR_DYNH_NET_SOCKET, /* ScrNetSocket — net.Socket */ - SCR_DYNH_NET_SERVER, /* ScrNetServer — net.Server (http.Server rides the same handle) */ - SCR_DYNH_H2_SESSION, /* ScrH2Session — Http2Session (client & server) */ - SCR_DYNH_H2_STREAM, /* ScrH2Stream — Http2Stream (client & server) */ - SCR_DYNH_HTTP_CLIENT, /* ScrHttpClientReq — http.ClientRequest */ - SCR_DYNH_HTTP_AGENT, /* ScrHttpAgent — http.Agent / https.Agent */ - SCR_DYNH_ABORT_SIGNAL, /* native static-fetch AbortSignal */ - SCR_DYNH_WEB_STREAM, /* native WHATWG ReadableStream */ - SCR_DYNH_WEB_READER, /* native ReadableStreamDefaultReader */ - SCR_DYNH_WEB_CONTROLLER, /* native ReadableStreamDefaultController */ - SCR_DYNH_FETCH_RESPONSE, /* native static-fetch Response */ - SCR_DYNH_FETCH_HEADERS, /* native static-fetch response Headers */ - SCR_DYNH_EVENT, /* ScrEvent — new Event() and AbortSignal's abort events */ - SCR_DYNH_ABORT_CONTROLLER, /* native static-fetch AbortController */ - SCR_DYNH_CHILD, /* child_process.ChildProcess */ - SCR_DYNH_FILE_HANDLE, /* fs/promises.FileHandle */ - SCR_DYNH_ARRAY_BUFFER, /* fixed-length ArrayBuffer storage */ - SCR_DYNH_SEGMENTER, /* default Unicode grapheme segmentation */ - SCR_DYNH_SEGMENTS, /* iterable immutable segmentation result */ - SCR_DYNH_BUILTIN_MODULE, /* native builtin export object */ - SCR_DYNH_GLOBAL, /* native global object identity */ - SCR_DYNH_WEAK_MAP, /* native weak-key metadata */ - SCR_DYNH_WEAK_SET, /* native weak membership */ - SCR_DYNH_SET, /* native Set, shared backing map */ - SCR_DYNH_REGEXP, /* native RegExp, shared compiled pattern */ - SCR_DYNH_STDIO, /* stable process stdin/stdout/stderr values */ - SCR_DYNH_URL, /* native WHATWG URL */ - SCR_DYNH_MAP, /* native Map, shared backing */ - SCR_DYNH_DATE, /* JavaScript Date with stable native identity */ - SCR_DYNH_ITERATOR, /* live native array/string/byte iterator */ - SCR_DYNH_CONSOLE, /* stored global console value */ - SCR_DYNH_SEARCH_PARAMS, /* live WHATWG URLSearchParams */ - SCR_DYNH_FETCH_REQUEST, - SCR_DYNH_WORKER, - SCR_DYNH_MESSAGE_PORT, - SCR_DYNH_SHARED_ARRAY_BUFFER, - SCR_DYNH_CONSOLE_INSTANCE, /* node:console Console instance */ - SCR_DYNH_EVENT_TARGET, /* ScrEventTarget — new EventTarget() */ - SCR_DYNH_CUSTOM_EVENT, /* ScrEvent with detail — new CustomEvent() */ - SCR_DYNH_BLOB, /* ScrBlob — new Blob() and Blob#slice() */ - SCR_DYNH_FILE, /* ScrBlob with a name — new File() */ - SCR_DYNH_KEY_OBJECT, /* node:crypto secret KeyObject (scr_lib.c) */ - SCR_DYNH_DIR, /* fs.Dir (scr_lib.c) */ - SCR_DYNH_CIPHER, /* node:crypto Cipheriv (scr_cipher.c) */ - SCR_DYNH_DECIPHER, /* node:crypto Decipheriv (scr_cipher.c) */ - SCR_DYNH_NET_SOCKET_ADDRESS, /* net.SocketAddress (scr_net.c) */ - SCR_DYNH_NET_BLOCK_LIST, /* net.BlockList (scr_net.c) */ - SCR_DYNH_DISPOSABLE_STACK, /* DisposableStack (scr_symbol.c) */ - SCR_DYNH_MESSAGE_EVENT, /* ScrEvent with data — BroadcastChannel's 'message' */ - SCR_DYNH_BROADCAST_CHANNEL, /* worker_threads BroadcastChannel (scr_worker_events.c) */ - SCR_DYNH_HASH, /* node:crypto Hash (scr_lib.c) */ - SCR_DYNH_HMAC, /* node:crypto Hmac (scr_lib.c) */ - SCR_DYNH_ITERATOR_HELPER, /* an iterator helper object over a checked iterator (scr_json.c) */ - SCR_DYNH_COUNT, + SCR_DYNH_ABORT_CONTROLLER = 15, /* native static-fetch AbortController */ + SCR_DYNH_ABORT_SIGNAL = 8, /* native static-fetch AbortSignal */ + SCR_DYNH_ARRAY_BUFFER = 18, /* fixed-length ArrayBuffer storage */ + SCR_DYNH_BLOB = 41, /* ScrBlob — new Blob() and Blob#slice() */ + SCR_DYNH_BROADCAST_CHANNEL = 51, /* worker_threads BroadcastChannel (scr_worker_events.c) */ + SCR_DYNH_BUILTIN_MODULE = 21, /* native builtin export object */ + SCR_DYNH_CHILD = 16, /* child_process.ChildProcess */ + SCR_DYNH_CIPHER = 45, /* node:crypto Cipheriv (scr_cipher.c) */ + SCR_DYNH_CONSOLE = 32, /* stored global console value */ + SCR_DYNH_CONSOLE_INSTANCE = 38, /* node:console Console instance */ + SCR_DYNH_CUSTOM_EVENT = 40, /* ScrEvent with detail — new CustomEvent() */ + SCR_DYNH_DATE = 30, /* JavaScript Date with stable native identity */ + SCR_DYNH_DECIPHER = 46, /* node:crypto Decipheriv (scr_cipher.c) */ + SCR_DYNH_DIR = 44, /* fs.Dir (scr_lib.c) */ + SCR_DYNH_DISPOSABLE_STACK = 49, /* DisposableStack (scr_symbol.c) */ + SCR_DYNH_EVENT = 14, /* ScrEvent — new Event() and AbortSignal's abort events */ + SCR_DYNH_EVENT_TARGET = 39, /* ScrEventTarget — new EventTarget() */ + SCR_DYNH_FETCH_HEADERS = 13, /* native static-fetch response Headers */ + SCR_DYNH_FETCH_REQUEST = 34, + SCR_DYNH_FETCH_RESPONSE = 12, /* native static-fetch Response */ + SCR_DYNH_FILE = 42, /* ScrBlob with a name — new File() */ + SCR_DYNH_FILE_HANDLE = 17, /* fs/promises.FileHandle */ + SCR_DYNH_GLOBAL = 22, /* native global object identity */ + SCR_DYNH_H2_SESSION = 4, /* ScrH2Session — Http2Session (client & server) */ + SCR_DYNH_H2_STREAM = 5, /* ScrH2Stream — Http2Stream (client & server) */ + SCR_DYNH_HASH = 52, /* node:crypto Hash (scr_lib.c) */ + SCR_DYNH_HMAC = 53, /* node:crypto Hmac (scr_lib.c) */ + SCR_DYNH_HTTP_AGENT = 7, /* ScrHttpAgent — http.Agent / https.Agent */ + SCR_DYNH_HTTP_CLIENT = 6, /* ScrHttpClientReq — http.ClientRequest */ + SCR_DYNH_HTTP_REQ = 0, /* ScrHttpReq — IncomingMessage (server req & client res) */ + SCR_DYNH_HTTP_RES = 1, /* ScrHttpRes — ServerResponse */ + SCR_DYNH_ITERATOR = 31, /* live native array/string/byte iterator */ + SCR_DYNH_ITERATOR_HELPER = 54, /* an iterator helper object over a checked iterator (scr_json.c) */ + SCR_DYNH_KEY_OBJECT = 43, /* node:crypto secret KeyObject (scr_lib.c) */ + SCR_DYNH_MAP = 29, /* native Map, shared backing */ + SCR_DYNH_MESSAGE_EVENT = 50, /* ScrEvent with data — BroadcastChannel's 'message' */ + SCR_DYNH_MESSAGE_PORT = 36, + SCR_DYNH_NET_BLOCK_LIST = 48, /* net.BlockList (scr_net.c) */ + SCR_DYNH_NET_SERVER = 3, /* ScrNetServer — net.Server (http.Server rides the same handle) */ + SCR_DYNH_NET_SOCKET = 2, /* ScrNetSocket — net.Socket */ + SCR_DYNH_NET_SOCKET_ADDRESS = 47, /* net.SocketAddress (scr_net.c) */ + SCR_DYNH_REGEXP = 26, /* native RegExp, shared compiled pattern */ + SCR_DYNH_SEARCH_PARAMS = 33, /* live WHATWG URLSearchParams */ + SCR_DYNH_SEGMENTER = 19, /* default Unicode grapheme segmentation */ + SCR_DYNH_SEGMENTS = 20, /* iterable immutable segmentation result */ + SCR_DYNH_SET = 25, /* native Set, shared backing map */ + SCR_DYNH_SHARED_ARRAY_BUFFER = 37, + SCR_DYNH_STDIO = 27, /* stable process stdin/stdout/stderr values */ + SCR_DYNH_URL = 28, /* native WHATWG URL */ + SCR_DYNH_WEAK_MAP = 23, /* native weak-key metadata */ + SCR_DYNH_WEAK_SET = 24, /* native weak membership */ + SCR_DYNH_WEB_CONTROLLER = 11, /* native ReadableStreamDefaultController */ + SCR_DYNH_WEB_READER = 10, /* native ReadableStreamDefaultReader */ + SCR_DYNH_WEB_STREAM = 9, /* native WHATWG ReadableStream */ + SCR_DYNH_WORKER = 35, } ScrDynHandleTag; /* Every per-tag table (scr_dynh_ops in scr_json.c, the message clone hooks - * in scr_message.c) is sized by this fixed capacity, never by - * SCR_DYNH_COUNT. Appending a tag for a link-gated unit therefore changes - * no always-linked storage or code: a program that never boxes the new - * handle links byte-identical objects. Raising the capacity is a - * program-wide layout change; do it in a change of its own, measured as - * neutral, and only when the enum outgrows it. */ + * in scr_message.c) is sized by this fixed capacity, never by the number of + * tags. Adding a tag for a link-gated unit therefore changes no + * always-linked storage or code: a program that never boxes the new handle + * links byte-identical objects. Raising the capacity is a program-wide + * layout change; do it in a change of its own, measured as neutral, and + * only when the tags outgrow it. */ #define SCR_DYNH_CAPACITY 128 -_Static_assert(SCR_DYNH_COUNT <= SCR_DYNH_CAPACITY, - "ScrDynHandleTag outgrew SCR_DYNH_CAPACITY: raise it in its own change"); typedef struct ScrDyn ScrDyn; typedef struct ScrBytes ScrBytes; /* full definition below (C11 repeat) */ diff --git a/packages/runtime/src/scr_message.test.c b/packages/runtime/src/scr_message.test.c index 0c6bd7ae3..a6c940783 100644 --- a/packages/runtime/src/scr_message.test.c +++ b/packages/runtime/src/scr_message.test.c @@ -175,7 +175,7 @@ static void check_documents(void) { /* A link-gated unit's handle crosses threads through the transfer hooks in * its ops: share on the sender, adopt on the receiver, drop with the * message. The test borrows the last capacity slot, which no unit uses, so - * it also proves that slots past SCR_DYNH_COUNT work. */ + * it also proves that slots past the tags in use work. */ #define TRANSFER_TAG ((ScrDynHandleTag)(SCR_DYNH_CAPACITY - 1)) typedef struct { _Atomic size_t rc; From dc6a638e63cf1663776e9d72e679d0965fa25015 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 22:10:17 -0500 Subject: [PATCH 53/53] Lower string and typed array iterators, Iterator.from and helper return() String iterators (s[Symbol.iterator]()) and typed array values(), keys() and entries() compile as iterator objects: generators over the code points and over the indices. The iterator helpers apply to them, and for...of, spreads and Array.from consume them. Iterator.from over an array, string, typed array, Set, Map or iterator object answers the value's own iterator, as Node does for iterators that already inherit from Iterator.prototype. Objects with their own next method keep the Iterator fence. As the root of a helper chain, Iterator.from(x) walks x itself: an array by index, a Set or Map live, a generator directly. h.return!() and h.return?.() compile when h is a helper call or a const initialized with one: helper objects always have return(), so both spellings are the call. Other iterator objects keep the fence, since the array, string, Map and Set iterators have no return(). flatMap callbacks may return Sets (their values) and Maps (their entries), walked live; a string result throws Node's TypeError and closes the source. --- .../builtin-apis/iterator-helpers.mdx | 2 +- .../src/frontend/lowering/containers/bytes.ts | 16 ++ .../lowering/containers/iterator-helpers.ts | 222 +++++++++++++++++- .../frontend/lowering/iterator-adapters.ts | 216 +++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 81 +++++-- .../src/frontend/lowering/lower-exprs.ts | 5 + packages/compiler/src/frontend/type-mapper.ts | 5 +- .../corpus/6071-iterator-sources.ts.json | 6 + .../corpus/6072-iterator-sources-js.js.json | 6 + .../string-iterator-helpers.ts.json | 6 - tests/corpus/6071-iterator-sources.ts | 70 ++++++ tests/corpus/6072-iterator-sources-js.js | 70 ++++++ .../collection-iterator-helpers.ts | 18 +- tests/diagnostics/es2025-fences.ts | 6 +- tests/diagnostics/string-iterator-helpers.ts | 3 - .../collection-iterator-helpers.ts.txt | 22 +- .../__snapshots__/es2025-fences.ts.txt | 8 +- .../string-iterator-helpers.ts.txt | 8 - tests/harness/corpus-costs.json | 4 + 19 files changed, 700 insertions(+), 74 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6071-iterator-sources.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6072-iterator-sources-js.js.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/string-iterator-helpers.ts.json create mode 100644 tests/corpus/6071-iterator-sources.ts create mode 100644 tests/corpus/6072-iterator-sources-js.js delete mode 100644 tests/diagnostics/string-iterator-helpers.ts delete mode 100644 tests/harness/__snapshots__/string-iterator-helpers.ts.txt diff --git a/docs/content/limitations/builtin-apis/iterator-helpers.mdx b/docs/content/limitations/builtin-apis/iterator-helpers.mdx index 01f779cb6..cb7cab1f4 100644 --- a/docs/content/limitations/builtin-apis/iterator-helpers.mdx +++ b/docs/content/limitations/builtin-apis/iterator-helpers.mdx @@ -1 +1 @@ -- **Iterator helpers:** `map`, `filter`, `take`, `drop`, `flatMap`, `toArray`, `forEach`, `reduce`, `some`, `every` and `find` compile on generators, on array, Map and Set iterators, and on the objects the helpers return. Helper objects support `.next()`, `for...of`, spreads, `Array.from` and `yield*`, pull their source lazily, and close it (run its `return()`) at the same points as Node: when `take` reaches its count, when a short-circuiting method finds its answer, when a callback throws, and when the loop consuming the helper exits early. `flatMap` callbacks return arrays or generators. Iterator helpers on string iterators, `Iterator.from`, the `Iterator` global, calling `return()` on a helper object in TypeScript, printing helper objects, and helper calls on untyped iterators in JavaScript are unsupported. +- **Iterator helpers:** `map`, `filter`, `take`, `drop`, `flatMap`, `toArray`, `forEach`, `reduce`, `some`, `every` and `find` compile on generators, on array, typed array, string, Map and Set iterators, and on the objects the helpers return. Helper objects support `.next()`, `.return()`, `for...of`, spreads, `Array.from` and `yield*`, pull their source lazily, and close it (run its `return()`) at the same points as Node: when `take` reaches its count, when a short-circuiting method finds its answer, when a callback throws, and when the loop consuming the helper exits early. In TypeScript, `h.return!()` and `h.return?.()` compile when `h` is a helper call or a `const` initialized with one. `flatMap` callbacks return arrays, generators, Sets or Maps; a string result throws Node's TypeError. `Iterator.from` accepts arrays, strings, typed arrays, Sets, Maps and iterator objects such as generators. In JavaScript, untyped array, string, Map and Set iterators answer the same methods. `Iterator.from` over an object with its own `next` method or over an untyped value, the `Iterator` global as a value, and printing typed helper objects are unsupported. diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index c41bf5163..91cf0b263 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -35,6 +35,7 @@ import { } from "../optional-arguments.js"; import { lowerCheckedArrayFrom } from "./array-construction.js"; import { lowerToNumberArgument } from "../lower-exprs.js"; +import { lowerBytesIteratorValue } from "../iterator-adapters.js"; /** TypedArray.prototype.sort and toSorted. The receiver and comparator * expressions are evaluated before entering the helper (buildBytesSortFn). @@ -462,6 +463,21 @@ export function lowerBytesMethodCall( if (name === "sort" || name === "toSorted") { return lowerBytesSortCall(lowerer, call, access, receiverIr); } + // values()/keys()/entries() as iterator objects (for...of heads and + // stored numeric iterators lower before this). + if ((name === "values" || name === "keys" || name === "entries") && nArgs === 0) { + const type = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (type?.kind === "generator") { + const value = lowerBytesIteratorValue( + lowerer, + lowerer.lowerExpr(access.expression), + name, + type, + loc, + ); + if (value) return value; + } + } if (name === "toReversed") { if (nArgs !== 0) { lowerer.noLowering(`.toReversed with ${nArgs} arguments on typed arrays`, call); diff --git a/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts b/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts index 7cb3753ac..70e0d85e0 100644 --- a/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts +++ b/packages/compiler/src/frontend/lowering/containers/iterator-helpers.ts @@ -5,6 +5,7 @@ import type { Lowerer } from "../lowerer.js"; import { BOOL, CAUGHT, + DYN, F64, type IrMapIntrinsicMethod, type IrSetIntrinsicMethod, @@ -28,12 +29,18 @@ import { concatStrings, throwTypeError } from "./dynamic-receivers.js"; import { type GenType, generatorLoopBinding, + resultRecordOf, genStep, genValue, stepDrivable, } from "../lower-generators.js"; import { exitBeforeReturns } from "./for-of.js"; -import { iteratorSlotAdapter } from "../iterator-adapters.js"; +import { + iteratorSlotAdapter, + lowerArrayIteratorValue, + lowerBytesIteratorValue, + lowerStringIteratorValue, +} from "../iterator-adapters.js"; import { lowerArrayCallback } from "./callback-arguments.js"; /* ── iterator helpers (ES2025) ─────────────────────────────────────────── */ @@ -189,6 +196,24 @@ export function lowerGeneratorHelperCall( collectionSeed(lowerer, source), ); } + // Iterator.from(arr) walks the array by index. + const arrayRoot = arrayIteratorCall(lowerer, cur); + if (arrayRoot !== null) { + const items = lowerer.lowerExpr(arrayRoot.receiver); + if (items.type.kind !== "array") + lowerer.badType(arrayRoot.receiver, lowerer.typeOf(arrayRoot.receiver)); + const elem = + arrayRoot.proj === "entries" ? entryPairType(lowerer, items.type.elem) : items.type.elem; + return lowerFusedChain( + lowerer, + call, + name, + stages, + { kind: "array", type: items.type, proj: arrayRoot.proj }, + items, + elem, + ); + } const src = lowerHelperSource(lowerer, cur); return lowerFusedChain( lowerer, @@ -201,6 +226,128 @@ export function lowerGeneratorHelperCall( ); } +/** `h.return!()` or `h.return?.()` on a helper object. The lib declares + * return() as optional on iterator objects, and a helper object always + * has one, so both spellings are the call: the helper completes and closes + * its source, and the result is `{ value: undefined, done: true }`. The + * receiver must be provably a helper object — a helper stage call, or a + * const binding initialized with one — since other iterator objects (the + * array, string, Map and Set iterators) have no return(). Null otherwise, + * and for the plain `h.return()` spelling (lowerGenMethodCall's). */ +export function lowerHelperReturnCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + let callee: ts.Expression = call.expression; + const nonNull = ts.isNonNullExpression(callee); + if (nonNull) callee = (callee as ts.NonNullExpression).expression; + if (!nonNull && call.questionDotToken === undefined) return null; + if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken) return null; + if (callee.name.text !== "return" || call.arguments.length !== 0) return null; + if (!isHelperObject(lowerer, callee.expression) || !lowerer.isStdlibMember(callee)) return null; + const callT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const gen = lowerer.lowerExpr(callee.expression); + if (gen.type.kind !== "generator" || gen.type.async) return null; + const loc = locOf(call); + const recT = resultRecordOf(lowerer, gen.type); + const resume: IrExpr = { kind: "genResume", mode: "return", gen, arg: null, type: recT, loc }; + // `?.` types the call `IteratorResult | undefined`, which has no mapping + // as a whole; the result is never undefined, so it is the record itself. + if (callT === null || typeEquals(callT, recT)) return resume; + const result = lowerer.coerceToExpected(resume, callT); + if (!typeEquals(result.type, callT)) lowerer.badType(call, lowerer.typeOf(call)); + return result; +} + +/** Whether `expr` is a helper stage call, or a const binding initialized + * with one. */ +function isHelperObject(lowerer: Lowerer, expr: ts.Expression): boolean { + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (isStageCall(lowerer, expr)) return true; + if (!ts.isIdentifier(expr)) return false; + const symbol = lowerer.resolveValueSymbol(expr); + const decl = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined; + if ( + !decl || + !ts.isVariableDeclaration(decl) || + decl.initializer === undefined || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & ts.NodeFlags.Const) === 0 + ) + return false; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + return isStageCall(lowerer, init); +} + +/** `Iterator.from(value)` over a typed built-in iterable: the value's own + * iterator, which already inherits from Iterator.prototype, so Node returns + * it rather than a wrapper. A generator (including a helper object or an + * array iterator) is itself; an array, string or typed array gives its + * values iterator; a Set its values and a Map its entries, adapted into a + * generator over the live collection. The result converts into the call's + * IteratorObject type. Null for other arguments (objects with their own + * next method, untyped values), which keep the Iterator fence. */ +export function lowerIteratorFromCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (access.name.text !== "from" || call.arguments.length !== 1) return null; + const argNode = call.arguments[0]!; + if (ts.isSpreadElement(argNode)) return null; + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const argT = lowerer.mapTypeOf(lowerer.typeOf(argNode)); + if (resultT?.kind !== "generator" || resultT.async || argT === null) return null; + const iterableKinds = ["generator", "array", "string", "bytes", "set", "map"]; + if (!iterableKinds.includes(argT.kind) || (argT.kind === "generator" && argT.async)) return null; + const loc = locOf(call); + const value = lowerer.lowerExpr(argNode); + const own = (yieldT: IrType): GenType => ({ + kind: "generator", + yieldT, + retT: VOID, + nextT: UNDEFINED_T, + }); + let iterator: IrExpr | null = null; + const t = value.type; + if (t.kind === "generator") iterator = value; + else if (t.kind === "array") + iterator = lowerArrayIteratorValue(lowerer, value, "values", own(t.elem), loc); + else if (t.kind === "string") + iterator = lowerStringIteratorValue(lowerer, value, own(STRING), loc); + else if (t.kind === "bytes") + iterator = lowerBytesIteratorValue(lowerer, value, "values", own(F64), loc); + else if (t.kind === "set" || t.kind === "map") { + const elem = t.kind === "set" ? t.elem : entryPairTypeOf(lowerer, t.key, t.value); + const native: IrExpr = { + kind: "dynInvoke", + recv: lowerer.coerceToExpected(value, DYN), + method: t.kind === "set" ? "values" : "entries", + calleeName: t.kind === "set" ? "set.values" : "map.entries", + args: [], + type: DYN, + loc, + }; + iterator = iteratorSlotAdapter(lowerer, native, own(elem)); + } + if (iterator === null) lowerer.badType(argNode, lowerer.typeOf(argNode)); + const result = lowerer.coerceToExpected(iterator, resultT); + if (!typeEquals(result.type, resultT)) lowerer.badType(call, lowerer.typeOf(call)); + return result; +} + +/** The interned [K, V] pair a Map's entries yield. */ +function entryPairTypeOf(lowerer: Lowerer, keyT: IrType, valueT: IrType): IrType { + return { + kind: "record", + shapeId: lowerer.shapes.intern( + [ + { name: "0", type: keyT }, + { name: "1", type: valueT }, + ], + true, + ), + }; +} + const ITER_STAGES = new Set(["map", "filter", "take", "drop", "flatMap"]); const ITER_TERMINALS = new Set(["toArray", "forEach", "reduce", "some", "every", "find"]); /** Native iterators that ride checked runtime values and adapt into @@ -353,12 +500,30 @@ export function lowerForOfHelperChain( } } -/** `arr.values()` or `arr.entries()` on an array value, as the root of a - * helper chain. */ +/** The argument of an `Iterator.from(x)` call, or null. As a chain's root, + * the call is the argument's own iterator, so the chain can walk the + * argument directly. */ +function iteratorFromArgument(lowerer: Lowerer, expr: ts.Expression): ts.Expression | null { + if (!ts.isCallExpression(expr) || !ts.isPropertyAccessExpression(expr.expression)) return null; + if (expr.questionDotToken || expr.expression.questionDotToken || expr.arguments.length !== 1) + return null; + const arg = expr.arguments[0]!; + if (ts.isSpreadElement(arg) || expr.expression.name.text !== "from") return null; + if (!lowerer.isStdlibGlobal(expr.expression.expression, "Iterator")) return null; + return lowerer.isStdlibMember(expr.expression) ? arg : null; +} + +/** `arr.values()`, `arr.entries()` or `Iterator.from(arr)` on an array value, + * as the root of a helper chain. */ function arrayIteratorCall( lowerer: Lowerer, expr: ts.Expression, ): { receiver: ts.Expression; proj: "values" | "entries" } | null { + const fromArg = iteratorFromArgument(lowerer, expr); + if (fromArg !== null) + return lowerer.mapTypeOf(lowerer.typeOf(fromArg))?.kind === "array" + ? { receiver: fromArg, proj: "values" } + : null; if (!ts.isCallExpression(expr) || !ts.isPropertyAccessExpression(expr.expression)) return null; if (expr.questionDotToken || expr.expression.questionDotToken || expr.arguments.length !== 0) return null; @@ -401,6 +566,17 @@ function collectionIteratorCall( lowerer: Lowerer, expr: ts.Expression, ): { receiver: ts.Expression; proj: "keys" | "values" | "entries" } | null { + // Iterator.from(set) is the Set's values iterator, Iterator.from(map) the + // Map's entries iterator. + const fromArg = iteratorFromArgument(lowerer, expr); + if (fromArg !== null) { + const kind = lowerer.mapTypeOf(lowerer.typeOf(fromArg))?.kind; + return kind === "set" + ? { receiver: fromArg, proj: "values" } + : kind === "map" + ? { receiver: fromArg, proj: "entries" } + : null; + } if (!ts.isCallExpression(expr) || !ts.isPropertyAccessExpression(expr.expression)) return null; if (expr.questionDotToken || expr.expression.questionDotToken || expr.arguments.length !== 0) return null; @@ -480,6 +656,11 @@ function lowerHelperSource( lowerer: Lowerer, expr: ts.Expression, ): { gen: IrExpr & { type: GenType }; elem: IrType } { + // Iterator.from(gen) is the generator itself; the call's IteratorObject + // type would add an adapter generator over it. + const fromArg = iteratorFromArgument(lowerer, expr); + if (fromArg !== null && lowerer.mapTypeOf(lowerer.typeOf(fromArg))?.kind === "generator") + return lowerHelperSource(lowerer, fromArg); const tsType = lowerer.typeOf(expr); const mapped = lowerer.mapTypeOf(tsType); const arg = lowerer.checker.getTypeArguments(tsType as ts.TypeReference)[0]; @@ -577,10 +758,17 @@ function lowerStage( if (ret.kind === "array") return { stage: { kind: "flatMap", fn }, out: ret.elem }; if (ret.kind === "generator" && ret.async !== true && stepDrivable(ret)) return { stage: { kind: "flatMap", fn }, out: ret.yieldT }; + // A Set flattens its values and a Map its entries, walked live. A string + // is rejected at run time with Node's TypeError (strings are not + // iterator objects). + if (ret.kind === "set") return { stage: { kind: "flatMap", fn }, out: ret.elem }; + if (ret.kind === "map") + return { stage: { kind: "flatMap", fn }, out: entryPairTypeOf(lowerer, ret.key, ret.value) }; + if (ret.kind === "string") return { stage: { kind: "flatMap", fn }, out: STRING }; lowerer.noLowering( `.flatMap over a callback returning '${lowerer.fmt(ret)}'`, s.argNode, - "callbacks returning an array or a generator are the supported forms", + "callbacks returning an array, a generator, a Set or a Map are the supported forms", ); } @@ -1245,8 +1433,9 @@ function walkCollection( } /** A flatMap stage's inner walk over the callback's result in local - * `innerId`: an array by index, or a generator driven (and closed on - * early exit) like the source. `stop` ends the walk early. */ + * `innerId`: an array by index, a Set's values or a Map's entries walked + * live, or a generator driven (and closed on early exit) like the source. + * A string result throws Node's TypeError. `stop` ends the walk early. */ function walkInner( lowerer: Lowerer, frame: ChainFrame, @@ -1257,6 +1446,27 @@ function walkInner( loc: SrcLoc, ): IrStmt[] { if (innerT.kind === "array") return [walkArray(frame, innerId, innerT, stop, body, loc)]; + if (innerT.kind === "set" || innerT.kind === "map") { + const source: ChainSource = { + kind: "collection", + type: innerT, + proj: innerT.kind === "set" ? "values" : "entries", + }; + return walkCollection(lowerer, frame, innerId, source, stop ?? boolLit(false, loc), body, loc); + } + if (innerT.kind === "string") { + return [ + throwTypeError( + { + kind: "strLit", + value: "Iterator.prototype.flatMap called on non-object", + type: STRING, + loc, + }, + loc, + ), + ]; + } if (innerT.kind !== "generator") throw new InternalCompilerError("lowerer bug: flatMap over a non-iterable result"); const pre: IrStmt[] = diff --git a/packages/compiler/src/frontend/lowering/iterator-adapters.ts b/packages/compiler/src/frontend/lowering/iterator-adapters.ts index fb59c4cee..812f29242 100644 --- a/packages/compiler/src/frontend/lowering/iterator-adapters.ts +++ b/packages/compiler/src/frontend/lowering/iterator-adapters.ts @@ -265,6 +265,222 @@ function dynIteratorAdapter( return { kind: "call", callee: name, args: [expr], type: expected, loc }; } +/** `ta.values()`, `ta.keys()` or `ta.entries()` on a typed array as a + * value: an interned generator over the indices, reading the length and + * the element at each step, as the typed array iterator does. Null when + * `type` is not the matching number (or [number, number]) generator. */ +export function lowerBytesIteratorValue( + lowerer: Lowerer, + receiver: IrExpr, + method: "values" | "keys" | "entries", + type: IrType, + loc: SrcLoc, +): IrExpr | null { + const bytesT = receiver.type; + if (bytesT.kind !== "bytes" || type.kind !== "generator") return null; + if (type.retT.kind !== "void" || type.nextT.kind !== "undefinedT") return null; + const pair = type.yieldT.kind === "record" ? lowerer.shapes.get(type.yieldT.shapeId) : undefined; + const pairOk = + pair?.tuple === true && + pair.fields.length === 2 && + pair.fields.every((f) => (f.name === "0" || f.name === "1") && f.type.kind === "f64"); + if (method === "entries" ? !pairOk : type.yieldT.kind !== "f64") return null; + const resultType = genResultRecord(type.yieldT, type.retT, lowerer.shapes, lowerer.unions); + if (!resultType) return null; + const key = `bytesIter:${method}:${typeKey(bytesT)}:${typeKey(type)}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + name = `%bytes.iter.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, name); + const list = varRef("list.0", bytesT, loc); + const index = varRef("index.0", F64, loc); + const element: IrExpr = { + kind: "bytesIntrinsic", + method: "get", + receiver: list, + args: [index], + type: F64, + loc, + }; + const yielded: IrExpr = + method === "keys" + ? index + : method === "values" + ? element + : { + kind: "recordLit", + fields: [ + { name: "0", value: index }, + { name: "1", value: element }, + ], + type: type.yieldT, + loc, + }; + lowerer.liftedFns.push({ + name, + params: [{ localId: "list.0", name: "list", type: bytesT }], + returnType: VOID, + locals: [ + { id: "list.0", name: "list", type: bytesT, mutable: false }, + { id: "index.0", name: "index", type: F64, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "index.0", + init: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, + { + kind: "while", + cond: { + kind: "bin", + op: "<", + left: index, + right: { + kind: "bytesIntrinsic", + method: "length", + receiver: list, + args: [], + type: F64, + loc, + }, + type: BOOL, + loc, + }, + body: [ + { kind: "exprStmt", expr: { kind: "yieldExpr", value: yielded, type: VOID, loc }, loc }, + { + kind: "assign", + localId: "index.0", + value: { + kind: "bin", + op: "+", + left: index, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + }, + loc, + }, + ], + loc, + }, + { kind: "return", value: null, loc }, + ], + generator: { yieldT: type.yieldT, nextT: UNDEFINED_T, resultType }, + loc, + }); + } + return { kind: "call", callee: name, args: [receiver], type, loc }; +} + +/** `s[Symbol.iterator]()` on a string value: an interned generator over + * its code points, the string iterator's steps (a lone surrogate is one + * step). Null when `type` is not the plain string-yielding generator. */ +export function lowerStringIteratorValue( + lowerer: Lowerer, + receiver: IrExpr, + type: IrType, + loc: SrcLoc, +): IrExpr | null { + if (receiver.type.kind !== "string" || type.kind !== "generator") return null; + if ( + type.yieldT.kind !== "string" || + type.retT.kind !== "void" || + type.nextT.kind !== "undefinedT" + ) + return null; + const resultType = genResultRecord(type.yieldT, type.retT, lowerer.shapes, lowerer.unions); + if (!resultType) return null; + const key = `strIter:${typeKey(type)}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + name = `%str.iter.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, name); + const text = varRef("text.0", STRING, loc); + const index = varRef("index.0", F64, loc); + const point = varRef("point.0", STRING, loc); + lowerer.liftedFns.push({ + name, + params: [{ localId: "text.0", name: "text", type: STRING }], + returnType: VOID, + locals: [ + { id: "text.0", name: "text", type: STRING, mutable: false }, + { id: "index.0", name: "index", type: F64, mutable: true }, + { id: "point.0", name: "point", type: STRING, mutable: false }, + ], + body: [ + { + kind: "varDecl", + localId: "index.0", + init: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, + { + kind: "while", + cond: { + kind: "bin", + op: "<", + left: index, + right: { + kind: "strIntrinsic", + method: "length", + receiver: text, + args: [], + type: F64, + loc, + }, + type: BOOL, + loc, + }, + body: [ + { + kind: "varDecl", + localId: "point.0", + init: { + kind: "strIntrinsic", + method: "cpAt", + receiver: text, + args: [index], + type: STRING, + loc, + }, + loc, + }, + { + kind: "assign", + localId: "index.0", + value: { + kind: "bin", + op: "+", + left: index, + right: { + kind: "strIntrinsic", + method: "length", + receiver: point, + args: [], + type: F64, + loc, + }, + type: F64, + loc, + }, + loc, + }, + { kind: "exprStmt", expr: { kind: "yieldExpr", value: point, type: VOID, loc }, loc }, + ], + loc, + }, + { kind: "return", value: null, loc }, + ], + generator: { yieldT: type.yieldT, nextT: UNDEFINED_T, resultType }, + loc, + }); + } + return { kind: "call", callee: name, args: [receiver], type, loc }; +} + /** A generator flowing into a slot with other channel types (a * `Generator` returned through an * `IterableIterator` annotation). The interned adapter generator diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 23b8b69d2..58cbb9543 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -94,6 +94,8 @@ import { lowerDynArrayFilterCall, lowerDynArrayFlatMapCall } from "./containers/ import { lowerGroupByStaticCall } from "./containers/collection-grouping.js"; import { lowerGeneratorHelperCall, + lowerHelperReturnCall, + lowerIteratorFromCall, lowerIteratorHelperCall, } from "./containers/iterator-helpers.js"; import { @@ -223,6 +225,7 @@ import { completeArgs, } from "./call-arguments.js"; import { isThisParameter, type ParamShape } from "./call-signatures.js"; +import { lowerStringIteratorValue } from "./iterator-adapters.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; import { @@ -365,6 +368,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // machinery (the member always exists on a POSIX target, so the // optional call IS the call; `process.getuid` itself has no value // lowering for the chain to guard). + // `h.return!()` / `h.return?.()` on a helper object: the method exists. + const helperReturn = lowerHelperReturnCall(lowerer, expr); + if (helperReturn) return helperReturn; const processOptional = lowerer.lowerProcessOptionalMethodCall(expr); if (processOptional) return processOptional; const reqStreamOptional = lowerCompatReqStreamOptionalCall(lowerer, expr); @@ -1616,6 +1622,17 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerer.unsupported("SC1090", expr, `method calls like '${expr.expression.getText()}'`); } + // `s[Symbol.iterator]()` on a string: the string iterator as a value. + if ( + ts.isElementAccessExpression(expr.expression) && + !expr.expression.questionDotToken && + !expr.questionDotToken && + expr.arguments.length === 0 + ) { + const iterator = lowerStringIteratorCall(lowerer, expr, expr.expression); + if (iterator) return iterator; + } + // The element spelling of an unsupported native Web method must fence // before lowering the callee into a checked-dynamic keyed read. if (ts.isElementAccessExpression(expr.expression)) { @@ -3157,10 +3174,32 @@ function lowerOptionalStringNumber( return { kind: "call", callee: helper, args: [arg], type: F64, loc }; } -/** Iterator types with checked runtime receivers: Map/Set iterators' - * helper calls lower in lowerGeneratorHelperCall; the string iterator's - * have no lowering. */ -const UNLOWERED_ITERATOR_TYPES = new Set(["MapIterator", "SetIterator", "StringIterator"]); +/** `s[Symbol.iterator]()` on a string-typed receiver: the generator over + * its code points. Null for other element calls. */ +function lowerStringIteratorCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.ElementAccessExpression, +): IrExpr | null { + let key = access.argumentExpression; + while (ts.isParenthesizedExpression(key)) key = key.expression; + if ( + !ts.isPropertyAccessExpression(key) || + lowerer.stdlibGlobalMember(key, "Symbol") !== "iterator" + ) + return null; + if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "string") return null; + const type = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (type?.kind !== "generator") return null; + const receiver = lowerer.lowerExpr(access.expression); + const value = lowerStringIteratorValue(lowerer, receiver, type, locOf(call)); + if (!value) lowerer.unsupported("SC1090", call, "this string iterator type"); + return value; +} + +/** Iterator types with checked runtime receivers, whose helper calls lower + * in lowerGeneratorHelperCall. */ +const CHECKED_ITERATOR_TYPES = new Set(["MapIterator", "SetIterator"]); const ITERATOR_HELPER_METHODS = new Set([ "map", "filter", @@ -3220,25 +3259,17 @@ function lowerDynReceiverMethodCall( const recvSymbol = recvTs.getSymbol(); if ( recvSymbol && - UNLOWERED_ITERATOR_TYPES.has(recvSymbol.name) && + CHECKED_ITERATOR_TYPES.has(recvSymbol.name) && lowerer.isStdlibSymbol(recvSymbol) && ITERATOR_HELPER_METHODS.has(access.name.text) && lowerer.isStdlibMember(access) ) { - if (recvSymbol.name === "MapIterator" || recvSymbol.name === "SetIterator") { - const helper = lowerGeneratorHelperCall(lowerer, call, access); - if (helper) return helper; - lowerer.noLowering( - `collection iterator .${access.name.text}()`, - call, - "stored Map/Set iterators support .next() and iteration; iterator helper methods have no lowering", - ); - } - // The checked runtime's string iterator has no helper methods. + const helper = lowerGeneratorHelperCall(lowerer, call, access); + if (helper) return helper; lowerer.noLowering( - `${recvSymbol.name}.${access.name.text}`, + `collection iterator .${access.name.text}()`, call, - "iterator helpers lower on generators and on array, Map and Set iterators — spread the string first: [...s].values()", + "iterator helpers on Map/Set iterators are called directly on the iterator, without optional chaining", ); } const arrayReceiver = lowerer.checker.isArrayType(recvTs); @@ -6644,10 +6675,10 @@ function recordAssignHelper( return helper; } -/** The `Iterator` global's statics (ES2025 — Iterator.from, and the - * abstract constructor as a value) have no lowering: every member fences - * with the working spelling named instead of the generic-method fence's - * monomorphization wording. */ +/** The `Iterator` global's statics (ES2025): `Iterator.from` over a typed + * built-in iterable lowers (lowerIteratorFromCall); every other member, + * and other arguments, fence with the working spelling named instead of + * the generic-method fence's monomorphization wording. */ function lowerIteratorStaticFence( lowerer: Lowerer, call: ts.CallExpression, @@ -6655,11 +6686,15 @@ function lowerIteratorStaticFence( ): IrExpr | null { if (!lowerer.isStdlibGlobal(access.expression, "Iterator")) return null; if (!lowerer.isStdlibMember(access)) return null; + const from = lowerIteratorFromCall(lowerer, call, access); + if (from) return from; lowerer.noLowering( `Iterator.${access.name.text}`, call, - "iterator helpers apply directly to generators and to array, Map and Set iterators: " + - "arr.values().map(f).take(n).toArray()", + access.name.text === "from" + ? "Iterator.from accepts arrays, strings, typed arrays, Sets, Maps and iterator objects such as generators" + : "iterator helpers apply directly to generators and to array, Map and Set iterators: " + + "arr.values().map(f).take(n).toArray()", ); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index a613a2af0..32f80ef5b 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -147,6 +147,7 @@ import { } from "../../diagnostics/diagnostic.js"; import { PoisonError, dynFallbackType, jsFuncNameOf, neverTaintedJsType, own } from "./lowerer.js"; import { lowerCollectionSpread } from "./containers/collection-methods.js"; +import { lowerHelperReturnCall } from "./containers/iterator-helpers.js"; import { arrayNumberRead, arrayValueRead, @@ -1904,6 +1905,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isPostfixUnaryExpression(expr)) return lowerIncDec(lowerer, expr, false); if (ts.isBinaryExpression(expr)) return lowerer.lowerBinary(expr); if (ts.isCallExpression(expr)) { + // `h.return?.()` on a helper object: the method exists, so the + // optional call is the call. + const helperReturn = lowerHelperReturnCall(lowerer, expr); + if (helperReturn) return helperReturn; // The tail of an optional chain whose `?.` sits deeper // (`x?.trim().toLowerCase()`): the whole tail short-circuits together. if (isOptionalChainTail(lowerer, expr)) return lowerer.lowerOptionalChain(expr); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index d61464b07..41d18a6a2 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -3267,8 +3267,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // Native collection iterators use checked runtime iterator objects. if (isStdlibInterface("MapIterator") || isStdlibInterface("SetIterator")) return DYN; // Array iterators (`list.values()` and friends) are generators over the - // live array: each step reads the current length and element. - if (isStdlibInterface("ArrayIterator")) { + // live array: each step reads the current length and element. String + // iterators (`s[Symbol.iterator]()`) are generators over the code points. + if (isStdlibInterface("ArrayIterator") || isStdlibInterface("StringIterator")) { const arg = checker.getTypeArguments(widened as ts.TypeReference)[0]; const yieldT = arg ? mapType(arg, ctx) : null; if (!yieldT || yieldT.kind === "void" || isUnitType(yieldT)) return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6071-iterator-sources.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6071-iterator-sources.ts.json new file mode 100644 index 000000000..2745f7978 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6071-iterator-sources.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6071-iterator-sources.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6072-iterator-sources-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6072-iterator-sources-js.js.json new file mode 100644 index 000000000..e89c895e4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6072-iterator-sources-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6072-iterator-sources-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/string-iterator-helpers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/string-iterator-helpers.ts.json deleted file mode 100644 index 0a716bec5..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/string-iterator-helpers.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/string-iterator-helpers.ts" - ], - "diags": [] -} diff --git a/tests/corpus/6071-iterator-sources.ts b/tests/corpus/6071-iterator-sources.ts new file mode 100644 index 000000000..c63469022 --- /dev/null +++ b/tests/corpus/6071-iterator-sources.ts @@ -0,0 +1,70 @@ +// Iterator objects from strings and typed arrays, Iterator.from over the +// built-in iterables, return() on helper objects, and flatMap over Set, +// Map and string results. +const log: string[] = []; +function* count(label: string, n: number): Generator { + try { + for (let i = 1; i <= n; i++) { + log.push(`${label}${i}`); + yield i; + } + } finally { + log.push(`${label}:closed`); + } +} +function flush(what: unknown): void { + console.log(what, "|", log.join(" ")); + log.length = 0; +} + +// String iterators step by code point; a lone surrogate is one step. +const text = "a\u{1F600}b\uD800"; +const chars = text[Symbol.iterator](); +console.log(chars.next(), chars.next().value?.length, [...chars].length); +console.log(text[Symbol.iterator]().map((c) => c.codePointAt(0)!.toString(16)).toArray()); +console.log("naïve"[Symbol.iterator]().filter((c) => c !== "ï").toArray().join("")); +for (const c of "xyz"[Symbol.iterator]().drop(1)) console.log("char", c); +console.log("hello"[Symbol.iterator]().some((c) => c === "l"), "hello"[Symbol.iterator]().find((c) => c > "h")); + +// Typed array iterators. +const bytes = new Uint8Array([9, 8, 7]); +console.log(bytes.values().map((x) => x * 2).toArray(), bytes.keys().filter((i) => i > 0).toArray()); +console.log(bytes.entries().map(([i, v]) => i * v).toArray()); +const floats = new Float64Array([0.5, 1.5, 2.5]).values().map((x) => x + 1); +console.log(floats.next(), [...floats]); +for (const [i, v] of new Int16Array([-1, 2, -3, 4]).entries().filter(([, v]) => v > 0)) console.log(i, v); + +// Iterator.from returns each built-in iterable's own iterator. +const list = [1, 2]; +const fromList = Iterator.from(list); +list.push(3); +console.log([...fromList], Iterator.from("a\u{1F600}").toArray()); +console.log(Iterator.from(new Set(["p", "q"])).toArray(), Iterator.from(new Map([["k", 1]])).toArray()); +console.log(Iterator.from(new Uint8Array([4, 5])).reduce((a, b) => a + b, 0)); +const fromGen = Iterator.from(count("a", 3)); +flush([fromGen.next(), fromGen.take(1).toArray()]); +flush(Iterator.from(count("b", 9).map((x) => x * 3)).take(2).toArray()); + +// return() on helper objects. +const started = count("c", 3); +started.next(); +const doubled = started.map((x) => x * 2); +flush([doubled.return!(), started.next()]); +const fresh = count("d", 3); +const kept = fresh.filter(() => true); +flush([kept.return?.(), fresh.next(), kept.next()]); +const stepping = count("e", 5).drop(1); +flush([stepping.next(), stepping.return!(), stepping.next()]); +flush(count("f", 4).map((x) => x).take(2).return!()); + +// flatMap over Set and Map results, and a string result's TypeError. +console.log([1, 2].values().flatMap((x) => new Set([x, x * 10])).toArray()); +console.log([1, 2].values().flatMap((x) => new Map([[`k${x}`, x]])).toArray()); +const pairs = new Map([["m", 1], ["n", 2]]); +for (const [k, v] of [0].values().flatMap(() => pairs)) console.log(k, v); +try { + count("g", 3).flatMap((x) => `s${x}`).toArray(); +} catch (e) { + log.push(`${(e as Error).name}: ${(e as Error).message}`); +} +flush("string result"); diff --git a/tests/corpus/6072-iterator-sources-js.js b/tests/corpus/6072-iterator-sources-js.js new file mode 100644 index 000000000..84dd00067 --- /dev/null +++ b/tests/corpus/6072-iterator-sources-js.js @@ -0,0 +1,70 @@ +// The JavaScript form: iterator objects from strings and typed arrays, +// Iterator.from over the built-in iterables, return() on helper objects, +// and flatMap over Set, Map and string results. +const log = []; +function* count(label , n ) { + try { + for (let i = 1; i <= n; i++) { + log.push(`${label}${i}`); + yield i; + } + } finally { + log.push(`${label}:closed`); + } +} +function flush(what ) { + console.log(what, "|", log.join(" ")); + log.length = 0; +} + +// String iterators step by code point; a lone surrogate is one step. +const text = "a\u{1F600}b\uD800"; +const chars = text[Symbol.iterator](); +console.log(chars.next(), chars.next().value.length, [...chars].length); +console.log(text[Symbol.iterator]().map((c) => c.length).toArray()); +console.log("naïve"[Symbol.iterator]().filter((c) => c !== "ï").toArray().join("")); +for (const c of "xyz"[Symbol.iterator]().drop(1)) console.log("char", c); +console.log("hello"[Symbol.iterator]().some((c) => c === "l"), "hello"[Symbol.iterator]().find((c) => c > "h")); + +// Typed array iterators. +const bytes = new Uint8Array([9, 8, 7]); +console.log(bytes.values().map((x) => x * 2).toArray(), bytes.keys().filter((i) => i > 0).toArray()); +console.log(bytes.entries().map(([i, v]) => i * v).toArray()); +const floats = new Float64Array([0.5, 1.5, 2.5]).values().map((x) => x + 1); +console.log(floats.next(), [...floats]); +for (const [i, v] of new Int16Array([-1, 2, -3, 4]).entries().filter(([, v]) => v > 0)) console.log(i, v); + +// Iterator.from returns each built-in iterable's own iterator. +const list = [1, 2]; +const fromList = Iterator.from(list); +list.push(3); +console.log([...fromList], Iterator.from("a\u{1F600}").toArray()); +console.log(Iterator.from(new Set(["p", "q"])).toArray(), Iterator.from(new Map([["k", 1]])).toArray()); +console.log(Iterator.from(new Uint8Array([4, 5])).reduce((a, b) => a + b, 0)); +const fromGen = Iterator.from(count("a", 3)); +flush([fromGen.next(), fromGen.take(1).toArray()]); +flush(Iterator.from(count("b", 9).map((x) => x * 3)).take(2).toArray()); + +// return() on helper objects. +const started = count("c", 3); +started.next(); +const doubled = started.map((x) => x * 2); +flush([doubled.return(), started.next()]); +const fresh = count("d", 3); +const kept = fresh.filter(() => true); +flush([kept.return?.(), fresh.next(), kept.next()]); +const stepping = count("e", 5).drop(1); +flush([stepping.next(), stepping.return(), stepping.next()]); +flush(count("f", 4).map((x) => x).take(2).return()); + +// flatMap over Set and Map results, and a string result's TypeError. +console.log([1, 2].values().flatMap((x) => new Set([x, x * 10])).toArray()); +console.log([1, 2].values().flatMap((x) => new Map([[`k${x}`, x]])).toArray()); +const pairs = new Map ([["m", 1], ["n", 2]]); +for (const [k, v] of [0].values().flatMap(() => pairs)) console.log(k, v); +try { + count("g", 3).flatMap((x) => `s${x}`).toArray(); +} catch (e) { + log.push(`${e.name}: ${e.message}`); +} +flush("string result"); diff --git a/tests/diagnostics/collection-iterator-helpers.ts b/tests/diagnostics/collection-iterator-helpers.ts index 127b5c64b..d204e05c3 100644 --- a/tests/diagnostics/collection-iterator-helpers.ts +++ b/tests/diagnostics/collection-iterator-helpers.ts @@ -1,9 +1,13 @@ -// Iterator helpers lower over Map/Set iterators, generators and array -// iterators. These helper forms are refused explicitly. +// Iterator helpers lower over Map/Set iterators, generators and array, +// string and typed array iterators. These helper forms are refused +// explicitly. const set = new Set([1, 2]); -// flatMap callbacks return arrays or generators. -set.values().flatMap((value) => new Set([value, value + 1])); -// return() on a helper object. -const mapped = set.values().map((value) => value * 2); -mapped.return!(); +// return() on an iterator object that is not provably a helper object +// (array, string, Map and Set iterators have no return method). +function close(it: IteratorObject): void { + it.return!(); +} +close(set.values().map((value) => value * 2)); +// throw() on a helper object (helpers have no throw method). +set.values().map((value) => value + 1).throw!(new Error("stop")); // Each helper call must produce a diagnostic. diff --git a/tests/diagnostics/es2025-fences.ts b/tests/diagnostics/es2025-fences.ts index 067da192e..1d141eb84 100644 --- a/tests/diagnostics/es2025-fences.ts +++ b/tests/diagnostics/es2025-fences.ts @@ -1,6 +1,6 @@ // The es2025-lib surface WITHOUT a lowering fences by name: resizable // ArrayBuffers and transfer, SharedArrayBuffer.grow, Atomics.waitAsync, -// first-class iterator objects, and the unlowered forms of the +// Iterator.from over objects with their own next method, and the unlowered forms of the // implemented statics (Promise.try with fewer arguments than parameters, // groupBy's literal-union keys, a set-like non-Set argument). @@ -21,8 +21,8 @@ sab.grow(16); const ia = new Int32Array(4); const waited = Atomics.waitAsync(ia, 0, 0, 1); -// First-class iterator objects. -const it = Iterator.from([1, 2, 3].values()); +// Iterator.from over an object with its own next method. +const it = Iterator.from({ next: (): IteratorResult => ({ value: 1, done: false }) }); // Promise.try with fewer arguments than the callback's parameters. const tried = Promise.try((a: number, b?: number) => a + (b ?? 0), 1); diff --git a/tests/diagnostics/string-iterator-helpers.ts b/tests/diagnostics/string-iterator-helpers.ts deleted file mode 100644 index ed0e2315f..000000000 --- a/tests/diagnostics/string-iterator-helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -// The string iterator has no iterator-helper lowering; the call is -// refused instead of failing at run time. -const upper = "abc"[Symbol.iterator]().map((c) => c.toUpperCase()); diff --git a/tests/harness/__snapshots__/collection-iterator-helpers.ts.txt b/tests/harness/__snapshots__/collection-iterator-helpers.ts.txt index 87470b621..9c56aa958 100644 --- a/tests/harness/__snapshots__/collection-iterator-helpers.ts.txt +++ b/tests/harness/__snapshots__/collection-iterator-helpers.ts.txt @@ -1,17 +1,17 @@ -collection-iterator-helpers.ts:5:22 - error SC2020: '.flatMap over a callback returning 'Set'' is part of the standard library types but has no scriptc lowering yet +collection-iterator-helpers.ts:8:3 - error SC2020: 'IteratorObject.return' is part of the standard library types but has no scriptc lowering yet - 4 | // flatMap callbacks return arrays or generators. - 5 | set.values().flatMap((value) => new Set([value, value + 1])); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 6 | // return() on a helper object. + 7 | function close(it: IteratorObject): void { + 8 | it.return!(); + | ^~~~~~~~~ + 9 | } - hint: callbacks returning an array or a generator are the supported forms + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -collection-iterator-helpers.ts:8:1 - error SC2020: 'IteratorObject.return' is part of the standard library types but has no scriptc lowering yet +collection-iterator-helpers.ts:12:1 - error SC2020: 'IteratorObject.throw' is part of the standard library types but has no scriptc lowering yet - 7 | const mapped = set.values().map((value) => value * 2); - 8 | mapped.return!(); - | ^~~~~~~~~~~~~ - 9 | // Each helper call must produce a diagnostic. + 11 | // throw() on a helper object (helpers have no throw method). + 12 | set.values().map((value) => value + 1).throw!(new Error("stop")); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 13 | // Each helper call must produce a diagnostic. hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file diff --git a/tests/harness/__snapshots__/es2025-fences.ts.txt b/tests/harness/__snapshots__/es2025-fences.ts.txt index d7eda27b6..b1ad939fe 100644 --- a/tests/harness/__snapshots__/es2025-fences.ts.txt +++ b/tests/harness/__snapshots__/es2025-fences.ts.txt @@ -45,12 +45,12 @@ es2025-fences.ts:22:16 - error SC2020: 'Atomics.waitAsync' is part of the standa es2025-fences.ts:25:12 - error SC2020: 'Iterator.from' is part of the standard library types but has no scriptc lowering yet - 24 | // First-class iterator objects. - 25 | const it = Iterator.from([1, 2, 3].values()); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 24 | // Iterator.from over an object with its own next method. + 25 | const it = Iterator.from({ next: (): IteratorResult => ({ value: 1, done: false }) }); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | - hint: iterator helpers apply directly to generators and to array, Map and Set iterators: arr.values().map(f).take(n).toArray() + hint: Iterator.from accepts arrays, strings, typed arrays, Sets, Maps and iterator objects such as generators es2025-fences.ts:28:27 - error SC2020: 'Promise.try over a callback of type '(a: number, b?: number | undefined) => number' with 1 argument' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/__snapshots__/string-iterator-helpers.ts.txt b/tests/harness/__snapshots__/string-iterator-helpers.ts.txt deleted file mode 100644 index ef768934e..000000000 --- a/tests/harness/__snapshots__/string-iterator-helpers.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -string-iterator-helpers.ts:3:15 - error SC2020: 'StringIterator.map' is part of the standard library types but has no scriptc lowering yet - - 2 | // refused instead of failing at run time. - 3 | const upper = "abc"[Symbol.iterator]().map((c) => c.toUpperCase()); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 4 | - - hint: iterator helpers lower on generators and on array, Map and Set iterators — spread the string first: [...s].values() \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 000efcdfc..0439e2afb 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2234,6 +2234,8 @@ "6053-class-to-primitive.ts": 4385, "6054-js-class-to-primitive.js": 2181, "6070-iterator-helpers-untyped.js": 4050, + "6071-iterator-sources.ts": 5150, + "6072-iterator-sources-js.js": 5350, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, "6103-typeof-builtin-bindings.cjs": 2202, @@ -5304,6 +5306,8 @@ "6053-class-to-primitive.ts": 36270, "6054-js-class-to-primitive.js": 2958, "6070-iterator-helpers-untyped.js": 6900, + "6071-iterator-sources.ts": 5150, + "6072-iterator-sources-js.js": 5350, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, "6103-typeof-builtin-bindings.cjs": 1796,