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

@proggeramlug

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions