Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions crates/perry-runtime/src/closure/dispatch/bound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ pub unsafe fn dispatch_bound_method(closure: *const ClosureHeader, args: &[f64])
let method_name_ptr = js_closure_get_capture_ptr(closure, 1) as *const i8;
let method_name_len = js_closure_get_capture_ptr(closure, 2) as usize;

// #6173: a SYMBOL-keyed class method read as a value — there is no name to
// re-resolve; the captures carry the already-resolved func_ptr + arity
// meta (see `SYMBOL_BOUND_METHOD_NAME` for the layout). Discriminated by
// pointer identity with the static marker, and it MUST run before any
// name-based interpretation of the captures below (slots 3/4 are not part
// of the name layout).
if method_name_ptr == crate::object::SYMBOL_BOUND_METHOD_NAME.as_ptr() as *const i8 {
return dispatch_symbol_bound_method(closure, namespace_obj, args);
}

// Private-method value (`const f = this.#m; f.call(o)`): a `#`-named method
// read off an instance yields the OWNER class's method function. Unlike a
// public method, its invocation must dispatch the OWNER's `#m` body with the
Expand Down Expand Up @@ -95,6 +105,57 @@ pub unsafe fn dispatch_bound_method(closure: *const ClosureHeader, args: &[f64])
)
}

/// #6173: invoke a symbol-bound class-method closure. `receiver` is capture
/// slot 0 (a NaN-boxed instance/prototype-ref, or the INT32 class ref for a
/// static method); the resolved func_ptr and packed param_count/has_rest/
/// is_static meta live in slots 3/4 (see `SYMBOL_BOUND_METHOD_NAME`).
/// Mirrors the direct-call symbol dispatch in `js_native_call_method_value`.
unsafe fn dispatch_symbol_bound_method(
closure: *const ClosureHeader,
receiver: f64,
args: &[f64],
) -> f64 {
let func_ptr = js_closure_get_capture_ptr(closure, 3) as usize;
let meta = js_closure_get_capture_ptr(closure, 4) as u64;
if func_ptr == 0 {
// A mis-shaped closure (e.g. a 3-capture name closure whose name
// pointer somehow aliased the marker) reads bounds-checked zeros here
// — fail soft rather than calling a null fn pointer.
return f64::from_bits(crate::value::TAG_UNDEFINED);
}
let param_count = (meta & 0xFFFF_FFFF) as u32;
let has_rest = (meta >> 32) & 1 == 1;
let is_static = (meta >> 33) & 1 == 1;
if is_static {
// Bind IMPLICIT_THIS to the class ref for the duration, exactly like
// the direct-call path. The one-shot static-`this` override (armed by
// the Function.prototype call/apply arms for a static bound-method
// value) still wins in the static-method prologue.
let prev_this = crate::object::js_implicit_this_set(receiver);
let result = crate::object::call_registered_static_method(
func_ptr,
args.as_ptr(),
args.len(),
param_count,
has_rest,
);
crate::object::js_implicit_this_set(prev_this);
result
} else {
// Computed symbol methods never synthesize an `arguments` object but
// DO carry `has_rest` — mirrors the direct-call path.
crate::object::call_vtable_method(
func_ptr,
receiver.to_bits() as i64,
args.as_ptr(),
args.len(),
param_count,
false,
has_rest,
)
}
}

/// Dispatch a `Function.prototype.bind` result (BOUND_FUNCTION_FUNC_PTR
/// sentinel). Reads the bound target/this/partial-args from the closure
/// captures, prepends the bound args to the call-time args, sets
Expand Down
63 changes: 63 additions & 0 deletions crates/perry-runtime/src/object/native_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,69 @@ pub(crate) fn build_bound_method_closure(
crate::value::js_nanbox_pointer(closure as i64)
}

/// #6173: sentinel "method name" installed in the name-capture slots (1, 2) of
/// a BOUND_METHOD closure whose target is a SYMBOL-keyed class method. A
/// symbol method has no string name to re-resolve at call time, so the
/// closure instead carries the already-resolved dispatch data in two extra
/// capture slots:
///
/// slot 0: receiver (NaN-boxed instance/prototype-ref, or the INT32 class
/// ref for a static method)
/// slot 1: `SYMBOL_BOUND_METHOD_NAME.as_ptr()` — the discriminant, compared
/// by ADDRESS in `dispatch_bound_method`, never by content
/// slot 2: `SYMBOL_BOUND_METHOD_NAME.len()`
/// slot 3: resolved method func_ptr
/// slot 4: packed meta — bits 0..32 param_count, bit 32 has_rest,
/// bit 33 is_static
///
/// Slots 1/2 deliberately remain a VALID `(ptr, len)` name pair pointing at
/// this static byte string: every reader that interprets a BOUND_METHOD's
/// captures as a method name (`bound_native_callable_module_and_method`, the
/// by-name dispatch fallbacks) stays memory-safe and merely sees a name that
/// resolves to nothing. Only pointer identity with THIS static means "symbol
/// bound"; even a pathological collision is harmless because reads of slots
/// 3/4 on a 3-capture name closure are bounds-checked to 0 → undefined.
pub(crate) static SYMBOL_BOUND_METHOD_NAME: &[u8] = b"@@__perry_symbol_bound_method__";

