From 0b91dbbce51281d6c61074138da500a649f06a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 18 May 2026 09:38:35 +0200 Subject: [PATCH] fix(jsruntime): Effect.pipe(map) chain composition `Effect.runSync(Effect.succeed(42).pipe(Effect.map(x => x + 1)))` returned `undefined` (TypeError: f is not a function inside Effect's pipeline) instead of `43`. PR #992 wired up `Effect.succeed(42)` to `js_call_v8_member_method`, but the follow-on `Effect.map(fn)` still failed because the Perry closure argument never crossed into V8 as a real `v8::Function`. Two-layer fix: 1. HIR `js_transform`: extend the closure -> `JsCreateCallback` rewrite to `StaticMethodCall` args when the class name is a JS-imported value (mirrors the existing `JsCallMethod` / `JsCallFunction` arms). This catches inline-Closure arg patterns like `Effect.map((x) => x + 1)`. 2. Bridge `native_object_to_v8`: add a `GC_TYPE_CLOSURE` arm gated on `CLOSURE_MAGIC` that wraps the closure in a `v8::Function` via a new `perry_closure_v8_trampoline` (closure pointer stashed in `v8::External`, body invokes `js_closure_call_array`). This catches the LocalGet / FuncRef path where the HIR transform only sees the variable, not the closure literal. Bumps version to 0.5.1002. Detailed root cause and validation in CHANGELOG. Fixture: test-files/test_effect_pipe_map.ts. --- CHANGELOG.md | 82 ++++++++++++++++ CLAUDE.md | 2 +- Cargo.lock | 136 +++++++++++++-------------- Cargo.toml | 2 +- crates/perry-hir/src/js_transform.rs | 48 +++++++++- crates/perry-jsruntime/src/bridge.rs | 84 +++++++++++++++++ test-files/test_effect_pipe_map.ts | 27 ++++++ 7 files changed, 308 insertions(+), 73 deletions(-) create mode 100644 test-files/test_effect_pipe_map.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7853d8910..488abe5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,88 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.1002 — fix(jsruntime): Effect.pipe(map) chain composition + +`Effect.runSync(Effect.succeed(42).pipe(Effect.map(x => x + 1)))` returned +`undefined` (with `[JS-INTEROP] 'Effect.runSync' threw: (FiberFailure) +TypeError: f is not a function`) instead of `43`. PR #992 fixed +`Effect.succeed(42)` by routing `StaticMethodCall` for V8-named imports +through `js_call_v8_member_method`, but the follow-on `Effect.map(fn)` +still failed because the Perry closure argument never reached V8 as a +real `v8::Function`. + +**Root cause — two layers** + +1. **HIR**: `js_transform`'s `Closure` → `JsCreateCallback` wrap ran in + the `JsCallMethod` / `JsCallFunction` / callable-JS-value arms only. + The `StaticMethodCall { class_name, args, .. }` arm just recursed on + args without rewriting them, so `Effect.map((x) => x + 1)` shipped + the raw `Closure` literal as a positional arg. + +2. **Codegen → bridge**: `emit_v8_member_method_call` lowers args + through `lower_expr` and packs them into a `[N x double]` alloca that + `js_call_v8_member_method` then forwards to V8 via + `native_to_v8(scope, fixup_native_for_v8(a))`. A raw closure pointer + has bits in the heap-pointer range `0x0000_0001_xxxx_xxxx`, so + `fixup_native_for_v8` tagged it as `POINTER_TAG`. `native_to_v8` then + dispatched to `native_object_to_v8`, which read the `GcHeader` — + recognized `GC_TYPE_PROMISE`/`ARRAY`/`OBJECT`, but had NO arm for + `GC_TYPE_CLOSURE`. The fallback paths (string-header sniff, array + heuristic) misidentified the closure and surfaced as a string-ish or + array-ish proxy object to V8. Effect's pipeline read `f = + transformer._op_action_fn`, found a non-function, and threw inside + `runSync`. + +**Fix** + +- `crates/perry-hir/src/js_transform.rs` (StaticMethodCall arm): + when the class name is a JS-imported value (`extern_func_to_js` + contains it), iterate the args and wrap any `Closure` literal in + `JsCreateCallback { closure, param_count }`. Inline closure params are + marked as JS values in a forked tracker so the body's `LocalGet`s of + those params route back through V8 (mirrors the existing + `JsCallMethod` arm). + +- `crates/perry-jsruntime/src/bridge.rs`: + - Add `perry_closure_v8_trampoline` + `native_closure_to_v8` — a + `v8::Function` whose `data` slot holds the closure's + `*const ClosureHeader` (in a `v8::External`); on invocation the + trampoline marshals args via `v8_to_native`, stashes the scope for + nested FFI, calls `js_closure_call_array(closure_env, args_ptr, + args_len)`, and routes the result back through `native_to_v8`. + - In `native_object_to_v8`, before the GC_TYPE_PROMISE/ARRAY/OBJECT + arms, add a `GC_TYPE_CLOSURE` arm gated on `CLOSURE_MAGIC` at + offset 12 (the same tag `js_value_typeof` uses). This is the + LocalGet / FuncRef fallback path — covers + `const fn = (x) => x + 1; Effect.map(fn)` patterns where the HIR + transform sees only a `LocalGet`, not a `Closure` literal. + +**Validation** + +``` +mkdir -p /tmp/perry-effect-2 && cd /tmp/perry-effect-2 +cat > test.ts <<'EOF' +import { Effect } from 'effect'; +const result = Effect.runSync( + Effect.succeed(42).pipe(Effect.map((x: number) => x + 1)), +); +console.log('out=' + result); +EOF +echo '{ "name": "test", "type": "module", "dependencies": { "effect": "3.10.0" } }' > package.json +npm install --silent +perry test.ts -o out && ./out +# pre-fix: out=undefined (TypeError: f is not a function) +# post-fix: out=43 +``` + +Also validated the named-local case (`const fn = (x) => x + 1; +Effect.map(fn)`) through the bridge's GC_TYPE_CLOSURE path: closure body +runs, `fn called with: 42` prints, result is `43`. Gap suite unchanged +(30 pass / 6 pre-existing fails). All four `test_issue_effect_*` +fixtures pass. + +Fixture: `test-files/test_effect_pipe_map.ts` (chain repro). + ## v0.5.1001 — fix(jsruntime/http): V8 listen keepalive + express handler smoke Post-#994 the V8-fallback `http.createServer().listen(port, cb)` smoke diff --git a/CLAUDE.md b/CLAUDE.md index 79040d288..c2eb37c3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1001 +**Current Version:** 0.5.1002 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index d19a2498f..ffc0a1b63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4905,7 +4905,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "base64", @@ -4960,14 +4960,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "log", @@ -4980,7 +4980,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-hir", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-hir", @@ -4997,7 +4997,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-dispatch", @@ -5007,7 +5007,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-hir", @@ -5016,7 +5016,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "base64", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-hir", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "serde", "serde_json", @@ -5045,7 +5045,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1001" +version = "0.5.1002" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5056,7 +5056,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "clap", @@ -5071,7 +5071,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "argon2", "perry-ffi", @@ -5079,7 +5079,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "reqwest", @@ -5088,7 +5088,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "bcrypt", "perry-ffi", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "rusqlite", @@ -5104,7 +5104,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "scraper", @@ -5112,14 +5112,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "chrono", "cron", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "chrono", "perry-ffi", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "rust_decimal", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "serde_json", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5160,21 +5160,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "bytes", "http-body-util", @@ -5188,7 +5188,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "lazy_static", "perry-ffi", @@ -5199,7 +5199,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5211,7 +5211,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "bytes", "http-body-util", @@ -5230,7 +5230,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "lazy_static", "perry-ffi", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "base64", "jsonwebtoken", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "lru", "perry-ffi", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "chrono", "perry-ffi", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "bson", "futures-util", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "chrono", "perry-ffi", @@ -5289,7 +5289,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "nanoid", "perry-ffi", @@ -5298,7 +5298,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "rustls", @@ -5309,7 +5309,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "lettre", "perry-ffi", @@ -5319,7 +5319,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "sqlx", @@ -5328,7 +5328,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "governor", "perry-ffi", @@ -5336,7 +5336,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "base64", "image", @@ -5345,14 +5345,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "lazy_static", "perry-ffi", @@ -5360,7 +5360,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "uuid", @@ -5368,7 +5368,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "perry-ffi", "regex", @@ -5378,7 +5378,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "futures-util", "lazy_static", @@ -5389,7 +5389,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "flate2", "perry-ffi", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5406,7 +5406,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-api-manifest", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "bytes", @@ -5444,7 +5444,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-diagnostics", @@ -5456,7 +5456,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "base64", @@ -5480,7 +5480,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -5550,7 +5550,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "perry-hir", @@ -5560,7 +5560,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5568,11 +5568,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1001" +version = "0.5.1002" [[package]] name = "perry-ui-android" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "itoa", "jni", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "rand 0.8.6", "serde", @@ -5597,7 +5597,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5616,7 +5616,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "block2", "libc", @@ -5631,7 +5631,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "block2", "libc", @@ -5649,11 +5649,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1001" +version = "0.5.1002" [[package]] name = "perry-ui-tvos" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "block2", "libc", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "block2", "libc", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "block2", "libc", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "libc", "perry-runtime", @@ -5710,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "base64", "ed25519-dalek", @@ -5724,7 +5724,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1001" +version = "0.5.1002" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 82888ca8c..b42726f49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.1001" +version = "0.5.1002" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-hir/src/js_transform.rs b/crates/perry-hir/src/js_transform.rs index c6bfa38e3..7050962ef 100644 --- a/crates/perry-hir/src/js_transform.rs +++ b/crates/perry-hir/src/js_transform.rs @@ -912,9 +912,51 @@ fn transform_expr( } } } - Expr::StaticMethodCall { args, .. } => { - for arg in args { - transform_expr(arg, js_imports, extern_func_to_js, local_name_to_js, tracker); + Expr::StaticMethodCall { + class_name, args, .. + } => { + // Issue: Effect.pipe(map) chain — when `class_name` is a JS-imported + // value (e.g. `import { Effect } from 'effect'`), the + // StaticMethodCall is routed through `js_call_v8_member_method` + // (see emit_v8_member_method_call). Any Closure args need to be + // wrapped in JsCreateCallback so V8 sees a real v8::Function + // instead of a raw native function pointer. Without this, + // `Effect.map((x) => x + 1)` passed the arrow as a number/pointer + // and Effect's internal `f` ended up "not a function". + if extern_func_to_js.contains_key(class_name) { + for arg in args.iter_mut() { + if let Expr::Closure { params, body, .. } = arg { + let param_count = params.len(); + let mut closure_tracker = tracker.clone(); + for param in params.iter() { + closure_tracker.mark_js_local(param.id); + } + transform_stmts( + body, + js_imports, + extern_func_to_js, + local_name_to_js, + &mut closure_tracker, + ); + let owned = std::mem::replace(arg, Expr::Undefined); + *arg = Expr::JsCreateCallback { + closure: Box::new(owned), + param_count, + }; + } else { + transform_expr( + arg, + js_imports, + extern_func_to_js, + local_name_to_js, + tracker, + ); + } + } + } else { + for arg in args { + transform_expr(arg, js_imports, extern_func_to_js, local_name_to_js, tracker); + } } } Expr::StaticFieldSet { value, .. } => { diff --git a/crates/perry-jsruntime/src/bridge.rs b/crates/perry-jsruntime/src/bridge.rs index e0dd78234..6758aabe9 100644 --- a/crates/perry-jsruntime/src/bridge.rs +++ b/crates/perry-jsruntime/src/bridge.rs @@ -105,6 +105,73 @@ fn native_class_constructor( retval.set(v8::Object::new(scope).into()); } +// Issue: Effect.pipe(map) chain — when a Perry closure (raw `*const +// ClosureHeader` pointer that's been NaN-boxed with POINTER_TAG) crosses +// into V8 as an argument, it must surface as a real v8::Function so JS +// code can invoke it. Without this wrapper, V8 saw a string/object proxy +// (from `native_object_to_v8`'s fallback paths) and threw "f is not a +// function" when Effect's internal pipeline tried to call the mapping +// function. +// +// Mirrors `native_callback_trampoline` (interop.rs) but stores the +// closure pointer directly in the v8::Function's `data` slot instead of +// going through the NATIVE_CALLBACKS registry — we already have the +// closure pointer in hand and don't need a stable callback_id for it. +fn perry_closure_v8_trampoline( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let data = args.data(); + if !data.is_external() { + retval.set(v8::undefined(scope).into()); + return; + } + let external = v8::Local::::try_from(data).unwrap(); + let closure_ptr = external.value() as i64; + if closure_ptr == 0 { + retval.set(v8::undefined(scope).into()); + return; + } + + let arg_count = args.length(); + let mut native_args: Vec = Vec::with_capacity(arg_count as usize); + for i in 0..arg_count { + let arg = args.get(i); + native_args.push(v8_to_native(scope, arg)); + } + + let _scope_guard = crate::stash_trampoline_scope(scope); + + type ClosureCallFn = unsafe extern "C" fn(i64, *const f64, i64) -> f64; + let func: ClosureCallFn = perry_runtime::closure::js_closure_call_array; + let result = unsafe { func(closure_ptr, native_args.as_ptr(), native_args.len() as i64) }; + + let v8_result = native_to_v8(scope, result); + retval.set(v8_result); +} + +/// Wrap a Perry closure (raw pointer to a `ClosureHeader` with +/// `CLOSURE_MAGIC` at offset 12) as a `v8::Function`. Used by +/// `native_object_to_v8` when an argument passed to V8 turns out to be a +/// native closure — typically when a `LocalGet` holding an arrow function +/// is passed to a V8-imported call site like `Effect.map(fn)`. +fn native_closure_to_v8<'s>( + scope: &mut v8::PinScope<'s, '_>, + ptr: *const u8, +) -> Option> { + if ptr.is_null() { + return None; + } + // Closure pointer is *const ClosureHeader. Stash the raw address in a + // v8::External so the trampoline can recover it on invocation. + let external = v8::External::new(scope, ptr as *mut std::ffi::c_void); + let function = v8::Function::builder(perry_closure_v8_trampoline) + .data(external.into()) + .build(scope)?; + Some(function.into()) +} + fn native_class_to_v8<'s>( scope: &mut v8::PinScope<'s, '_>, class_id: u32, @@ -851,6 +918,23 @@ fn native_object_to_v8<'s>( return native_promise_to_v8(scope, ptr as *mut perry_runtime::promise::Promise); } + // Issue: Effect.pipe(map) chain — a Perry closure passed to V8 as + // an arg (e.g. `Effect.map(fn)` where `fn` is a local arrow) lands + // here with POINTER_TAG. Confirm the `CLOSURE_MAGIC` tag before + // wrapping so we don't misidentify a generic native object as a + // closure. The HIR-level `JsCreateCallback` rewrite handles inline + // `Closure` literals; this is the LocalGet / FuncRef fallback + // path. + if gc_header.obj_type == perry_runtime::gc::GC_TYPE_CLOSURE { + const CLOSURE_TYPE_TAG_OFFSET: usize = 12; + let type_tag = unsafe { *(ptr.add(CLOSURE_TYPE_TAG_OFFSET) as *const u32) }; + if type_tag == perry_runtime::closure::CLOSURE_MAGIC { + if let Some(func_value) = native_closure_to_v8(scope, ptr) { + return func_value; + } + } + } + if is_arena && gc_header.obj_type == perry_runtime::gc::GC_TYPE_ARRAY { // GC-tracked array: ArrayHeader { length: u32, capacity: u32 } + f64 elements let header = ptr as *const perry_runtime::array::ArrayHeader; diff --git a/test-files/test_effect_pipe_map.ts b/test-files/test_effect_pipe_map.ts new file mode 100644 index 000000000..fff5b016e --- /dev/null +++ b/test-files/test_effect_pipe_map.ts @@ -0,0 +1,27 @@ +// Effect.pipe(Effect.map(fn)) chain composition through the V8 boundary. +// +// Pre-fix (PR #992 ships `Effect.succeed(42)` but stops there): +// - `Effect.map((x) => x + 1)` lowered via `StaticMethodCall { Effect, map }` +// → `js_call_v8_member_method` (issue/PR #992 path). +// - The inline arrow argument was NOT wrapped in `JsCreateCallback` +// because the HIR `js_transform` pass only ran that rewrite for +// `JsCallMethod` / `JsCallFunction` / callable JS values — the +// `StaticMethodCall` arm just recursed on its args. +// - At the V8 boundary the closure pointer crossed through +// `fixup_native_for_v8` → POINTER_TAG → `native_object_to_v8`, which +// misinterpreted the closure as a string/array/object proxy. V8 saw a +// non-function and Effect's internal pipeline threw +// "TypeError: f is not a function" inside `runSync`. +// +// Fix: wrap Closure args of `StaticMethodCall` on V8-imported classes in +// `JsCreateCallback`, and (defense in depth) make `native_object_to_v8` +// detect `GC_TYPE_CLOSURE` and wrap the closure as a v8::Function. Both +// paths surface the user's mapping function as a real JS callable so the +// chained `Effect.runSync(...)` returns the mapped value. + +import { Effect } from 'effect'; + +const result = Effect.runSync( + Effect.succeed(42).pipe(Effect.map((x: number) => x + 1)), +); +console.log('out=' + result);