Skip to content
46 changes: 46 additions & 0 deletions changelog.d/8889-error-own-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Fixed an fs error's `code`/`errno`/`syscall`/`path`: they are now own properties
of the **error object** rather than entries in side tables keyed by the error's
**message string address**.

Two defects followed from that keying, both verified against node.

**The wrong error got the metadata.** Any `new Error(m)` built from the same
message text inherited an unrelated fs error's fields — `.code` returned
`ENOENT` where node returns `undefined`, along with `.syscall`, `.errno` and
`.path`. The metadata belonged to the string, so anything holding that string
answered to it.

**They were invisible to reflection.** In node these are ordinary own
properties; served from a side table behind property *getters* they appeared in
none of the enumeration paths:

| | before | after (= node) |
|---|---|---|
| `Object.keys(e)` | `[]` | `code,errno,path,syscall` |
| `hasOwnProperty('code')` | `false` | `true` |
| `getOwnPropertyDescriptor` | `undefined` | `{value,writable,enumerable,configurable}` |
| `JSON.stringify(e)` | `{}` | `{"errno":-2,"code":"ENOENT",…}` |
| `{...e}` | `{}` | same |

Any code that logged or serialised a caught fs error lost its whole payload.

Three sites each held a different wrong assumption about errors. The fs builders
keyed on the message string. `JSON.stringify` hardcoded `"{}"` for
`GC_TYPE_ERROR` — correct for a *plain* error, whose `message`/`name`/`stack`
are non-enumerable, but wrong once an error carries enumerable own properties,
so it also dropped **user-assigned** ones (`e.foo=1; JSON.stringify(e)` gave
`{}` where node gives `{"foo":1}`; that half is independent of fs).
`Object.assign`/spread had no Error arm and copied nothing. All three now
enumerate through `exotic_own_keys(.., enumerable_only = true)` — the same
enumeration `Object.keys` uses — so they cannot drift apart again.

Property **order** is fixed too. `ERROR_USER_PROPS` was a `HashMap` with an
alphabetical `sort_by` bolted on for determinism: stable, but not node's. Own
string keys enumerate in insertion order per ECMA-262, and that order is
observable through all four paths above. The store is insertion-ordered now,
reassignment keeps a key's original position (`o.a=1; o.b=2; o.a=3` enumerates
`a,b`), and the fs fields install in node's `uvException` order. The GC root
scanner over these properties moved to the ordered store.

