fix(jsruntime): Effect.pipe(map) chain composition#1000
Merged
Conversation
`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.
This was referenced May 18, 2026
proggeramlug
added a commit
that referenced
this pull request
May 18, 2026
…1009) PR #973 lowered bare built-in idents (`Promise`, `Array`, `Date`, ...) as `PropertyGet { GlobalGet(0), name }` so they route through the globalThis singleton closure path. Two codegen call sites that specialize `.then()` dispatch were still pattern-matching the legacy `Expr::GlobalGet(_)` shape only: - `type_analysis::is_promise_expr` for `Promise.resolve/reject/all/race/ allSettled/any(...)` and `Array.fromAsync(...)`. - `lower_call.rs`'s fused `Promise.resolve(x).then(cb)` fast path that routes to `js_promise_resolved_then`. When `is_promise_expr` returned false, `.then(cb)` fell through to the generic native method dispatch which doesn't enqueue the callback — microtask-02..07 and edge-promises went silent in compile-smoke's Native no-fallback gate, and on Linux V8 surfaced the same shape as `TypeError: then is not a function`. The Native gate had been red on every PR since #973 (admin-bypassed on #997 / #1000 / #1003 / #1004). Extract `type_analysis::is_global_builtin_named(expr, name)` that matches both shapes (legacy `GlobalGet(_)` and the post-#973 `PropertyGet { GlobalGet(0), name }`) and route both call sites through it. Validation: `scripts/run_native_no_fallback_tests.sh` — 35 passed, 0 failed (was 28/7 pre-fix).
This was referenced May 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Effect.runSync(Effect.succeed(42).pipe(Effect.map(x => x + 1)))returnedundefined(TypeError: f is not a function) instead of43. PR feat(jsruntime): V8 named-import static-method dispatch for Effect.succeed #992 fixedEffect.succeed(42)but the follow-onEffect.map(fn)lost its closure arg at the V8 boundary.js_transformto wrapClosureargs ofStaticMethodCallon V8-imported classes inJsCreateCallback, and add aGC_TYPE_CLOSUREarm tonative_object_to_v8that wraps the closure pointer as a realv8::Function(covers the LocalGet/FuncRef fallback path).test-files/test_effect_pipe_map.ts.Root cause
StaticMethodCall { class_name: "Effect", method_name: "map", args: [Closure {...}] }flowed throughemit_v8_member_method_call->js_call_v8_member_method. Each arg was packed vianative_to_v8(scope, fixup_native_for_v8(a)). The closure's raw*const ClosureHeaderhad heap-pointer bits (0x0000_0001_xxxx_xxxx), sofixup_native_for_v8tagged itPOINTER_TAG.native_to_v8then dispatched tonative_object_to_v8, which had arms forGC_TYPE_PROMISE/ARRAY/OBJECTbut notCLOSURE— fallback string/array heuristics misidentified the closure and surfaced as a proxy object. Effect's pipeline triedf = transformer._op_action_fn, got a non-function, threw insiderunSync.Fix
crates/perry-hir/src/js_transform.rs—StaticMethodCallarm now checksextern_func_to_js.contains_key(class_name)and rewrites anyClosurearg intoJsCreateCallback { closure, param_count }, marking the closure's params as JS values on a cloned tracker (mirrors the existingJsCallMethodarm).crates/perry-jsruntime/src/bridge.rs:perry_closure_v8_trampoline+native_closure_to_v8: build av8::Functionwhosedataslot holds the closure's raw*const ClosureHeader(wrapped inv8::External). On invocation the trampoline marshals args viav8_to_native, stashes the scope for nested FFI (stash_trampoline_scope), callsjs_closure_call_array(closure_env, args_ptr, args_len), and returns throughnative_to_v8.native_object_to_v8gains aGC_TYPE_CLOSUREarm gated onCLOSURE_MAGICat offset 12 (same tagjs_value_typeofuses), routed tonative_closure_to_v8. Defense-in-depth for the LocalGet / FuncRef fallback path that the HIR transform can't see.Test plan
Effect.runSync(Effect.succeed(42).pipe(Effect.map((x: number) => x + 1)))returns43(was:undefinedwith internalf is not a function).const fn = (x) => x + 1; Effect.runSync(Effect.succeed(42).pipe(Effect.map(fn)))returns43(named local case — exercises the new bridge GC_TYPE_CLOSURE arm).test_issue_effect_*fixtures still pass (arguments_length_returned_fn,factory_static_dispatch,tag_deeper,tag_undefined).test-files/test_effect_pipe_map.tsexercises the chain.