Size handle tables by a fixed capacity and let handles cross threads

Appending a ScrDynHandleTag used to grow the always-linked scr_dynh_ops
table, which shifted .bss and code addresses in every program, including
programs that never box the new handle (Blob and File paid this when
they were added). The table is now sized by SCR_DYNH_CAPACITY (128; 52
tags are in use), so a link-gated unit can add a tag without changing
any other program's executable. This change is the one layout shift that
makes later tags free.

Structured clone between threads (postMessage, workerData) rejected
every handle other than MessagePorts and buffers. A handle's ops can now
carry a ScrDynHandleTransfer (share on the sender, adopt on the
receiver, drop with the message), which scr_message.c uses for any tag,
so a gated unit such as a future WebAssembly.Module needs no reference
from the message encoder and no per-tag registry. No handle declares the
hooks yet; scr_message.test.c exercises them across threads through a
test handle in the last capacity slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 15:19:38 -07:00
co-authored by Claude Opus 5.5
parent d3bfa69323
commit 4b78531ab4
5 changed files with 164 additions and 3 deletions
+29
View File
@@ -174,6 +174,17 @@ typedef enum {
SCR_DYNH_COUNT,
} 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. */
#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) */
typedef struct ScrClosure ScrClosure; /* full definition below (C11 repeat) */
@@ -1162,7 +1173,25 @@ typedef struct ScrDynHandleOps {
* lookup): +1 value, or NULL when the class has no member for `key`
* (the read answers undefined). NULL hook = no symbol-keyed members. */
ScrDyn *(*symbol_get)(void *h, const ScrDyn *key);
/* Cross-thread structured clone (postMessage, workerData); NULL =
* DataCloneError. See ScrDynHandleTransfer. */
const struct ScrDynHandleTransfer *transfer;
} ScrDynHandleOps;
/* How a handle crosses threads, supplied by the handle's owning unit so the
* message encoder (scr_message.c) needs no reference to gated units and no
* per-tag registry. share runs on the sending thread: it answers a payload
* that any thread may use, +1, or NULL when this handle cannot be cloned
* (with an exception pending, or DataCloneError is thrown). adopt runs on
* the receiving thread and answers a fresh box over the payload (+1; the
* message keeps its payload reference). drop releases the payload when the
* message is freed, whether or not it was delivered. All three must be
* callable from any thread. */
typedef struct ScrDynHandleTransfer {
void *(*share)(void *h);
ScrDyn *(*adopt)(void *payload);
void (*drop)(void *payload);
} ScrDynHandleTransfer;
ScrDyn *scr_dyn_native_handle_iterator(ScrDyn *source, unsigned selection);
/* ── promises in the checked-dynamic tree (SCR_DYN_PROMISE) ────────────────────────────
+1 -1
View File
@@ -3649,7 +3649,7 @@ ScrDyn *scr_dyn_bind(ScrDyn *target, ScrDyn *const *args, size_t argc) {
* always-linked core never references gated units. A missing tag at use
* is an internal error: emitted programs install a unit's ops whenever
* they can box its handles. */
static SCR_TL const ScrDynHandleOps *scr_dynh_ops[SCR_DYNH_COUNT];
static SCR_TL const ScrDynHandleOps *scr_dynh_ops[SCR_DYNH_CAPACITY];
/* BroadcastChannel's EventTarget table (scr_et_broadcast_install). */
SCR_TL const ScrEtBroadcastOps *scr_et_broadcast_ops = NULL;
+21 -1
View File
@@ -23,6 +23,8 @@ typedef enum {
SCR_MESSAGE_TYPED_REF,
/* A transferred MessagePort: its channel end (+1), the side in number. */
SCR_MESSAGE_PORT,
/* A native handle cloned through its unit's ScrDynHandleTransfer. */
SCR_MESSAGE_HANDLE,
} ScrMessageKind;
const ScrMessagePortOps *scr_message_port_ops = NULL;
@@ -49,6 +51,9 @@ typedef struct {
bool data_view;
ScrDyn *published; /* SCR_MESSAGE_PUBLISHED: immortal root, owns nothing */
ScrChannel *channel; /* SCR_MESSAGE_PORT */
/* SCR_MESSAGE_HANDLE: the owning unit's hooks and its payload (+1). */
const ScrDynHandleTransfer *transfer;
void *payload;
/* SCR_MESSAGE_TYPED_REF: the capsule's process-wide adapters and type key
* (vtables, adapters and type keys are shared by every thread). */
struct {
@@ -389,7 +394,20 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input)
break;
}
bool buffer = scr_buffer_storage_is(value);
if (value->kind == SCR_DYN_HANDLE && !buffer) goto unsupported;
if (value->kind == SCR_DYN_HANDLE && !buffer) {
const ScrDynHandleTransfer *transfer = scr_dyn_handle_ops_of(value)->transfer;
if (!transfer) goto unsupported;
void *payload = transfer->share(value->v.handle.ptr);
if (!payload) {
if (scr_exc_pending()) return SIZE_MAX;
goto unsupported;
}
node = &encoder->message->nodes[id];
node->kind = SCR_MESSAGE_HANDLE;
node->transfer = transfer;
node->payload = payload;
break;
}
const ScrBytes *bytes = buffer ? value->v.handle.ptr : value->v.bytes;
size_t storage = scr_message_storage(encoder, bytes);
/* Adding the storage node can reallocate the node array. */
@@ -765,6 +783,7 @@ ScrDyn *scr_message_decode(const ScrMessage *message) {
case SCR_MESSAGE_PORT:
values[i] = scr_message_port_ops->adopt(node->channel, (int)node->number);
break;
case SCR_MESSAGE_HANDLE: values[i] = node->transfer->adopt(node->payload); break;
}
if (!values[i] || scr_exc_pending()) goto done;
}
@@ -820,6 +839,7 @@ void scr_message_free(ScrMessage *message) {
free(node->data);
scr_shared_release(node->shared);
if (node->kind == SCR_MESSAGE_PORT) scr_message_port_ops->release(node->channel);
if (node->kind == SCR_MESSAGE_HANDLE) node->transfer->drop(node->payload);
}
free(message->nodes);
free(message);
+111
View File
@@ -172,6 +172,115 @@ static void check_documents(void) {
scr_message_free(message);
}
/* 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. */
#define TRANSFER_TAG ((ScrDynHandleTag)(SCR_DYNH_CAPACITY - 1))
typedef struct {
_Atomic size_t rc;
int value;
} TransferBox;
static _Atomic int transfer_shares, transfer_adopts, transfer_drops;
static void *transfer_retain(void *h) {
((TransferBox *)h)->rc++;
return h;
}
static void transfer_release(void *h) {
if (--((TransferBox *)h)->rc == 0) free(h);
}
static void *transfer_share(void *h) {
TransferBox *source = h;
if (source->value < 0) return NULL;
transfer_shares++;
TransferBox *copy = malloc(sizeof *copy);
copy->rc = 1;
copy->value = source->value;
return copy;
}
static ScrDyn *transfer_adopt(void *payload) {
transfer_adopts++;
return scr_dyn_new_handle(payload, TRANSFER_TAG);
}
static void transfer_drop(void *payload) {
transfer_drops++;
transfer_release(payload);
}
static const ScrDynHandleTransfer transfer_hooks = {transfer_share, transfer_adopt, transfer_drop};
static const ScrDynHandleOps transfer_ops = {
.cls = "Transferable",
.retain = transfer_retain,
.release = transfer_release,
.transfer = &transfer_hooks,
};
static const ScrDynHandleOps opaque_ops = {
.cls = "Opaque",
.retain = transfer_retain,
.release = transfer_release,
};
static void *receive_handles(void *data) {
scr_context_enter(1);
scr_init();
scr_lib_init(0, NULL);
scr_dyn_handle_install(TRANSFER_TAG, &transfer_ops);
ScrDyn *root = scr_message_decode(data);
assert(root && !scr_exc_pending());
ScrDyn *first = scr_dyn_obj_get(root, "first", 5);
assert(first->kind == SCR_DYN_HANDLE && first->v.handle.tag == TRANSFER_TAG);
assert(((TransferBox *)first->v.handle.ptr)->value == 7);
/* One handle reached twice decodes to one box. */
assert(first == scr_dyn_obj_get(root, "again", 5));
assert(transfer_adopts == 1);
scr_dyn_release(root);
scr_collect_cycles();
scr_context_cleanup();
return NULL;
}
static ScrDyn *transfer_handle(int value, const ScrDynHandleOps *ops) {
scr_dyn_handle_install(TRANSFER_TAG, ops);
TransferBox *box = malloc(sizeof *box);
box->rc = 1;
box->value = value;
ScrDyn *handle = scr_dyn_new_handle(box, TRANSFER_TAG);
transfer_release(box);
return handle;
}
static void check_handle_transfer(void) {
ScrDyn *root = scr_dyn_new_obj();
ScrDyn *handle = transfer_handle(7, &transfer_ops);
scr_dyn_obj_set(root, "first", 5, scr_dyn_retain(handle));
scr_dyn_obj_set(root, "again", 5, handle);
ScrMessage *message = scr_message_encode(root);
assert(message && !scr_exc_pending());
assert(transfer_shares == 1);
scr_dyn_release(root);
pthread_t receiver;
assert(pthread_create(&receiver, NULL, receive_handles, message) == 0);
assert(pthread_join(receiver, NULL) == 0);
assert(transfer_drops == 0);
scr_message_free(message);
assert(transfer_drops == 1);
/* A handle whose unit declines (share answers NULL) and a handle with no
* transfer hooks both throw DataCloneError, releasing what was shared. */
root = scr_dyn_new_arr();
scr_dyn_arr_push(root, transfer_handle(1, &transfer_ops));
scr_dyn_arr_push(root, transfer_handle(-1, &transfer_ops));
assert(!scr_message_encode(root) && scr_exc_pending());
scr_exc_clear();
assert(transfer_shares == 2 && transfer_drops == 2);
scr_dyn_release(root);
root = transfer_handle(3, &opaque_ops);
assert(!scr_message_encode(root) && scr_exc_pending());
scr_exc_clear();
assert(transfer_shares == 2);
scr_dyn_release(root);
scr_collect_cycles();
}
int main(void) {
scr_init();
scr_lib_init(0, NULL);
@@ -188,6 +297,8 @@ int main(void) {
check_getters();
check_rejection();
check_documents();
check_handle_transfer();
scr_context_cleanup();
puts("message graphs preserve identity, sparse values, byte aliases and getter order");
puts("native handles cross threads through their transfer hooks");
}
+2 -1
View File
@@ -34,7 +34,8 @@ test.skipIf(process.platform === "win32")(
env: { ...process.env, UBSAN_OPTIONS: "halt_on_error=1" },
});
expect(result.stdout).toBe(
"message graphs preserve identity, sparse values, byte aliases and getter order\n",
"message graphs preserve identity, sparse values, byte aliases and getter order\n" +
"native handles cross threads through their transfer hooks\n",
);
expect(result.stderr).toBe("");
} finally {