Verified by running three repro programs against node on the same host:
byte-identical output, key order included.
11 changes: 11 additions & 0 deletions changelog.d/8890-claimed-receiver-brand-gated-layout-note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Array performance: an erased Array declaration admits object-backed Array
subclasses and typed arrays, so a canonical integer key on such a receiver now
brands the receiver once and takes the receiver-unknown numeric read tiers
(inline typed-array read, dense-subclass shape cache, complete dispatcher)
instead of the plain-array tier's out-of-line feedback fallback; and the
guarded in-bounds element store decides inline — with the exact
pointer-bearing classification of the old and new values plus the array's
element-shape bit — whether the GC layout note has any work before calling it.
wolf-ecs (noctjs/ecs-benchmark) on the Mac mini reference box: add/remove
-3.2% then -2.3%, entity-cycle -3.7% then -2.0%, each 11/11 paired wins,
semantics probes byte-identical to Node.
85 changes: 76 additions & 9 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,15 +412,82 @@ fn lower_array_index_get_via_canonical_i32_split(
.cond_br(&is_canonical_i32, &element_label, &runtime_label);

ctx.current_block = element_idx;
let element_value = lower_guarded_array_index_get(
ctx,
arr_box,
&idx_i32,
"aidx.dynamic",
require_numeric_layout,
coerce_numeric_fallback,
receiver_slot,
)?;
let element_value = if preserve_claimed_receiver_fallback {
// An erased Array declaration admits object-backed Array subclasses
// (`class Archetype extends Array` — wolf-ecs `packed[sparse[x]]`) and
// typed arrays as readily as plain Arrays. The guarded plain-array
// tier rejects those on its `GC_TYPE_ARRAY` brand and its feedback
// fallback then classifies the receiver out of line on every read.
// Read the brand once here: a plain Array keeps the guarded tier,
// every other heap pointer takes the receiver-unknown numeric tiers
// (inline typed-array read, dense-subclass `arrlike.ic`, complete
// dispatcher) that the runtime-key arm already uses for the same
// receivers. Non-pointers keep the guarded tier's unchanged fallback.
let brand_idx = ctx.new_block("aidx.claimed.brand");
let array_idx = ctx.new_block("aidx.claimed.array");
let other_idx = ctx.new_block("aidx.claimed.other");
let claimed_merge_idx = ctx.new_block("aidx.claimed.merge");
let brand_label = ctx.block_label(brand_idx);
let array_label = ctx.block_label(array_idx);
let other_label = ctx.block_label(other_idx);
let claimed_merge_label = ctx.block_label(claimed_merge_idx);
{
let blk = ctx.block();
let arr_bits = blk.bitcast_double_to_i64(arr_box);
let arr_handle = blk.and(I64, &arr_bits, crate::nanbox::POINTER_MASK_I64);
let tag = blk.lshr(I64, &arr_bits, "48");
let is_pointer = blk.icmp_eq(I64, &tag, "32765"); // POINTER_TAG
// The same heap band the receiver-unknown tiers dereference in.
let above_handle_band = blk.icmp_ugt(I64, &arr_handle, "1048575");
let below_heap_limit = blk.icmp_ult(I64, &arr_handle, "140737488355328");
let in_heap = blk.and(I1, &above_handle_band, &below_heap_limit);
let heap_candidate = blk.and(I1, &is_pointer, &in_heap);
blk.cond_br(&heap_candidate, &brand_label, &array_label);
}
ctx.current_block = brand_idx;
{
let blk = ctx.block();
let arr_bits = blk.bitcast_double_to_i64(arr_box);
let arr_handle = blk.and(I64, &arr_bits, crate::nanbox::POINTER_MASK_I64);
let gc_type_addr = blk.sub(I64, &arr_handle, "8");
let gc_type_ptr = blk.inttoptr(I64, &gc_type_addr);
let gc_type = blk.load(I8, &gc_type_ptr);
let is_array = blk.icmp_eq(I8, &gc_type, "1"); // GC_TYPE_ARRAY
blk.cond_br(&is_array, &array_label, &other_label);
}
ctx.current_block = array_idx;
let array_value = lower_guarded_array_index_get(
ctx,
arr_box,
&idx_i32,
"aidx.dynamic",
require_numeric_layout,
coerce_numeric_fallback,
receiver_slot,
)?;
let array_end = ctx.block().label.clone();
ctx.block().br(&claimed_merge_label);
ctx.current_block = other_idx;
let other_value =
lower_inline_dyn_typed_array_get(ctx, arr_box, idx_double, coerce_numeric_fallback);
let other_end = ctx.block().label.clone();
ctx.block().br(&claimed_merge_label);
ctx.current_block = claimed_merge_idx;
ctx.block().phi(
DOUBLE,
&[(&array_value, &array_end), (&other_value, &other_end)],
)
} else {
lower_guarded_array_index_get(
ctx,
arr_box,
&idx_i32,
"aidx.dynamic",
require_numeric_layout,
coerce_numeric_fallback,
receiver_slot,
)?
};
let element_end = ctx.block().label.clone();
ctx.block().br(&merge_label);

Expand Down
78 changes: 78 additions & 0 deletions crates/perry-codegen/src/expr/index_get_claim_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,84 @@ fn any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index() {
);
}

/// The canonical-i32 arm of the same `packed[sparse[x]]` site: an erased
/// Array declaration admits object-backed Array subclasses, so a canonical
/// integer key must not be committed to the guarded plain-array tier — whose
/// feedback fallback classifies the receiver out of line on every read (the
/// 2.2× wolf-ecs regression after #8872). The element arm brands the
/// receiver once and sends non-`GC_TYPE_ARRAY` heap pointers to the
/// receiver-unknown numeric tiers instead.
#[test]
fn claimed_array_receiver_brands_before_committing_a_canonical_key_to_the_plain_tier() {
const SPARSE: u32 = 41;
let ir = ir_for(
"claimed_receiver_brand",
vec![
Stmt::Let {
id: ITEMS,
name: "packed".to_string(),
ty: Type::Array(Box::new(Type::Any)),
mutable: false,
init: Some(Expr::PropertyGet {
object: Box::new(Expr::Object(vec![(
"value".to_string(),
Expr::Array(vec![Expr::Number(7.0)]),
)])),
property: "value".to_string(),
byte_offset: 0,
}),
},
Stmt::Let {
id: SPARSE,
name: "sparse".to_string(),
ty: Type::Array(Box::new(Type::Any)),
mutable: false,
init: Some(Expr::PropertyGet {
object: Box::new(Expr::Object(vec![(
"value".to_string(),
Expr::Array(vec![Expr::Number(0.0)]),
)])),
property: "value".to_string(),
byte_offset: 0,
}),
},
Stmt::Let {
id: RESULT,
name: "result".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::IndexGet {
object: Box::new(Expr::LocalGet(ITEMS)),
index: Box::new(Expr::IndexGet {
object: Box::new(Expr::LocalGet(SPARSE)),
index: Box::new(Expr::Integer(0)),
}),
}),
},
],
);
assert!(
ir.contains("aidx.canonical") && ir.contains("aidx.claimed.brand"),
"the canonical-i32 arm must brand the claimed receiver before the plain tier:\n{ir}"
);
let brand = super::class_field_barrier_tests::block_body(&ir, "aidx.claimed.brand")
.expect("the brand block exists");
assert!(
brand.contains("load i8, ptr") && brand.contains("icmp eq i8") && brand.contains(", 1"),
"the brand block must read the GcHeader type byte and test GC_TYPE_ARRAY:\n{brand}"
);
assert!(
ir.contains("aidx.claimed.array") && ir.contains("aidx.dynamic.fast"),
"a plain Array keeps the guarded element tier:\n{ir}"
);
assert!(
ir.contains("aidx.claimed.other")
&& ir.matches("arrlike.ic.family_token").count() >= 2
&& ir.matches("tav.get.brand").count() >= 2,
"every other heap receiver must reach the inline typed-array and dense-subclass tiers from BOTH the canonical and the runtime-key arm:\n{ir}"
);
}

