Freeze class instances held in untyped values

Object.freeze, Object.seal and Object.preventExtensions of a class
instance held in an untyped value threw SC2020 at run time. Node changes
the instance itself: writes through its typed references then throw the
read-only TypeError in strict mode code, adding properties through any
reference fails, and Object.isFrozen, isSealed and isExtensible report
the state through either reference.

- In a program that freezes, seals or prevents extensions of an untyped
  value that is not a fresh literal, every class hierarchy that can be held
  there gets the integrity word that static Object.freeze already uses, and
  with it the existing write checks.
- The runtime applies the operation to the instance's checked view and
  commits the view. The commit of such a hierarchy stores the view's
  integrity state in the word (scr_dyn_class_view_integrity) and makes the
  instance's property bag not extensible with it. A hierarchy without the
  word still throws SC2020.
- These commits replace and remove the bag's field entries whatever their
  attributes, so a sealed or frozen view does not leave stale field values
  in the bag.
- The analysis behind records and arrays now starts from these calls only,
  rather than from every untyped freeze the lowering emits, such as the
  frozen strings array of a tagged template.
- Programs without such calls compile as before. The runtime ABI moves to
  v38.

Corpus 7407 covers freeze, seal and preventExtensions through unknown of
classes and subclasses, writes through typed and untyped references and
through methods, properties added before and after the operation, and the
integrity queries, and matches Node.

Refs #1374

Co-authored-by: blurname <50226652+blurname@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-10-23 20:13:09 -05:00
co-authored by blurname
parent e9833e3260
commit 7bce848e4d
26 changed files with 372 additions and 47 deletions
@@ -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 `#<Object>` 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 '#<Object>'` (or `of function '…'`) suffix.
+2 -2
View File
@@ -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");
});
});
@@ -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<string, Int32Abi>;
/** 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 ?? []);
@@ -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}`;
+2 -2
View File
@@ -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;
@@ -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: {
@@ -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",
@@ -39,8 +39,14 @@ import { runtimeRooted } from "./class-descriptors.js";
type ClassObjectType = Extract<IrType, { kind: "object" }>;
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 };
}
@@ -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({
@@ -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<IrType, { kind: "record" }>;
@@ -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<string>();
const arrays = new Set<string>();
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
@@ -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
}
@@ -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<string>();
/** typeKeys of the array types the program may freeze (IrModule.freezableArrays). */
readonly freezableArrays = new Set<string>();
/** 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] } : {}),
};
+5
View File
@@ -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
+2
View File
@@ -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)
@@ -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");
});
@@ -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 () => {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7407-frozen-class-instances.ts"
],
"diags": []
}
@@ -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,
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) ──────
+9
View File
@@ -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). */
+78 -18
View File
@@ -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;
+143
View File
@@ -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();
@@ -0,0 +1 @@
{ "plain": 3923, "san": 3742 }
@@ -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: {