/// #6173: materialize a symbol-keyed class method (already resolved via
/// `lookup_class_symbol_method_in_chain`) as a callable bound-method value.
/// See [`SYMBOL_BOUND_METHOD_NAME`] for the capture layout. All captures are
/// populated immediately after allocation, BEFORE any allocating call — the
/// capture slots are GC-scanned roots (mirrors `build_bound_method_closure`).
pub(crate) fn build_symbol_bound_method_closure(
receiver: f64,
func_ptr: usize,
param_count: u32,
has_rest: bool,
is_static: bool,
) -> f64 {
let closure = crate::closure::js_closure_alloc(crate::closure::BOUND_METHOD_FUNC_PTR, 5);
if closure.is_null() {
return f64::from_bits(crate::value::TAG_UNDEFINED);
}
crate::closure::js_closure_set_capture_f64(closure, 0, receiver);
crate::closure::js_closure_set_capture_ptr(
closure,
1,
SYMBOL_BOUND_METHOD_NAME.as_ptr() as i64,
);
crate::closure::js_closure_set_capture_ptr(closure, 2, SYMBOL_BOUND_METHOD_NAME.len() as i64);
crate::closure::js_closure_set_capture_ptr(closure, 3, func_ptr as i64);
let meta: i64 = (param_count as i64) | ((has_rest as i64) << 32) | ((is_static as i64) << 33);
crate::closure::js_closure_set_capture_ptr(closure, 4, meta);
// Spec `.length` = declared params minus a trailing rest param.
set_builtin_closure_length(
closure as usize,
if has_rest {
param_count.saturating_sub(1)
} else {
param_count
},
);
crate::gc::runtime_write_barrier_root_heap_word(closure as u64);
crate::value::js_nanbox_pointer(closure as i64)
}

/// Resolve the owning class id for a `js_class_method_bind` receiver: a class
/// constructor/prototype ref (INT32-tagged) or a real class instance pointer.
/// Resolve the effective receiver for a BOUND_METHOD dispatch. When the
Expand Down
58 changes: 58 additions & 0 deletions crates/perry-runtime/src/symbol/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,34 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6
if let Some(vb) = class_static_symbol_lookup(class_id, sym_f64) {
return f64::from_bits(vb);
}
// #6173: a `static [S]() {}` (and, through a prototype ref, an
// instance `[S]() {}`) registers in CLASS_SYMBOL_METHODS, which this
// resolver never consulted — so reading `D[S]` as a VALUE returned
// undefined even though the direct call `D[S]()` dispatched fine via
// `js_native_call_method_value`'s independent lookup. Materialize a
// bound-method closure carrying the resolved target. Runs after the
// accessor branch (getter priority) and the static symbol-FIELD
// lookup (a `static [S] = v` initializer runs after method
// installation and shadows the method, matching class-init order).
// USER symbols only: well-known symbol methods (`[Symbol.iterator]`,
// `[Symbol.toPrimitive]`, …) are lowered to synthetic `@@name`
// members with dedicated consumers (GetIterator, `js_to_primitive`,
// the using-block desugar) and established name-based resolution —
// keep them on those paths rather than changing their behavior here.
if sym_key != 0 && !crate::symbol::is_well_known_symbol(sym_key) {
let is_proto_ref = crate::object::class_prototype_ref_id(obj_f64).is_some();
if let Some((func_ptr, param_count, has_rest)) =
crate::object::lookup_class_symbol_method_in_chain(class_id, sym_key, !is_proto_ref)
{
return crate::object::build_symbol_bound_method_closure(
obj_f64,
func_ptr,
param_count,
has_rest,
!is_proto_ref,
);
}
}
// #1758: a class ref whose own static symbols miss may inherit the
// symbol from a class-expression parent (`class Sub extends make(...) {}`
// → `Sub[TypeId]`). Walk the CLASS_PROTOTYPE_OBJECTS chain.
Expand Down Expand Up @@ -500,6 +528,36 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6
);
}
}
// #6173: a USER symbol-keyed instance method (`[S]() {}`)
// lives in CLASS_SYMBOL_METHODS — the table the direct
// call path resolves through — not in the accessor /
// well-known tables checked above, so a bare `obj[S]`
// read returned undefined while `obj[S]()` worked.
// Materialize the resolved target as a bound method. Own
// symbol props (checked earlier) still shadow it, and the
// accessor branch above keeps getter priority. This also
// fixes instance-side `S in obj`, whose presence check
// (`js_object_has_property`) delegates to this resolver.
// USER symbols only: a well-known computed method (e.g.
// `[Symbol.toPrimitive]() {}`) is lowered to a synthetic
// `@@name` vtable member and keeps resolving through the
// name-based #1838 tail below, preserving the existing
// behavior for every well-known symbol.
if !crate::symbol::is_well_known_symbol(sym_key) {
if let Some((func_ptr, param_count, has_rest)) =
crate::object::lookup_class_symbol_method_in_chain(
class_id, sym_key, false,
)
{
return crate::object::build_symbol_bound_method_closure(
obj_f64,
func_ptr,
param_count,
has_rest,
false,
);
}
}
}
}
}
Expand Down
108 changes: 108 additions & 0 deletions test-files/test_gap_6173_symbol_method_value_read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// #6173: reading a SYMBOL-keyed class method as a VALUE must return the
// function, for both instance and static receivers. The direct call form
// (`obj[S]()`) already worked — it dispatches through
// `js_native_call_method_value`'s independent CLASS_SYMBOL_METHODS lookup —
// but the bare read went through `js_object_get_symbol_property`, which never
// consulted that table and returned `undefined`.
//
// Validated byte-for-byte against `node --experimental-strip-types`.

