Keep named properties written to arrays through checked views

This commit is contained in:
Chris Tate
2026-10-21 17:00:19 -05:00
parent 5954d475b7
commit 238b08352b
13 changed files with 266 additions and 7 deletions
@@ -176,6 +176,7 @@ export function streamTypedRefCommitAdapter(
const check = host.dyn.dynCheckHelper(t);
host.declare(`declare void @scr_arr_release(ptr)`);
host.declare(`declare void @scr_arr_outline(ptr)`);
host.declare(`declare void @scr_arr_adopt_view_properties(ptr, ptr, ptr)`);
const lines = [
`define internal void @${commit}(ptr %target, ptr %d) ${FN_ATTRS} { ; commit live stream element ${typeKey(t)}`,
`entry:`,
@@ -183,6 +184,8 @@ export function streamTypedRefCommitAdapter(
` %missing = icmp eq ptr %next, null`,
` br i1 %missing, label %done, label %swap`,
`swap:`,
// Properties move with the storage (in its out-of-line part).
` call void @scr_arr_adopt_view_properties(ptr %next, ptr %d, ptr %target)`,
// Inline slots live in each header's block; move them out first.
` call void @scr_arr_outline(ptr %target)`,
` call void @scr_arr_outline(ptr %next)`,
@@ -864,6 +864,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "events":
case "blob":
case "wasm":
case "array":
case "record":
case "disposable":
case "punycode":
@@ -1519,6 +1519,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"regex.groupNumber": "scr_regex_group_number",
"regex.groupParseInt": "scr_regex_group_parse_int",
"regex.resultProp": "scr_regex_result_prop",
"array.namedKeys": "scr_arr_named_keys",
// queueMicrotask's checked-dynamic form (borrowed dyn; a non-function
// throws synchronously), the minted setImmediate value, and the
// timers/promises immediate — all mark the loop live.
@@ -105,6 +105,7 @@ const INERT_LIB_CALLS = new Set([
"regex.groupNumber",
"regex.groupParseInt",
"regex.resultProp",
"array.namedKeys",
"process.cwd",
"process.argv",
"process.execArgv",
@@ -11594,10 +11594,23 @@ function lowerForInArray(
try {
const arr = lowerer.declareHiddenLocal("%inarr", arrExpr.type);
const len = lowerer.declareHiddenLocal("%inlen", F64);
const keysT: IrType = { kind: "array", elem: STRING };
const keys = lowerer.declareHiddenLocal("%inkeys", keysT);
const total = lowerer.declareHiddenLocal("%intotal", F64);
const i = lowerer.declareHiddenLocal("%ini", F64);
i.mutable = true;
const arrRef = (): IrExpr => ({ kind: "varRef", localId: arr.id, type: arrExpr.type, loc });
const keysRef = (): IrExpr => ({ kind: "varRef", localId: keys.id, type: keysT, loc });
const lenRef = (): IrExpr => ({ kind: "varRef", localId: len.id, type: F64, loc });
const iRef = (): IrExpr => ({ kind: "varRef", localId: i.id, type: F64, loc });
const isIndex = (): IrExpr => ({
kind: "bin",
op: "<",
left: iRef(),
right: lenRef(),
type: BOOL,
loc,
});
const liveLen = (): IrExpr => ({
kind: "arrIntrinsic",
method: "length",
@@ -11606,7 +11619,44 @@ function lowerForInArray(
type: F64,
loc,
});
const keyInit: IrExpr = { kind: "toString", operand: iRef(), type: STRING, loc };
// Indices first, then the named keys the array had when the loop began.
const keyInit: IrExpr = {
kind: "ternary",
cond: isIndex(),
then: { kind: "toString", operand: iRef(), type: STRING, loc },
else_: {
kind: "arrayGet",
arr: keysRef(),
index: { kind: "bin", op: "-", left: iRef(), right: lenRef(), type: F64, loc },
type: STRING,
loc,
},
type: STRING,
loc,
};
// An index is visited while it is below the live length and not a hole.
const visits: IrExpr = {
kind: "ternary",
cond: isIndex(),
then: {
kind: "logical",
op: "&&",
left: { kind: "bin", op: "<", left: iRef(), right: liveLen(), type: BOOL, loc },
right: {
kind: "bin",
op: "!==",
left: { kind: "arrayState", arr: arrRef(), index: iRef(), type: F64, loc },
right: { kind: "numLit", value: 0, type: F64, loc },
type: BOOL,
loc,
},
type: BOOL,
loc,
},
else_: { kind: "boolLit", value: true, type: BOOL, loc },
type: BOOL,
loc,
};
// The three binding forms, sharing the per-visit key declaration.
let keyDecl: IrStmt;
@@ -11670,6 +11720,32 @@ function lowerForInArray(
body: [
{ kind: "varDecl", localId: arr.id, init: arrExpr, loc },
{ kind: "varDecl", localId: len.id, init: liveLen(), loc },
{
kind: "varDecl",
localId: keys.id,
init: { kind: "libCall", fn: "array.namedKeys", args: [arrRef()], type: keysT, loc },
loc,
},
{
kind: "varDecl",
localId: total.id,
init: {
kind: "bin",
op: "+",
left: lenRef(),
right: {
kind: "arrIntrinsic",
method: "length",
receiver: keysRef(),
args: [],
type: F64,
loc,
},
type: F64,
loc,
},
loc,
},
{
kind: "for",
init: {
@@ -11682,7 +11758,7 @@ function lowerForInArray(
kind: "bin",
op: "<",
left: iRef(),
right: { kind: "varRef", localId: len.id, type: F64, loc },
right: { kind: "varRef", localId: total.id, type: F64, loc },
type: BOOL,
loc,
},
@@ -11702,7 +11778,7 @@ function lowerForInArray(
body: [
{
kind: "if",
cond: { kind: "bin", op: "<", left: iRef(), right: liveLen(), type: BOOL, loc },
cond: visits,
then: [keyDecl, ...writes, ...body],
else_: null,
loc,
@@ -3724,6 +3724,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"regex.groupParseInt": { argTypes: [arrayOf(STRING), F64, F64], result: F64 },
// `.index` / `.input` (args[1]) of a match result. Borrowed; never throws.
"regex.resultProp": { argTypes: [arrayOf(STRING), STRING], result: DYN },
// The string keys `for...in` visits on an array (args[0], any element
// type) after its indices. Borrowed; never throws.
"array.namedKeys": { argTypes: [null], result: arrayOf(STRING) },
// node:events EventEmitter: receivers are emitter-hierarchy objects and
// the chaining forms (on/off/removeAll/setMax) return the receiver's
// own class — program-dependent object types, checked in the libCall
+6
View File
@@ -4262,6 +4262,12 @@ function validateFunction(
if (!ok) err(`libCall dyn.objEntryPairs must return a [string, any] tuple array`, e.loc);
return true;
}
if (e.fn === "array.namedKeys") {
if (e.args[0]?.type.kind !== "array") {
err(`libCall array.namedKeys arg 0: expected an array, got ${e.args[0]?.type.kind}`, e.loc);
}
return true;
}
if (e.fn === "fetch.streamFrom") {
const t = e.args[0]?.type;
const ok =
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6987-array-view-named-properties.ts"
],
"diags": []
}
+60 -4
View File
@@ -4795,11 +4795,67 @@ void scr_arr_copy_metadata(ScrArr *a, ScrDyn *target) {
if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE &&
scr_arr_match_copy(a, target))
return;
if (!SCR_AX(a)->metadata) return;
for (size_t i = 0; i < SCR_AX(a)->metadata->v.obj.len; i++) {
ScrDynEntry *entry = &SCR_AX(a)->metadata->v.obj.entries[i];
metadata = SCR_AX(a)->metadata;
if (!metadata) return;
for (size_t i = 0; i < metadata->v.obj.len; i++) {
const ScrDynEntry *entry = &metadata->v.obj.entries[i];
ScrStr *key = scr_str_new(entry->key, entry->key_len);
scr_dyn_key_set(target, key, entry->value);
if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) {
scr_dyn_key_set(target, key, entry->value);
} else {
/* Properties defined through a view keep their attributes. */
ScrDyn *name = scr_dyn_new_str(key);
ScrDyn *descriptor = scr_dyn_own_descriptor(metadata, key);
if (descriptor) scr_dyn_release(scr_dyn_define_property(target, name, descriptor));
scr_dyn_release(descriptor);
scr_dyn_release(name);
}
scr_str_release(key);
}
}
ScrArr *scr_arr_named_keys(ScrArr *a) {
/* Most arrays have no out-of-line storage: they share one immortal empty
* result, which `for...in` only reads. */
static ScrArr none = {.rc = SIZE_MAX, .elem = SCR_ELEM_STR};
const ScrArrExt *x = scr_arr_ext_of(a);
if (!x) return &none;
ScrArr *keys = scr_arr_new(SCR_ELEM_STR, 0);
for (size_t i = 0; i < x->prop_len; i++)
scr_arr_push_ref(keys, scr_str_new(x->props[i].key, x->props[i].key_len));
const ScrDyn *metadata = x->metadata;
if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE) {
static const char *const names[] = {"index", "input", "groups"};
for (size_t i = 0; i < 3; i++) scr_arr_push_ref(keys, scr_str_new(names[i], strlen(names[i])));
return keys;
}
if (!metadata || metadata->kind != SCR_DYN_OBJ) return keys;
for (size_t i = 0; i < metadata->v.obj.len; i++) {
const ScrDynEntry *entry = &metadata->v.obj.entries[i];
if (!entry->enumerable || scr_arr_prop_index(a, entry->key, entry->key_len) != SIZE_MAX) continue;
scr_arr_push_ref(keys, scr_str_new(entry->key, entry->key_len));
}
return keys;
}
void scr_arr_adopt_view_properties(ScrArr *a, ScrDyn *view, ScrArr *target) {
ScrArrExt *from = scr_arr_ext_of(target);
if (from && from->prop_len) {
/* Both arrays have the same element type, so the slots keep their owner. */
ScrArrExt *x = scr_arr_ext(a);
ScrArrProp *props = x->props;
size_t len = x->prop_len, cap = x->prop_cap;
x->props = from->props;
x->prop_len = from->prop_len;
x->prop_cap = from->prop_cap;
from->props = props;
from->prop_len = len;
from->prop_cap = cap;
}
ScrDyn *named = view && view->kind == SCR_DYN_ARR ? view->v.arr.properties : NULL;
if (!named || named->v.obj.len == 0) return;
ScrArrExt *x = scr_arr_ext(a);
ScrDyn *old = x->metadata;
x->metadata = scr_dyn_retain(named);
scr_dyn_release(old);
}
+9
View File
@@ -317,6 +317,15 @@ static inline ScrArr *scr_arr_retain(ScrArr *a) {
* the candidate-root buffer like every headered type. */
void scr_arr_release(ScrArr *a);
void scr_arr_copy_metadata(ScrArr *a, struct ScrDyn *target);
/* The commit of a live array view calls this on `a`, the array converted
* from `view`, before `target` takes its storage: `a` takes the view's named
* (non-index) properties as its metadata and the noncanonical numeric
* properties of `target`, so neither is lost when the storage is swapped. */
void scr_arr_adopt_view_properties(ScrArr *a, struct ScrDyn *view, ScrArr *target);
/* The enumerable string keys `for...in` visits after the indices: the
* noncanonical numeric properties, then the named properties in insertion
* order. +1. */
ScrArr *scr_arr_named_keys(ScrArr *a);
/* Installed by scr_regex.c when it records a pending match source; this
* unit links without the regex runtime. Writes the pending metadata of
* `a` into its dynamic copy or materializes it, answering whether it
+17
View File
@@ -10269,6 +10269,23 @@ void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) {
}
return;
}
/* A live native array keeps its named properties in its metadata, so
* deleting one goes through the view and its commit. Elements and
* `length` keep the boundary below. */
size_t element;
if (recv->kind == SCR_DYN_TYPED_REF && recv->v.typed_ref.commit && scr_dyn_typed_ref_array_ops(recv) &&
!scr_typed_ref_is_length(key) && !scr_typed_ref_canonical_index(key->data, key->len, &element)) {
ScrDyn *view = scr_dyn_typed_ref_materialize(recv);
if (!scr_exc_pending() && view->kind == SCR_DYN_ARR) {
bool own = view->v.arr.properties && scr_dyn_obj_get(view->v.arr.properties, key->data, key->len);
if (own) scr_dyn_key_delete(view, key, strict);
if (own && !scr_exc_pending()) scr_dyn_typed_ref_commit(recv);
scr_dyn_release(view);
return;
}
scr_dyn_release(view);
if (scr_exc_pending()) return;
}
if (recv->kind == SCR_DYN_FUNC) {
if (scr_dyn_fn_property_fence(key)) return;
ScrDyn *table = scr_dyn_fn_properties(recv);
@@ -0,0 +1,79 @@
// Named properties written to a typed array through an `unknown` view stay
// on the array: later views read them, key walks list them after the
// indices, and `for...in` over the typed array visits them. Holes are
// skipped by `for...in`.
type Bag = Record<string, unknown>;
const numbers: number[] = [1, 2, 3];
const numbersView = numbers as unknown as Bag;
numbersView.extra = "named";
console.log(numbersView.extra, (numbers as unknown as Bag).extra);
console.log(Object.keys(numbersView), JSON.stringify(numbers), numbers.length);
console.log("extra" in numbersView, Object.hasOwn(numbersView, "extra"));
numbers.push(4);
numbers[0] = 10;
console.log((numbers as unknown as Bag).extra, numbers.join(","));
console.log((numbers.slice() as unknown as Bag).extra);
(numbers as unknown as Bag).extra = "again";
(numbers as unknown as Bag).count = 2;
console.log(Object.entries(numbers as unknown as Bag));
for (const key in numbers) console.log("numbers key", key);
delete (numbers as unknown as Bag).extra;
console.log((numbers as unknown as Bag).extra, Object.keys(numbers as unknown as Bag));
const words: string[] = ["a", "b"];
(words as unknown as Bag).label = { nested: [1, 2] };
const label = (words as unknown as Bag).label as { nested: number[] };
console.log(label.nested.length, words.length);
for (const key in words) console.log("words key", key);
interface Point {
x: number;
y: number;
}
const points: Point[] = [{ x: 1, y: 2 }];
(points as unknown as Bag).origin = "zero";
console.log((points as unknown as Bag).origin, points[0]!.x);
for (const key in points) console.log("points key", key);
class Item {
name: string;
constructor(name: string) {
this.name = name;
}
}
const items: Item[] = [new Item("first")];
(items as unknown as Bag).kind = "items";
console.log((items as unknown as Bag).kind, items[0]!.name);
const flags: boolean[] = [true, false];
Object.defineProperty(flags as unknown as Bag, "hidden", { value: 1, enumerable: false });
(flags as unknown as Bag).shown = 2;
console.log((flags as unknown as Bag).hidden, (flags as unknown as Bag).shown);
for (const key in flags) console.log("flags key", key);
const holes: (number | undefined)[] = [1, , 3, undefined];
for (const key in holes) console.log("holes key", key);
(holes as unknown as Bag).tail = true;
for (const key in holes) console.log("holes key after", key);
const offsets: number[] = [1, 2];
offsets[-1] = 5;
offsets[1.5] = 6;
(offsets as unknown as Bag).name = "offsets";
console.log(offsets[-1], offsets[1.5], (offsets as unknown as Bag).name);
for (const key in offsets) console.log("offsets key", key);
const shrinking: number[] = [1, 2, 3, 4];
(shrinking as unknown as Bag).after = "kept";
for (const key in shrinking) {
console.log("shrinking key", key);
if (key === "1") shrinking.length = 2;
}
const match = /b(?<rest>c)?/.exec("abc");
if (match) for (const key in match) console.log("match key", key);
@@ -0,0 +1 @@
{ "plain": 3300, "san": 4400 }