From 9bdb36ed46e20e2bf68bae9d2e6cc1a4293637a1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 12:57:41 -0500 Subject: [PATCH] Report published values as frozen, sealed and not extensible Under Node.js, publish() deep-freezes everything it reaches, so Object.isFrozen and Object.isSealed return true and Object.isExtensible returns false for a published object, array, Map or Set. Native builds already refused writes into published values, but answered these queries as for an unpublished value: publication marks an object immortal and sets no frozen bit. - record.isFrozen, array.isFrozen and the extensibility test of Maps and Sets also read an immortal rc word as published, the test of the published write guards, for the types the program may publish (PublishedTypes). Other types emit the code they did. - scr_dyn_is_frozen, scr_dyn_is_sealed and scr_dyn_is_extensible answer for a published object, array, Map or Set, or a checked view of one, before materializing the view. --- .../compiler/src/backend/llvm/lib-dispatch.ts | 49 +++++++- packages/compiler/src/backend/publication.ts | 5 + .../published-integrity-queries.ts.json | 6 + packages/runtime/src/scr_json.c | 15 +++ tests/corpus/published-integrity-queries.ts | 107 ++++++++++++++++++ 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/published-integrity-queries.ts.json create mode 100644 tests/corpus/published-integrity-queries.ts diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index c3acf766f..80ec73e5a 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -24,7 +24,7 @@ import { } from "./lib-shared.js"; import { isDynUndefined } from "../../ir/runtime-features.js"; import { DYN_KIND } from "./dyn.js"; -import { caBytes } from "./lib-abi.js"; +import { caBytes, callRuntime, ptr } from "./lib-abi.js"; import { emitInlineAtomicsOp } from "./expr-bytes.js"; import { regexLiteralCaptures } from "./regex-matchers.js"; import { registerReplay } from "./emit-journal.js"; @@ -1110,14 +1110,44 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { if (e.fn === "record.isFrozen") { // The frozen bit of the record's rc word (scr_rc.h SCR_RC_FROZEN) is // its second-highest bit; an immortal word sets the highest one too. - const value = host.emitReadReceiver(e.args[0]!); + // Where the record's shape may be published, an immortal word reads as + // frozen: publish() freezes what it reaches, as Node's deep freeze does. + const receiver = e.args[0]!; + const value = host.emitReadReceiver(receiver); const S = host.sizeType; const rc = host.B.tmp(), - top = host.B.tmp(), - t = host.B.tmp(); + top = host.B.tmp(); + let t = host.B.tmp(); host.B.line(`${rc} = load ${S}, ptr ${value.name}`); host.B.line(`${top} = lshr ${S} ${rc}, ${S === "i64" ? 62 : 30}`); host.B.line(`${t} = icmp eq ${S} ${top}, 1`); + if ( + receiver.type.kind === "record" && + host.publishedTypes?.recordGuarded(receiver.type.shapeId) === true + ) + t = orPublished(host, t, rc); + return { name: t, type: e.type }; + } + if ( + (e.fn === "array.isFrozen" || e.fn === "collection.isExtensible") && + host.publishedTypes?.containerPublished(e.args[0]!.type) === true + ) { + // publish() freezes what it reaches, as Node's deep freeze does: a + // published array is frozen, and a published Map or Set is not extensible. + const value = host.emitReadReceiver(e.args[0]!); + const answer = callRuntime( + host, + e.fn === "array.isFrozen" ? "scr_arr_is_frozen" : "scr_map_is_extensible", + "i1", + [ptr(value.name)], + ); + const rc = host.B.tmp(); + host.B.line(`${rc} = load ${host.sizeType}, ptr ${value.name}`); + if (e.fn === "array.isFrozen") return { name: orPublished(host, answer, rc), type: e.type }; + const mortal = host.B.tmp(), + t = host.B.tmp(); + host.B.line(`${mortal} = icmp ne ${host.sizeType} ${rc}, -1`); + host.B.line(`${t} = and i1 ${answer}, ${mortal}`); return { name: t, type: e.type }; } const prefix = e.fn.slice(0, e.fn.indexOf(".")) as LibCallPrefix; @@ -1346,3 +1376,14 @@ function emitPromiseAllTuple(host: LlvmEmitterContext, e: LibCallExpr): LlValue B.line(`call void @scr_promise_release(ptr ${all})`); return host.own({ name: result, type: e.type }); } + +/** `frozen` or'ed with "the object whose rc word is `rc` is published": an + * immortal word (all ones), the test of the published write guards + * (emitter.ts emitPublishedTest). */ +function orPublished(host: LlvmEmitterContext, frozen: string, rc: string): string { + const immortal = host.B.tmp(), + t = host.B.tmp(); + host.B.line(`${immortal} = icmp eq ${host.sizeType} ${rc}, -1`); + host.B.line(`${t} = or i1 ${frozen}, ${immortal}`); + return t; +} diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts index 38e429a47..607ee3139 100644 --- a/packages/compiler/src/backend/publication.ts +++ b/packages/compiler/src/backend/publication.ts @@ -73,6 +73,11 @@ export class PublishedTypes { return this.containers.has(key) || this.frozenArrays.has(key); } + /** Whether an array, Map or Set of type `t` may be published. */ + containerPublished(t: IrType): boolean { + return this.containers.has(typeKey(t)); + } + /** Whether an array of type `t` may be published, and whether it may be * frozen: the two states its write guard tests. */ arrayGuards(t: IrType): { published: boolean; frozen: boolean } { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/published-integrity-queries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/published-integrity-queries.ts.json new file mode 100644 index 000000000..2e5570a24 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/published-integrity-queries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/published-integrity-queries.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index eb40100cf..93a0bc1ae 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2967,6 +2967,18 @@ static bool scr_dyn_typed_ref_view_answer(ScrDyn *object, bool (*query)(const Sc return true; } +/* Whether `object` is a published object, array, Map or Set, or the checked + * view of one (its referent's rc word is immortal, the test of the published + * write guards). publish() freezes what it reaches, as Node's deep freeze + * does: such a value is frozen, sealed and not extensible. */ +static bool scr_dyn_published_object(const ScrDyn *object) { + if (object->kind == SCR_DYN_TYPED_REF) + return object->v.typed_ref.ptr != NULL && SCR_RC_PUBLISHED(object->v.typed_ref.ptr); + if (object->kind == SCR_DYN_OBJ || object->kind == SCR_DYN_ARR) return object->rc == SIZE_MAX; + const ScrMap *collection = scr_dyn_native_collection(object); + return collection != NULL && SCR_RC_PUBLISHED(collection); +} + static ScrDyn *scr_bytes_properties(const ScrDyn *v); static bool scr_dyn_is_extensible_view(const ScrDyn *view) { if (view->kind == SCR_DYN_BYTES) return !view->v.bytes->properties || !view->v.bytes->properties->non_extensible; @@ -3043,6 +3055,7 @@ ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { bool scr_dyn_is_extensible(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return false; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isExtensible"); return false; } + if (scr_dyn_published_object(object)) return false; bool answer; if (scr_dyn_typed_ref_view_answer(object, scr_dyn_is_extensible_view, &answer)) return answer; ScrMap *collection = scr_dyn_native_collection(object); @@ -3094,6 +3107,7 @@ ScrDyn *scr_dyn_seal(ScrDyn *object) { bool scr_dyn_is_sealed(const ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return false; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isSealed"); return false; } + if (scr_dyn_published_object(object)) return true; bool answer; if (scr_dyn_typed_ref_view_answer((ScrDyn *)object, scr_dyn_is_sealed, &answer)) return answer; ScrMap *collection = scr_dyn_native_collection(object); @@ -19103,6 +19117,7 @@ ScrDyn *scr_dyn_freeze_data(ScrDyn *value) { bool scr_dyn_is_frozen(const ScrDyn *value) { if (scr_dyn_class_reflection_fence(value)) return false; + if (scr_dyn_published_object(value)) return true; bool answer; if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer; ScrMap *collection = scr_dyn_native_collection(value); diff --git a/tests/corpus/published-integrity-queries.ts b/tests/corpus/published-integrity-queries.ts new file mode 100644 index 000000000..9ec0c7cf3 --- /dev/null +++ b/tests/corpus/published-integrity-queries.ts @@ -0,0 +1,107 @@ +import { publish } from "@scriptc/threads"; + +// Object.isFrozen, Object.isSealed and Object.isExtensible on published +// values. Under Node.js, publish() deep-freezes the graph, so every object it +// reaches is frozen, sealed and not extensible. Unpublished values of the same +// types keep their own state. + +class Box { + items: number[]; + constructor(items: number[]) { + this.items = items; + } +} + +class Link { + next: Link | undefined = undefined; + label = "link"; +} + +interface Point { + x: number; + inner: { y: number }; +} + +function report(name: string, value: object): void { + console.log(name, Object.isFrozen(value), Object.isSealed(value), Object.isExtensible(value)); +} + +const box = publish(new Box([])); +report("class", box); +report("class field array", box.items); +report("numbers", publish([1, 2])); +report("strings", publish(["a", "b"])); +const point: Point = publish({ x: 1, inner: { y: 2 } }); +report("record", point); +report("nested record", point.inner); +report("map", publish(new Map([["a", 1]]))); +report("set", publish(new Set([1]))); +const chain = new Link(); +chain.next = new Link(); +publish(chain); +report("chain", chain); +report("chain next", chain.next!); +const loose: unknown = publish({ z: [3] }); +report("unknown", loose as object); +report("unknown field", (loose as { z: number[] }).z); + +// The same queries on statically typed values. +const numbers = publish([4, 5]); +const pair = publish({ x: 3, inner: { y: 4 } }); +const table = publish(new Map([["b", 2]])); +const members = publish(new Set(["s"])); +console.log( + "typed", + Object.isFrozen(numbers), + Object.isFrozen(pair), + Object.isFrozen(pair.inner), + Object.isFrozen(table), + Object.isFrozen(members), + Object.isFrozen(box), + Object.isFrozen(box.items), +); +console.log("typed sealed", Object.isSealed(table), Object.isSealed(members), Object.isSealed(box)); +console.log( + "typed extensible", + Object.isExtensible(table), + Object.isExtensible(members), + Object.isExtensible(box), +); +const otherNumbers = [4, 5]; +const otherPair = { x: 3, inner: { y: 4 } }; +const otherTable = new Map([["b", 2]]); +console.log( + "typed unpublished", + Object.isFrozen(otherNumbers), + Object.isFrozen(otherPair), + Object.isFrozen(otherTable), + Object.isExtensible(otherTable), + Object.isFrozen(new Box([])), +); + +// Unpublished values of the same types. +report("unpublished class", new Box([1])); +report("unpublished numbers", [1, 2]); +report("unpublished record", { x: 1, inner: { y: 2 } }); +report("unpublished map", new Map()); +report("unpublished set", new Set()); +const fresh = new Link(); +report("unpublished chain", fresh); +fresh.next = new Link(); +report("unpublished chain next", fresh.next); + +// Frozen without publishing. +report("frozen record", Object.freeze({ x: 1, inner: { y: 2 } })); +report("frozen numbers", Object.freeze([1, 2])); + +// Writes into published values still throw. +try { + box.items.push(1); +} catch (e) { + console.log((e as Error).name, (e as Error).message); +} +try { + (chain as { label: string }).label = "changed"; +} catch (e) { + console.log((e as Error).name, (e as Error).message); +}