const S = Symbol("s");

// ── instance symbol method ──────────────────────────────────────────────────
class F {
[S]() {
return 99;
}
}
const f: any = new F();
console.log(typeof f[S]); // "function"
console.log(f[S]()); // 99 (call path, regression guard)
const m = f[S];
console.log(m()); // 99 (read-then-call)

// ── static symbol method ────────────────────────────────────────────────────
class D {
static [S]() {
return 42;
}
}
console.log(typeof (D as any)[S]); // "function"
console.log((D as any)[S]()); // 42 (call path, regression guard)
const sm = (D as any)[S];
console.log(sm()); // 42 (read-then-call)

// ── args and rest params flow through the bound value ───────────────────────
// (No `this` in the detached calls: Node binds `this === undefined` for a
// detached call, while Perry's bound-method values keep read-time snapshot
// semantics — same as its string-keyed method values.)
class WithArgs {
[S](a: number, b: number, ...rest: number[]) {
return a + b + rest.length;
}
}
const wa: any = new WithArgs();
const wam = wa[S];
console.log(wam(1, 2)); // 3
console.log(wam(1, 2, 8, 9, 10)); // 6
console.log(wa[S].length); // 2 (rest param excluded)

// `this` binding via the direct call form (regression guard for the call path)
class WithThis {
base = 7;
[S](a: number) {
return this.base + a;
}
}
const wt: any = new WithThis();
console.log(wt[S](1)); // 8

// ── inherited through a subclass chain ──────────────────────────────────────
class Base {
[S]() {
return "base-inst";
}
static [S]() {
return "base-static";
}
}
class Mid extends Base {}
class Leaf extends Mid {}
const leaf: any = new Leaf();
console.log(typeof leaf[S], leaf[S]()); // function base-inst
console.log(typeof (Leaf as any)[S], (Leaf as any)[S]()); // function base-static

// ── a symbol-keyed GETTER still resolves through the accessor path ─────────
const G = Symbol("g");
class WithGetter {
get [G]() {
return "got";
}
static get [G]() {
return "static-got";
}
}
const wg: any = new WithGetter();
console.log(wg[G]); // "got" (getter invoked, NOT a bound method)
console.log((WithGetter as any)[G]); // "static-got"

// ── an OWN symbol property shadows the class method ─────────────────────────
const shadowed: any = new F();
shadowed[S] = 123;
console.log(shadowed[S]); // 123

// ── well-known symbol method reads keep working ─────────────────────────────
class Iter {
*[Symbol.iterator]() {
yield 1;
yield 2;
}
}
const it: any = new Iter();
console.log(typeof it[Symbol.iterator]); // "function"
console.log([...it].join(",")); // 1,2

// ── instance-side `in` shares the resolver ──────────────────────────────────
console.log(S in f); // true
console.log(G in wg); // true (accessor presence)
console.log(Symbol("other") in f); // false
Loading