Repository navigation
Description
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: 1Even 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 0This 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:
- A namespace-member getter dispatch that was registered with an invalid function pointer during an earlier module's init
- A cross-module re-export chain where one of the
export const X = HM.Yevaluations goes through a getter closure whosefunc_ptris null/garbage - Same-class-id collision (similar to perry-codegen:
@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 alias
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-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.- 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
.afiles (setstrip = falseon those[profile.release.package]blocks inCargo.toml) would surface symbolic frame names instead of<unnamed>. - Promising next step: instrument
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).
Activity
proggeramlug commentedon May 11, 2026
Closed by commit bfdca4c (v0.5.801).
Root cause (
crates/perry-codegen/src/expr.rs:3580—PropertyGetlowering of namespace member access):When
const X = NS.X(where NS is aimport * as NS from …and X is exported by NS), the lowering unconditionally emittedbl perry_fn_<src>__<X>()— a zero-arg call to the source's wrapper symbol. Forlet/constexports that wrapper is a 3-instruction trivial getter (load global, return), so the zero-arg call returns the closure value correctly. But forexport function X(...)declarations, perry's codegen makesperry_fn_<src>__<X>the function body itself — so calling it with zero args invokes the function with garbage args, returning the body's result instead of a closure handle bound to X.The two existing call sites that already make this distinction (
Expr::ExternFuncReflowering atexpr.rs:10432and the namespace member CALL path atlower_call.rs:547) consultimported_varsto discriminate vars from function decls. The namespace member VALUE path atexpr.rs:3580did not.Concrete failure path in this issue:
HashMap.ts:364bindskeySet = keySet_.keySet.keySetisexport function keySet(self) { return makeImpl(self) }ininternal/hashMap/keySet.ts. Pre-fix HashMap.ts__init invoked the keySet body with no args (instead of just storing its handle). The body calledmakeImpl(an imported var frominternal/hashSet.ts) — but the topo-sorted init order ran HashMap.ts before hashSet.ts, so the makeImpl global was still 0.0. The 0.0 was handed tojs_closure_call1as the closure pointer, trippingthrow_not_callablewith the literalvalue is not a function.Fix: mirror the var-vs-func split. If
propertyis inimported_vars, keep the existing zero-arg getter call. Otherwise emitjs_closure_alloc_singleton(@__perry_wrap_perry_fn_<src>__<property>)and NaN-box the resulting closure handle — same singleton-allocation path the source module's ownExpr::FuncRef(id)value-reads use.Validation:
import {} from "effect"no longer throws at HashMap.ts__init. Verified via objdump on the recompiled HashMap.ts.o — the bl at offset 0x520 →perry_fn_..._keySetis nowjs_closure_alloc_singleton(@__perry_wrap_..._keySet)at 0x528.Follow-up: the Effect end-to-end DoD (#321) is not yet satisfied. After #671 closes, init advances to
internal/tracer.ts__init(the 75th call) where it hits a different blocker —Context.Reference()(...)runs at tracer.ts top-level butinternal/tracer.tsinitializes BEFOREContext.tsin the topo order (Context is at position 259), so Context's per-module globals are still 0.0 when tracer reads them. That's a circular-import / init-order issue, filed separately.fix(hir,codegen,compile): closes #680 — type-only imports flipped top…
fix(compile): #684 — type-only imports must not register exports into…