Skip to content

perry-runtime: Effect framework throws TypeError: value is not a function during HashMap.ts module init (Effect end-to-end blocker, post-#611) #671

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions