diff --git a/changelog.d/9123-prototype-method-delete-guard.md b/changelog.d/9123-prototype-method-delete-guard.md new file mode 100644 index 0000000000..7a4674a512 --- /dev/null +++ b/changelog.d/9123-prototype-method-delete-guard.md @@ -0,0 +1,6 @@ +### fix(runtime): honor prototype method deletion in typed direct guards + +Typed-feedback direct-method guards now consult the same per-name prototype +invalidation latch as inline shape guards. Deleting a declared prototype +method therefore falls back to ordinary dispatch and throws TypeError +instead of invoking the stale compiled method body. Fixes #9123. diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 1c6884ab59..61dd016e6d 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -106,6 +106,11 @@ fn method_direct_call_contract( ); }; let name_hash = hash_bytes(method_bytes); + let method_guard_slot = crate::object::class_prototype_method_guard_slot(method_name); + if crate::object::class_prototype_fast_guard_invalidated_for_method(method_guard_slot) { + return (shape_addr, class_id, gc_type, name_hash, false); + } + if object_addr == 0 || expected_class_id == 0 || !crate::object::shapes::is_shape_id(expected_shape_id) diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 06ce8302e7..6ecd1440aa 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -2251,6 +2251,46 @@ fn typed_feedback_method_direct_guard_fails_for_own_method_replacement() { assert_eq!(site.fallback_calls, 1); } +#[test] +fn typed_feedback_method_direct_guard_fails_after_method_invalidation() { + let _guard = typed_feedback_test_lock(); + reset_typed_feedback_for_tests(); + register( + 9123, + TypedFeedbackSiteKind::MethodCall, + "obj.deleted_9123()", + ); + + let class_id = 0x7EED_9123; + let method_name = b"deleted_9123"; + let (obj, _, _, receiver) = class_instance(class_id, b"x"); + let expected_shape_id = shape_id(obj); + unsafe { register_test_method(class_id, method_name) }; + + let guard = || unsafe { + js_typed_feedback_method_direct_call_guard( + 9123, + receiver, + class_id, + expected_shape_id, + method_name.as_ptr() as *const i8, + method_name.len(), + test_direct_method_ptr(), + ) + }; + assert_eq!(guard(), 1); + + // A delete has no replacement value for the contract to discover. The + // sticky per-name latch is the authoritative evidence that the declared + // vtable method may no longer be callable. + crate::object::invalidate_class_prototype_fast_guards_for_method("deleted_9123"); + assert_eq!(guard(), 0); + + let site = &typed_feedback_snapshot().sites[0]; + assert_eq!(site.guard_passes, 1); + assert_eq!(site.guard_failures, 1); +} + #[test] fn typed_feedback_method_direct_guard_fails_for_prototype_method_registration() { let _guard = typed_feedback_test_lock(); diff --git a/crates/perry/tests/issue_9123_method_delete_invalidation.rs b/crates/perry/tests/issue_9123_method_delete_invalidation.rs new file mode 100644 index 0000000000..e3f6e456b9 --- /dev/null +++ b/crates/perry/tests/issue_9123_method_delete_invalidation.rs @@ -0,0 +1,73 @@ +//! Regression coverage for #9123: deleting a declared prototype method must +//! invalidate compiler-emitted direct-method guards. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn prototype_method_delete_invalidates_direct_dispatch() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +class C { + a = 5; + + inc(): number { + return this.a + 1; + } +} + +function viaParam(c: C, n: number): number { + let last = 0; + for (let i = 0; i < n; i++) { + last = c.inc(); + } + return last; +} + +delete (C.prototype as any).inc; +try { + console.log(viaParam(new C(), 1)); +} catch (error) { + console.log("threw:", (error as Error).constructor.name); +} +"#, + ) + .expect("write source"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-auto-optimize") + .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), "threw: TypeError\n"); +}