Summary
After #611 closed the v0.5.706-era SIGSEGV in Utils.ts__init, the Effect end-to-end DoD repro from #321 now gets further on perry 0.5.795. The binary compiles + links cleanly + boots into main without segfaulting — but during module init of effect/src/HashMap.ts (the 26th module-init call from _main in import {} from "effect"'s topological order), the runtime throws an uncaught TypeError: value is not a function. Exit 1, clean teardown, no SIGSEGV — strict improvement over #611's segfault but still blocks the DoD.
Repro
mkdir effect-321 && cd effect-321
cat > package.json <<'JSON'
{ "name": "effect-321", "version": "0.0.0", "type": "module",
"perry": { "compilePackages": ["effect"] } }
JSON
bun add effect@3.21.2
cat > test_bare.ts <<'TS'
console.log("[1] before");
import {} from "effect";
console.log("[2] after");
TS
perry compile test_bare.ts -o /tmp/out
/tmp/out
# stdout: (nothing)
# stderr: TypeError: value is not a function
# at <anonymous>
# exit: 1
Even with an empty named-import binding the crash fires before [1] before prints — i.e. during pre-main module init.
Bisection
lldb backtrace (binary is stripped; mapping addresses to module via the entry .o's relocation table):
* thread #1, stop reason = breakpoint on __exit
* frame #0: libsystem_kernel.dylib`__exit
frame #1: libsystem_c.dylib`exit + 68
frame #2: out_b`<unnamed> + 12 ← _Exit / panic-exit
frame #3: out_b`<unnamed> + 28 ← throw bubbles to top
frame #4: out_b`<unnamed> + 440 ← throw machinery
frame #5: out_b`<unnamed> + 504
frame #6: out_b`<unnamed> + 32 ← closure-call dispatch
frame #7: out_b`<unnamed> + 200
frame #8: out_b`<unnamed> + 672
frame #9: out_b`_main + 128 ← return-address from `bl <call#26>`
frame #10: dyld`start
Mapping _main + 128 (return addr) → bl @ _main + 124 → unlinked offset 0x290 + 0x7C = 0x30C. Looking up that offset in the entry .o's ARM64_RELOC_BRANCH26 table:
0x30c ARM64_RELOC_BRANCH26 _node_modules_effect_src_HashMap_ts__init
So the throw is during effect/src/HashMap.ts__init. The 25 module inits that run before it (Types, HKT, Function, Equivalence, Predicate, ChildExecutorDecision×3, GlobalValue, internal/errors, Utils (#611's old crash site, now clean), Hash, Equal, NonEmptyIterable, Order, Pipeable, Unify, internal/doNotation, internal/hashMap/keySet, internal/hashMap/config, internal/hashMap/bitwise, internal/hashMap/array, internal/stack, internal/hashMap/node, internal/hashMap) all complete cleanly.
Throw-site analysis
The literal message "value is not a function" traces uniquely to crates/perry-runtime/src/closure.rs:772-781:
#[cold]
#[inline(never)]
fn throw_not_callable() -> ! {
crate::error::js_throw_type_error_not_a_function(
std::ptr::null(), // no receiver kind
0,
b"value".as_ptr(), // hard-coded prop label "value"
5,
)
}
Called from js_closure_callN (and the _array / _apply_with_spread dispatch entry points) when func_ptr validation fails. So somewhere during HashMap.ts's top-level init, a code path runs js_closure_callN(invalid_ptr, ...). The literal "value" is just the throw helper's placeholder — it's not a property name in the user code.
HashMap.ts top-level shape
The whole top level is pure bindings — no explicit function calls:
import * as HM from "./internal/hashMap.js"
import * as keySet_ from "./internal/hashMap/keySet.js"
// ... type-only imports erased ...
const TypeId: unique symbol = HM.HashMapTypeId as TypeId // line 14
export const isHashMap: { ... } = HM.isHashMap // line 108
export const empty: <K, V>() => HashMap<K, V> = HM.empty // line 116
export const make: <...>(...) => HashMap<...> = HM.make // line 124-129
export const fromIterable: <K, V>(entries: ...) => ... = HM.fromIterable // line 137
export const isEmpty: <K, V>(self: ...) => boolean = HM.isEmpty // line 145
export const get: { ... } = HM.get // line 154-171
export const getHash: { ... } = HM.getHash // line 179-194
export const unsafeGet: { ... } = HM.unsafeGet // line 203-...
// ... ~30 more `export const X: ... = HM.Y` aliases ...
Every right-hand-side is a namespace member read (HM.X or keySet_.X) — these shouldn't invoke any closure. But somewhere in lowering these for module-init, perry is emitting a js_closure_callN with a func_ptr that fails validation, hence the throw.
What I tried for a standalone repro
A two-file mimic of the HashMap.ts shape (8 const X = NS.Y cross-module property bindings, plus const TypeId: unique symbol = NS.SymA as TypeId) runs clean — prints all 6 markers and exits 0. So the bug needs more state pollution / specific shape than the surface pattern.
// inner.ts
export const SymA: unique symbol = Symbol.for("test/SymA") as SymA;
export type SymA = typeof SymA;
export const empty = <K, V>() => ({ kind: "empty" });
export const isHashMap = (u: unknown): u is any => true;
// ... etc, all simple bindings
// entry.ts
import * as HM from "./inner.ts";
const TypeId: unique symbol = HM.SymA as TypeId;
export const isHashMap = HM.isHashMap;
export const empty = HM.empty;
// ... etc
console.log("done"); // prints "done", exit 0
This means the trigger requires something specific to one of the 25 earlier-init'd Effect modules (or to one of the few import * namespaces HashMap.ts pulls in — HM = internal/hashMap.ts, keySet_ = internal/hashMap/keySet.ts). Likely candidates:
Status snapshot for #321
| Phase |
v0.5.706 |
v0.5.795 |
| Modules collected |
362 ✅ |
362 ✅ |
| Codegen failures |
0 ✅ |
0 ✅ |
| Missing-symbol refs |
0 ✅ |
0 ✅ |
| Link |
clean ✅ |
clean ✅ |
| Boots into main |
❌ SIGSEGV |
✅ |
| Init runs cleanly |
❌ Utils.ts SIGSEGV |
❌ HashMap.ts TypeError |
result: 42 |
— |
— |
The crash has migrated from Utils.ts (11th init, segfault, #611) to HashMap.ts (26th init, controlled TypeError). Big improvement — segfault → uncaught throw — but #321 still blocked.
Notes for whoever picks this up
--keep-intermediates keeps the entry .o (test_bare_ts.o); objdump -r test_bare_ts.o | grep "_node_modules_effect_src.*__init$" | sort gives the exact init order.
- Re-running with
lldb -o "b __exit" -o "run" -o "bt" -o "quit" --batch <binary> captures the backtrace through the throw.
- Unstripping the perry-runtime / perry-stdlib
.a files (set strip = false on those [profile.release.package] blocks in Cargo.toml) would surface symbolic frame names instead of <unnamed>.
- Promising next step: instrument
throw_not_callable in closure.rs:774 to also print the calling code's address (std::backtrace::Backtrace::capture()) before throwing — would identify the exact code path in HashMap.ts__init that calls js_closure_callN(invalid_ptr).
Refs #321, #611.
Summary
After #611 closed the v0.5.706-era SIGSEGV in
Utils.ts__init, the Effect end-to-end DoD repro from #321 now gets further on perry 0.5.795. The binary compiles + links cleanly + boots into main without segfaulting — but during module init ofeffect/src/HashMap.ts(the 26th module-init call from_maininimport {} from "effect"'s topological order), the runtime throws an uncaughtTypeError: value is not a function. Exit 1, clean teardown, no SIGSEGV — strict improvement over #611's segfault but still blocks the DoD.Repro
Even with an empty named-import binding the crash fires before
[1] beforeprints — i.e. during pre-mainmodule init.Bisection
lldbbacktrace (binary is stripped; mapping addresses to module via the entry.o's relocation table):Mapping
_main + 128(return addr) →bl @ _main + 124→ unlinked offset0x290 + 0x7C = 0x30C. Looking up that offset in the entry.o'sARM64_RELOC_BRANCH26table:So the throw is during
effect/src/HashMap.ts__init. The 25 module inits that run before it (Types,HKT,Function,Equivalence,Predicate,ChildExecutorDecision×3,GlobalValue,internal/errors,Utils(#611's old crash site, now clean),Hash,Equal,NonEmptyIterable,Order,Pipeable,Unify,internal/doNotation,internal/hashMap/keySet,internal/hashMap/config,internal/hashMap/bitwise,internal/hashMap/array,internal/stack,internal/hashMap/node,internal/hashMap) all complete cleanly.Throw-site analysis
The literal message
"value is not a function"traces uniquely tocrates/perry-runtime/src/closure.rs:772-781:Called from
js_closure_callN(and the_array/_apply_with_spreaddispatch entry points) when func_ptr validation fails. So somewhere during HashMap.ts's top-level init, a code path runsjs_closure_callN(invalid_ptr, ...). The literal"value"is just the throw helper's placeholder — it's not a property name in the user code.HashMap.ts top-level shape
The whole top level is pure bindings — no explicit function calls:
Every right-hand-side is a namespace member read (
HM.XorkeySet_.X) — these shouldn't invoke any closure. But somewhere in lowering these for module-init, perry is emitting ajs_closure_callNwith a func_ptr that fails validation, hence the throw.What I tried for a standalone repro
A two-file mimic of the HashMap.ts shape (8
const X = NS.Ycross-module property bindings, plusconst TypeId: unique symbol = NS.SymA as TypeId) runs clean — prints all 6 markers and exits 0. So the bug needs more state pollution / specific shape than the surface pattern.This means the trigger requires something specific to one of the 25 earlier-init'd Effect modules (or to one of the few
import *namespaces HashMap.ts pulls in —HM=internal/hashMap.ts,keySet_=internal/hashMap/keySet.ts). Likely candidates:export const X = HM.Yevaluations goes through a getter closure whosefunc_ptris null/garbage@perry_class_keys_<module>__<ClassName>symbol-name collisions when the same class name appears twice in one module #336 / perry-codegen: cross-module class-name collision emits method bodies under the wrong module prefix #431) causing the namespace-getter table to aliasStatus snapshot for #321
result: 42The crash has migrated from
Utils.ts(11th init, segfault, #611) toHashMap.ts(26th init, controlled TypeError). Big improvement — segfault → uncaught throw — but #321 still blocked.Notes for whoever picks this up
--keep-intermediateskeeps the entry.o(test_bare_ts.o);objdump -r test_bare_ts.o | grep "_node_modules_effect_src.*__init$" | sortgives the exact init order.lldb -o "b __exit" -o "run" -o "bt" -o "quit" --batch <binary>captures the backtrace through the throw..afiles (setstrip = falseon those[profile.release.package]blocks inCargo.toml) would surface symbolic frame names instead of<unnamed>.throw_not_callableinclosure.rs:774to also print the calling code's address (std::backtrace::Backtrace::capture()) before throwing — would identify the exact code path in HashMap.ts__init that callsjs_closure_callN(invalid_ptr).Refs #321, #611.