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.
This commit is contained in:
Chris Tate
2026-10-22 08:07:37 -05:00
parent f87a04fa06
commit fbb4fbc5b0
12 changed files with 177 additions and 7 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7139-message-typed-records.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7360-worker-typed-records.ts"
],
"diags": []
}
+3
View File
@@ -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
+4
View File
@@ -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. */
+5 -3
View File
@@ -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;
+5
View File
@@ -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);
+51 -2
View File
@@ -1,4 +1,5 @@
#include "scr_message.h"
#include "scr_mailbox.h"
#include <stdio.h>
#include <stdlib.h>
@@ -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; }
+3 -2
View File
@@ -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;
}
@@ -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<string, unknown>;
if ("left" in object) checks += ` same ${object["left"] === object["right"]}`;
const pos = object["pos"] as Record<string, unknown> | 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);
}
+37
View File
@@ -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);
});
}
@@ -0,0 +1 @@
{ "plain": 4300, "san": 4400 }
@@ -0,0 +1 @@
{ "plain": 2700, "san": 2900 }