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: 1
Bisection (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 Context creates a binding to the LIVE namespace object — not the value at import time.
- JS function declarations are hoisted — the
Reference const-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.X to 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) |
Refs #321, #671.
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 throwsTypeError: value is not a function— now during the 75th module init from_main:effect/src/internal/tracer.ts__init.Repro
Same as #671:
Bisection (lldb on stripped binary, mapping addresses to module via
--keep-intermediates-preserved entry .o)Mapping
_main + 324(return addr) →bl @ _main + 320→ unlinked offset0x290 + 0x140 = 0x3d0→objdump -r test_bare_ts.o:Then within
internal/tracer.ts__init(function start in its.ois0x19e8), the bl at frame 8's+0x270lands at.ooffset0x19e8 + 0x270 = 0x1c58:So the throw is
js_closure_call2(closure_ptr, …)with an invalid closure_ptr.Throw-site analysis
The bl chain in
internal/tracer.ts__initleading up to the failingjs_closure_call2:This corresponds to
internal/tracer.ts:148:i.e.
Context.Referenceis fetched, then immediately called with two args. The closure pointer at the call site is the value returned bybl 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$ | sortshows the_maintopo order:internal/tracer.ts__initis at position 75, butContext.ts__initis at position 259 (andinternal/context.ts__initis 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.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>__Referencewhose backing global isn't filled in until Context.ts__init runs.Possible directions
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
Refs #321, #671.