Skip to content

perry-runtime: Effect — internal/tracer.ts init runs before Context.ts (circular-import topo-sort exposes #671's next blocker) #680

@proggeramlug

Description

@proggeramlug

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 = 0x3d0objdump -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:

  1. import * as Context creates a binding to the LIVE namespace object — not the value at import time.
  2. 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.

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