From d1f3eda7326b81ca84b9fcdb6a29f574aa14f54a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 04:50:06 -0500 Subject: [PATCH] Freeze arrays, Maps and Sets that other references still hold Object.freeze of an array that another reference may still hold stopped at compile time with SC2020, and freezing an array, Map or Set held in an untyped value threw SC2020 at run time. Node freezes the object itself: a frozen array refuses every later write through any reference with the TypeError of the operation's first write, and a frozen Map or Set only stops being extensible, since its entries are not properties. - An array a program may freeze keeps a frozen bit in its storage flags. An array type is freezable when a typed Object.freeze names it, or, in a program that freezes untyped values, when a value of it can be held as a live untyped reference. The writes of those types test the bit with the guard published arrays already use; other array types are unchanged. - The guard's errors follow Node for every operation: splice, fill, copyWithin, sort and reverse throw at their first write, and do not throw when they would write nothing. Published arrays get the same errors, and a statement splice of a published array is now guarded. - A Map or Set records that it is not extensible, for Object.freeze, seal and preventExtensions, typed or untyped. Object.isFrozen, isSealed and isExtensible report it, and its entries stay writable. - Writes to a frozen array through an untyped value are refused with the same errors, as are writes to frozen checked arrays and objects, which now name the property and the object as Node does. - Programs that neither freeze nor publish arrays compile to the same IR and LLVM. Corpus 7406 covers typed and untyped freezes of arrays, Maps and Sets, every array write on frozen arrays of zero, one and three elements, comparator sorts, nested arrays, and frozen arrays that are released, and matches Node. Refs #1374 Co-authored-by: blurname <50226652+blurname@users.noreply.github.com> --- packages/compiler/src/backend/llvm/common.ts | 3 + packages/compiler/src/backend/llvm/emitter.ts | 63 +++++++- .../src/backend/llvm/expr-containers.ts | 39 ++++- .../compiler/src/backend/llvm/lib-dispatch.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 4 + .../compiler/src/backend/publication.test.ts | 31 ++++ packages/compiler/src/backend/publication.ts | 27 +++- .../src/frontend/lowering/frozen-records.ts | 62 +++++--- .../src/frontend/lowering/lower-calls.ts | 57 ++++++- .../compiler/src/frontend/lowering/lowerer.ts | 9 +- .../compiler/src/ir/builtin-signatures.ts | 10 ++ .../src/ir/function-references.test.ts | 1 + packages/compiler/src/ir/ir.ts | 4 + .../corpus/7406-frozen-containers.ts.json | 6 + packages/runtime/src/scr_array.c | 122 +++++++++++++++ packages/runtime/src/scr_array.h | 22 ++- packages/runtime/src/scr_dyn_invoke.c | 35 +++-- packages/runtime/src/scr_json.c | 142 +++++++++++++----- packages/runtime/src/scr_map.c | 6 + packages/runtime/src/scr_map.h | 8 + packages/runtime/src/scr_publish.c | 58 +------ packages/runtime/src/scr_rc.h | 8 +- tests/corpus/7406-frozen-containers.ts | 132 ++++++++++++++++ .../7406-frozen-containers.ts.json | 1 + 24 files changed, 690 insertions(+), 161 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7406-frozen-containers.ts.json create mode 100644 tests/corpus/7406-frozen-containers.ts create mode 100644 tests/harness/corpus-costs/7406-frozen-containers.ts.json diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index ac4e28708..cca1b603b 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -29,6 +29,9 @@ export const SCR_ARR_FIELD = { * (SCR_ARR_STATES in scr_array.h). */ export const SCR_ARR_STATES = 16; +/** The `spare` flag of a frozen %ScrArr (SCR_ARR_FROZEN in scr_array.h). */ +export const SCR_ARR_FROZEN = 32; + /** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a * size-type count. */ export function loadArrCount( diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index c0cc9fef2..a66e00736 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -184,7 +184,7 @@ import { } from "./string-slices.js"; import { LazyCaptures } from "./lazy-captures.js"; import { StackCaptures } from "./stack-captures.js"; -import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD } from "./common.js"; +import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD, SCR_ARR_FROZEN } from "./common.js"; import { ffiExtendsNarrowIntegers } from "../targets.js"; import { emitLiteralExpr, @@ -6264,6 +6264,41 @@ export class LlEmitter { return ok; } + /** Branches on "the array `arr` is frozen" (SCR_ARR_FROZEN in its storage + * flags, scr_array.h), or published too when `published`; like + * emitPublishedTest, leaves the builder in the cold block and answers the + * continuation label. */ + private emitFrozenArrayTest(arr: string, published: boolean): string { + const B = this.B; + const sparep = B.tmp(), + spare = B.tmp(), + bit = B.tmp(); + let frozen = B.tmp(); + B.line( + `${sparep} = getelementptr inbounds %ScrArr, ptr ${arr}, i32 0, i32 ${SCR_ARR_FIELD.spare}`, + ); + B.line(`${spare} = load i16, ptr ${sparep}`); + B.line(`${bit} = and i16 ${spare}, ${SCR_ARR_FROZEN}`); + B.line(`${frozen} = icmp ne i16 ${bit}, 0`); + if (published) { + const rc = B.tmp(), + immortal = B.tmp(), + either = B.tmp(); + B.line(`${rc} = load ${this.sizeType}, ptr ${arr}`); + B.line(`${immortal} = icmp eq ${this.sizeType} ${rc}, -1`); + B.line(`${either} = or i1 ${frozen}, ${immortal}`); + frozen = either; + } + this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); + const expected = B.tmp(); + B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`); + const cold = B.newLabel("arr.frozen"), + ok = B.newLabel("arr.ok"); + B.condBr(expected, cold, ok); + B.startBlock(cold); + return ok; + } + private publishedNamesSym: string | null = null; /** The class-name table the hierarchy field guards index by preorder. */ @@ -6328,15 +6363,29 @@ export class LlEmitter { op: number, index = f64Lit(0), count = f64Lit(0), + third: string | null = null, ): void { - if (this.publishedTypes?.containerGuarded(arrType) !== true) return; + const published = this.publishedTypes; + if (published?.containerGuarded(arrType) !== true) return; const B = this.B; - const ok = this.emitPublishedTest(arr); - this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`); + const guards = published.arrayGuards(arrType); + const ok = guards.frozen + ? this.emitFrozenArrayTest(arr, guards.published) + : this.emitPublishedTest(arr); const threw = B.tmp(); - B.line( - `${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`, - ); + if (third === null) { + this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`); + B.line( + `${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`, + ); + } else { + this.declare( + `declare zeroext i1 @scr_throw_published_array_range(i32, ptr, double, double, double)`, + ); + B.line( + `${threw} = call zeroext i1 @scr_throw_published_array_range(i32 ${op}, ptr ${arr}, double ${index}, double ${count}, double ${third})`, + ); + } const unwind = B.newLabel("pub.throw"); B.condBr(threw, unwind, ok); B.startBlock(unwind); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 1ba5da818..93cc615a4 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -257,6 +257,16 @@ function emitDiscarded(host: LlvmEmitterContext, e: IrExpr): void { const start = host.emitExpr(e.args[0]!); const count = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF; const values = items.map((item) => host.emitExpr(item)); + // Before the values move into the buffer: a throw releases them with the + // frame. + host.emitPublishedArrayGuard( + r.name, + e.receiver.type, + 10, + start.name, + count, + f64Lit(values.length), + ); if (acc === "ref") values.forEach((value) => host.toHeap(value)); let buffer = "null"; if (values.length > 0) { @@ -978,7 +988,25 @@ export function emitArrIntrinsic( case "fill": case "fillUndefined": { const args = e.args.map((arg) => host.emitExpr(arg)); - host.emitPublishedArrayGuard(r.name, e.receiver.type, 7); + // fill's range is its last two arguments; copyWithin's target, + // start and end are its three. + if (e.method === "copyWithin") + host.emitPublishedArrayGuard( + r.name, + e.receiver.type, + 9, + args[0]!.name, + args[1]!.name, + args[2]!.name, + ); + else + host.emitPublishedArrayGuard( + r.name, + e.receiver.type, + 8, + args[args.length - 2]!.name, + args[args.length - 1]!.name, + ); if (host.checkedFills.has(e)) { // A new int32 array with checked stores, filled with a value that // may not be an int32: widen it first then. @@ -1042,7 +1070,7 @@ export function emitArrIntrinsic( // removes to the end (+Infinity, the slice convention). const start = host.emitExpr(e.args[0]!); const cnt = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF; - host.emitPublishedArrayGuard(r.name, e.receiver.type, 7); + host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, cnt, f64Lit(0)); host.declare(`declare ptr @scr_arr_splice(ptr, double, double)`); const t = B.tmp(); B.line(`${t} = call ptr @scr_arr_splice(ptr ${r.name}, double ${start.name}, double ${cnt})`); @@ -1052,7 +1080,12 @@ export function emitArrIntrinsic( const start = host.emitExpr(e.args[0]!); const count = host.emitExpr(e.args[1]!); const items = emitBorrowedInput(host, e.args[2]!); - host.emitPublishedArrayGuard(r.name, e.receiver.type, 7); + if (host.publishedTypes?.containerGuarded(e.receiver.type) === true) { + host.declare(`declare double @scr_arr_len(ptr)`); + const n = B.tmp(); + B.line(`${n} = call double @scr_arr_len(ptr ${items.name})`); + host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, count.name, n); + } host.declare(`declare ptr @scr_arr_splice_insert(ptr, double, double, ptr)`); const t = B.tmp(); B.line( diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 4f1a4fef8..cc8953fc4 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -891,6 +891,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "blob": case "wasm": case "array": + case "collection": case "record": case "disposable": case "punycode": diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 6fbad7e11..f8e59e7b7 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1016,6 +1016,10 @@ export const LIB_FN_SYMS: Record = { "class.throwNotExtensible": "scr_class_throw_not_extensible", "record.freeze": "scr_record_freeze", "record.throwFrozen": "scr_record_throw_frozen", + "array.freeze": "scr_arr_freeze", + "array.isFrozen": "scr_arr_is_frozen", + "collection.preventExtensions": "scr_map_prevent_extensions", + "collection.isExtensible": "scr_map_is_extensible", "dyn.nativeSetNew": "scr_dyn_native_set_new", "dyn.nativeSetIs": "scr_dyn_native_set_is", "dyn.nativeMapIs": "scr_dyn_native_map_is", diff --git a/packages/compiler/src/backend/publication.test.ts b/packages/compiler/src/backend/publication.test.ts index 149ea4bb9..7b3e5f5cc 100644 --- a/packages/compiler/src/backend/publication.test.ts +++ b/packages/compiler/src/backend/publication.test.ts @@ -85,3 +85,34 @@ test("published types close over fields, containers and whole hierarchies", () = computePublishedTypes({ ...withStores, functions: withStores.functions.slice(1) }), ).toBeNull(); }); + +test("freezable array types carry the array write guard without a publish", () => { + const numbers = arrayOf(F64); + const push: IrStmt = { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: ref("a", numbers), + args: [{ kind: "numLit", value: 1, type: F64, loc }], + type: F64, + loc, + }, + loc, + }; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [fn("main", []), fn("pushNumber", [push])], + freezableArrays: ["array"], + }; + const guarded = computePublishedTypes(mod)!; + expect(guarded.containerGuarded(numbers)).toBe(true); + expect(guarded.arrayGuards(numbers)).toEqual({ published: false, frozen: true }); + expect(guarded.containerGuarded(arrayOf(STRING))).toBe(false); + // A frozen array's push throws, so the function may throw. + expect(computeMayThrow(mod).fns.has("pushNumber")).toBe(true); + const { freezableArrays: _frozen, ...plain } = mod; + expect(computePublishedTypes(plain)).toBeNull(); +}); diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts index af65fa39a..7f7918fb1 100644 --- a/packages/compiler/src/backend/publication.ts +++ b/packages/compiler/src/backend/publication.ts @@ -46,6 +46,10 @@ export class PublishedTypes { readonly records = new Set(); /** typeKeys of array, map and set types. */ readonly containers = new Set(); + /** typeKeys of the array types the program may freeze + * (IrModule.freezableArrays): a frozen array refuses writes like a + * published one, so their stores carry the same guard. */ + readonly frozenArrays = new Set(); /** Constructor stores into a `this` nothing else can reach yet * (immutability.ts): never a published target, so never guarded. */ privateStores: ReadonlySet = new Set(); @@ -64,10 +68,16 @@ export class PublishedTypes { } containerGuarded(t: IrType): boolean { - return ( - (t.kind === "array" || t.kind === "map" || t.kind === "set") && - this.containers.has(typeKey(t)) - ); + if (t.kind !== "array" && t.kind !== "map" && t.kind !== "set") return false; + const key = typeKey(t); + return this.containers.has(key) || this.frozenArrays.has(key); + } + + /** 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 } { + const key = typeKey(t); + return { published: this.containers.has(key), frozen: this.frozenArrays.has(key) }; } } @@ -82,7 +92,13 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null { }; for (const fn of mod.functions) everyStmtList(fn.body, { expr: visit, stmt: (_s: IrStmt) => true }); - if (roots.length === 0) return null; + const frozenArrays = mod.freezableArrays ?? []; + if (roots.length === 0 && frozenArrays.length === 0) return null; + if (roots.length === 0) { + const frozen = new PublishedTypes(); + for (const key of frozenArrays) frozen.frozenArrays.add(key); + return frozen; + } const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const children = new Map(); @@ -95,6 +111,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null { const records = new Map((mod.records ?? []).map((r) => [r.id, r])); const unions = new Map((mod.unions ?? []).map((u) => [u.id, u])); const out = new PublishedTypes(); + for (const key of frozenArrays) out.frozenArrays.add(key); out.privateStores = computeConstructionFacts(mod).privateStores; const seen = new Set(); const stack = [...roots]; diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index f61de39ec..30eb9e455 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -3,6 +3,7 @@ import { BOOL, VOID, shapeHasAccessorSlots, + typeKey, type IrExpr, type IrFunction, type IrStmt, @@ -15,24 +16,31 @@ import type { Lowerer } from "./lowerer.js"; import { recordHasOwnHelper } from "./lower-calls.js"; import { isStrictCode } from "./strict-mode.js"; -/* Object.freeze of records that other references can still write. Only the - * shapes a program may freeze pay for it: their values keep a frozen bit in - * the rc word (scr_rc.h SCR_RC_FROZEN), their release ignores the bit, and - * every write to them checks it first (guardFrozenRecordWrites). A shape is +/* Object.freeze of records and arrays that other references can still + * write. Only the types a program may freeze pay for it. A record keeps its + * frozen bit in the rc word (scr_rc.h SCR_RC_FROZEN), which its release + * ignores, and every write to it checks the bit first + * (guardFrozenWrites). An array keeps it in its storage flags + * (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 * held as a live untyped reference. */ type RecordType = Extract; type RecordWrite = Extract; +type FreezeFn = "record.freeze" | "array.freeze" | "collection.preventExtensions"; -/** Object.freeze of a record: sets its frozen bit and answers the record. */ -export function lowerRecordFreeze( +/** Object.freeze of a record or an array, or Object.freeze, seal or + * preventExtensions of a Map or Set: `fn` sets the value's bit, and the + * call answers the value. */ +export function lowerNativeFreeze( lowerer: Lowerer, + fn: FreezeFn, target: IrExpr, - type: RecordType, loc: SrcLoc, ): IrExpr { + const type = target.type; const slot = lowerer.declareHiddenLocal("%freezeTarget", type); const receiver = (): IrExpr => varRef(slot.id, type, loc); return { @@ -41,7 +49,7 @@ export function lowerRecordFreeze( { kind: "varDecl", localId: slot.id, init: target, loc }, { kind: "exprStmt", - expr: { kind: "libCall", fn: "record.freeze", args: [receiver()], type: VOID, loc }, + expr: { kind: "libCall", fn, args: [receiver()], type: VOID, loc }, loc, }, ], @@ -92,9 +100,14 @@ function holdsAbsentState(e: IrExpr): boolean { return e.kind === "ternary" && (holdsAbsentState(e.then) || holdsAbsentState(e.else_)); } -/** The shapes whose values the program may freeze. */ -function freezableShapes(lowerer: Lowerer, functions: IrFunction[]): Set { +/** The record shapes and array types (typeKeys) whose values the program + * may freeze. */ +function freezableTypes( + lowerer: Lowerer, + functions: IrFunction[], +): { shapes: Set; arrays: Set } { const shapes = new Set(); + const arrays = new Set(); const live: IrType[] = []; let dynFreeze = false; for (const fn of functions) @@ -103,13 +116,16 @@ function freezableShapes(lowerer: Lowerer, functions: IrFunction[]): Set if (e.kind === "libCall" && e.fn === "record.freeze") { const target = e.args[0]; if (target && target.type.kind === "record") shapes.add(target.type.shapeId); + } 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); return true; }, stmt: () => true, }); - if (!dynFreeze) return shapes; + if (!dynFreeze) 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 @@ -135,7 +151,14 @@ function freezableShapes(lowerer: Lowerer, functions: IrFunction[]): Set seen.add(`union:${type.unionId}`); for (const arm of lowerer.unions.get(type.unionId)?.arms ?? []) stack.push(arm); break; - case "array": + case "array": { + const key = typeKey(type); + if (seen.has(key)) break; + seen.add(key); + arrays.add(key); + stack.push(type.elem); + break; + } case "set": stack.push(type.elem); break; @@ -156,7 +179,7 @@ function freezableShapes(lowerer: Lowerer, functions: IrFunction[]): Set break; } } - return shapes; + return { shapes, arrays }; } /** The guarded form of a write to a freezable record: the receiver and the @@ -186,15 +209,18 @@ function guardedWrite( } /** Checks the frozen bit before every write to a record of a freezable - * shape. A write to a frozen record throws Node's TypeError in strict mode - * code; in sloppy-mode code it leaves the record unchanged. Deleting a key - * the record does not have succeeds either way. */ -export function guardFrozenRecordWrites(lowerer: Lowerer, functions: IrFunction[]): void { - const freezable = freezableShapes(lowerer, functions); + * shape, and lists the freezable array types, whose writes the backend + * guards (IrModule.freezableArrays). A write to a frozen record throws + * Node's TypeError in strict mode code; in sloppy-mode code it leaves the + * record unchanged. Deleting a key the record does not have succeeds either + * way. */ +export function guardFrozenWrites(lowerer: Lowerer, functions: IrFunction[]): void { + const { shapes: freezable, arrays } = freezableTypes(lowerer, functions); for (const id of freezable) { const shape = lowerer.shapes.get(id); if (shape) shape.freezable = true; } + for (const key of arrays) lowerer.freezableArrays.add(key); if (freezable.size === 0) return; const lifted = lowerer.liftedFns.length; for (const fn of functions) { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e2ed5f9fe..819f90534 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -245,7 +245,7 @@ import { import { fenceSymbolFieldCopy } from "./symbol-fields.js"; import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js"; import { lowerClassFieldDefine, lowerClassIntegrity } from "./class-attributes.js"; -import { freezableRecordType, lowerRecordFreeze } from "./frozen-records.js"; +import { freezableRecordType, lowerNativeFreeze } from "./frozen-records.js"; import { classStaticDataFor } from "./class-static-data.js"; import { lowerUrlFactory } from "./lower-url.js"; import { nullishValueUnitOf } from "./binding-analysis.js"; @@ -8422,6 +8422,22 @@ function lowerObjectStaticCall( locOf(call), ); if (fnIntegrity) return fnIntegrity; + // A Map or Set has no own properties: freeze, seal and preventExtensions + // all only make it not extensible. + if (value.type.kind === "map" || value.type.kind === "set") { + if (member === "preventExtensions" || member === "seal") + return lowerNativeFreeze(lowerer, "collection.preventExtensions", value, locOf(call)); + const extensible: IrExpr = { + kind: "libCall", + fn: "collection.isExtensible", + args: [value], + type: BOOL, + loc: locOf(call), + }; + return member === "isExtensible" + ? extensible + : { kind: "unary", op: "!", operand: extensible, type: BOOL, loc: locOf(call) }; + } if (member === "preventExtensions" || member === "seal") { const integrity = lowerClassIntegrity(lowerer, call, member, value); if (integrity) return integrity; @@ -8461,15 +8477,36 @@ function lowerObjectStaticCall( // reference would need a runtime frozen bit (strict mode throws). if (member === "isFrozen" && call.arguments.nodes.length === 1) { const argNode = call.arguments.nodes[0]!; - if (tryLowerExpression(lowerer, argNode)?.type.kind === "record") { - const record = lowerer.lowerExpr(argNode); - if (record.type.kind === "record") + const probedKind = tryLowerExpression(lowerer, argNode)?.type.kind; + if ( + probedKind === "record" || + probedKind === "array" || + probedKind === "map" || + probedKind === "set" + ) { + const native = lowerer.lowerExpr(argNode); + const loc = locOf(call); + if (native.type.kind === "record" || native.type.kind === "array") return { kind: "libCall", - fn: "record.isFrozen", - args: [record], + fn: native.type.kind === "record" ? "record.isFrozen" : "array.isFrozen", + args: [native], type: BOOL, - loc: locOf(call), + loc, + }; + if (native.type.kind === "map" || native.type.kind === "set") + return { + kind: "unary", + op: "!", + operand: { + kind: "libCall", + fn: "collection.isExtensible", + args: [native], + type: BOOL, + loc, + }, + type: BOOL, + loc, }; } const value = lowerer.lowerExprExpecting(argNode, DYN); @@ -8523,7 +8560,11 @@ function lowerObjectStaticCall( const integrity = lowerClassIntegrity(lowerer, call, "freeze", value); if (integrity) return integrity; if (freezableRecordType(lowerer, value.type)) - return lowerRecordFreeze(lowerer, value, value.type, locOf(call)); + return lowerNativeFreeze(lowerer, "record.freeze", value, locOf(call)); + if (value.type.kind === "array") + return lowerNativeFreeze(lowerer, "array.freeze", value, locOf(call)); + if (value.type.kind === "map" || value.type.kind === "set") + return lowerNativeFreeze(lowerer, "collection.preventExtensions", value, locOf(call)); lowerer.noLowering( "Object.freeze of a possibly-aliased value", call, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ba6d1214f..95deb648b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -150,7 +150,7 @@ import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optio import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; import { guardClassAttributeWrites } from "./class-attributes.js"; -import { guardFrozenRecordWrites, markSloppyRecordWrites } from "./frozen-records.js"; +import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js"; import { applyHandleProperties } from "./handle-properties.js"; import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; @@ -3246,6 +3246,8 @@ export class Lowerer { * absent-state writes of `delete r.f` (markSloppyRecordWrites). */ readonly sloppyRecordWrites = new Set(); readonly recordFieldDeletes = new Set(); + /** typeKeys of the array types the program may freeze (IrModule.freezableArrays). */ + readonly freezableArrays = new Set(); readonly liftedFns: IrFunction[] = []; /** The first lifted function of each name (liftedFnNamed), for the * liftedFns indexed so far. liftedFns only grows. */ @@ -7694,7 +7696,7 @@ export class Lowerer { finalizePrivateBrands(this, functions); trackClassFieldCreation(this, functions); guardClassAttributeWrites(this, functions); - guardFrozenRecordWrites(this, functions); + guardFrozenWrites(this, functions); applyHandleProperties(this, functions); // Globals typed by a class that never REGISTERED (a JS class whose @@ -7803,6 +7805,9 @@ export class Lowerer { ...(this.threadSlotKeys.size > 0 ? { threadSlotKeys: [...this.threadSlotKeys].sort() } : {}), + ...(this.freezableArrays.size > 0 + ? { freezableArrays: [...this.freezableArrays].sort() } + : {}), ...(isNodeEsmFile(this.entry, this.program) ? {} : { commonJsEntry: true }), ...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}), }; diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 1b2c1082d..717f7ed8a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -283,6 +283,16 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * object's text in the message ("#") and the operation (0 assign, * 1 add, 2 delete). */ "record.throwFrozen": { argTypes: [STRING, STRING, F64], result: VOID }, + /** Object.freeze of an array (scr_arr_freeze): sets its frozen bit, which + * the write guards of IrModule.freezableArrays test, and isFrozen reads. + * The receiver is any array type. */ + "array.freeze": { argTypes: [null], result: VOID }, + "array.isFrozen": { argTypes: [null], result: BOOL }, + /** Object.freeze, seal or preventExtensions of a Map or Set + * (scr_map_prevent_extensions), which only makes it not extensible: its + * entries stay writable. The receiver is any map or set type. */ + "collection.preventExtensions": { argTypes: [null], result: VOID }, + "collection.isExtensible": { argTypes: [null], result: BOOL }, "dyn.nativeSetNew": { argTypes: [DYN], result: DYN }, "dyn.nativeSetIs": { argTypes: [DYN], result: BOOL }, "dyn.nativeMapIs": { argTypes: [DYN], result: BOOL }, diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 26539b17a..91e1d5f05 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -219,6 +219,7 @@ const MODULE_FIELDS: Record>, Role> = { "wasmUntranslated[].sha256": "other", "wasmUntranslated[].reason": "other", "threadSlotKeys[]": "other", + "freezableArrays[]": "other", }; const loc = { file: "references.ts", start: 0, end: 0 }; diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 3211e48f3..df23f0360 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1056,6 +1056,10 @@ export interface IrModule { * each one's hierarchy carries a slot word (the slots.* intrinsics; * backend/llvm/thread-slots.ts). */ threadSlotKeys?: string[]; + /** typeKeys of the array types whose values the program may freeze + * (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 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/test/ts7/baselines/order-parity/tests/corpus/7406-frozen-containers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7406-frozen-containers.ts.json new file mode 100644 index 000000000..34fb3ba0e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7406-frozen-containers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7406-frozen-containers.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 91328a1e8..af810483f 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -1689,6 +1689,126 @@ void scr_arr_release(ScrArr *a) { double scr_arr_len(ScrArr *a) { return SCR_ARR_LEN_F64(a->len); } +/* ── frozen arrays ────────────────────────────────────────────────────── + * Node's TypeErrors for writes to a frozen array, shared by the emitted + * guards of published and frozen arrays and the checked-dynamic tier. */ +static void scr_frozen_throw(const char *before, double index, const char *after) { + char text[64]; + size_t n = scr_f64_to_str(index, text); + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, before); + scr_jb_write(&buffer, text, n); + scr_jb_puts(&buffer, after); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); +} + +/* A relative array index argument (fill, copyWithin, splice): ToIntegerOrInfinity + * of `value`, counted from the end when negative, clamped to [0, len]. */ +static size_t scr_frozen_relative_index(double value, size_t len) { + if (value != value) return 0; + double n = trunc(value); + if (n < 0) return n + (double)len <= 0 ? 0 : (size_t)(n + (double)len); + return n >= (double)len ? len : (size_t)n; +} + +static void scr_frozen_read_only(double index) { + scr_frozen_throw("Cannot assign to read only property '", index, "' of object '[object Array]'"); +} + +static void scr_frozen_not_extensible(double index) { + scr_frozen_throw("Cannot add property ", index, ", object is not extensible"); +} + +static void scr_frozen_undeletable(double index) { + scr_frozen_throw("Cannot delete property '", index, "' of [object Array]"); +} + +/* Each case finds the operation's first write in the order the spec makes + * its writes. */ +bool scr_array_frozen_error(int op, size_t len, double x, double y, double z) { + switch (op) { + case 0: + if (x >= 0 && x < (double)len && x == (double)(size_t)x) scr_frozen_read_only(x); + else scr_frozen_not_extensible(x); + return true; + case 1: + if (y == 0) break; + scr_frozen_not_extensible((double)len); + return true; + case 2: + case 6: + if (len == 0) break; + if (op == 6 && len > 1) scr_frozen_read_only(0); + else scr_frozen_undeletable((double)(len - 1)); + return true; + case 3: break; + case 4: + if (y == 0) break; + scr_frozen_not_extensible(len ? (double)len + y - 1 : 0); + return true; + case 5: + if (!(x >= 0 && x < (double)len && x == (double)(size_t)x)) return false; + scr_frozen_undeletable(x); + return true; + case 7: + if (len < 2) return false; + scr_frozen_read_only(0); + return true; + case 8: { + size_t k = scr_frozen_relative_index(x, len), final = scr_frozen_relative_index(y, len); + if (k >= final) return false; + scr_frozen_read_only((double)k); + return true; + } + case 9: { + size_t to = scr_frozen_relative_index(x, len), from = scr_frozen_relative_index(y, len); + size_t final = scr_frozen_relative_index(z, len); + if (final <= from || len <= to) return false; + size_t count = final - from < len - to ? final - from : len - to; + /* An overlapping copy toward the end writes its last index first. */ + if (from < to && to < from + count) to += count - 1; + scr_frozen_read_only((double)to); + return true; + } + case 10: { + size_t start = scr_frozen_relative_index(x, len); + double count = y != y ? 0 : trunc(y); + size_t removed = count <= 0 ? 0 : count >= (double)(len - start) ? len - start : (size_t)count; + size_t items = (size_t)z; + if (items < removed) { + /* Elements after the removed ones move down, then the tail goes. */ + if (start < len - removed) scr_frozen_read_only((double)(start + items)); + else scr_frozen_undeletable((double)(len - 1)); + return true; + } + if (items > removed) { + /* Elements after the removed ones move up, past the end first. */ + if (len - removed > start) scr_frozen_not_extensible((double)(len - removed + items - 1)); + else if (start < len) scr_frozen_read_only((double)start); + else scr_frozen_not_extensible((double)start); + return true; + } + if (items > 0) { + scr_frozen_read_only((double)start); + return true; + } + break; + } + default: + return false; + } + static const char length[] = "Cannot assign to read only property 'length' of object '[object Array]'"; + scr_throw_error_msg(SCR_ERR_TYPE, length, sizeof length - 1); + return true; +} + +void scr_arr_freeze(ScrArr *a) { + if (a->rc != SIZE_MAX) a->spare |= SCR_ARR_FROZEN; +} + +bool scr_arr_is_frozen(const ScrArr *a) { return scr_arr_frozen(a); } + bool scr_arr_has(const ScrArr *a, double i) { size_t idx; if (!scr_arr_valid_index(i, &idx)) return scr_arr_prop_get(a, i, NULL); @@ -4990,6 +5110,8 @@ bool scr_arr_sort_dense(ScrArr *a, ScrClosure *f, uint32_t arity, bool owned, in if (a->rc == SIZE_MAX || elem == SCR_ELEM_CREF || !scr_arr_dense_values(a)) return false; size_t n = a->len; if (n < 2) return true; + /* A frozen array takes the snapshot path, whose write-back throws. */ + if (scr_arr_frozen(a)) return false; if (n > SIZE_MAX / 2 / sizeof(uint64_t)) scr_arr_oom(); ScrSortCompare c = scr_sort_compare_for(a, f, arity, order, key); /* Nothing else can reach a private copy: its own storage is sorted, and diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 4666750ea..d0c0ea1a8 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -128,7 +128,8 @@ typedef struct ScrArr { * header. Kept beside `elem` so the RC fast paths test it with one load. */ uint8_t cyc; /* The low SCR_ARR_OPS_SHIFT bits: storage flags (SCR_ARR_INLINE_SLOTS, - * SCR_ARR_HEAD_ROOM, SCR_ARR_UNSHIFTED, SCR_ARR_HAS_EXT, SCR_ARR_STATES). The rest: the + * SCR_ARR_HEAD_ROOM, SCR_ARR_UNSHIFTED, SCR_ARR_HAS_EXT, SCR_ARR_STATES) + * and SCR_ARR_FROZEN. The rest: the * registry id of the element type's ops (scr_arr_ops), 0 for every kind * but SCR_ELEM_REF and SCR_ELEM_CREF. */ uint16_t spare; @@ -204,6 +205,10 @@ _Static_assert(sizeof(ScrArr) == sizeof(size_t) + 16 + sizeof(void *), #define SCR_ARR_UNSHIFTED 4u #define SCR_ARR_HAS_EXT 8u #define SCR_ARR_STATES 16u +/* Object.freeze of the array (scr_arr_freeze). Emitted guards test it + * before the writes of array types a program may freeze; the runtime's own + * writes assume it is clear. */ +#define SCR_ARR_FROZEN 32u /* An array starts without state bytes. Without them, every index below * `len` is a present value in dense storage: len <= cap, there are no @@ -214,6 +219,19 @@ _Static_assert(sizeof(ScrArr) == sizeof(size_t) + 16 + sizeof(void *), * states (scr_arr_add_states), which it keeps. */ static inline bool scr_arr_has_states(const ScrArr *a) { return (a->spare & SCR_ARR_STATES) != 0; } +static inline bool scr_arr_frozen(const ScrArr *a) { return (a->spare & SCR_ARR_FROZEN) != 0; } +/* Sets SCR_ARR_FROZEN; immortal arrays are left as they are. */ +void scr_arr_freeze(ScrArr *a); +bool scr_arr_is_frozen(const ScrArr *a); +/* Throws Node's TypeError for the first write that the array operation + * `op` makes to a frozen array of length `len`, or answers false when the + * operation writes nothing. op: 0 index write (x = index), 1 push (y = + * argument count), 2 pop, 3 length write, 4 unshift (y = argument count), + * 5 delete (x = index), 6 shift, 7 reverse or sort, 8 fill (x = start, y = + * end), 9 copyWithin (x = target, y = start, z = end), 10 splice (x = + * start, y = delete count, z = item count). */ +bool scr_array_frozen_error(int op, size_t len, double x, double y, double z); + /* The start of the dense slots' allocation: `data` less any head room. */ static inline uint64_t *scr_arr_block(const ScrArr *a) { return (a->spare & SCR_ARR_HEAD_ROOM) ? a->data - (size_t)a->data[-1] : a->data; @@ -236,7 +254,7 @@ static inline struct ScrArrExt *scr_arr_ext_of(const ScrArr *a) { * Id 0 is no ops; ids are assigned on each descriptor's first array, in * any thread. A program with more element types than the field holds keeps * the ops of the rest in the array's ext (SCR_ARR_OPS_OVERFLOW). */ -#define SCR_ARR_OPS_SHIFT 5 +#define SCR_ARR_OPS_SHIFT 6 #define SCR_ARR_OPS_OVERFLOW (UINT16_MAX >> SCR_ARR_OPS_SHIFT) extern const ScrArrOps *scr_arr_ops_table[SCR_ARR_OPS_OVERFLOW]; /* By registry id: 1 when the descriptor has SCR_ARR_OPS_RELEASE_NOTHING. diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 4039fda94..727e3e835 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -740,6 +740,8 @@ static ScrDyn *dyn_live_array_method(ScrDyn *recv, int mid, ScrDyn *const *args, if (!ops) return NULL; ScrArr *arr = recv->v.typed_ref.ptr; size_t len = arr->len; + /* A frozen array's view refuses the write. */ + if (scr_arr_frozen(arr) && ((mid == DYN_M_push) || (mid == DYN_M_pop))) return NULL; if ((mid == DYN_M_push) && argc == 1 && ops->set) { if (!ops->set(arr, (double)len, args[0])) return NULL; *handled = true; @@ -1098,16 +1100,20 @@ static ScrDyn *scr_dyn_invoke_impl( return NULL; } } - if (recv->v.arr.frozen && ((mid == DYN_M_push) || - (mid == DYN_M_pop) || (mid == DYN_M_shift) || - (mid == DYN_M_unshift) || (mid == DYN_M_splice))) { - static const char message[] = "Cannot assign to read only property 'length'"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + /* A frozen array throws at the operation's first write, as in Node. */ + int frozen_op = !recv->v.arr.frozen ? -1 + : (mid == DYN_M_push) ? 1 + : (mid == DYN_M_pop) ? 2 + : (mid == DYN_M_shift) ? 6 + : (mid == DYN_M_unshift) ? 4 + : (mid == DYN_M_splice) ? 10 + : ((mid == DYN_M_sort) || (mid == DYN_M_reverse)) ? 7 + : -1; + double items = (mid == DYN_M_splice) ? (argc > 2 ? (double)(argc - 2) : 0) : (double)argc; + if (frozen_op >= 0 && + scr_array_frozen_error(frozen_op, len, (double)splice_start, frozen_op == 10 ? (double)splice_removed : items, items)) return NULL; - } - if ((grows && recv->non_extensible) || (shrinks && recv->v.arr.sealed && !(mid == DYN_M_shift)) || - (recv->v.arr.frozen && (((mid == DYN_M_sort) && len > 1) || - ((mid == DYN_M_reverse) && len > 1)))) { + if ((grows && recv->non_extensible) || (shrinks && recv->v.arr.sealed && !(mid == DYN_M_shift))) { static const char message[] = "Cannot modify a sealed or frozen array"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); return NULL; @@ -1376,11 +1382,7 @@ static ScrDyn *scr_dyn_invoke_impl( if (!dyn_arr_length_unchanged(recv, len)) return NULL; size_t start = dyn_rel_index(startD, len); size_t end = dyn_rel_index(endD, len); - if (recv->v.arr.frozen && start < end) { - static const char message[] = "Cannot assign to read only array index"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); - return NULL; - } + if (recv->v.arr.frozen && scr_array_frozen_error(8, len, (double)start, (double)end, 0)) return NULL; ScrDyn *fill = argc ? args[0] : scr_dyn_undefined(); if (start < end) scr_dyn_region_store(recv, fill); for (size_t i = start; i < end; i++) { @@ -1409,11 +1411,8 @@ static ScrDyn *scr_dyn_invoke_impl( size_t end = dyn_rel_index(endD, len); size_t count = end > start ? end - start : 0; if (count > len - target) count = len - target; - if (recv->v.arr.frozen && count > 0) { - static const char message[] = "Cannot assign to read only array index"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + if (recv->v.arr.frozen && scr_array_frozen_error(9, len, (double)target, (double)start, (double)end)) return NULL; - } if (start < target && target < start + count) { for (size_t i = count; i > 0; i--) { ScrDyn *old = recv->v.arr.items[target + i - 1]; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index a542b5c24..13bd4d066 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2302,8 +2302,32 @@ static void *scr_dyn_typed_record(const ScrDyn *d) { return d->v.typed_ref.ptr; } -/* Freezes the checked view of a frozen record: its fields, and every - * property added through the view. */ +/* The native array an array capsule refers to, or NULL for any other + * value. */ +static ScrArr *scr_dyn_typed_array(const ScrDyn *d) { + if (d->kind != SCR_DYN_TYPED_REF || d->v.typed_ref.type_key_len < 6 || + memcmp(d->v.typed_ref.type_key, "array<", 6) != 0) + return NULL; + return d->v.typed_ref.ptr; +} + +/* The native Map or Set a handle holds, or NULL for any other value. */ +static ScrMap *scr_dyn_native_collection(const ScrDyn *d) { + if (d->kind != SCR_DYN_HANDLE || (d->v.handle.tag != SCR_DYNH_MAP && d->v.handle.tag != SCR_DYNH_SET)) + return NULL; + return d->v.handle.ptr; +} + +/* Whether a capsule's native record or array is frozen. */ +static bool scr_dyn_typed_ref_frozen(const ScrDyn *d) { + void *record = scr_dyn_typed_record(d); + if (record) return scr_rc_frozen(record); + ScrArr *array = scr_dyn_typed_array(d); + return array && scr_arr_frozen(array); +} + +/* Freezes the checked view of a frozen record or array: its fields or + * elements, and every property added through the view. */ static void scr_dyn_record_view_freeze(ScrDyn *view) { view->non_extensible = true; if (view->kind == SCR_DYN_OBJ) { @@ -2350,9 +2374,9 @@ static bool scr_dyn_class_integrity_fence(const ScrDyn *object, const char *oper ScrJsonBuf buffer; scr_jb_init(&buffer); scr_jb_puts(&buffer, operation); - scr_jb_puts(&buffer, scr_dyn_typed_record(object) - ? " of an object held in an untyped value is not supported yet" - : " of a class instance held in an untyped value is not supported yet"); + scr_jb_puts(&buffer, scr_dyn_typed_record(object) ? " of an object held in an untyped value is not supported yet" + : scr_dyn_typed_array(object) ? " of an array held in an untyped value is not supported yet" + : " of a class instance held in an untyped value is not supported yet"); ScrStr *message = scr_jb_finish(&buffer); scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); scr_str_release(message); @@ -2400,6 +2424,11 @@ static ScrDyn *scr_dyn_fn_existing_table(const ScrDyn *function) { ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object) || scr_dyn_error_prototype_fence(object)) return NULL; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.preventExtensions"); return NULL; } + ScrMap *collection = scr_dyn_native_collection(object); + if (collection) { + scr_map_prevent_extensions(collection); + return 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) { @@ -2419,6 +2448,8 @@ bool scr_dyn_is_extensible(ScrDyn *object) { if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isExtensible"); 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); + if (collection) return scr_map_is_extensible(collection); if (scr_dyn_plain_fn(object)) { ScrDyn *table = scr_dyn_fn_existing_table(object); answer = !table || !table->non_extensible; @@ -2431,6 +2462,11 @@ bool scr_dyn_is_extensible(ScrDyn *object) { ScrDyn *scr_dyn_seal(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object) || scr_dyn_error_prototype_fence(object)) return NULL; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.seal"); return NULL; } + ScrMap *collection = scr_dyn_native_collection(object); + if (collection) { + scr_map_prevent_extensions(collection); + return 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); @@ -2455,6 +2491,8 @@ bool scr_dyn_is_sealed(const ScrDyn *object) { if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isSealed"); return false; } 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); + if (collection) return !scr_map_is_extensible(collection); if (object->symbol_properties && !scr_dyn_is_sealed(object->symbol_properties)) return false; if (scr_dyn_plain_fn(object)) { ScrDyn *table = scr_dyn_fn_existing_table(object); @@ -3679,16 +3717,14 @@ ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d) { fresh->rc = fresh_rc; scr_dyn_release(fresh); } - void *record = scr_dyn_typed_record(capsule); - if (record && scr_rc_frozen(record)) scr_dyn_record_view_freeze(capsule->v.typed_ref.materialized); + if (scr_dyn_typed_ref_frozen(capsule)) scr_dyn_record_view_freeze(capsule->v.typed_ref.materialized); return scr_dyn_retain(capsule->v.typed_ref.materialized); } void scr_dyn_typed_ref_commit(ScrDyn *d) { - /* A frozen record's view cannot have changed its fields. */ + /* A frozen record's or array's view cannot have changed its contents. */ if (d && d->kind == SCR_DYN_TYPED_REF && d->v.typed_ref.commit && - d->v.typed_ref.materialized && - !(scr_dyn_typed_record(d) && scr_rc_frozen(d->v.typed_ref.ptr))) { + d->v.typed_ref.materialized && !scr_dyn_typed_ref_frozen(d)) { d->v.typed_ref.commit(d->v.typed_ref.ptr, d->v.typed_ref.materialized); } @@ -3927,6 +3963,8 @@ static bool scr_typed_ref_array_has_own(const ScrDyn *d, const ScrStr *key) { static bool scr_typed_ref_array_set(ScrDyn *d, const ScrStr *key, const ScrDyn *value) { const ScrDynTypedArrayOps *ops = scr_dyn_typed_ref_array_ops(d); if (!ops || !ops->set) return false; + /* A frozen array's view refuses the write. */ + if (scr_arr_frozen(d->v.typed_ref.ptr)) return false; /* Numeric properties live in the native array's property table, which * holds values of the element type. */ double number; @@ -9230,6 +9268,26 @@ bool scr_dyn_in(const ScrDyn *v, const ScrStr *key) { return false; } +/* Node's TypeErrors for assigning a read-only property and deleting a + * non-configurable one of a checked object: the object reads as + * #, or [object Object] without a prototype. */ +static void scr_dyn_throw_property(const ScrDyn *object, const char *key, size_t key_len, bool deleting) { + const char *name = scr_dyn_class_name(object); + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, deleting ? "Cannot delete property '" : "Cannot assign to read only property '"); + scr_jb_write(&buffer, key, key_len); + scr_jb_puts(&buffer, deleting ? "' of " : "' of object '"); + if (object->null_proto) scr_jb_puts(&buffer, "[object Object]"); + else { + scr_jb_puts(&buffer, "#<"); + scr_jb_puts(&buffer, name ? name : "Object"); + scr_jb_puts(&buffer, ">"); + } + if (!deleting) scr_jb_puts(&buffer, "'"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); +} + static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, const ScrDyn *receiver) { for (size_t i = 0; i < recv->v.obj.len; i++) { ScrDynEntry *entry = &recv->v.obj.entries[i]; @@ -9245,8 +9303,7 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con return; } if (entry->accessor || !entry->writable) { - static const char msg[] = "Cannot assign to read only property"; - scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + scr_dyn_throw_property(receiver, key->data, key->len, false); return; } } @@ -9273,8 +9330,7 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con return; } if (inherited->accessor || !inherited->writable) { - static const char message[] = "Cannot assign to read only property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_dyn_throw_property(receiver, key->data, key->len, false); scr_dyn_release(table); return; } @@ -9354,8 +9410,7 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { if (recv->kind == SCR_DYN_ARR) { if (key->len == 6 && memcmp(key->data, "length", 6) == 0) { if (recv->v.arr.frozen) { - static const char message[] = "Cannot assign to read only property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_array_frozen_error(3, recv->v.arr.len, 0, 0, 0); return; } double number; @@ -9374,8 +9429,7 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { while (recv->v.arr.len < length) scr_dyn_arr_push_hole(recv); while (recv->v.arr.len > length) { if (recv->v.arr.sealed && scr_dyn_arr_has_index(recv, recv->v.arr.len - 1)) { - static const char message[] = "Cannot delete non-configurable property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_array_frozen_error(5, recv->v.arr.len, (double)(recv->v.arr.len - 1), 0, 0); return; } scr_dyn_release(recv->v.arr.items[--recv->v.arr.len]); @@ -9393,14 +9447,13 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { } if (is_index && idx < 4294967295ULL) { if (scr_dyn_array_prototype_element_fence(recv)) return; - if (recv->v.arr.frozen && idx < recv->v.arr.len) { - static const char message[] = "Cannot assign to read only property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + if (recv->v.arr.frozen && idx < recv->v.arr.len && scr_dyn_arr_has_index(recv, idx)) { + scr_array_frozen_error(0, recv->v.arr.len, (double)idx, 0, 0); return; } if (recv->non_extensible && !scr_dyn_arr_has_index(recv, idx)) { - static const char message[] = "Cannot add property, object is not extensible"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + /* An absent index, a hole included, is a property to add. */ + scr_array_frozen_error(0, 0, (double)idx, 0, 0); return; } if (idx > recv->v.arr.len + 1000000) { @@ -11229,10 +11282,7 @@ void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) { if (recv->kind == SCR_DYN_OBJ) { ScrDynEntry *entry = scr_dyn_entry(recv, key); if (entry && !entry->configurable) { - if (strict) { - static const char message[] = "Cannot delete non-configurable property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); - } + if (strict) scr_dyn_throw_property(recv, key->data, key->len, true); return; } scr_json_delete_member(recv, key); @@ -11244,10 +11294,7 @@ void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) { for (size_t i = 0; i < key->len; i++) index = index * 10 + (size_t)(key->data[i] - '0'); if (!scr_dyn_arr_has_index(recv, index)) return; if (recv->v.arr.sealed) { - if (strict) { - static const char message[] = "Cannot delete non-configurable property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); - } + if (strict) scr_array_frozen_error(5, recv->v.arr.len, (double)index, 0, 0); return; } if (!recv->v.arr.presence) { @@ -15682,18 +15729,29 @@ ScrStr *scr_dyn_to_string_method(const ScrDyn *d, const ScrStr *enc, const ScrSt return scr_dyn_to_string_method_args(d, enc, what, NULL, 0); } -/* Freezing a record capsule freezes the native record, whose emitted writes - * then refuse to change it, and its checked view. */ -static ScrDyn *scr_dyn_freeze_record(ScrDyn *capsule, void *record) { - scr_record_freeze(record); - if (capsule->v.typed_ref.materialized) scr_dyn_record_view_freeze(capsule->v.typed_ref.materialized); - return scr_dyn_retain(capsule); +/* Freezing a record or array capsule freezes the native value, whose + * emitted writes then refuse to change it, and its checked view. A Map or + * Set handle only stops being extensible: its entries stay writable. Answers + * NULL for every other value. */ +static ScrDyn *scr_dyn_freeze_native(ScrDyn *value) { + void *record = scr_dyn_typed_record(value); + ScrArr *array = record ? NULL : scr_dyn_typed_array(value); + ScrMap *collection = scr_dyn_native_collection(value); + if (collection) { + scr_map_prevent_extensions(collection); + return scr_dyn_retain(value); + } + if (!record && !array) return NULL; + if (record) scr_record_freeze(record); + else scr_arr_freeze(array); + if (value->v.typed_ref.materialized) scr_dyn_record_view_freeze(value->v.typed_ref.materialized); + return scr_dyn_retain(value); } ScrDyn *scr_dyn_freeze(ScrDyn *value) { if (scr_dyn_class_reflection_fence(value) || scr_dyn_error_prototype_fence(value)) return NULL; - void *record = scr_dyn_typed_record(value); - if (record) return scr_dyn_freeze_record(value, record); + ScrDyn *native = scr_dyn_freeze_native(value); + if (native) return native; 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); @@ -15705,8 +15763,8 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value) { ScrDyn *scr_dyn_freeze_data(ScrDyn *value) { if (scr_dyn_class_reflection_fence(value) || scr_dyn_error_prototype_fence(value)) return NULL; - void *record = scr_dyn_typed_record(value); - if (record) return scr_dyn_freeze_record(value, record); + ScrDyn *native = scr_dyn_freeze_native(value); + if (native) return native; if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL; if (value->symbol_properties) scr_dyn_release(scr_dyn_freeze_data(value->symbol_properties)); if (value->kind == SCR_DYN_ARR) { @@ -15737,6 +15795,8 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { if (scr_dyn_class_reflection_fence(value)) return false; 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); + if (collection) return !scr_map_is_extensible(collection); if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false; if (scr_dyn_plain_fn(value)) { ScrDyn *table = scr_dyn_fn_existing_table(value); diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 1a6a3fc00..1cb77b0f6 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -988,6 +988,12 @@ double scr_map_size(const ScrMap *m) { return (double)m->nlive; } * table. Regrowth is amortized against the insertions that need it. * A map without entries has empty tables already: clearing it does no * work and keeps its storage for the next fill. */ +void scr_map_prevent_extensions(ScrMap *m) { + if (m->rc != SIZE_MAX) m->flags |= SCR_MAP_NOT_EXTENSIBLE; +} + +bool scr_map_is_extensible(const ScrMap *m) { return (m->flags & SCR_MAP_NOT_EXTENSIBLE) == 0; } + void scr_map_clear(ScrMap *m) { size_t walked = m->nentries; if (walked == 0) return; diff --git a/packages/runtime/src/scr_map.h b/packages/runtime/src/scr_map.h index d1ca387bf..a581cc1c7 100644 --- a/packages/runtime/src/scr_map.h +++ b/packages/runtime/src/scr_map.h @@ -116,6 +116,10 @@ typedef struct ScrMapOps { * whose object has a null prototype (Object.groupBy results). Compiled * code sets and tests the byte; the runtime never copies it. */ #define SCR_MAP_NULL_PROTO 1u +/* Object.freeze, Object.seal or Object.preventExtensions of a Map or Set: + * it has no own properties, so it is then frozen, sealed and not + * extensible at once. Its entries stay writable, as in Node. */ +#define SCR_MAP_NOT_EXTENSIBLE 2u typedef struct ScrMap { size_t rc; /* SIZE_MAX = immortal (unused for maps; kept per convention) */ @@ -272,6 +276,10 @@ void scr_map_release_v(void *m); void scr_map_trace_v(void *m, ScrTraceVisit visit, void *ctx); double scr_map_size(const ScrMap *m); /* live entries (Map.size) */ +/* Object.freeze, seal and preventExtensions of a Map or Set + * (SCR_MAP_NOT_EXTENSIBLE); immortal maps are left as they are. */ +void scr_map_prevent_extensions(ScrMap *m); +bool scr_map_is_extensible(const ScrMap *m); void scr_map_clear(ScrMap *m); bool scr_map_has_f64(const ScrMap *m, double key); diff --git a/packages/runtime/src/scr_publish.c b/packages/runtime/src/scr_publish.c index 4767122dd..afbe4d6af 100644 --- a/packages/runtime/src/scr_publish.c +++ b/packages/runtime/src/scr_publish.c @@ -527,62 +527,12 @@ void scr_throw_published_field(const char *prop, const char *owner) { scr_pub_throw("Cannot assign to read only property '%s' of object '#<%s>'", prop, owner); } -static void scr_pub_index_text(double index, char *out, size_t size) { - char buf[64]; - size_t n = scr_f64_to_str(index, buf); - if (n > size - 1) n = size - 1; - memcpy(out, buf, n); - out[n] = 0; +bool scr_throw_published_array(int op, const ScrArr *a, double index, double n) { + return scr_array_frozen_error(op, a->len, index, n, 0); } -/* op: 0 index write, 1 push (n = argument count), 2 pop, 3 length write, - * 4 unshift (n = argument count), 5 delete, 6 shift, 7 other in-place - * rewrites (splice, reverse, fill, copyWithin, sort). Returns false when - * Node's operation would not write the frozen array at all (it then is not - * an error: e.g. reversing an empty array). */ -bool scr_throw_published_array(int op, const ScrArr *a, double index, double n) { - static const char length[] = "Cannot assign to read only property 'length' of object '[object Array]'"; - char text[64]; - size_t len = a->len; - switch (op) { - case 0: - scr_pub_index_text(index, text, sizeof text); - if (index >= 0 && index < (double)len && index == (double)(size_t)index) - scr_pub_throw("Cannot assign to read only property '%s' of object '[object Array]'%s", text, ""); - else scr_pub_throw("Cannot add property %s, object is not extensible%s", text, ""); - return true; - case 1: - if (n == 0) break; - scr_pub_index_text((double)len, text, sizeof text); - scr_pub_throw("Cannot add property %s, object is not extensible%s", text, ""); - return true; - case 2: - case 6: - if (len == 0) break; - if (op == 6 && len > 1) { - scr_pub_throw("Cannot assign to read only property '0' of object '[object Array]'%s%s", "", ""); - return true; - } - scr_pub_index_text((double)(len - 1), text, sizeof text); - scr_pub_throw("Cannot delete property '%s' of [object Array]%s", text, ""); - return true; - case 3: break; - case 4: - if (n == 0) break; - scr_pub_index_text(len ? (double)len + n - 1 : 0, text, sizeof text); - scr_pub_throw("Cannot add property %s, object is not extensible%s", text, ""); - return true; - case 5: - scr_pub_index_text(index, text, sizeof text); - scr_pub_throw("Cannot delete property '%s' of [object Array]%s", text, ""); - return true; - default: - if (len == 0) return false; - scr_pub_throw("Cannot assign to read only property '0' of object '[object Array]'%s%s", "", ""); - return true; - } - scr_throw_error_msg(SCR_ERR_TYPE, length, sizeof length - 1); - return true; +bool scr_throw_published_array_range(int op, const ScrArr *a, double x, double y, double z) { + return scr_array_frozen_error(op, a->len, x, y, z); } void scr_throw_published_collection(bool set) { diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 87eb2536a..ccb74514d 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -743,12 +743,14 @@ void scr_pub_dyn_fn(void *v, ScrPub *ctx); void scr_pub_arr(void *v, ScrPub *ctx, ScrPubFn elem); void scr_pub_map(void *v, ScrPub *ctx, ScrPubFn key, ScrPubFn val); void scr_publish_refuse(const char *message); -/* Writes into published objects (emitted guards): Node's frozen-object - * TypeErrors. scr_throw_published_array answers false (and throws nothing) - * when Node's operation would not write the array. */ +/* Writes into published or frozen objects (emitted guards): Node's + * frozen-object TypeErrors. The array guards answer false (and throw + * nothing) when Node's operation would not write the array; their ops are + * scr_array_frozen_error's (scr_array.h). */ struct ScrArr; SCR_COLD void scr_throw_published_field(const char *prop, const char *owner); bool scr_throw_published_array(int op, const struct ScrArr *a, double index, double n); +bool scr_throw_published_array_range(int op, const struct ScrArr *a, double x, double y, double z); SCR_COLD void scr_throw_published_collection(bool set); /* Young-in nursery (scr_cycle.c built with SCR_YIN, linked by programs diff --git a/tests/corpus/7406-frozen-containers.ts b/tests/corpus/7406-frozen-containers.ts new file mode 100644 index 000000000..450cbf8cb --- /dev/null +++ b/tests/corpus/7406-frozen-containers.ts @@ -0,0 +1,132 @@ +// Object.freeze of arrays, Maps and Sets that other references still hold. +// A frozen array refuses every write through any reference with Node's +// TypeError for the operation's first write (this module is strict mode +// code). A frozen Map or Set only stops being extensible: its entries stay +// writable, and Object.isFrozen, isSealed and isExtensible report it. +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 writeAny(value: any): void { + value[0] = 7; +} + +function pushAny(value: any): void { + value.push(8); +} + +function integrity(value: unknown): string { + return [Object.isFrozen(value), Object.isSealed(value), Object.isExtensible(value)].join(" "); +} + +function arrays(): void { + const a: number[] = [1, 2, 3]; + const alias = a; + console.log(Object.isFrozen(a), integrity(a)); + Object.freeze(alias); + console.log(Object.isFrozen(a), integrity(a)); + try { a[0] = 9; } catch (e) { report(e); } + try { a[5] = 9; } catch (e) { report(e); } + try { a.length = 0; } catch (e) { report(e); } + try { a.push(4); } catch (e) { report(e); } + try { a.pop(); } catch (e) { report(e); } + try { a.shift(); } catch (e) { report(e); } + try { a.unshift(0); } catch (e) { report(e); } + try { a.splice(0, 1); } catch (e) { report(e); } + try { a.splice(1, 0, 7); } catch (e) { report(e); } + try { a.reverse(); } catch (e) { report(e); } + try { a.sort(); } catch (e) { report(e); } + try { a.fill(0, 1); } catch (e) { report(e); } + try { a.copyWithin(1, 0); } catch (e) { report(e); } + console.log(a.join(), a.length); + + const one: string[] = ["x"]; + Object.freeze(one); + console.log(one.reverse().join(), one.sort().join(), one.copyWithin(0, 1).join()); + try { one.splice(0, 1); } catch (e) { report(e); } + try { one.shift(); } catch (e) { report(e); } + const none: string[] = []; + Object.freeze(none); + console.log(none.reverse().length, none.fill("y").length); + try { none.pop(); } catch (e) { report(e); } + try { none.splice(0, 0, "z"); } catch (e) { report(e); } + + let calls = 0; + const byValue = (x: number, y: number): number => { + calls++; + return x - y; + }; + const unsorted: number[] = [3, 1, 2]; + Object.freeze(unsorted); + try { unsorted.sort(byValue); } catch (e) { report(e); } + const single: number[] = [5]; + Object.freeze(single); + console.log(unsorted.join(), calls > 0, single.sort(byValue).join()); + + const open: number[] = [4, 5]; + open.push(6); + open[0] = 1; + console.log(open.join(), Object.isFrozen(open)); +} + +function throughUnknown(): void { + const a: number[] = [1, 2]; + console.log(integrity(a)); + const back = freezeUnknown(a); + console.log(back === a, Object.isFrozen(a), integrity(a)); + try { a.push(3); } catch (e) { report(e); } + try { a[1] = 5; } catch (e) { report(e); } + try { writeAny(a); } catch (e) { report(e); } + try { pushAny(a); } catch (e) { report(e); } + console.log(a.join()); + + const rows: number[][] = [[1], [2]]; + freezeUnknown(rows[1]); + rows[0]!.push(10); + try { rows[1]!.push(20); } catch (e) { report(e); } + console.log(JSON.stringify(rows), Object.isFrozen(rows), Object.isFrozen(rows[1])); +} + +function collections(): void { + const m = new Map([["x", 1]]); + console.log(integrity(m)); + const frozen = Object.freeze(m); + m.set("y", 2); + m.delete("x"); + console.log(frozen === m, Object.isFrozen(m), Object.isSealed(m), Object.isExtensible(m), m.size); + const s = new Set([1]); + freezeUnknown(s); + s.add(2); + console.log(integrity(s), s.size); + const sealed = new Set(); + Object.seal(sealed); + const fresh = new Map(); + Object.preventExtensions(fresh); + console.log(Object.isFrozen(sealed), Object.isExtensible(fresh), integrity(new Map())); +} + +function lifetimes(): void { + const kept: string[][] = []; + for (let round = 0; round < 3; round++) { + const row = ["r" + round, "s" + round]; + if (round !== 1) Object.freeze(row); + kept.push(row); + } + for (const row of kept) { + try { row[0] = "changed"; } catch (e) { console.log(row[1], (e as Error).message); } + } + console.log(JSON.stringify(kept)); + kept.length = 0; + console.log(kept.length); +} + +arrays(); +throughUnknown(); +collections(); +lifetimes(); diff --git a/tests/harness/corpus-costs/7406-frozen-containers.ts.json b/tests/harness/corpus-costs/7406-frozen-containers.ts.json new file mode 100644 index 000000000..30bfa4b73 --- /dev/null +++ b/tests/harness/corpus-costs/7406-frozen-containers.ts.json @@ -0,0 +1 @@ +{ "plain": 3146, "san": 3439 }