|
2 | 2 |
|
3 | 3 | Detailed changelog for Perry. See CLAUDE.md for concise summaries. |
4 | 4 |
|
| 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 | + |
5 | 87 | ## v0.5.1001 — fix(jsruntime/http): V8 listen keepalive + express handler smoke |
6 | 88 |
|
7 | 89 | Post-#994 the V8-fallback `http.createServer().listen(port, cb)` smoke |
|
0 commit comments