fn dynamic_key_read_ir(name: &str, key_type: Type) -> String {
let param = |id, name: &str, ty| Param {
id,
Expand Down
54 changes: 54 additions & 0 deletions crates/perry-codegen/src/expr/index_set_barrier_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,60 @@ fn the_guarded_property_receiver_store_follows_one_forwarding_edge_inline() {
/// re-resolves the receiver through the tracked resolver on every call, so a
/// pointer store into an array whose raw-f64 bits are already clear must not
/// reach it at all.
/// The scalar-aware layout note (`js_gc_note_slot_layout_aware`) returns
/// without acting when the old and new values share a pointer classification,
/// unless both are pointers and the array carries an element-shape proof. The
/// guarded fast arm now decides that inline — the exact runtime
/// `layout_pointer_bearing_bits` predicate on both values plus the
/// `GC_ARRAY_ELEMENT_SHAPE` bit of the `_reserved` word `deref.live` loaded —
/// and calls the note only from the gated `laynote` block.
#[test]
fn the_fast_arm_layout_note_is_gated_on_the_pointer_classification_and_shape_bit() {
let ir = ir();
let live = block_body(&ir, "idxset.recv_prop.deref.live.")
.expect("guarded store emits its `deref.live` block");
let reserved = live
.lines()
.map(str::trim)
.find(|line| line.contains("load i16"))
.and_then(|line| line.split(" = ").next())
.expect("`deref.live` loads the live head's `_reserved` word")
.to_string();

let fast = block_body(&ir, "idxset.recv_prop.fast.").expect("fast block");
assert!(
!fast.contains("js_gc_note_slot_layout_aware"),
"the fast arm must not call the layout note unconditionally:\n{fast}"
);
assert!(
fast.contains(&format!("and i16 {reserved}, 2048")),
"the gate must test GC_ARRAY_ELEMENT_SHAPE (0x800) on the live head's `_reserved`:\n{fast}"
);
// Exact runtime predicate, applied to both the stored and the old bits:
// tag test, payload test, bare-address range and alignment, selected.
assert!(
fast.matches("select i1").count() >= 2
&& fast.matches(", 32765").count() >= 2
&& fast.matches(", 32767").count() >= 2
&& fast.matches(", 32762").count() >= 2
&& fast.matches("icmp uge i64").count() >= 2
&& fast.matches("icmp ule i64").count() >= 2
&& fast.contains("icmp ne i1"),
"both values must be classified with the exact pointer-bearing predicate and compared:\n{fast}"
);
let (gate, _) = branch_into_block(&ir, "idxset.recv_prop.laynote.")
.expect("the layout note sits behind a conditional branch");
assert!(
gate.trim().starts_with("br i1"),
"gate must be a conditional branch, got `{gate}`"
);
let note = block_body(&ir, "idxset.recv_prop.laynote.").expect("the layout note block exists");
assert!(
note.contains("call void @js_gc_note_slot_layout_aware("),
"the note call must live inside the gated block:\n{note}"
);
}

#[test]
fn the_fast_arm_numeric_note_is_gated_on_the_raw_f64_header_bits() {
let ir = ir();
Expand Down
79 changes: 66 additions & 13 deletions crates/perry-codegen/src/expr/index_set_guarded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ use anyhow::Result;
use crate::nanbox::POINTER_MASK_I64;
use crate::types::{I1, I16, I32, I64, I8};

use super::write_barrier::{
emit_jsvalue_slot_store_deferred_layout_note_on_block, emit_layout_note_slot_aware_on_block,
emit_layout_pointer_bearing_check,
};
use super::{
emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_scalar_aware_on_block,
emit_write_barrier_slot_value_and_generation_tested, FnCtx,
Expand Down Expand Up @@ -207,7 +211,7 @@ pub(super) fn emit_guarded_inbounds_array_store(
// stored over a pointer is exactly the store that must clear
// `GC_ARRAY_ELEMENT_SHAPE`. Class fields have no such per-slot array
// invariant, which is why that half of #7511's argument does not transfer.
let (arr_handle, element_addr, value_bits) = {
let (arr_handle, element_addr, value_bits, layout_note) = {
let blk = ctx.block();
// The live (possibly forwarded-once) head proved by `deref.live`,
// which is this block's only predecessor.
Expand All @@ -221,20 +225,69 @@ pub(super) fn emit_guarded_inbounds_array_store(
// in-bounds arm: the guard proved the slot holds a valid value, so the
// scalar-aware note can skip the layout hashmap on a
// scalar-over-scalar store (#5094).
let value_bits = emit_jsvalue_slot_store_scalar_aware_on_block(
blk,
&element_ptr,
val_double,
if !layout_note_needed {
let value_bits = emit_jsvalue_slot_store_scalar_aware_on_block(
blk,
&element_ptr,
val_double,
&arr_handle,
idx_i32,
false,
&arr_handle,
&element_addr,
false,
)
.unwrap_or_else(|| blk.bitcast_double_to_i64(val_double));
(arr_handle, element_addr, value_bits, None)
} else {
// The scalar-aware note itself, opened up: the runtime
// (`layout_note_slot_aware`) returns without acting when the old
// and new values share a pointer classification — unless both are
// pointers AND the array carries an element-shape proof
// (`GC_ARRAY_ELEMENT_SHAPE` in the `_reserved` word `deref.live`
// already loaded), which the pointer-over-pointer arm maintains. A
// classification change must always reach `layout_note_slot`.
// Decide that inline with the exact runtime predicate and call the
// note only when it has work: the ECS `ents[id] = arch` store is a
// pointer over a pointer into a proof-free array on every iteration.
let (value_bits, old_bits) = emit_jsvalue_slot_store_deferred_layout_note_on_block(
blk,
&element_ptr,
val_double,
);
let new_is_pointer = emit_layout_pointer_bearing_check(blk, &value_bits);
let old_is_pointer = emit_layout_pointer_bearing_check(blk, &old_bits);
let classification_changed = blk.icmp_ne(I1, &new_is_pointer, &old_is_pointer);
let shape_bits = blk.and(I16, &reserved, "2048"); // GC_ARRAY_ELEMENT_SHAPE
let has_element_shape = blk.icmp_ne(I16, &shape_bits, "0");
let pointer_over_pointer_noted = blk.and(I1, &new_is_pointer, &has_element_shape);
let note_needed = blk.or(I1, &classification_changed, &pointer_over_pointer_noted);
(
arr_handle,
element_addr,
value_bits,
Some((old_bits, note_needed)),
)
}
};
if let Some((old_bits, note_needed)) = layout_note {
let note_idx = ctx.new_block(&format!("{}.laynote", block_prefix));
let note_done_idx = ctx.new_block(&format!("{}.laynote.done", block_prefix));
let note_label = ctx.block_label(note_idx);
let note_done_label = ctx.block_label(note_done_idx);
ctx.block()
.cond_br(&note_needed, &note_label, &note_done_label);
ctx.current_block = note_idx;
emit_layout_note_slot_aware_on_block(
ctx.block(),
&arr_handle,
idx_i32,
layout_note_needed,
&arr_handle,
&element_addr,
false,
)
.unwrap_or_else(|| blk.bitcast_double_to_i64(val_double));
(arr_handle, element_addr, value_bits)
};
&value_bits,
&old_bits,
);
ctx.block().br(&note_done_label);
ctx.current_block = note_done_idx;
}
if write_barrier_needed {
// `arr_handle` is the live head `deref.live` just proved through its
// own `obj_type == GC_TYPE_ARRAY` / `!GC_FLAG_FORWARDED` header reads,
Expand Down
Loading
Loading