Skip to content

Commit 409aead

Browse files
authored
fix(jsruntime): Effect.pipe(map) chain composition (#1000)
`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.
1 parent 17e99ea commit 409aead

7 files changed

Lines changed: 308 additions & 73 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,88 @@
22

33
Detailed changelog for Perry. See CLAUDE.md for concise summaries.
44

5+
## v0.5.1002 — fix(jsruntime): Effect.pipe(map) chain composition
6+
7+
`Effect.runSync(Effect.succeed(42).pipe(Effect.map(x => x + 1)))` returned
8+
`undefined` (with `[JS-INTEROP] 'Effect.runSync' threw: (FiberFailure)
9+
TypeError: f is not a function`) instead of `43`. PR #992 fixed
10+
`Effect.succeed(42)` by routing `StaticMethodCall` for V8-named imports
11+
through `js_call_v8_member_method`, but the follow-on `Effect.map(fn)`
12+
still failed because the Perry closure argument never reached V8 as a
13+
real `v8::Function`.
14+
15+
**Root cause — two layers**
16+
17+
1. **HIR**: `js_transform`'s `Closure` → `JsCreateCallback` wrap ran in
18+
the `JsCallMethod` / `JsCallFunction` / callable-JS-value arms only.
19+
The `StaticMethodCall { class_name, args, .. }` arm just recursed on
20+
args without rewriting them, so `Effect.map((x) => x + 1)` shipped
21+
the raw `Closure` literal as a positional arg.
22+
23+
2. **Codegen → bridge**: `emit_v8_member_method_call` lowers args
24+
through `lower_expr` and packs them into a `[N x double]` alloca that
25+
`js_call_v8_member_method` then forwards to V8 via
26+
`native_to_v8(scope, fixup_native_for_v8(a))`. A raw closure pointer
27+
has bits in the heap-pointer range `0x0000_0001_xxxx_xxxx`, so
28+
`fixup_native_for_v8` tagged it as `POINTER_TAG`. `native_to_v8` then
29+
dispatched to `native_object_to_v8`, which read the `GcHeader` —
30+
recognized `GC_TYPE_PROMISE`/`ARRAY`/`OBJECT`, but had NO arm for
31+
`GC_TYPE_CLOSURE`. The fallback paths (string-header sniff, array
32+
heuristic) misidentified the closure and surfaced as a string-ish or
33+
array-ish proxy object to V8. Effect's pipeline read `f =
34+
transformer._op_action_fn`, found a non-function, and threw inside
35+
`runSync`.
36+
37+
**Fix**
38+
39+
- `crates/perry-hir/src/js_transform.rs` (StaticMethodCall arm):
40+
when the class name is a JS-imported value (`extern_func_to_js`
41+
contains it), iterate the args and wrap any `Closure` literal in
42+
`JsCreateCallback { closure, param_count }`. Inline closure params are
43+
marked as JS values in a forked tracker so the body's `LocalGet`s of
44+
those params route back through V8 (mirrors the existing
45+
`JsCallMethod` arm).
46+
47+
- `crates/perry-jsruntime/src/bridge.rs`:
48+
- Add `perry_closure_v8_trampoline` + `native_closure_to_v8` — a
49+
`v8::Function` whose `data` slot holds the closure's
50+
`*const ClosureHeader` (in a `v8::External`); on invocation the
51+
trampoline marshals args via `v8_to_native`, stashes the scope for
52+
nested FFI, calls `js_closure_call_array(closure_env, args_ptr,
53+
args_len)`, and routes the result back through `native_to_v8`.
54+
- In `native_object_to_v8`, before the GC_TYPE_PROMISE/ARRAY/OBJECT
55+
arms, add a `GC_TYPE_CLOSURE` arm gated on `CLOSURE_MAGIC` at
56+
offset 12 (the same tag `js_value_typeof` uses). This is the
57+
LocalGet / FuncRef fallback path — covers
58+
`const fn = (x) => x + 1; Effect.map(fn)` patterns where the HIR
59+
transform sees only a `LocalGet`, not a `Closure` literal.
60+
61+
**Validation**
62+
63+
```
64+
mkdir -p /tmp/perry-effect-2 && cd /tmp/perry-effect-2
65+
cat > test.ts <<'EOF'
66+
import { Effect } from 'effect';
67+
const result = Effect.runSync(
68+
Effect.succeed(42).pipe(Effect.map((x: number) => x + 1)),
69+
);
70+
console.log('out=' + result);
71+
EOF
72+
echo '{ "name": "test", "type": "module", "dependencies": { "effect": "3.10.0" } }' > package.json
73+
npm install --silent
74+
perry test.ts -o out && ./out
75+
# pre-fix: out=undefined (TypeError: f is not a function)
76+
# post-fix: out=43
77+
```
78+
79+
Also validated the named-local case (`const fn = (x) => x + 1;
80+
Effect.map(fn)`) through the bridge's GC_TYPE_CLOSURE path: closure body
81+
runs, `fn called with: 42` prints, result is `43`. Gap suite unchanged
82+
(30 pass / 6 pre-existing fails). All four `test_issue_effect_*`
83+
fixtures pass.
84+
85+
Fixture: `test-files/test_effect_pipe_map.ts` (chain repro).
86+
587
## v0.5.1001 — fix(jsruntime/http): V8 listen keepalive + express handler smoke
688

789
Post-#994 the V8-fallback `http.createServer().listen(port, cb)` smoke

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
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.
1010

11-
**Current Version:** 0.5.1001
11+
**Current Version:** 0.5.1002
1212

1313

1414
## TypeScript Parity Status

0 commit comments

Comments
 (0)