From fbb4fbc5b0c59c91bbb6f2e3fc15f97c7fa203dc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:07:37 -0500 Subject: [PATCH 1/2] Encode posted records from their fields, and queue a message in one block postMessage of a typed record boxes it as a live record capsule, and the message encoder materialized the capsule's checked-dynamic view to read it: it built the view object, listed its own keys, tested each one for enumerability and read it back, then released the view and the key list. On an object-only run of bench/channel-messages that was about half of the encode time. - A live record capsule whose view was never created (no undeclared properties, no writes through an untyped view) now encodes from the record's field hooks (ScrDynTypedRecordOps), which answer exactly the view's keys, order and boxed values. Nested records encode the same way to a depth of 32, then through their views as before. Capsules with a view, published values, and every other value encode as before. - The mail event that carries a message lives in the message's block, so a queued message is one allocation instead of two. Corpus 7139 and 7360 post typed records on a MessageChannel and between a worker and the main thread: optional fields, nested records, a record reached twice, a chain deeper than the recursion limit, a parsed record with undeclared keys, and a function-valued field. --- .../corpus/7139-message-typed-records.ts.json | 6 ++ .../corpus/7360-worker-typed-records.ts.json | 6 ++ packages/runtime/src/scr_dyn.h | 3 + packages/runtime/src/scr_json.c | 4 ++ packages/runtime/src/scr_mailbox.c | 8 ++- packages/runtime/src/scr_mailbox.h | 5 ++ packages/runtime/src/scr_message.c | 53 +++++++++++++++++- packages/runtime/src/scr_worker.c | 5 +- tests/corpus/7139-message-typed-records.ts | 55 +++++++++++++++++++ tests/corpus/7360-worker-typed-records.ts | 37 +++++++++++++ .../7139-message-typed-records.ts.json | 1 + .../7360-worker-typed-records.ts.json | 1 + 12 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7139-message-typed-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7360-worker-typed-records.ts.json create mode 100644 tests/corpus/7139-message-typed-records.ts create mode 100644 tests/corpus/7360-worker-typed-records.ts create mode 100644 tests/harness/corpus-costs/7139-message-typed-records.ts.json create mode 100644 tests/harness/corpus-costs/7360-worker-typed-records.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7139-message-typed-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7139-message-typed-records.ts.json new file mode 100644 index 000000000..5e50dcd13 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7139-message-typed-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7139-message-typed-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7360-worker-typed-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7360-worker-typed-records.ts.json new file mode 100644 index 000000000..4925143ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7360-worker-typed-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7360-worker-typed-records.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2602e0341..62987708a 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -364,6 +364,9 @@ typedef struct ScrDynTypedRecordOps { * for-in over a record with every field present (atomic slot). */ ScrArr *keys; } ScrDynTypedRecordOps; +/* A record capsule's ops while it answers from the native record (no view + * created yet), or NULL. */ +const ScrDynTypedRecordOps *scr_dyn_typed_ref_live_record(const ScrDyn *d); /* Prototype members of a compiled class seen as `unknown`, emitted per * class by the compiler: the string names of methods, getters and setters diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 355a34aee..1c85a928a 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3591,6 +3591,10 @@ static const ScrDynTypedRecordOps *scr_typed_ref_record_ops(const ScrDyn *d) { return (const ScrDynTypedRecordOps *)(const void *)d->v.typed_ref.array; } +const ScrDynTypedRecordOps *scr_dyn_typed_ref_live_record(const ScrDyn *d) { + return d->kind == SCR_DYN_TYPED_REF ? scr_typed_ref_record_ops(d) : NULL; +} + static bool scr_typed_ref_record_field(const ScrDynTypedRecordOps *ops, const ScrStr *key, size_t *out) { /* Compiled reads pass the interned literal the field names also use. */ diff --git a/packages/runtime/src/scr_mailbox.c b/packages/runtime/src/scr_mailbox.c index a8dc7e5cc..b2af22774 100644 --- a/packages/runtime/src/scr_mailbox.c +++ b/packages/runtime/src/scr_mailbox.c @@ -140,9 +140,11 @@ ScrMailbox *scr_mailbox_retain(ScrMailbox *mailbox) { void scr_mail_event_free(ScrMailEvent *event) { if (!event) return; - scr_message_free(event->message); free(event->data); - free(event); + ScrMessage *message = event->message; + /* An event in its message's block goes with the message. */ + if (!event->in_message) free(event); + scr_message_free(message); } void scr_mailbox_release(ScrMailbox *mailbox) { @@ -460,7 +462,7 @@ void scr_channel_set_owner(ScrChannel *channel, int side, ScrMailbox *owner, uns * messages as ready, so it does not wait while the queue is non-empty. */ bool scr_channel_post(ScrChannel *channel, int side, ScrMessage *message, unsigned message_kind, unsigned wake_kind) { - ScrMailEvent *event = calloc(1, sizeof(*event)); + ScrMailEvent *event = message ? scr_message_mail_event(message) : calloc(1, sizeof(*event)); if (!event) scr_trap("scriptc: out of memory\n"); event->kind = message_kind; event->message = message; diff --git a/packages/runtime/src/scr_mailbox.h b/packages/runtime/src/scr_mailbox.h index 4e5f253b5..a4acfccd0 100644 --- a/packages/runtime/src/scr_mailbox.h +++ b/packages/runtime/src/scr_mailbox.h @@ -12,8 +12,13 @@ typedef struct ScrMailEvent { unsigned char *data; /* raw bytes (a worker's captured stdout or stderr), owned */ size_t length; struct ScrMailEvent *next; + bool in_message; /* the event is the one message's block holds (scr_message_mail_event) */ } ScrMailEvent; +/* The event that carries `message`, in the message's own block: a queued + * message is then one allocation. Freeing the event frees the message. */ +ScrMailEvent *scr_message_mail_event(ScrMessage *message); + ScrMailbox *scr_mailbox_new(void); ScrMailbox *scr_mailbox_retain(ScrMailbox *mailbox); void scr_mailbox_release(ScrMailbox *mailbox); diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 508266103..29cbb1be0 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -1,4 +1,5 @@ #include "scr_message.h" +#include "scr_mailbox.h" #include #include @@ -72,6 +73,7 @@ struct ScrMessage { bool storage; /* has STORAGE nodes */ bool releases; /* has STORAGE, PORT or HANDLE nodes, which own resources */ bool readable; /* see scr_message_readable */ + ScrMailEvent event; /* the event that carries the message (scr_message_mail_event) */ }; typedef struct { @@ -98,6 +100,10 @@ typedef struct { * and properties allocates only its block. */ #define SCR_MESSAGE_INLINE 8 +/* How deep live records nest before the rest of a graph encodes through + * the visit stack (scr_message_value recurses for them). */ +#define SCR_MESSAGE_RECORD_DEPTH 32 + typedef struct { ScrMessageNode *nodes; ScrMessageVisit *visits; @@ -121,6 +127,7 @@ typedef struct { bool storage; bool releases; bool shared; /* a node was reached twice */ + size_t record_depth; /* live records being encoded from their fields */ bool adopts; /* has PORT or HANDLE nodes */ ScrMessageNode inline_nodes[SCR_MESSAGE_INLINE]; ScrMessageVisit inline_visits[SCR_MESSAGE_INLINE]; @@ -148,6 +155,7 @@ static void scr_message_encoder_init(ScrMessageEncoder *encoder, const ScrDyn *t encoder->transfer = transfer; encoder->uncaught = uncaught; encoder->storage = encoder->releases = encoder->shared = encoder->adopts = false; + encoder->record_depth = 0; } static bool scr_message_listed(const ScrMessageEncoder *encoder, const ScrDyn *value) { @@ -321,6 +329,9 @@ static ScrDyn *scr_message_uncaught_keys(const ScrDyn *view) { return keys; } +static void scr_message_property(ScrMessageEncoder *encoder, size_t id, + const ScrStr *key, size_t value, bool hidden); + static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) { bool uncaught = encoder->uncaught; encoder->uncaught = false; @@ -382,6 +393,39 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) node->v.published = (ScrDyn *)input; return id; } + /* A live record capsule (scr_dyn_typed_ref_bind_record) whose view was + * never created encodes its fields from the record: the view would hold + * exactly those keys, in that order, with the values its ops box. Nested + * records encode the same way up to a depth, then through their views + * and the visit stack. */ + const ScrDynTypedRecordOps *record = + input->kind == SCR_DYN_TYPED_REF && encoder->record_depth < SCR_MESSAGE_RECORD_DEPTH + ? scr_dyn_typed_ref_live_record(input) : NULL; + if (record) { + encoder->visits[id].view = (ScrDyn *)input; + encoder->nodes[id].kind = SCR_MESSAGE_OBJECT; + void *rec = input->v.typed_ref.ptr; + encoder->record_depth++; + size_t result = id; + for (size_t i = 0; i < record->count; i++) { + if (!record->fixed && !record->has(rec, i)) continue; + ScrDyn *child = record->get(rec, i); + if (!child || scr_exc_pending()) { + scr_dyn_release(child); + result = SIZE_MAX; + break; + } + size_t next = scr_message_value(encoder, child); + scr_dyn_release(child); + if (next == SIZE_MAX) { + result = SIZE_MAX; + break; + } + scr_message_property(encoder, id, record->names[i], next, false); + } + encoder->record_depth--; + return result; + } ScrDyn *value = input->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(input) : (ScrDyn *)input; encoder->visits[id].view = value; @@ -756,8 +800,8 @@ static ScrMessage *scr_message_pack(ScrMessageEncoder *encoder) { ScrMessageProperty *slots = (ScrMessageProperty *)(nodes + count); ScrMessageTyped *records = (ScrMessageTyped *)(slots + properties); char *text = (char *)(records + typed); - *message = (ScrMessage){nodes, count, encoder->storage, encoder->releases, - !encoder->shared && !encoder->adopts}; + *message = (ScrMessage){.nodes = nodes, .count = count, .storage = encoder->storage, + .releases = encoder->releases, .readable = !encoder->shared && !encoder->adopts}; memcpy(nodes, encoder->nodes, count * sizeof(*nodes)); if (typed) memcpy(records, encoder->typed, typed * sizeof(*records)); size_t first = 0; @@ -1003,6 +1047,11 @@ done:; bool scr_message_readable(const ScrMessage *message) { return message->readable; } +ScrMailEvent *scr_message_mail_event(ScrMessage *message) { + message->event = (ScrMailEvent){.message = message, .in_message = true}; + return &message->event; +} + int32_t scr_message_kind(const ScrMessage *message, size_t node) { return message->nodes[node].kind; } double scr_message_number(const ScrMessage *message, size_t node) { return message->nodes[node].v.number; } diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index 67f4ec084..59e245ea0 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -99,9 +99,10 @@ static bool scr_worker_stats(void) { static ScrMailEvent *scr_worker_event(unsigned kind, uint64_t source, int code, ScrMessage *message) { - ScrMailEvent *event = calloc(1, sizeof(*event)); + ScrMailEvent *event = message ? scr_message_mail_event(message) : calloc(1, sizeof(*event)); if (!event) scr_trap("scriptc: out of memory\n"); - *event = (ScrMailEvent){.kind = kind, .source = source, .code = code, .message = message}; + *event = (ScrMailEvent){ + .kind = kind, .source = source, .code = code, .message = message, .in_message = message != NULL}; return event; } diff --git a/tests/corpus/7139-message-typed-records.ts b/tests/corpus/7139-message-typed-records.ts new file mode 100644 index 000000000..678b9cc48 --- /dev/null +++ b/tests/corpus/7139-message-typed-records.ts @@ -0,0 +1,55 @@ +import { MessageChannel } from "node:worker_threads"; + +// Typed records posted on a MessageChannel arrive as plain objects with +// the same keys, order and values as in Node: optional fields present and +// absent, nested records, a record reached twice, a record nested deeper +// than the encoder recurses, a record parsed with undeclared keys, and a +// function-valued field, which postMessage rejects. +type Point = { x: number; y: number }; +type Item = { id: number; name: string; done: boolean; pos: Point; note?: string }; +type Pair = { left: Point; right: Point }; +type Link = { value: number; next?: Link }; + +const { port1, port2 } = new MessageChannel(); +const lines: string[] = []; +const expected = 7; +port1.on("message", (value: unknown) => { + let checks = ""; + if (!Array.isArray(value)) { + const object = value as Record; + if ("left" in object) checks += ` same ${object["left"] === object["right"]}`; + const pos = object["pos"] as Record | undefined; + if (pos !== undefined) checks += ` negative zero ${Object.is(pos["x"], -0)}`; + } + lines.push(JSON.stringify(value) + checks); + if (lines.length === expected) { + for (const line of lines) console.log(line); + port1.close(); + } +}); + +const item: Item = { id: 1, name: "first", done: false, pos: { x: 1, y: 2 } }; +port2.postMessage(item); +const noted: Item = { id: 2, name: "", done: true, pos: { x: -0, y: 0.5 }, note: "n" }; +port2.postMessage(noted); + +const shared: Point = { x: 3, y: 4 }; +const pair: Pair = { left: shared, right: shared }; +port2.postMessage(pair); + +let chain: Link = { value: 0 }; +for (let i = 1; i < 40; i++) chain = { value: i, next: chain }; +port2.postMessage(chain); + +const parsed = JSON.parse('{"y":6,"extra":true,"x":5}') as Point; +port2.postMessage(parsed); +port2.postMessage({ x: 7, y: 8 } as Point); +port2.postMessage([item.pos, noted.pos]); + +type WithFunction = { id: number; run: () => number }; +const bad: WithFunction = { id: 9, run: () => 1 }; +try { + port2.postMessage(bad); +} catch (error) { + console.log("rejected", (error as Error).name); +} diff --git a/tests/corpus/7360-worker-typed-records.ts b/tests/corpus/7360-worker-typed-records.ts new file mode 100644 index 000000000..6749398f3 --- /dev/null +++ b/tests/corpus/7360-worker-typed-records.ts @@ -0,0 +1,37 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Typed records posted to a worker and back keep their keys, order and +// values, as in Node: records with optional fields, nested records and a +// record in workerData. +type Point = { x: number; y: number }; +type Job = { id: number; label: string; urgent: boolean; at: Point; note?: string }; +type Reply = { id: number; sum: number; label: string }; + +if (isMainThread) { + const start: Point = { x: 10, y: 20 }; + const worker = new Worker(new URL(import.meta.url), { workerData: start }); + const replies: string[] = []; + worker.on("message", (reply: Reply) => { + replies.push(`${JSON.stringify(reply)} ${Object.keys(reply).join(",")}`); + if (replies.length === 3) { + for (const line of replies) console.log(line); + void worker.terminate(); + } + }); + const jobs: Job[] = [ + { id: 1, label: "a", urgent: false, at: { x: 1, y: 2 } }, + { id: 2, label: "b", urgent: true, at: { x: 3, y: 4 }, note: "n" }, + { id: 3, label: "", urgent: false, at: { x: -1, y: 0.25 } }, + ]; + for (const job of jobs) worker.postMessage(job); +} else { + const origin = workerData as Point; + parentPort!.on("message", (job: Job) => { + const reply: Reply = { + id: job.id, + sum: job.at.x + job.at.y + origin.x + origin.y, + label: `${job.label}${job.urgent ? "!" : ""}${job.note ?? ""}`, + }; + parentPort!.postMessage(reply); + }); +} diff --git a/tests/harness/corpus-costs/7139-message-typed-records.ts.json b/tests/harness/corpus-costs/7139-message-typed-records.ts.json new file mode 100644 index 000000000..f0ed6cbc2 --- /dev/null +++ b/tests/harness/corpus-costs/7139-message-typed-records.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 4400 } diff --git a/tests/harness/corpus-costs/7360-worker-typed-records.ts.json b/tests/harness/corpus-costs/7360-worker-typed-records.ts.json new file mode 100644 index 000000000..cf0403d1d --- /dev/null +++ b/tests/harness/corpus-costs/7360-worker-typed-records.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 2900 } From 08081ca1a2185a824a4fe8068447ec39e8e924f7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 10:22:22 -0500 Subject: [PATCH 2/2] Encode each field of a posted record completely before the next A typed record encoded from its field hooks queued a plain-object field on the visit stack and went on to the next field, so getters on later fields ran before the properties of earlier ones were read. Node's serializer finishes each field, nested properties included, before the next. The record path now drains the visit stack down to where it started after each field, so properties are read in Node's order. Corpus 7139 posts a record whose fields hold getter-bearing objects and prints the read order. --- packages/runtime/src/scr_message.c | 28 +++++++++++++++----- tests/corpus/7139-message-typed-records.ts | 30 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 29cbb1be0..d4eb263cf 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -331,6 +331,7 @@ static ScrDyn *scr_message_uncaught_keys(const ScrDyn *view) { static void scr_message_property(ScrMessageEncoder *encoder, size_t id, const ScrStr *key, size_t value, bool hidden); +static bool scr_message_drain(ScrMessageEncoder *encoder, size_t base); static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) { bool uncaught = encoder->uncaught; @@ -407,6 +408,9 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) void *rec = input->v.typed_ref.ptr; encoder->record_depth++; size_t result = id; + /* A field's value encodes completely, its containers' properties + * included, before the next field's: getters run in Node's order. */ + size_t base = encoder->depth; for (size_t i = 0; i < record->count; i++) { if (!record->fixed && !record->has(rec, i)) continue; ScrDyn *child = record->get(rec, i); @@ -422,6 +426,10 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) break; } scr_message_property(encoder, id, record->names[i], next, false); + if (encoder->depth > base && !scr_message_drain(encoder, base)) { + result = SIZE_MAX; + break; + } } encoder->record_depth--; return result; @@ -832,10 +840,11 @@ static ScrMessage *scr_message_pack(ScrMessageEncoder *encoder) { return message; } -static ScrMessage *scr_message_encode_run(ScrMessageEncoder *encoder, const ScrDyn *value) { - ScrMessage *message = NULL; - if (scr_message_value(encoder, value) == SIZE_MAX) goto done; - while (encoder->depth && !scr_exc_pending()) { +/* Encodes the properties of the containers on the visit stack above + * `base`, depth first, as Node's serializer visits them (getters run in + * that order). False when a property could not be read or encoded. */ +static bool scr_message_drain(ScrMessageEncoder *encoder, size_t base) { + while (encoder->depth > base && !scr_exc_pending()) { size_t id = encoder->stack[encoder->depth - 1]; ScrMessageVisit *visit = &encoder->visits[id]; if (visit->cursor == visit->keys->v.arr.len) { encoder->depth--; continue; } @@ -843,7 +852,7 @@ static ScrMessage *scr_message_encode_run(ScrMessageEncoder *encoder, const ScrD bool error = encoder->nodes[id].kind == SCR_MESSAGE_ERROR; if (key->kind != SCR_DYN_STR || (!error && !scr_dyn_property_is_enumerable(visit->view, key->v.str))) continue; ScrDyn *child = scr_dyn_bag_get(visit->view, key->v.str, visit->view); - if (!child || scr_exc_pending()) { scr_dyn_release(child); break; } + if (!child || scr_exc_pending()) { scr_dyn_release(child); return false; } bool hidden = error; if (visit->uncaught) { /* Node keeps an uncaught Error's data properties except functions @@ -854,10 +863,15 @@ static ScrMessage *scr_message_encode_run(ScrMessageEncoder *encoder, const ScrD } size_t next = scr_message_value(encoder, child); scr_dyn_release(child); - if (next == SIZE_MAX) break; + if (next == SIZE_MAX) return false; scr_message_property(encoder, id, key->v.str, next, hidden); } -done: + return !scr_exc_pending(); +} + +static ScrMessage *scr_message_encode_run(ScrMessageEncoder *encoder, const ScrDyn *value) { + ScrMessage *message = NULL; + if (scr_message_value(encoder, value) != SIZE_MAX) scr_message_drain(encoder, 0); if (!scr_exc_pending()) message = scr_message_pack(encoder); else for (size_t i = 0; i < encoder->count; i++) scr_message_drop(&encoder->nodes[i]); diff --git a/tests/corpus/7139-message-typed-records.ts b/tests/corpus/7139-message-typed-records.ts index 678b9cc48..35d0d1588 100644 --- a/tests/corpus/7139-message-typed-records.ts +++ b/tests/corpus/7139-message-typed-records.ts @@ -53,3 +53,33 @@ try { } catch (error) { console.log("rejected", (error as Error).name); } + +// Fields that hold objects with getters: each field's value is read +// completely, its getters included, before the next field's, as in Node. +type Holder = { id: number; first: unknown; second: unknown }; +const reads: string[] = []; +const getters: Holder = { + id: 1, + first: { + get a() { + reads.push("first.a"); + return 1; + }, + nested: { + get b() { + reads.push("first.nested.b"); + return 2; + }, + }, + }, + second: { + get c() { + reads.push("second.c"); + return 3; + }, + }, +}; +const { port1: getterPort, port2: getterSender } = new MessageChannel(); +getterSender.postMessage(getters); +console.log("getter order", reads.join(",")); +getterPort.close();