From 52ee277e46893634b4313e840625ce15ea4d17b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:10:41 +0200 Subject: [PATCH 1/7] fix(runtime): honor closure own function method overrides --- changelog.d/10045-closure-own-bind.md | 11 +++ .../src/object/native_call_method.rs | 25 +++-- .../closure_override_tests.rs | 47 ++++++++++ crates/perry/tests/async_resource_own_bind.rs | 20 ++++ scripts/test-async-resource-own-bind.mjs | 43 +++++++++ tests/modules/async_resource_own_bind/main.ts | 94 +++++++++++++++++++ .../async_resource_own_bind/require.cjs | 4 + 7 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 changelog.d/10045-closure-own-bind.md create mode 100644 crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs create mode 100644 crates/perry/tests/async_resource_own_bind.rs create mode 100644 scripts/test-async-resource-own-bind.mjs create mode 100644 tests/modules/async_resource_own_bind/main.ts create mode 100644 tests/modules/async_resource_own_bind/require.cjs diff --git a/changelog.d/10045-closure-own-bind.md b/changelog.d/10045-closure-own-bind.md new file mode 100644 index 0000000000..9372ef98ac --- /dev/null +++ b/changelog.d/10045-closure-own-bind.md @@ -0,0 +1,11 @@ +Fix dynamic calls to a closure's own `bind`, `call`, `apply`, and `toString` +properties. These overrides now take precedence over Function.prototype's +intrinsic dispatch, including native constructors such as AsyncResource and +AsyncLocalStorage with their own static `bind` methods. Own accessors are +invoked once, and non-callable own properties throw instead of falling through +to an intrinsic. Ordinary Function.prototype fast paths remain unchanged, +including `apply` with an arguments object. + +Adds a runtime regression and a bounded, application-independent native matrix +covering builtin/import/require/alias forms, async context and receiver capture, +custom own methods/accessors, non-callable overrides, and intrinsic fallbacks. diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 175adf6fd4..10ce82556d 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -18,6 +18,8 @@ mod primitive_methods; mod proto_dispatch; mod string_methods; +#[cfg(test)] +mod closure_override_tests; #[cfg(test)] mod code_point_at_dispatch_tests; #[cfg(test)] @@ -1716,15 +1718,22 @@ pub unsafe extern "C-unwind" fn js_native_call_method( if crate::value::addr_class::is_above_handle_band(raw_addr) && crate::closure::is_closure_ptr(raw_addr) && !crate::closure::closure_is_key_deleted(raw_addr, method_name) - // apply/call/bind/toString on a closure receiver have dedicated - // spec-accurate arms below; the dynamic-prop read would resolve - // them through the Function.prototype expando fallback to the - // GENERIC thunks, which lose arguments-object argArrays - // (`G.apply(this, arguments)`). - && !matches!(method_name, "apply" | "call" | "bind" | "toString") { - let dyn_val = crate::closure::closure_get_dynamic_prop(raw_addr, method_name); - if dyn_val.to_bits() != crate::value::TAG_UNDEFINED { + // #10045: own overrides (including AsyncResource.bind) beat the + // Function.prototype fast paths. Keep those fast paths on a miss: + // the generic prototype thunks lose arguments-object argArrays + // (`G.apply(this, arguments)`). An own undefined/non-callable slot + // is not a miss: invoking it must throw instead of using a builtin. + let intrinsic_name = matches!(method_name, "apply" | "call" | "bind" | "toString"); + let own_override = intrinsic_name + && (crate::closure::closure_has_own_dynamic_prop(raw_addr, method_name) + || crate::object::get_accessor_descriptor(raw_addr, method_name).is_some()); + let dyn_val = if !intrinsic_name || own_override { + crate::closure::closure_get_dynamic_prop(raw_addr, method_name) + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + }; + if dyn_val.to_bits() != crate::value::TAG_UNDEFINED || own_override { // #6438: same rebind as the GC_TYPE_CLOSURE arm below — // `closure_get_dynamic_prop` may return a method read off the // closure's `Object.setPrototypeOf` proto, whose bound `this` diff --git a/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs b/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs new file mode 100644 index 0000000000..61972ce311 --- /dev/null +++ b/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs @@ -0,0 +1,47 @@ +//! #10045: own callable properties must beat Function.prototype fast paths. +use crate::{closure, gc::RuntimeHandleScope, value}; + +extern "C" fn original(_closure: *const closure::ClosureHeader, _arg: f64) -> f64 { + -1.0 +} + +extern "C" fn own_method(_closure: *const closure::ClosureHeader, _arg: f64) -> f64 { + crate::object::js_implicit_this_get() +} + +#[test] +fn closure_own_function_methods_override_intrinsic_dispatch() { + let _lock = crate::gc::global_side_table_test_lock(); + let scope = RuntimeHandleScope::new(); + closure::js_register_closure_arity(original as *const u8, 1); + closure::js_register_closure_arity(own_method as *const u8, 1); + for method in ["bind", "call", "apply", "toString"] { + let receiver = scope.root_nanbox_f64(value::js_nanbox_pointer(closure::js_closure_alloc( + original as *const u8, + 0, + ) as i64)); + let implementation = scope.root_nanbox_f64(value::js_nanbox_pointer( + closure::js_closure_alloc(own_method as *const u8, 0) as i64, + )); + closure::closure_set_dynamic_prop( + value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as usize, + method, + implementation.get_nanbox_f64(), + ); + let args = [42.0]; + let result = unsafe { + super::js_native_call_method( + receiver.get_nanbox_f64(), + method.as_ptr().cast(), + method.len(), + args.as_ptr(), + args.len(), + ) + }; + assert_eq!( + result.to_bits(), + receiver.get_nanbox_u64(), + "own {method} receiver" + ); + } +} diff --git a/crates/perry/tests/async_resource_own_bind.rs b/crates/perry/tests/async_resource_own_bind.rs new file mode 100644 index 0000000000..61706fd2d0 --- /dev/null +++ b/crates/perry/tests/async_resource_own_bind.rs @@ -0,0 +1,20 @@ +//! Application-independent native coverage of dynamic own function overrides. +use std::{path::Path, process::Command}; + +#[test] +fn standalone_regression() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("node") + .arg(root.join("scripts/test-async-resource-own-bind.mjs")) + .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) + .env("PERRY_WORKSPACE_ROOT", &root) + .current_dir(&root) + .output() + .expect("run bounded Node regression driver"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/scripts/test-async-resource-own-bind.mjs b/scripts/test-async-resource-own-bind.mjs new file mode 100644 index 0000000000..3f272e3623 --- /dev/null +++ b/scripts/test-async-resource-own-bind.mjs @@ -0,0 +1,43 @@ +// #10045: application-independent dispatch, context, and fallback regression. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-async-own-bind-')); +const env = { ...process.env }; +if (env.PERRY_TEST_WASM === '1' && env.PERRY_RUNTIME_DIR) delete env.PERRY_WORKSPACE_ROOT; +let passed = false; +function run(name, executable, args, timeout, extraEnv = {}) { + const result = spawnSync(executable, args, { cwd: work, env: { ...env, ...extraEnv }, + encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024 }); + fs.writeFileSync(path.join(work, `${name}.log`), `${result.stdout ?? ''}${result.stderr ?? ''}`); + if (result.error || result.status !== 0) throw new Error(`${name}: ${result.error ?? result.status}`); + return result.stdout; +} +try { + for (const [fixture, expected] of [ + ['main.ts', 'PASS: own function methods and async context binding\n'], + ['require.cjs', 'PASS: require AsyncResource.bind\n'], + ]) { + const source = path.join(root, 'tests/modules/async_resource_own_bind', fixture); + if (run(`node-${fixture}`, process.execPath, [source], 15000) !== expected) throw new Error('Node witness missing'); + for (const opt of ['0', 's', 'z']) { + const label = `${fixture}-O${opt}`; + const output = path.join(work, `native-${label}${process.platform === 'win32' ? '.exe' : ''}`); + run(`compile-${label}`, compiler, ['compile', source, '-o', output, + '--cache-dir', path.join(work, `cache-${label}`), '--platform', 'bun', + '--no-auto-optimize', '--no-color', ...(env.PERRY_TEST_WASM === '1' ? ['--enable-wasm-runtime'] : [])], + 120000, { PERRY_LL_OPT_LEVEL: opt }); + if (run(`native-${label}`, output, [], 15000) !== expected) throw new Error(`${label} witness mismatch`); + console.log(`PASS async-resource-own-bind ${label}`); + } + } + passed = true; +} finally { + if (passed) fs.rmSync(work, { recursive: true }); + else console.error(`Retained regression diagnostics: ${work}`); +} diff --git a/tests/modules/async_resource_own_bind/main.ts b/tests/modules/async_resource_own_bind/main.ts new file mode 100644 index 0000000000..6e6bc32c46 --- /dev/null +++ b/tests/modules/async_resource_own_bind/main.ts @@ -0,0 +1,94 @@ +import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks'; + +function check(ok: boolean, label: string) { + if (!ok) throw new Error(label); +} + +function checkResource(Resource: any, label: string) { + const calls: number[] = []; + const bound = Resource.bind((value: number) => { + calls.push(value); + return value + 1; + }); + check(typeof bound === 'function', `${label}: callable`); + check(bound(41) === 42 && calls.length === 1 && calls[0] === 41, + `${label}: dispatch callback, not constructor`); +} + +checkResource(AsyncResource, 'import'); +const hooks: any = process.getBuiltinModule('async_hooks'); +checkResource(hooks.AsyncResource, 'getBuiltinModule'); +const alias: any = hooks.AsyncResource; +checkResource(alias, 'alias'); + +const storage = new AsyncLocalStorage(); +let captured: any; +let storageBound: any; +let snapshot: any; +const receiver = { value: 7 }; +storage.run('captured', () => { + captured = alias.bind(function(this: any, n: number) { + return `${storage.getStore()}:${this.value + n}`; + }, 'independent-bind-test', receiver); + storageBound = hooks.AsyncLocalStorage.bind(() => storage.getStore()); + snapshot = hooks.AsyncLocalStorage.snapshot(); +}); +storage.run('caller', () => { + check(captured(5) === 'captured:12', 'resource captures context and receiver'); + check(storageBound() === 'captured', 'AsyncLocalStorage.bind context'); + check(snapshot(() => storage.getStore()) === 'captured', 'snapshot context'); + check(storage.getStore() === 'caller', 'caller context restored'); +}); + +function ownOverrides(fn: any) { + let calls = 0; + for (const key of ['bind', 'call', 'apply', 'toString']) { + fn[key] = function(this: any, n: number) { + check(this === fn, `${key}: receiver`); + calls++; + return n + 1; + }; + check(fn[key](41) === 42, `${key}: own implementation`); + delete fn[key]; + } + check(calls === 4, 'all own implementations called'); + for (const key of ['bind', 'call', 'apply', 'toString']) { + for (const value of [undefined, null, 123]) { + fn[key] = value; + let threw = false; + try { fn[key](41); } catch (error) { threw = error instanceof TypeError; } + check(threw, `${key}: non-callable must throw`); + delete fn[key]; + } + } + let gets = 0; + Object.defineProperty(fn, 'bind', { + configurable: true, + get() { + check(this === fn, 'getter receiver'); + gets++; + return function(this: any, n: number) { + check(this === fn, 'getter result receiver'); + return n + 1; + }; + }, + }); + check(fn.bind(41) === 42 && gets === 1, 'own accessor once'); + delete fn.bind; +} +ownOverrides(function() { throw new Error('original must not run'); }); + +function ordinary(fn: any) { + const receiver = { value: 10 }; + check(fn.call(receiver, 2, 3) === 15, 'ordinary call'); + check(fn.apply(receiver, [2, 3]) === 15, 'ordinary apply array'); + const bound = fn.bind(receiver, 2); + check(bound(3) === 15, 'ordinary bind'); + function forward(this: any, _a: number, _b: number) { + return fn.apply(receiver, arguments); + } + check(forward(2, 3) === 15, 'ordinary apply arguments object'); + check(typeof fn.toString() === 'string', 'ordinary toString'); +} +ordinary(function(this: any, a: number, b: number) { return this.value + a + b; }); +console.log('PASS: own function methods and async context binding'); diff --git a/tests/modules/async_resource_own_bind/require.cjs b/tests/modules/async_resource_own_bind/require.cjs new file mode 100644 index 0000000000..cd46cf3fea --- /dev/null +++ b/tests/modules/async_resource_own_bind/require.cjs @@ -0,0 +1,4 @@ +const hooks = require('node:async_hooks'); +const bound = hooks.AsyncResource.bind(value => value + 1); +if (bound(41) !== 42) throw new Error('require static bind callback'); +console.log('PASS: require AsyncResource.bind'); From 4e3a5915ea3f8ad817b564307047c254eb255fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:11:07 +0200 Subject: [PATCH 2/7] docs: key own function dispatch changeset to PR 10046 --- .../{10045-closure-own-bind.md => 10046-closure-own-bind.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10045-closure-own-bind.md => 10046-closure-own-bind.md} (100%) diff --git a/changelog.d/10045-closure-own-bind.md b/changelog.d/10046-closure-own-bind.md similarity index 100% rename from changelog.d/10045-closure-own-bind.md rename to changelog.d/10046-closure-own-bind.md From 52941fa5eebafde69508b66f019952d5de056d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:28:34 +0200 Subject: [PATCH 3/7] fix(codegen): install native module before named import cell initialization --- changelog.d/10046-closure-own-bind.md | 6 ++ crates/perry-codegen/src/lower_call/mod.rs | 16 ++++ .../lower_call/named_import_install_tests.rs | 90 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 crates/perry-codegen/src/lower_call/named_import_install_tests.rs diff --git a/changelog.d/10046-closure-own-bind.md b/changelog.d/10046-closure-own-bind.md index 9372ef98ac..a7f0b2bf4c 100644 --- a/changelog.d/10046-closure-own-bind.md +++ b/changelog.d/10046-closure-own-bind.md @@ -6,6 +6,12 @@ invoked once, and non-callable own properties throw instead of falling through to an intrinsic. Ordinary Function.prototype fast paths remain unchanged, including `apply` with an arguments object. +Named builtin ESM imports now install their module's dispatch/attachment bucket +before initializing the export snapshot. This prevents an early named import +from caching an AsyncResource/AsyncLocalStorage constructor without its own +static methods or prototype. Installation remains per-module, so unrelated +native modules remain eligible for dead stripping. + Adds a runtime regression and a bounded, application-independent native matrix covering builtin/import/require/alias forms, async context and receiver capture, custom own methods/accessors, non-callable overrides, and intrinsic fallbacks. diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index d9832bc16a..e421f5024d 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -71,6 +71,8 @@ pub(crate) use func_ref::{ mod jsx; pub(crate) mod method_override; pub(crate) use method_override::emit_inline_direct_method_shape_guard; +#[cfg(test)] +mod named_import_install_tests; mod namespace_call; mod native; mod native_module_dispatch; @@ -390,6 +392,20 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R // dispatcher) before lowering the call, so the throw carries `at file:line`. // No-op in the default build; offset 0 (synthesized refs) resolves to none. if let Expr::ExternFuncRef { name, .. } = callee { + // Named builtin imports preload an ESM export cell before user code. + // Unlike NativeModuleRef property reads, that synthesized call never + // constructed a namespace or installed its prototype/static handlers. + // Install only the named module before caching an undecorated callable + // (e.g. AsyncResource without its own bind). Preserve module stripping. + if name == "js_native_module_named_esm_export_value" + && !ctx.import_function_prefixes.contains_key(name) + { + if let Some(Expr::String(module)) = args.first() { + if let Some(install) = crate::nm_install::nm_install_symbol(module) { + ctx.block().call_void(install, &[]); + } + } + } if name == "js_global_get_or_throw_unresolved" { let off = ctx.strings.pending_call_offset(); crate::expr::calls::emit_call_location_at(ctx, off); diff --git a/crates/perry-codegen/src/lower_call/named_import_install_tests.rs b/crates/perry-codegen/src/lower_call/named_import_install_tests.rs new file mode 100644 index 0000000000..8d4addb32a --- /dev/null +++ b/crates/perry-codegen/src/lower_call/named_import_install_tests.rs @@ -0,0 +1,90 @@ +//! Named ESM builtin cells must be initialized after their module's installer. +use crate::{compile_module, CompileOptions}; +use perry_hir::{types::Type, Expr, Function, Module, Stmt}; + +fn compile_lookup(module_name: &str, property: &str) -> String { + let mut module = Module::new("named_import_install.ts"); + module.functions.push(Function { + id: 0, + name: "lookup".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_native_module_named_esm_export_value".into(), + param_types: vec![Type::String, Type::String], + return_type: Type::Any, + }), + args: vec![ + Expr::String(module_name.into()), + Expr::String(property.into()), + ], + type_args: Vec::new(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let bytes = compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("compile named import cell lookup"); + let ir = String::from_utf8(bytes).expect("LLVM IR"); + let start = ir + .find("define double @perry_fn_named_import_install_ts__lookup(") + .expect("lookup function emitted"); + let tail = &ir[start..]; + let end = tail.find("\n}\n").expect("lookup function terminated"); + tail[..end + 3].to_string() +} + +#[test] +fn named_builtin_cell_installs_only_its_module_before_lookup() { + for (module, property, installer) in [ + ("async_hooks", "AsyncResource", "js_nm_install_async_hooks"), + ( + "node:async_hooks", + "AsyncLocalStorage", + "js_nm_install_async_hooks", + ), + ("util", "inherits", "js_nm_install_util"), + ] { + let ir = compile_lookup(module, property); + let install = ir + .find(&format!("call void @{installer}(")) + .expect("module installer emitted"); + let lookup = ir + .find("call double @js_native_module_named_esm_export_value(") + .expect("export cell lookup emitted"); + assert!( + install < lookup, + "installer must precede cache population: {ir}" + ); + assert!( + !ir.contains("@js_nm_install_all("), + "no blanket install: {ir}" + ); + assert!( + !ir.contains("call void @js_nm_install_fs("), + "no unrelated module: {ir}" + ); + } +} + +#[test] +fn unknown_named_module_does_not_install_unrelated_buckets() { + let ir = compile_lookup("unknown-module", "value"); + assert!(ir.contains("call double @js_native_module_named_esm_export_value(")); + assert!(!ir.contains("call void @js_nm_install_")); +} From 57e2abd8fae8066da7f0fceb4e0d7c294d85679c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:42:00 +0200 Subject: [PATCH 4/7] fix(runtime): reject non-callable own function method overrides --- .../src/object/native_call_method.rs | 18 +++++++++++++++++- tests/modules/async_resource_own_bind/main.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 10ce82556d..9a8b180416 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1734,13 +1734,29 @@ pub unsafe extern "C-unwind" fn js_native_call_method( f64::from_bits(crate::value::TAG_UNDEFINED) }; if dyn_val.to_bits() != crate::value::TAG_UNDEFINED || own_override { + let dyn_val = root_scope.root_nanbox_f64(dyn_val); + // The permissive value-call bridge returns undefined for + // nullish callees. A member invocation must instead reject a + // non-callable own value. The prototype-object probe may + // allocate, so keep the resolved method in a mutable root. + if own_override + && !crate::proxy::proxy_wraps_callable(dyn_val.get_nanbox_f64()) + && !crate::object::is_function_prototype_object_value(dyn_val.get_nanbox_f64()) + { + crate::error::js_throw_type_error_not_a_function( + std::ptr::null(), + 0, + method_name.as_ptr(), + method_name.len(), + ); + } // #6438: same rebind as the GC_TYPE_CLOSURE arm below — // `closure_get_dynamic_prop` may return a method read off the // closure's `Object.setPrototypeOf` proto, whose bound `this` // (an object-literal method binds the literal) would otherwise // win over IMPLICIT_THIS and leave `this` as the PROTO. let bound = crate::closure::clone_closure_rebind_this( - dyn_val.to_bits(), + dyn_val.get_nanbox_u64(), f64::from_bits(object().to_bits()), ); // #8495: root the displaced receiver across the call below — the diff --git a/tests/modules/async_resource_own_bind/main.ts b/tests/modules/async_resource_own_bind/main.ts index 6e6bc32c46..3b655b28a2 100644 --- a/tests/modules/async_resource_own_bind/main.ts +++ b/tests/modules/async_resource_own_bind/main.ts @@ -53,7 +53,7 @@ function ownOverrides(fn: any) { } check(calls === 4, 'all own implementations called'); for (const key of ['bind', 'call', 'apply', 'toString']) { - for (const value of [undefined, null, 123]) { + for (const value of [undefined, null, 123, NaN, true, 'text', {}, []]) { fn[key] = value; let threw = false; try { fn[key](41); } catch (error) { threw = error instanceof TypeError; } @@ -75,6 +75,14 @@ function ownOverrides(fn: any) { }); check(fn.bind(41) === 42 && gets === 1, 'own accessor once'); delete fn.bind; + const callableOwner: any = function() { throw new Error('original callable owner'); }; + callableOwner.bind = new Proxy(function(this: any, n: number) { + check(this === callableOwner, 'callable proxy receiver'); + return n + 1; + }, {}); + check(callableOwner.bind(41) === 42, 'own callable proxy'); + callableOwner.bind = Function.prototype; + check(callableOwner.bind(41) === undefined, 'Function.prototype itself is callable'); } ownOverrides(function() { throw new Error('original must not run'); }); From ae054d1920758aa37a9f91e108fcbae58bc0f631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:44:07 +0200 Subject: [PATCH 5/7] fix(runtime): preserve receiver for proxy-valued own methods --- .../src/object/native_call_method.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 9a8b180416..41cc0945c7 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1772,11 +1772,19 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // introduced `refreshed_args` and reached ten sites but not // this one. let call_args = refreshed_args(); - let result = crate::closure::js_native_call_value( - f64::from_bits(bound), - call_args.as_ptr(), - call_args.len(), - ); + let result = if crate::proxy::js_proxy_is_proxy(f64::from_bits(bound)) == 1 { + crate::proxy::call_proxy_value_with_this( + f64::from_bits(bound), + object(), + &call_args, + ) + } else { + crate::closure::js_native_call_value( + f64::from_bits(bound), + call_args.as_ptr(), + call_args.len(), + ) + }; IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); return result; } From 8978f00994afce1028b7585a7c759364424c51a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:46:54 +0200 Subject: [PATCH 6/7] test(runtime): cover receiver preservation for own proxy methods --- .../closure_override_tests.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs b/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs index 61972ce311..c499c69119 100644 --- a/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs +++ b/crates/perry-runtime/src/object/native_call_method/closure_override_tests.rs @@ -9,8 +9,7 @@ extern "C" fn own_method(_closure: *const closure::ClosureHeader, _arg: f64) -> crate::object::js_implicit_this_get() } -#[test] -fn closure_own_function_methods_override_intrinsic_dispatch() { +fn check_own_method_dispatch(proxy: bool) { let _lock = crate::gc::global_side_table_test_lock(); let scope = RuntimeHandleScope::new(); closure::js_register_closure_arity(original as *const u8, 1); @@ -23,6 +22,15 @@ fn closure_own_function_methods_override_intrinsic_dispatch() { let implementation = scope.root_nanbox_f64(value::js_nanbox_pointer( closure::js_closure_alloc(own_method as *const u8, 0) as i64, )); + if proxy { + let handler = scope.root_nanbox_f64(value::js_nanbox_pointer( + crate::object::js_object_alloc(0, 0) as i64, + )); + implementation.set_nanbox_f64(crate::proxy::js_proxy_new( + implementation.get_nanbox_f64(), + handler.get_nanbox_f64(), + )); + } closure::closure_set_dynamic_prop( value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as usize, method, @@ -45,3 +53,13 @@ fn closure_own_function_methods_override_intrinsic_dispatch() { ); } } + +#[test] +fn closure_own_function_methods_override_intrinsic_dispatch() { + check_own_method_dispatch(false); +} + +#[test] +fn closure_own_function_methods_preserve_proxy_receiver() { + check_own_method_dispatch(true); +} From 901ee7a410670fad331e4c8ea0a4073e0f1b8964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 08:48:10 +0200 Subject: [PATCH 7/7] test: prepare coherent providers for own-bind integration suite --- .github/workflows/test.yml | 8 +++++++- changelog.d/10046-closure-own-bind.md | 2 ++ crates/perry/tests/async_resource_own_bind.rs | 1 + scripts/test-async-resource-own-bind.mjs | 7 ++++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80e63da524..04b67b47b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1457,6 +1457,12 @@ jobs: if: steps.scope.outputs.rust_work == 'true' run: rm -rf target/perry-auto-* target/debug/libperry_ext_*.a 2>/dev/null || true + - name: Install the pinned integration-test Node oracle + if: steps.scope.outputs.suites != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version-file: .node-version + - name: Run scoped integration suites if: steps.scope.outputs.suites != '' env: @@ -1480,7 +1486,7 @@ jobs: # RFC-2945 abort guards that a JS throw trips — the opposite of the # shipped semantics. See the longer note in `cargo-test`. if printf '%s\n' "$SUITES" | grep -qE '^(perry|perry-stdlib) '; then - if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value) '; then + if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value|child_output_late_iterator|async_resource_own_bind) '; then # Prepare all require providers in one graph, outside the fixture's # timeout. A second perry-dev graph inside cargo test exceeded its # ten-minute bound on fresh runners (#9989/#9990). diff --git a/changelog.d/10046-closure-own-bind.md b/changelog.d/10046-closure-own-bind.md index a7f0b2bf4c..8524485698 100644 --- a/changelog.d/10046-closure-own-bind.md +++ b/changelog.d/10046-closure-own-bind.md @@ -15,3 +15,5 @@ native modules remain eligible for dead stripping. Adds a runtime regression and a bounded, application-independent native matrix covering builtin/import/require/alias forms, async context and receiver capture, custom own methods/accessors, non-callable overrides, and intrinsic fallbacks. +The standalone CI suite prepares coherent native providers outside its fixture +timeouts, uses the pinned Node oracle, and publishes failed compiler output. diff --git a/crates/perry/tests/async_resource_own_bind.rs b/crates/perry/tests/async_resource_own_bind.rs index 61706fd2d0..6b09d1dc94 100644 --- a/crates/perry/tests/async_resource_own_bind.rs +++ b/crates/perry/tests/async_resource_own_bind.rs @@ -8,6 +8,7 @@ fn standalone_regression() { .arg(root.join("scripts/test-async-resource-own-bind.mjs")) .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) .env("PERRY_WORKSPACE_ROOT", &root) + .env("PERRY_TEST_BUILD_RUNTIME", "1") .current_dir(&root) .output() .expect("run bounded Node regression driver"); diff --git a/scripts/test-async-resource-own-bind.mjs b/scripts/test-async-resource-own-bind.mjs index 3f272e3623..2efbf57d31 100644 --- a/scripts/test-async-resource-own-bind.mjs +++ b/scripts/test-async-resource-own-bind.mjs @@ -4,8 +4,10 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +import { prepareRequireRuntime } from './test-require-runtime.mjs'; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +prepareRequireRuntime(root); const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-async-own-bind-')); const env = { ...process.env }; @@ -15,7 +17,10 @@ function run(name, executable, args, timeout, extraEnv = {}) { const result = spawnSync(executable, args, { cwd: work, env: { ...env, ...extraEnv }, encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024 }); fs.writeFileSync(path.join(work, `${name}.log`), `${result.stdout ?? ''}${result.stderr ?? ''}`); - if (result.error || result.status !== 0) throw new Error(`${name}: ${result.error ?? result.status}`); + if (result.error || result.status !== 0) { + throw new Error(`${name}: ${result.error ?? result.status} (signal ${result.signal ?? 'none'})\n` + + `${result.stdout ?? ''}${result.stderr ?? ''}`); + } return result.stdout; } try {