Skip to content
Closed
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
12 changes: 12 additions & 0 deletions changelog.d/10210-static-getter-call-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Fix method calls inside static getters of classes that extend a call expression
(effect v4 `class Svc extends Context.Service<Svc, Shape>()(id) {}` and
OpenCode's `ConfigService.Service` wrapper). `this.of(x)` in such a getter threw
"of is not a function" although reading `this.of` returned the function: the
dynamic method-call dispatcher resolved only the static-method vtable for a
per-evaluation class object, and the class-ref read path looked for the
function-valued parent only on the receiver's own class. The class-object call
arm now resolves the name exactly as the read path does (static accessors, the
parent class object's statics, the ancestor function's swapped prototype) and
calls the closure with `this` bound to the receiver; string- and symbol-keyed
static reads walk the class chain for the function-valued ancestor. This
unblocks OpenCode v1.18.30's runtime bootstrap (#10210).
Original file line number Diff line number Diff line change
Expand Up @@ -1461,8 +1461,13 @@ pub extern "C" fn js_object_get_field_by_name(
// named static off the parent closure — its OWN props
// (`Svc.key` → "Svc") plus, via the closure getter, its
// static prototype (`Svc._op` → "Tag" on TagProto).
// #10210: the edge is keyed by the class that directly
// `extends <function>`, which may be an ANCESTOR of this
// class (`class Flags extends ConfigTag {}` where
// `ConfigTag extends Context.Service()(id)`), so walk the
// parent chain like `super()` dispatch does.
if let Some(closure_ptr) =
super::super::class_registry::class_parent_closure(class_id)
super::super::class_registry::parent_closure_in_chain(class_id)
{
let v = crate::closure::closure_get_dynamic_prop(closure_ptr, name);
let vb = JSValue::from_bits(v.to_bits());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,45 @@ pub(super) unsafe fn dispatch_primitive(
));
}
}
// #10210: the name may resolve where the static-method vtable cannot
// see it — a static ACCESSOR whose getter returns a function
// (`static get layer()`), a static of a per-evaluation parent class
// object, or the swapped prototype of a FUNCTION-valued ancestor
// (effect v4 `Context.Service<..>()(id)` is `function KeyClass(){}`
// + `Object.setPrototypeOf(KeyClass, ServiceProto)`, and a static
// getter on a subclass calls `this.of(x)` with `this` = this class
// object). The read path already resolves all of these, so mirror
// the class-ref arm (#5437): read the property exactly as
// `const f = C.of; f.call(C, x)` would, then call it with `this`
// bound to the receiver. Only a closure is dispatched here; anything
// else keeps falling through to the generic scan and the normal
// not-a-function error.
if class_id != 0 && !method_name_ptr.is_null() && method_name_len > 0 {
let key = crate::string::js_string_from_bytes(
method_name_ptr as *const u8,
method_name_len as u32,
);
let receiver = JSValue::from_bits(object_handle.get_nanbox_f64().to_bits())
.as_pointer::<ObjectHeader>();
let method = js_object_get_field_by_name(receiver, key);
if method.is_pointer()
&& crate::closure::is_closure_ptr(crate::value::js_nanbox_get_pointer(
f64::from_bits(method.bits()),
) as usize)
{
let method = root_scope.root_nanbox_u64(method.bits());
let receiver = object_handle.get_nanbox_f64();
let bound =
crate::closure::clone_closure_rebind_this(method.get_nanbox_u64(), receiver);
let _this = ImplicitThisScope::bind(receiver);
let args = refreshed_args();
return Some(crate::closure::js_native_call_value(
f64::from_bits(bound),
args.as_ptr(),
args.len(),
));
}
}
}

