diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 99b78ded5..030b03b4b 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -4,7 +4,8 @@ Class instances support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible` when the static type of the argument names the class. A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. - Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. + `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. + The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. A write to a read-only property of a checked dictionary or a function throws a `TypeError` whose message omits Node's `'x' of object '#'` (or `of function '…'`) suffix. diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index 8ac579449..f85b6908f 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 38, marker: "scr_runtime_abi_v38" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v37"); + expect(error?.stderr).toContain("scr_runtime_abi_v38"); }); }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8106a6478..3bf1c34d7 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1387,6 +1387,10 @@ export class LlEmitter { * commit back into their objects. Library builds keep their commits: * their values cross a native boundary. */ readonly liveViewsReadOnly: boolean; + /** The program freezes, seals or prevents extensions of untyped values + * (IrModule.untypedIntegrity): the commits of class views store the + * view's integrity state in the instance's integrity word. */ + readonly untypedIntegrity: boolean; /** Functions whose direct calls use i32 parameters or results. */ readonly int32Abis: ReadonlyMap; /** Pure number functions with speculative integer clones. */ @@ -1599,8 +1603,12 @@ export class LlEmitter { initializerBindings: this.initializerBindings, nodes: this.nodeIndex, }); + this.untypedIntegrity = mod.untypedIntegrity === true; this.liveViewsReadOnly = - mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0; + mod.lib === undefined && + !this.untypedIntegrity && + !dynamicStores.all && + dynamicStores.names.size === 0; this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map(); this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []); this.programLifetime = new Set(mod.contracts?.programLifetime ?? []); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 1acc75cec..757e4349f 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -437,10 +437,14 @@ export function streamTypedRefCommitAdapter( host.declare(`declare ptr @scr_str_new(ptr, ${host.sizeType})`); host.declare(`declare void @scr_str_release(ptr)`); host.declare(`declare void @scr_dyn_key_delete(ptr, ptr, i1 zeroext)`); + // The view of a sealed or frozen instance holds its fields as + // non-configurable entries, which the bag copies. + const withAttributes = meta.def.fields.some((field) => field.name === CLASS_ATTRIBUTES); + const placeholderSet = withAttributes ? "scr_dyn_obj_set_field" : "scr_dyn_obj_set"; if (meta.def.tracksOwnFields) { host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); - host.declare(`declare void @scr_dyn_obj_set(ptr, ptr, ${host.sizeType}, ptr)`); - } + host.declare(`declare void @${placeholderSet}(ptr, ptr, ${host.sizeType}, ptr)`); + } else if (withAttributes) host.declare(`declare void @scr_dyn_obj_delete_field(ptr, ptr)`); lines.push( ` %bag = call ptr @scr_dyn_new_obj()`, ` %bag_copy = call ptr @scr_dyn_copy_property_descriptors(ptr %bag, ptr %d)`, @@ -477,14 +481,16 @@ export function streamTypedRefCommitAdapter( `bag_field${index}_present:`, ` %bag_undefined${index} = call ptr @scr_dyn_undefined()`, ` %bag_placeholder${index} = call ptr @scr_dyn_retain_v(ptr %bag_undefined${index})`, - ` call void @scr_dyn_obj_set(ptr %bag, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr %bag_placeholder${index})`, + ` call void @${placeholderSet}(ptr %bag, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr %bag_placeholder${index})`, ` br label %bag_field${index}_after`, `bag_field${index}_after:`, ); else lines.push( ` %bag_key${index} = call ptr @scr_str_new(ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")})`, - ` call void @scr_dyn_key_delete(ptr %bag, ptr %bag_key${index}, i1 zeroext false)`, + withAttributes + ? ` call void @scr_dyn_obj_delete_field(ptr %bag, ptr %bag_key${index})` + : ` call void @scr_dyn_key_delete(ptr %bag, ptr %bag_key${index}, i1 zeroext false)`, ` call void @scr_str_release(ptr %bag_key${index})`, ); }); @@ -516,6 +522,20 @@ export function streamTypedRefCommitAdapter( host.declare(`declare void @scr_error_commit_dyn(ptr, ptr)`); lines.push(` call void @scr_error_commit_dyn(ptr %target, ptr %d)`); } + // Untyped Object.freeze, seal and preventExtensions apply to the view; + // the instance's integrity word records them. + if (host.untypedIntegrity && meta.def.fields.some((field) => field.name === CLASS_ATTRIBUTES)) { + const bag = meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) + ? "%bag" + : "null"; + host.declare(`declare double @scr_dyn_class_view_integrity(ptr, double, ptr)`); + lines.push( + ` %word_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${classFieldIndex(meta, CLASS_ATTRIBUTES).index}`, + ` %word = load double, ptr %word_ptr`, + ` %word_next = call double @scr_dyn_class_view_integrity(ptr %d, double %word, ptr ${bag})`, + ` store double %word_next, ptr %word_ptr`, + ); + } lines.push(` br label %done`, `done:`, ` ret void`, `}`, ``); host.resolveThunkDefs.push(...lines); return `@${commit}`; diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 11bde6c2b..612cd2b2e 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 37 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v37" as const; +export const RUNTIME_ABI_VERSION = 38 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v38" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index 9846e8d89..5227f6062 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 38, marker: "scr_runtime_abi_v38" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index e2b24f8ca..e401d5ed2 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -128,7 +128,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 38, marker: "scr_runtime_abi_v38" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/src/frontend/lowering/class-attributes.ts b/packages/compiler/src/frontend/lowering/class-attributes.ts index 4e7bbe801..de7cc8ca7 100644 --- a/packages/compiler/src/frontend/lowering/class-attributes.ts +++ b/packages/compiler/src/frontend/lowering/class-attributes.ts @@ -39,8 +39,14 @@ import { runtimeRooted } from "./class-descriptors.js"; type ClassObjectType = Extract; function attributeClass(lowerer: Lowerer, target: IrExpr): ClassInfo | null { - if (!isDynTypedRefType(target.type)) return null; - const info = lowerer.classes.get(target.type.className); + return isDynTypedRefType(target.type) + ? attributeClassNamed(lowerer, target.type.className) + : null; +} + +/** The compiled class `className`, when its instances can carry attributes. */ +function attributeClassNamed(lowerer: Lowerer, className: string): ClassInfo | null { + const info = lowerer.classes.get(className); if (!info || runtimeRooted(info) || info.decl == null) return null; return info; } @@ -62,6 +68,20 @@ function addHiddenField(info: ClassInfo, name: string): void { add(root); } +/** In a program that freezes, seals or prevents extensions of untyped + * values, gives every hierarchy whose instances can be held there the + * integrity word. The runtime applies the operation to the instance's + * checked view, whose commit stores the view's integrity state in the word + * (backend streamTypedRefCommitAdapter), and the write checks below read + * it. */ +export function addUntypedIntegrityWords(lowerer: Lowerer): void { + if (!lowerer.untypedIntegrity) return; + for (const [className, info] of lowerer.classes) { + if (isDynTypedRefType({ kind: "object", className }) && attributeClassNamed(lowerer, className)) + addHiddenField(info, CLASS_ATTRIBUTES); + } +} + function hiddenFieldGet(receiver: IrExpr, className: string, field: string, loc: SrcLoc): IrExpr { return { kind: "fieldGet", obj: receiver, className, field, type: F64, loc }; } diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 1a4883e5a..4ef003f81 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -499,6 +499,8 @@ export function lowerPropertyAccessExpr( }; } if (lowerer.stdlibGlobalMember(expr, "Object") === "freeze") { + // Object.freeze as a value can freeze any untyped value it is passed. + lowerer.untypedIntegrity = true; const name = "%builtin.Object.freeze"; if (!lowerer.liftedFns.some((fn) => fn.name === name)) lowerer.liftedFns.push({ diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 30eb9e455..fc770a0cb 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -24,7 +24,8 @@ import { isStrictCode } from "./strict-mode.js"; * (scr_array.h SCR_ARR_FROZEN), which the backend's array write guards test * for the array types the module lists (IrModule.freezableArrays). A type is * freezable when a static Object.freeze names it, or, in a program that - * freezes untyped values, when a value of it or of a type reaching it can be + * freezes, seals or prevents extensions of untyped values + * (noteUntypedIntegrity), when a value of it or of a type reaching it can be * held as a live untyped reference. */ type RecordType = Extract; @@ -59,6 +60,17 @@ export function lowerNativeFreeze( }; } +/** Records that the program freezes, seals or prevents extensions of the + * untyped value `node` lowers to. Unless it is a fresh object or array + * literal, the value can be any record, array or class instance the program + * holds as an untyped reference, so those types become freezable. */ +export function noteUntypedIntegrity(lowerer: Lowerer, node: ts.Expression): void { + let inner = node; + while (ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner)) inner = inner.expression; + if (!ts.isObjectLiteralExpression(inner) && !ts.isArrayLiteralExpression(inner)) + lowerer.untypedIntegrity = true; +} + function isRecordWrite(s: IrStmt): s is RecordWrite { return s.kind === "recordSet" || s.kind === "recordKeySet" || s.kind === "recordKeyDelete"; } @@ -109,7 +121,6 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; - let dynFreeze = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -119,13 +130,12 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "libCall" && e.fn === "dyn.freeze") dynFreeze = true; - else if (e.kind === "dynFrom") live.push(e.value.type); + } else if (e.kind === "dynFrom") live.push(e.value.type); return true; }, stmt: () => true, }); - if (!dynFreeze) return { shapes, arrays }; + if (!lowerer.untypedIntegrity) return { shapes, arrays }; // An untyped reference to a class instance, or to a record or array // boxed live, reaches the values its fields and elements hold as further // live references. Class instances also cross into untyped code through diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 8ac97fd32..0db330077 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -249,7 +249,7 @@ import { import { fenceSymbolFieldCopy } from "./symbol-fields.js"; import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js"; import { lowerClassFieldDefine, lowerClassIntegrity } from "./class-attributes.js"; -import { freezableRecordType, lowerNativeFreeze } from "./frozen-records.js"; +import { freezableRecordType, lowerNativeFreeze, noteUntypedIntegrity } from "./frozen-records.js"; import { classStaticDataFor } from "./class-static-data.js"; import { lowerUrlFactory } from "./lower-url.js"; import { nullishValueUnitOf } from "./binding-analysis.js"; @@ -8544,6 +8544,7 @@ function lowerObjectStaticCall( isSealed: "dyn.isSealed", } as const; const fn = operation[member as keyof typeof operation]; + if (!member.startsWith("is")) noteUntypedIntegrity(lowerer, call.arguments.nodes[0]!); return { kind: "libCall", fn, @@ -8610,8 +8611,10 @@ function lowerObjectStaticCall( (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) ? lowerer.lowerExprExpecting(argNode, DYN) : lowerer.lowerExpr(argNode); - if (value.type.kind === "dyn") + if (value.type.kind === "dyn") { + noteUntypedIntegrity(lowerer, argNode); return { kind: "libCall", fn: "dyn.freeze", args: [value], type: DYN, loc: locOf(call) }; + } if (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) { return value; // fresh — freeze is identity here, honestly } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3582e8e8a..f14f20265 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -151,7 +151,7 @@ import type { FsPathPlan } from "./builtins/fs-paths.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js"; import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; -import { guardClassAttributeWrites } from "./class-attributes.js"; +import { addUntypedIntegrityWords, guardClassAttributeWrites } from "./class-attributes.js"; import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js"; import { applyHandleProperties } from "./handle-properties.js"; import { installBufferValue } from "./containers/bytes.js"; @@ -3296,6 +3296,9 @@ export class Lowerer { readonly recordFieldDeletes = new Set(); /** typeKeys of the array types the program may freeze (IrModule.freezableArrays). */ readonly freezableArrays = new Set(); + /** Whether the program freezes, seals or prevents extensions of untyped + * values other than fresh literals (noteUntypedIntegrity). */ + untypedIntegrity = false; readonly liftedFns: IrFunction[] = []; /** The first lifted function of each name (liftedFnNamed), for the * liftedFns indexed so far. liftedFns only grows. */ @@ -7767,6 +7770,7 @@ export class Lowerer { finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); trackClassFieldCreation(this, functions); + addUntypedIntegrityWords(this); guardClassAttributeWrites(this, functions); guardFrozenWrites(this, functions); applyHandleProperties(this, functions); @@ -7881,6 +7885,7 @@ export class Lowerer { ...(this.freezableArrays.size > 0 ? { freezableArrays: [...this.freezableArrays].sort() } : {}), + ...(this.untypedIntegrity ? { untypedIntegrity: true as const } : {}), ...(isNodeEsmFile(this.entry, this.program) ? {} : { commonJsEntry: true }), ...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}), }; diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 5916a5166..636634dab 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1073,6 +1073,11 @@ export interface IrModule { * (frontend frozen-records.ts): their writes test the array's frozen bit * (SCR_ARR_FROZEN) and throw Node's TypeError when it is set. */ freezableArrays?: string[]; + /** The program freezes, seals or prevents extensions of untyped values + * (frontend frozen-records.ts noteUntypedIntegrity). Class hierarchies + * that can be held there carry an integrity word, which the runtime sets + * through the commit of the instance's checked view. */ + untypedIntegrity?: true; /** 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 diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index a694e8d2c..8152775d1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -123,6 +123,8 @@ export function validateModule(mod: IrModule): IrValidationError[] { errors.push({ message: "worker execution mode must be a boolean", loc: moduleLoc }); if (mod.declBoundary !== undefined && mod.declBoundary !== true) errors.push({ message: "declBoundary must be true when present", loc: moduleLoc }); + if (mod.untypedIntegrity !== undefined && mod.untypedIntegrity !== true) + errors.push({ message: "untypedIntegrity must be true when present", loc: moduleLoc }); if (mod.lifetimeRegions !== undefined && mod.lifetimeRegions !== true) errors.push({ message: "lifetimeRegions must be true when present", loc: moduleLoc }); if (mod.workers && mod.lib !== undefined) diff --git a/packages/compiler/test/class-attributes.test.ts b/packages/compiler/test/class-attributes.test.ts index 1a6fea654..a0e6efa62 100644 --- a/packages/compiler/test/class-attributes.test.ts +++ b/packages/compiler/test/class-attributes.test.ts @@ -82,3 +82,33 @@ b.x = 2; expect(throwing.length).toBeGreaterThan(0); expect(throwing.every((name) => name.includes("constructor"))).toBe(true); }); + +test("an untyped freeze gives every class hierarchy attribute state", async () => { + const mod = await lowerModule(` +export {}; +class Point { x = 1; } +class Labeled extends Point { label = "a"; } +function freezeUnknown(value: unknown): unknown { return Object.freeze(value); } +function move(p: Point): void { p.x = 2; } +freezeUnknown(new Labeled()); +move(new Point()); +`); + expect(mod.untypedIntegrity).toBe(true); + expect(fieldsOf(mod, "Point")).toContain("%attributes"); + expect(fieldsOf(mod, "Labeled")).toContain("%attributes"); + expect(functionsCalling(mod, "class.throwReadOnly")).toContain("move"); +}); + +test("untyped freezes of fresh literals and tagged templates leave classes unchanged", async () => { + const mod = await lowerModule( + ` +class Point { constructor() { this.x = 1; } } +const tag = (strings) => strings.length; +const frozen = Object.freeze({ a: 1 }); +console.log(tag\`a\${1}b\`, frozen.a, new Point().x); +`, + "main.js", + ); + expect(mod.untypedIntegrity).toBeUndefined(); + expect(fieldsOf(mod, "Point")).not.toContain("%attributes"); +}); diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index cecebb49c..2a0f8371e 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -378,7 +378,7 @@ done: expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v37"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v38"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -712,7 +712,7 @@ done: .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v37"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v38"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7407-frozen-class-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7407-frozen-class-instances.ts.json new file mode 100644 index 000000000..da868130c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7407-frozen-class-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7407-frozen-class-instances.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index e492c36b4..951883c07 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -340,7 +340,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 38, marker: "scr_runtime_abi_v38" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index e22f2a37c..988c899cf 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -437,7 +437,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 38, marker: "scr_runtime_abi_v38" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 086340014..819121c3f 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v37(void) {} +void scr_runtime_abi_v38(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h index 3707b8d50..20c591cbb 100644 --- a/packages/runtime/src/scr_core.h +++ b/packages/runtime/src/scr_core.h @@ -81,7 +81,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v37(void); +void scr_runtime_abi_v38(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 5b1598a8b..bf3b8d426 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1182,6 +1182,12 @@ void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor); void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits); void scr_dyn_class_view_attributes(ScrDyn *view, double bits); +/* The integrity state of a class instance's checked view as CLASS_* bits, + * added to `bits`. The commits of hierarchies with an integrity word store + * it in the word, in programs that freeze, seal or prevent extensions of + * untyped values. `bag`, the instance's new property bag or NULL, stops + * being extensible with the view. */ +double scr_dyn_class_view_integrity(const ScrDyn *view, double bits, ScrDyn *bag); ScrDyn *scr_class_define_field(ScrDyn *current, double field_bits, double object_bits, ScrStr *name, ScrDyn *descriptor); double scr_class_defined_bits(ScrDyn *holder, ScrStr *name); @@ -1211,6 +1217,9 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value); void scr_dyn_arr_named_put(ScrDyn *arr, const char *key, size_t key_len, ScrDyn *value); void scr_dyn_key_set_computed(ScrDyn *recv, ScrDyn *key, ScrDyn *value); void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict); +/* Removes the own property `key` of an ordinary object whatever its + * attributes, as scr_dyn_obj_set_field replaces values. */ +void scr_dyn_obj_delete_field(ScrDyn *obj, const ScrStr *key); /* This thread's global object box (borrowed). Every spelling of the global * object answers it; its symbol-keyed properties are the global object's * (scr_node_builtin.c). */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 40fd73508..2f8e4b6f8 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2389,9 +2389,74 @@ void scr_record_throw_frozen(ScrStr *key, ScrStr *owner, double operation) { scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); } -/* A class instance's integrity state lives in its native layout, which only - * statically typed calls can reach (the compiler adds the state to the - * hierarchies they name). Through an untyped value the operation throws. */ +/* ── class instance attributes ─────────────────────────────────────────── + * A hierarchy passed to Object.freeze, Object.seal, Object.preventExtensions + * or Object.defineProperty keeps an integrity state per instance and an + * attribute word per redefined field (CLASS_* and FIELD_* bits in the + * compiler's ir.ts). The checked view of an instance carries them, so + * reflection over the view answers as Node does. */ +enum { + SCR_CLASS_NOT_EXTENSIBLE = 1, + SCR_CLASS_SEALED = 2, + SCR_CLASS_FROZEN = 4, + SCR_FIELD_NOT_WRITABLE = 1, + SCR_FIELD_NOT_ENUMERABLE = 2, + SCR_FIELD_NOT_CONFIGURABLE = 4, +}; + +/* Set by scr_dyn_class_view_integrity, which only the commits of + * hierarchies with an integrity word call. */ +static SCR_TL bool scr_class_integrity_committed; + +double scr_dyn_class_view_integrity(const ScrDyn *view, double bits, ScrDyn *bag) { + scr_class_integrity_committed = true; + unsigned state = (unsigned)bits; + if (view->kind != SCR_DYN_OBJ || !view->non_extensible) return state; + state |= SCR_CLASS_NOT_EXTENSIBLE; + if (bag && bag->kind == SCR_DYN_OBJ) bag->non_extensible = true; + bool sealed = true, frozen = true; + for (size_t i = 0; i < view->v.obj.len; i++) { + const ScrDynEntry *entry = &view->v.obj.entries[i]; + if (entry->configurable) sealed = false; + if (!entry->accessor && entry->writable) frozen = false; + } + if (sealed) state |= SCR_CLASS_SEALED; + if (sealed && frozen) state |= SCR_CLASS_FROZEN; + return state; +} + +/* Object.freeze, Object.seal or Object.preventExtensions (`operation`) of + * the class instance a capsule refers to. In a program that applies them to + * untyped values, every hierarchy that can be held there keeps an + * integrity word (class-attributes.ts), and its commit stores the + * integrity state of the instance's checked view in the word + * (scr_dyn_class_view_integrity). The operation applies to the view, and + * the commit records it. A first commit of the unchanged view finds out + * whether the hierarchy has the word. Answers false for other values and + * for instances whose hierarchy has no word, whose state stays as it was, + * and true when the operation applied or threw. */ +static bool scr_dyn_class_integrity(ScrDyn *object, ScrDyn *(*operation)(ScrDyn *)) { + if (object->kind != SCR_DYN_TYPED_REF || !object->v.typed_ref.commit || + object->v.typed_ref.type_key_len < 7 || memcmp(object->v.typed_ref.type_key, "object:", 7) != 0) + return false; + ScrDyn *view = scr_dyn_typed_ref_materialize(object); + bool word = false; + if (!scr_exc_pending()) { + scr_class_integrity_committed = false; + scr_dyn_typed_ref_commit(object); + word = scr_class_integrity_committed; + } + if (word && !scr_exc_pending()) { + scr_dyn_release(operation(view)); + if (!scr_exc_pending()) scr_dyn_typed_ref_commit(object); + } + scr_dyn_release(view); + return word || scr_exc_pending(); +} + +/* A class instance's integrity state lives in its native layout. Through an + * untyped value whose hierarchy has no integrity word, the operation + * throws. */ static bool scr_dyn_class_integrity_fence(const ScrDyn *object, const char *operation) { if (object->kind != SCR_DYN_TYPED_REF) return false; ScrJsonBuf buffer; @@ -2456,6 +2521,8 @@ ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { scr_map_prevent_extensions(collection); return scr_dyn_retain(object); } + if (scr_dyn_class_integrity(object, scr_dyn_prevent_extensions)) + return scr_exc_pending() ? NULL : scr_dyn_retain(object); if (scr_dyn_class_integrity_fence(object, "Object.preventExtensions")) return NULL; ScrDyn *table = scr_dyn_fn_integrity_table(object); if (table) { @@ -2494,6 +2561,8 @@ ScrDyn *scr_dyn_seal(ScrDyn *object) { scr_map_prevent_extensions(collection); return scr_dyn_retain(object); } + if (scr_dyn_class_integrity(object, scr_dyn_seal)) + return scr_exc_pending() ? NULL : scr_dyn_retain(object); if (scr_dyn_class_integrity_fence(object, "Object.seal")) return NULL; if (object->symbol_properties) scr_dyn_release(scr_dyn_seal(object->symbol_properties)); ScrDyn *table = scr_dyn_fn_integrity_table(object); @@ -12118,6 +12187,10 @@ static void scr_json_delete_member(ScrDyn *object, const ScrStr *key) { } } +void scr_dyn_obj_delete_field(ScrDyn *obj, const ScrStr *key) { + if (obj->kind == SCR_DYN_OBJ) scr_json_delete_member(obj, key); +} + /* Ordinary native objects carry removable own data properties. Dense * arrays, typed references and handles keep their explicit boundary; * deleting from a materialized snapshot would lose the mutation. */ @@ -16724,6 +16797,8 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value) { if (scr_dyn_class_reflection_fence(value) || scr_dyn_error_prototype_fence(value)) return NULL; ScrDyn *native = scr_dyn_freeze_native(value); if (native) return native; + if (scr_dyn_class_integrity(value, scr_dyn_freeze_data)) + return scr_exc_pending() ? NULL : scr_dyn_retain(value); if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL; ScrDyn *table = scr_dyn_fn_integrity_table(value); if (!table) return scr_dyn_freeze_data(value); @@ -16829,21 +16904,6 @@ ScrDyn *scr_caught_to_dyn(const ScrCaught *c) { } } -/* ── class instance attributes ─────────────────────────────────────────── - * A hierarchy passed to Object.freeze, Object.seal, Object.preventExtensions - * or Object.defineProperty keeps an integrity state per instance and an - * attribute word per redefined field (CLASS_* and FIELD_* bits in the - * compiler's ir.ts). The checked view of an instance carries them, so - * reflection over the view answers as Node does. */ -enum { - SCR_CLASS_NOT_EXTENSIBLE = 1, - SCR_CLASS_SEALED = 2, - SCR_CLASS_FROZEN = 4, - SCR_FIELD_NOT_WRITABLE = 1, - SCR_FIELD_NOT_ENUMERABLE = 2, - SCR_FIELD_NOT_CONFIGURABLE = 4, -}; - void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits) { unsigned b = (unsigned)bits; if (b == 0 || view->kind != SCR_DYN_OBJ) return; diff --git a/tests/corpus/7407-frozen-class-instances.ts b/tests/corpus/7407-frozen-class-instances.ts new file mode 100644 index 000000000..0732b8c35 --- /dev/null +++ b/tests/corpus/7407-frozen-class-instances.ts @@ -0,0 +1,143 @@ +// Object.freeze, Object.seal and Object.preventExtensions of class instances +// held in untyped values. The operation changes the instance itself: writes +// through its typed references, through untyped references and through its +// methods then throw Node's TypeError (this module is strict mode code), and +// Object.isFrozen, isSealed and isExtensible report the state through either +// reference. +export {}; + +function report(e: unknown): void { + console.log((e as Error).name + ": " + (e as Error).message); +} + +function freezeUnknown(value: unknown): unknown { + return Object.freeze(value); +} + +function sealUnknown(value: unknown): unknown { + return Object.seal(value); +} + +function preventUnknown(value: unknown): unknown { + return Object.preventExtensions(value); +} + +function integrity(value: unknown): string { + return [Object.isFrozen(value), Object.isSealed(value), Object.isExtensible(value)].join(" "); +} + +function writeAny(value: any, key: string, next: unknown): void { + value[key] = next; +} + +function deleteAny(value: any, key: string): void { + delete value[key]; +} + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + move(dx: number): void { + this.x += dx; + } +} + +class Labeled extends Point { + label: string; + constructor(x: number, y: number, label: string) { + super(x, y); + this.label = label; + } + rename(label: string): void { + this.label = label; + } +} + +class Counter { + count = 0; + tags: string[] = []; + bump(): number { + return ++this.count; + } +} + +function frozen(): void { + const p = new Point(1, 2); + console.log(integrity(p)); + const same = freezeUnknown(p); + console.log(same === p, integrity(p), Object.isFrozen(p)); + try { p.x = 5; } catch (e) { report(e); } + try { p.move(1); } catch (e) { report(e); } + try { writeAny(p, "y", 9); } catch (e) { report(e); } + try { writeAny(p, "z", 1); } catch (e) { report(e); } + console.log(p.x, p.y, JSON.stringify(p)); + + const l = new Labeled(3, 4, "a"); + const base: Point = l; + freezeUnknown(base); + console.log(integrity(l), integrity(base)); + try { l.rename("b"); } catch (e) { report(e); } + try { base.x = 0; } catch (e) { report(e); } + try { writeAny(l, "label", "c"); } catch (e) { report(e); } + console.log(l.x, l.label, l instanceof Labeled); + freezeUnknown(l); + console.log(integrity(l)); +} + +function sealed(): void { + const c = new Counter(); + sealUnknown(c); + console.log(integrity(c)); + console.log(c.bump(), c.bump()); + c.tags.push("kept"); + try { writeAny(c, "extra", 1); } catch (e) { report(e); } + writeAny(c, "count", 10); + console.log(c.count, c.tags.join(), JSON.stringify(c)); + freezeUnknown(c); + console.log(integrity(c)); + try { c.bump(); } catch (e) { report(e); } + c.tags.push("still"); + console.log(c.count, c.tags.join()); +} + +function notExtensible(): void { + const l = new Labeled(0, 0, "n"); + preventUnknown(l); + console.log(integrity(l)); + l.move(2); + l.rename("m"); + try { writeAny(l, "extra", true); } catch (e) { report(e); } + console.log(l.x, l.label, Object.keys(l).join()); + sealUnknown(l); + console.log(integrity(l)); + writeAny(l, "y", 7); + console.log(l.y); +} + +function untouched(): void { + const p = new Point(5, 6); + const q = new Point(7, 8); + freezeUnknown(q); + p.x = 50; + writeAny(p, "y", 60); + console.log(p.x, p.y, integrity(p), integrity(q)); +} + +function added(): void { + const p = new Point(1, 1); + writeAny(p, "extra", 1); + freezeUnknown(p); + console.log(integrity(p), Object.keys(p).join()); + try { deleteAny(p, "extra"); } catch (e) { report(e); } + console.log((p as any).extra, JSON.stringify(p)); +} + +frozen(); +sealed(); +notExtensible(); +untouched(); +added(); diff --git a/tests/harness/corpus-costs/7407-frozen-class-instances.ts.json b/tests/harness/corpus-costs/7407-frozen-class-instances.ts.json new file mode 100644 index 000000000..34061c3eb --- /dev/null +++ b/tests/harness/corpus-costs/7407-frozen-class-instances.ts.json @@ -0,0 +1 @@ +{ "plain": 3923, "san": 3742 } diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index c5476328e..030408deb 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -109,7 +109,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 38, marker: "scr_runtime_abi_v38" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: {