From 02c4611482daaabbcf168cea7d146a85d6854850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 06:09:30 +0200 Subject: [PATCH] fix(runtime): resolve symbol props written to declared class prototypes from instances Claude-Session: https://claude.ai/code/session_01As1fetJAqDFib4n7Wm5Suo --- changelog.d/class-prototype-symbol-props.md | 1 + crates/perry-runtime/src/symbol/get.rs | 39 ++++++ .../tests/class_prototype_symbol_props.rs | 127 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 changelog.d/class-prototype-symbol-props.md create mode 100644 crates/perry/tests/class_prototype_symbol_props.rs diff --git a/changelog.d/class-prototype-symbol-props.md b/changelog.d/class-prototype-symbol-props.md new file mode 100644 index 0000000000..e7346b158c --- /dev/null +++ b/changelog.d/class-prototype-symbol-props.md @@ -0,0 +1 @@ +Resolve symbol-keyed properties that were added to a declared class's `.prototype` after the declaration (`C.prototype[S] = f`, `Object.defineProperty(C.prototype, S, ...)`, `Object.assign(C.prototype, { [Symbol.iterator]() {} })`) from instances and subclass instances. The symbol read fallback only walked the dynamic prototype-object table (`Object.create` / class-expression parents) and never the declared prototype object those writes land on, so `typeof instance[Symbol.iterator]` was `undefined` and `Symbol.iterator in instance` was false while the same string-keyed writes worked. drizzle-orm's `applyEffectWrapper` installs effect's `Effectable.Prototype` on its query classes this way, so every `yield* db.run(...)` in OpenCode's database layer failed with "next is not a function" (tracker #10107). diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 1fa3794a4d..28d8648b1f 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -1202,6 +1202,20 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { return v; } + // A symbol-keyed property added to a DECLARED class's + // `.prototype` after the declaration — `C.prototype[S] = f`, + // `Object.defineProperty(C.prototype, S, ...)`, or + // `Object.assign(C.prototype, { [Symbol.iterator]() {} })` — + // is stored on the declared prototype object, which the + // CLASS_PROTOTYPE_OBJECTS walk above never visits (that table + // holds `Object.create` / class-expression parents). String + // keys already resolve there; symbol keys returned undefined, + // so drizzle-orm's `applyEffectWrapper` (effect's + // `Effectable.Prototype` assigned onto query classes) left + // `yield* query` with no iterator ("next is not a function"). + if let Some(v) = declared_prototype_chain_symbol(obj_f64, sym_f64, cid) { + return v; + } // #1838: a class can define a computed well-known-symbol METHOD // (`[Symbol.iterator]() {}`) — class lowering names it // `@@iterator` in the vtable (class_members.rs), NOT as a symbol @@ -1226,6 +1240,31 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 f64::from_bits(TAG_UNDEFINED) } +/// Walk the declared class prototype objects (`C.prototype` for `C` and each +/// ancestor) for a symbol-keyed property written after the class declaration. +/// Accessors run with the original receiver; data properties are returned as +/// stored. Nearest class first, so a subclass's prototype write shadows a +/// base class's. +unsafe fn declared_prototype_chain_symbol(receiver: f64, sym: f64, mut class_id: u32) -> Option { + for _ in 0..32 { + let declared = crate::object::class_decl_prototype_object(class_id); + if !declared.is_null() { + let proto_value = crate::value::js_nanbox_pointer(declared as i64); + if let Some(acc) = accessors::symbol_accessor_property(proto_value, sym) { + return Some(accessors::invoke_symbol_accessor_getter(acc.get, receiver)); + } + if let Some(value) = own_symbol_property(proto_value, sym) { + return Some(value); + } + } + match crate::object::get_parent_class_id(class_id) { + Some(parent) if parent != 0 && parent != class_id => class_id = parent, + _ => break, + } + } + None +} + /// #1838: map a well-known symbol value to the synthetic `@@` vtable key /// that class lowering assigns to a computed `[Symbol.X]() {}` method (see /// `lower_decl/class_members.rs`). Returns `None` for symbols that don't name a diff --git a/crates/perry/tests/class_prototype_symbol_props.rs b/crates/perry/tests/class_prototype_symbol_props.rs new file mode 100644 index 0000000000..8dfd89194b --- /dev/null +++ b/crates/perry/tests/class_prototype_symbol_props.rs @@ -0,0 +1,127 @@ +//! Symbol-keyed properties added to a declared class's `.prototype` after the +//! declaration (`C.prototype[S] = f`, `Object.defineProperty(C.prototype, S, ...)`, +//! `Object.assign(C.prototype, { [Symbol.iterator]() {} })`) must be visible on +//! instances and subclass instances, exactly like string keys. drizzle-orm's +//! `applyEffectWrapper` installs effect's `Effectable.Prototype` this way, and +//! `yield* query` needs the copied `[Symbol.iterator]`. + +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 t = (name: string, f: () => any) => { try { console.log(name, JSON.stringify(f())) } catch (e: any) { console.log(name, "THROW", e.message) } } +const fn = function (this: any) { return "called" } +const custom = Symbol("custom") +class A1 {}; (A1.prototype as any)[Symbol.iterator] = fn +t("P1", () => typeof (new A1() as any)[Symbol.iterator]) +class A2 {}; Object.assign(A2.prototype, { [Symbol.iterator]: fn }) +t("P2", () => typeof (new A2() as any)[Symbol.iterator]) +class A3 {}; Object.defineProperty(A3.prototype, Symbol.iterator, { value: fn, writable: true, configurable: true }) +t("P3", () => typeof (new A3() as any)[Symbol.iterator]) +class A4 {}; Object.assign(A4.prototype, { [custom]: fn }) +t("P4", () => (new A4() as any)[custom]()) +class A5 {}; (A5.prototype as any)[Symbol.iterator] = fn +class A5s extends A5 {} +t("P5", () => typeof (new A5s() as any)[Symbol.iterator]) +t("P6", () => Symbol.iterator in (new A2() as any)) +class A7 {}; Object.defineProperty(A7.prototype, custom, { get(this: any) { return this.v }, configurable: true }) +t("P7", () => { const a: any = new A7(); a.v = 7; return a[custom] }) +class A8 { [Symbol.iterator]() { return "declared" } } +t("P8", () => (new A8() as any)[Symbol.iterator]()) +class SingleShotGen { called = false; constructor(public self: any) {} next(a?: any) { return this.called ? { value: a, done: true } : ((this.called = true), { value: this.self, done: false }) } } +const EffectProto: any = { op: "E", [Symbol.iterator]() { return new SingleShotGen(this) } } +class Raw { constructor(public execute: () => number) {} } +Object.assign(Raw.prototype, { ...EffectProto }) +class SubRaw extends Raw {} +t("Y1", () => { const r = new Raw(() => 1); function* g(): Generator { const x = yield* r; return x } const it = g(); const a = it.next(); const b = it.next("sent"); return [a.value === r, b.value, b.done] }) +t("Y2", () => { const r = new SubRaw(() => 2); function* g(): Generator { return yield* r } const it = g(); it.next(); return it.next("sub").value }) +class It {}; (It.prototype as any)[Symbol.iterator] = function* () { yield 1; yield 2 } +t("Y3", () => { const out: number[] = []; for (const v of new It() as any) out.push(v); return [out, [...(new It() as any)]] }) +"#; + +const EXPECTED: &str = "P1 \"function\" +P2 \"function\" +P3 \"function\" +P4 \"called\" +P5 \"function\" +P6 true +P7 7 +P8 \"declared\" +Y1 [true,\"sent\",true] +Y2 \"sub\" +Y3 [[1,2],[1,2]] +"; + +#[test] +fn symbol_props_written_to_declared_class_prototypes_reach_instances() { + 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); +}