// #5142: a promise can carry user-attached own expando methods.
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-runtime/src/symbol/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6
// the parent closure — own symbol props plus, via the closure symbol
// getter, its static prototype (`Svc[TagTypeId]`/`Svc[EffectTypeId]`
// live on TagProto). Recurse into the closure-aware getter so its proto
// walk fires.
if let Some(closure_ptr) = crate::object::class_parent_closure(class_id) {
// walk fires. #10210: the edge may sit on an ancestor class — walk
// the chain.
if let Some(closure_ptr) = crate::object::parent_closure_in_chain(class_id) {
let closure_f64 =
f64::from_bits(crate::value::js_nanbox_pointer(closure_ptr as i64).to_bits());
let v = js_object_get_symbol_property(closure_f64, sym_f64);
Expand Down
147 changes: 147 additions & 0 deletions crates/perry/tests/issue_10210_static_getter_call.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! Regression for #10210: a static getter on a class whose `extends` clause is a
//! call expression (effect v4's `Context.Service<..>()(id)` returns a plain
//! `function KeyClass(){}` whose `[[Prototype]]` was swapped with
//! `Object.setPrototypeOf`) must be able to call `this.of(x)` — the CALL path
//! has to resolve the name wherever the READ path does (static accessors,
//! per-evaluation parent class objects, and the function-valued ancestor's
//! swapped prototype), and the closure-parent edge must be found on an
//! ancestor of the receiver's class, not only on the class itself.

use std::path::PathBuf;
use std::process::Command;
use std::sync::Once;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("canonicalize workspace root")
}

fn runtime_dir() -> PathBuf {
static BUILD_RUNTIME: Once = Once::new();
BUILD_RUNTIME.call_once(|| {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let build = Command::new(cargo)
.current_dir(workspace_root())
.arg("build")
.arg("-p")
.arg("perry-runtime-static")
.arg("-p")
.arg("perry-stdlib-static")
.output()
.expect("build static runtime archives");
assert!(
build.status.success(),
"static runtime build failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr)
);
});
let target = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace_root().join("target"));
target.join("debug")
}

const SOURCE: &str = r#"
const Proto: any = { of(self: any) { return self } }
const Key = function () {
function K() {}
Object.setPrototypeOf(K, Proto)
;(K as any).isK = true
return function (key: string) { (K as any).key = key; return K }
}
const t = (name: string, f: () => any) => {
try { console.log(name, JSON.stringify(f())) } catch (e: any) { console.log(name, "THROW", e.message) }
}
// nested class + subclass, static getter calls this.of(x)
const mk1 = (id: string) => { class T extends (Key as any)()(id) { static get g() { return (this as any).of({ n: 1 }) } }; return T as any }
class S1 extends mk1("S1") {}
t("N1", () => S1.g)
// same receiver, read first then call
const mk2 = (id: string) => { class T extends (Key as any)()(id) { static get g() { const f = (this as any).of; return [typeof f, f.call(this, { n: 2 })] } }; return T as any }
class S2 extends mk2("S2") {}
t("N2", () => S2.g)
// alias `this` into a local first (opencode's `const tag = this`)
const mk4 = (id: string) => { class T extends (Key as any)()(id) { static get g() { const tag = this as any; return tag.of({ n: 4 }) } }; return T as any }
class S4 extends mk4("S4") {}
t("N4", () => S4.g)
// nested, no subclass
t("N5", () => mk1("T5").g)
// top-level + subclass
class T6 extends (Key as any)()("T6") { static get g() { return (this as any).of({ n: 6 }) } }
class S6 extends T6 {}
t("N6", () => S6.g)
// top-level, no subclass
t("N7", () => T6.g)
// the closure-parent edge sits on an ancestor: inherited reads on a grandchild
class T8 extends (Key as any)()("T8") {}
class S8 extends T8 {}
t("N8", () => [S8.key, (S8 as any).isK, typeof (S8 as any).of])
// static METHOD with the same body
const mk10 = (id: string) => { class T extends (Key as any)()(id) { static m() { return (this as any).of({ n: 10 }) } }; return T as any }
class S10 extends mk10("S10") {}
t("N10", () => S10.m())
// calling the function a static getter returns, on a class extending a call expression
const mk11 = (id: string) => { class T extends (Key as any)()(id) { static get g() { return () => 11 } }; return T as any }
t("M1", () => mk11("T11").g())
// the closure returned by a static getter calls tag.of (config-service.ts shape)
const mk12 = (id: string) => { class T extends (Key as any)()(id) { static get layer() { const tag = this as any; return () => tag.of({ n: 12 }) } }; return T as any }
class S12 extends mk12("S12") {}
t("M2", () => { const thunk = S12.layer; return thunk() })
"#;

const EXPECTED: &str = "N1 {\"n\":1}
N2 [\"function\",{\"n\":2}]
N4 {\"n\":4}
N5 {\"n\":1}
N6 {\"n\":6}
N7 {\"n\":6}
N8 [\"T8\",true,\"function\"]
N10 {\"n\":10}
M1 11
M2 {\"n\":12}
";

#[test]
fn static_getter_calls_this_of_on_call_expression_heritage() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
let output = dir.path().join("main_bin");
std::fs::write(&entry, SOURCE).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(dir.path())
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.arg("--no-cache")
.env("PERRY_NO_AUTO_OPTIMIZE", "1")
.env("PERRY_RUNTIME_DIR", runtime_dir())
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output)
.current_dir(dir.path())
.output()
.expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
assert_eq!(String::from_utf8_lossy(&run.stdout), EXPECTED);
}
Loading