Repository navigation
Description
Summary
After #671 (v0.5.801) eliminated the keySet throw at HashMap.ts__init, the Effect end-to-end DoD repro (#321 / #671 same import {} from \"effect\" test_bare.ts) gets further but still throws TypeError: value is not a function — now during the 75th module init from _main: effect/src/internal/tracer.ts__init.
Repro
Same as #671:
mkdir effect-tracer && cd effect-tracer
cat > package.json <<'JSON'
{ \"name\": \"effect-tracer\", \"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
# stderr: TypeError: value is not a function
# at <anonymous>
# exit: 1Bisection (lldb on stripped binary, mapping addresses to module via --keep-intermediates-preserved entry .o)
* thread #1
* frame #9: out`__perry_user_main + 324 ← bl returned to main+0x144
frame #8: out`<unnamed __init> + 628 ← bl in this init function
frame #7: out`<unnamed callee> + 288 ← throw machinery
frame #6: out`<unnamed callee> + 256 ← throw_not_callable
frame #5..#2: throw / exit machinery
frame #1: libsystem_c.dylib`exit
frame #0: libsystem_kernel.dylib`__exit
Mapping _main + 324 (return addr) → bl @ _main + 320 → unlinked offset 0x290 + 0x140 = 0x3d0 → objdump -r test_bare_ts.o:
0x3d0 ARM64_RELOC_BRANCH26 _node_modules_effect_src_internal_tracer_ts__init
Then within internal/tracer.ts__init (function start in its .o is 0x19e8), the bl at frame 8's +0x270 lands at .o offset 0x19e8 + 0x270 = 0x1c58:
0x1c58 ARM64_RELOC_BRANCH26 _js_closure_call2
So the throw is js_closure_call2(closure_ptr, …) with an invalid closure_ptr.
Throw-site analysis
The bl chain in internal/tracer.ts__init leading up to the failing js_closure_call2:
+0x1f0 bl perry_fn_node_modules_effect_src_Context_ts__Reference ; getter for Context.Reference (returns the closure value)
+0x200 bl perry_fn_node_modules_effect_src_Function_ts__constFalse ; getter for constFalse
+0x224 bl js_inline_arena_slow_alloc ; heap-alloc the options object
…
+0x270 bl js_closure_call2 ; FAILS here
This corresponds to internal/tracer.ts:148:
export const DisablePropagation = Context.Reference<Tracer.DisablePropagation>()(
\"effect/Tracer/DisablePropagation\",
{ defaultValue: constFalse, … }
)i.e. Context.Reference is fetched, then immediately called with two args. The closure pointer at the call site is the value returned by bl perry_fn_Context_ts__Reference. That getter loads _perry_global_node_modules_effect_src_Context_ts__N — but at this point in init, Context.ts__init hasn't run yet, so the global is still 0.0.
Why the init order is wrong
objdump -r test_bare_ts.o | grep BRANCH26.*__init$ | sort shows the _main topo order: internal/tracer.ts__init is at position 75, but Context.ts__init is at position 259 (and internal/context.ts__init is at 258). Tracer needs Context, but Context comes much later.
Perry's DFS topo sort lives at crates/perry/src/commands/compile.rs:1609. It breaks cycles at the back-edge (already-visiting node), so the deeper-in-cycle module gets emitted first. There's a real circular dep somewhere in Context.ts / internal/context.ts → … → tracer.ts (probably via Effect.ts or one of the runtime modules), and the cycle-break direction puts tracer ahead of Context.
In Node, this works because:
import * as Contextcreates a binding to the LIVE namespace object — not the value at import time.- JS function declarations are hoisted — the
Referenceconst-bound arrow can't be called until after Context.ts evaluates anyway, but the namespace lookup is dynamic.
In perry the namespace member access lowers to a static bl perry_fn_<Context>__Reference whose backing global isn't filled in until Context.ts__init runs.
Possible directions
- Two-phase init: emit two passes — phase 1 allocates all globals as null/undefined, phase 2 evaluates initializers. Cycle-break is then naturally handled because all bindings are reachable (as undefined) before any initializer runs.
- Reorder topo-sort: detect cycles and emit a warning/error so users can break the cycle. This is what TS does when run through Node — circular imports are noisy but don't crash.
- Late-bind namespace members: lower
Context.Xto a runtime lookup that returns undefined until populated, instead of a static getter. Slower but matches JS semantics.
The first option is most aligned with how Node/V8 handles ESM cyclic imports.
Status snapshot for #321
| Phase | v0.5.795 | v0.5.801 (post #671) |
|---|---|---|
| Modules collected | 362 ✅ | 362 ✅ |
| Codegen failures | 0 ✅ | 0 ✅ |
| Link | clean ✅ | clean ✅ |
| Boots into main | ✅ | ✅ |
| Init runs cleanly | ❌ HashMap.ts (#671) | ❌ internal/tracer.ts (this issue) |
Activity
proggeramlug commentedon May 11, 2026
Fixed by commit 17bdef6 (v0.5.807). Two coordinated fixes:
Fix 1 — type-only imports filtered from topo dep graph (
crates/perry/src/commands/compile.rs:1567): the topo-sort dep loop now skipsimport.type_onlyimports. Pre-fixinternal/tracer.ts'simport type * as Tracer from "../Tracer.js"formed a phantom cycle with Tracer.ts's valueimport * as internal from "./internal/tracer.js", and DFS cycle-break put internal/tracer.ts ahead of Tracer.ts → Context.ts. Post-fix Context.ts moves to position 37, internal/tracer.ts to 86 (provider before consumer).Fix 2 — per-namespace member resolution (
crates/perry-codegen/src/{expr,lower_call}.rs): exposed once fix 1 landed:defaultServices.tsSIGSEGV'd because the flatimport_function_prefixes: HashMap<String, String>map collided when multipleimport * as random / tracer / clock(each exportingmake) registered. The LAST insert won, sorandom.makeresolved totracer.make→tracer.make(Math.random())was called instead ofrandom.make(Math.random()), dereferencing a number as an options-object pointer. New per-namespace mapnamespace_member_prefixes: HashMap<(namespace_local, member_name), source_prefix>is populated alongside the flat map and consulted first by the two lookup paths.Validation: Pre-fix the test_bare.ts repro failed at the 75th init (
internal/tracer.ts). Post-fix it advances to the ~310th init (Schema.ts) where it hits a different blocker —(number).slice is not a function— likely another namespace-collision-class bug in stdlib dispatch that's separate from #680. Filing a follow-up.Status snapshot for #321:
random.makeresolutionfix(cjs-wrap): refs #665 — forward class identity through `exports.X …
fix(compile): missing namespace_member_prefixes in empty_opts test he…
fix(compile): #684 — type-only imports must not register exports into…
fix(compile,codegen): PerryTS#5922 — namespace-reexport member prefix…
fix(compile,codegen): PerryTS#5924 — namespace-reexport origin-name c…
fix(compile,codegen): #5922/#5924/#5927 — namespace-reexport member r…
fix(compile,codegen): PerryTS#5922 — namespace-reexport member prefix…
fix(compile,codegen): PerryTS#5924 — namespace-reexport origin-name c…
fix(compile): drop init-call back-edges the topo sort already broke (#…