Skip to content

effect: HttpApi server dies at startup with "TypeError: undefined is not iterable" (logger/fork already work) #6438

Closed

Description

@proggeramlug

Summary

An @effect/platform HttpApi server compiles and links cleanly, but dies at startup with TypeError: undefined is not iterable before it ever binds a port. Node serves the same program fine.

This is the last thing between Perry and a working Effect HTTP server: Effect.logInfo, Effect.gen, Effect.fork and Fiber.join all already work natively (see "What already works" below).

Repro

Sources: https://github.com/bjacobso/effect-compiled-experiment

git clone https://github.com/bjacobso/effect-compiled-experiment && cd effect-compiled-experiment
npm install
perry compile src/effect/web.ts -o dist/web
PORT=3111 ./dist/web        # TypeError: undefined is not iterable

src/effect/web.ts is a ~20-line program: HttpLayerRouter.addHttpApi(Api) + NodeHttpServer.layer(createServer, …) + Layer.launch(...).pipe(NodeRuntime.runMain), where Api is an HttpApi.make("Api") with one Schema.Struct success response.

Deps: [email protected], @effect/[email protected], @effect/[email protected]. perry.compilePackages lists effect + the platform packages (311–371 modules, all compiled native, 0 JS fallback).

Expected vs actual

$ curl http://127.0.0.1:3111/api/health
node : {"ok":true,"service":"perry-effect"}   [HTTP 200]
perry: <no listener>                          [HTTP 000]
       TypeError: undefined is not iterable
           at <anonymous>

The server never starts — the throw happens during layer construction / startup, not per-request.

Environment

  • Perry 889f39eda (v0.5.1258), macOS arm64
  • Coherent full cargo build --release, compiled with auto-optimize (the js_stdlib_init_dispatch no-op-stub warning is absent, so stdlib dispatch is correctly linked — this is not the isolated--p-build trap)

What already works (scoping)

Same repo, same build — these are byte-identical to node --experimental-strip-types:

  • src/effect/logger.ts — Effect.logInfo(...).pipe(NodeRuntime.runMain) → [HH:MM:SS.mmm] INFO (#0): ...
  • src/effect/forking.ts — Effect.gen + Effect.fork + Fiber.join → all three lines, fibers #0/#1, joined result

So the fiber runtime, the pretty logger, generators and forking are all fine. The failure is specific to the HttpApi/layer path.

Diagnostic note

Breaking on js_typeerror_new under lldb does not hit, even though the TypeError is thrown and printed. The auto-optimized build links perry-ext-* staticlibs that bundle their own runtime copy, so the executing throw is a different instance of the symbol than the one lldb binds. Anyone digging in should be aware the obvious breakpoint is misleading — the perry-ext-* staticlib runtime copy is the one to instrument.

undefined is not iterable suggests a destructuring / spread / for…of over an undefined that should be a collection — plausibly a Layer/Context lookup returning undefined where Effect expects an iterable of services.

Activity

  1. proggeramlug commented on Jul 15, 2026

    @proggeramlug
    ContributorAuthor

    Root cause found: static fields of a class EXPRESSION are one module-global, shared across every evaluation

    Narrowed from the Effect server all the way to a self-contained repro. No Effect dependency needed.

    Minimal repro

    function baseWithStatic(ast: any) {
      return class SchemaClass { static ast = ast }
    }
    
    function fMulti(typeParameters: any[], ast: any) {
      return class DeclareClass extends baseWithStatic(ast) {
        static annotations(): any { return (this as any).typeParameters }
        static typeParameters = [...typeParameters]
      }
    }
    
    const M1 = fMulti([1], { a: 1 })
    const M2 = fMulti([2], { a: 2 })
    const M3 = fMulti([3], { a: 3 })
    const M4 = fMulti([4], { a: 4 })
    const M5 = fMulti([5], { a: 5 })
    
    console.log(M1.typeParameters)   // node: [1]   perry: undefined
    console.log(M5.typeParameters)   // node: [5]   perry: undefined
                  perry           node
    M1            undefined       [1]
    M5            undefined       [5]
    single call   [9]             [9]     <-- 1 evaluation works
    

    The trigger is multiplicity: evaluating the same class expression more than once. A single evaluation is fine; from the 2nd on, every instance (including the first) reads undefined. Method name, field-name shadowing, and extends-shape are all red herrings — I ruled each out individually.

    Why

    perry-codegen/src/codegen/module_globals_emit.rs (Phase E):

    Each static foo: T = init becomes @perry_static_<modprefix>__<class>__<field> initialized to 0.0. The init expression runs in compile_module_entry's main/init function before user code.

    So static fields are one module-level global per (module, class-name, field-name), with the initializer hoisted to module init. That holds for a class declaration (evaluated exactly once, at module scope). It breaks for a class expression inside a function:

    • each evaluation must yield a distinct class object with its own statics — but they all share one global slot; and
    • the init is hoisted to module init, where the enclosing function's parameters (typeParameters) are not in scope at all.

    Object.getPrototypeOf(Child) === Parent is also false under Perry (node: true), which looks like the same area: class-object identity/statics are modeled per source class rather than per evaluation.

    Effect impact

    effect/src/Schema.ts:

    function makeDeclareClass(typeParameters, ast) {
      return class DeclareClass extends make(ast) {
        static override annotations(annotations) {
          return makeDeclareClass(this.typeParameters, mergeSchemaAnnotations(this.ast, annotations))
        }
        static typeParameters = [...typeParameters]
      }
    }

    makeDeclareClass is called many times during Schema.ts module init. Instrumenting Schema.ts side-by-side (identical probes in src/ for Perry and dist/esm/ for node):

    perry:  [MDC] x5 (isArray=true)
            [ANN] this.name= DateFromSelf | own typeParameters?= false
                  | tp typeof= undefined | protoName= DeclareClass | proto own tp?= true
            [MDC] typeParameters typeof= undefined
            TypeError: undefined is not iterable
    
    node:   [MDC] x5 (isArray=true)
            [ANN] this.typeParameters typeof= object isArray=true
            [MDC] isArray=true ...
    

    Identical control flow; this.typeParameters is the single divergence → makeDeclareClass(undefined, …) → [...undefined] → the reported TypeError.

    Fix direction

    A class expression evaluated N times needs N independent static slots, and its static initializers must run at class-evaluation time in the enclosing scope, not hoisted to module init. Concretely: keep the module-global scheme for module-scope class declarations (where it's sound and cheap), and give class expressions evaluated in a function per-class-object static storage initialized at evaluation time.

    The existing init_static_fields_late path and the computed-key static side table (sf.key_expr.is_some() → runtime side table, refs #420/#894) look like the natural place to hang per-evaluation storage — computed-key statics already avoid the string-named global for a related reason.

    Environment

    Perry 889f39eda (v0.5.1258), macOS arm64, coherent full cargo build --release.

  2. proggeramlug commented on Jul 15, 2026

    @proggeramlug
    ContributorAuthor

    Exact gate found, plus a partial fix (do NOT land as-is) and what the complete fix needs

    The gate

    perry-hir/src/lower/lower_expr/arm_class.rs (~line 188):

    let at_module_top = ctx.scope_depth == 0 && ctx.inside_block_scope == 0;
    if !at_module_top
        && parent_expr.is_none()          // <-- excludes every `class extends <expr>`
        && (!named_statics.is_empty()
            || !static_symbol_registrations.is_empty()
            || !captured_args.is_empty())
    {
        ... Expr::ClassExprFresh ...
    }

    ClassExprFresh is exactly the right machinery — its own doc cites this case:

    (class C { ... } in value position, e.g. effect's make(ast) => class SchemaClass { static ast = ast }). Codegen allocates a real heap "class object" … and writes the per-evaluation static fields as the object's OWN properties … so make(a) !== make(b) … The static-field initializers are evaluated against the current scope each time the expression runs.

    and #1772's comment states the failure mode verbatim:

    Issue #1772: regular-named static fields with an initializer (static ast = ast) … need the same per-evaluation treatment, otherwise a class expression returned from a factory (effect's make) shares one template class and .ast is undefined/clobbered.

    But parent_expr.is_none() excludes class expressions with heritage — which is precisely effect's class DeclareClass extends make(ast) { static typeParameters = [...typeParameters] }. So it takes the shared-template ClassRef path and hits the exact "shares one template class and .ast is undefined/clobbered" outcome #1772 describes.

    Verified by HIR trace:

    class expr, NO extends   -> ClassExprFresh                  (per-evaluation, correct)
    class expr, WITH extends -> ClassRef("C__class_expr_9")     (shared template -> broken)
    

    Partial fix (works, but incomplete)

    Dropping parent_expr.is_none() and sequencing the dynamic-parent registration ahead of the fresh class object:

    let fresh_expr = Expr::ClassExprFresh { template: synthetic_name.clone(), ... };
    let mut seq: Vec<Expr> = Vec::new();
    if let Some(p) = parent_expr {
        seq.push(Expr::RegisterClassParentDynamic { class_name: synthetic_name, parent_expr: p });
    }
    seq.extend(computed_member_registrations);
    if seq.is_empty() { return Ok(fresh_expr); }
    seq.push(fresh_expr);
    return Ok(Expr::Sequence(seq));

    Results:

    • the minimal repro above now matches node exactly (M1 -> [1], M5 -> [5], previously both undefined);
    • TypeError: undefined is not iterable is gone — the effect HttpApi server gets materially further.

    Why it must NOT land as-is

    The server now fails later with effect's own schema validation:

    Error: Unsupported schema or overlapping types
    at path: ["_tag"]
    details: cannot extend "BadArgument" with "BadArgument"
    

    @effect/platform/src/Error.ts has PlatformError = Schema.Union(BadArgument, SystemError) — the union seeing BadArgument twice means SystemError and BadArgument collapsed onto the same template.

    Cause: RegisterClassParentDynamic keys the parent edge by class_name / template class_id (js_register_class_parent_dynamic is class_id-keyed), so it is last-wins across evaluations. The partial fix makes statics per-evaluation but leaves the parent edge shared. effect gives each DeclareClass a different parent (make(ast) returns a fresh SchemaClass per call), so all instances collapse onto the final parent.

    What the complete fix needs

    ClassExprFresh already allocates a real heap class object and hangs per-evaluation statics on it as own properties. The parent edge needs the same treatment: store the parent value on that class object, and have static/method dispatch, new, and instanceof consult the object's own parent when the receiver is a fresh class object — instead of the template-keyed CLASS_REGISTRY edge.

    Until per-object parents exist, class expressions with heritage are stuck choosing between shared statics (today) and shared parents (partial fix). Both are wrong for a factory invoked more than once.

    Related: Object.getPrototypeOf(Child) === Parent is false under Perry (node: true) — same root: class-object identity/edges are modeled per source class, not per evaluation.

  3. proggeramlug commented on Jul 15, 2026

    @proggeramlug
    ContributorAuthor

    Deeper finding: the real blocker is per-evaluation class IDENTITY for function-scoped classes

    Completing the per-object parent edge (below) fixes the static-field facet and gets effect past the original TypeError, but the HttpApi server then fails at the same underlying disease one level deeper:

    Error: Unsupported schema or overlapping types
    at path: ["_tag"]
    details: cannot extend "BadArgument" with "BadArgument"
    

    effect/src/Schema.ts:

    export const TaggedError = (identifier?) => (tag, fieldsOr, annotations) => {
      class Base extends data_.Error {}            // class DECLARATION inside a function body
      ;(Base.prototype as any).name = tag          // per-call prototype mutation
      ...
      class TaggedErrorClass extends makeClass({ ... })
      ...
    }

    @effect/platform/src/Error.ts calls this twice — BadArgument and SystemError — then PlatformError = Schema.Union(BadArgument, SystemError). Each call must produce a distinct class carrying its own prototype.name / _tag. Perry gives a function-scoped class declaration one shared compile-time class_id, so both collapse onto the same template and the union sees BadArgument twice.

    So the general statement is broader than #1772/#6438's static-field framing:

    Any class defined inside a function body and evaluated more than once shares a single compile-time template — its statics, its prototype, and its identity. Correct semantics need one class per evaluation.

    ClassExprFresh is the existing partial answer for class expressions with per-evaluation statics; it stamps the fresh heap object with the template's class_id, so anything keyed by class_id (prototype contents, dynamic parent edge, capture snapshot — the last of which already carries a documented "per-evaluation overwrite limitation" in its own comment) is still shared. Class declarations inside a function body don't even get that much.

    Supporting evidence, all on 889f39eda:

    class expr, no extends            -> ClassExprFresh   (per-evaluation statics: correct)
    class expr, with extends          -> ClassRef         (shared template: broken)
    class DECL inside fn, evaluated Nx -> ClassRef        (shared template: broken)
    Object.getPrototypeOf(Child) === Parent  -> false     (node: true)
    

    That last one is the same root surfacing directly: class-object identity is modeled per source class, not per evaluation.

    Status of the fix in progress

    Two changes, both verified against node:

    1. arm_class.rs — allow ClassExprFresh for class expressions with heritage (drop parent_expr.is_none()), sequencing RegisterClassParentDynamic ahead of the fresh object.

    2. Per-object parent edge — js_class_object_pin_parent(obj, template_cid) copies this evaluation's parent onto the class object right after the registration (write-right-before-use), and the by-name read walks that pinned edge for inherited static fields.

      Two traps worth recording for whoever picks this up:

      • the pinned value may be an INT32 ClassRef, not a pointer — casting it to *mut ObjectHeader and dereferencing is an instant SIGSEGV;
      • reading the pin marker through the ordinary by-name path re-enters the pinned-parent walk and recurses to the stack guard page. It must be an own-only keys-array scan. A re-entrancy flag doesn't work either — it would abort the legitimate parent→grandparent walk.

    Result: the minimal repro and the multi-level factory chain now match node exactly

    own on D5                 : [5]   (was undefined)
    D5.annotations()          : [5]   (was undefined)
    DateFromSelf.typeParameters : [6] (was undefined)
    DateFromSelf.annotations() : [6]  (was undefined)
    ChildOfD3.typeParameters  : [3]   (was undefined)
    

    and effect's TypeError: undefined is not iterable is gone. But the server still does not start, because TaggedError's function-scoped class declarations remain shared. Fixing #6438 end-to-end therefore needs per-evaluation class identity, not just per-evaluation statics.

  4. proggeramlug commented on Jul 16, 2026

    @proggeramlug
    ContributorAuthor

    The remaining blocker, located exactly: function-scoped class DECLARATIONS share one identity

    PR #6449 fixes the class-EXPRESSION facet and removes TypeError: undefined is not iterable. The HttpApi server then stops at:

    Error: Unsupported schema or overlapping types
    at path: ["_tag"]
    details: cannot extend "BadArgument" with "BadArgument"
    

    Source — effect/src/Schema.ts, TaggedError:

    export const TaggedError = (identifier?) => (tag, fieldsOr, annotations) => {
      class Base extends data_.Error {}                    // class DECLARATION in a fn body
      ;(Base.prototype as any).name = tag                  // per-call prototype mutation
      ...
      class TaggedErrorClass extends makeClass({ ... }) {  // class DECLARATION in a fn body
        static _tag = tag                                  // per-call static capturing `tag`
      }
      return TaggedErrorClass
    }

    @effect/platform/src/Error.ts calls this twice (BadArgument, SystemError) then Schema.Union(BadArgument, SystemError).

    Why #6449 doesn't reach it

    lower_decl/body_stmt.rs:281 — the function-nested class DECLARATION path. The static initializers do re-run per evaluation (its own comment: "here they belong in the function body so they run when the class declaration is evaluated"), but the class is registered as a single ClassRef:

    let class = lower_class_decl(ctx, class_decl, false)?;
    result.extend(build_interleaved_static_init_stmts(...));   // re-runs per call
    ctx.pending_classes.push(class);                           // ONE shared class_id

    so every evaluation writes the same @perry_static_…__TaggedErrorClass___tag global. Two calls, one identity, one slot → BadArgument and SystemError end up with the same _tag. Same for Base.prototype.name = tag: one shared prototype, last-wins.

    Why there is no narrow fix

    A ClassRef is the class id, so distinct per-call state requires distinct identity. Making function-scoped declarations per-evaluation means the binding must become a local holding a class value, which changes how the name resolves at every new C(), static call, instanceof, and export site — the machinery body_stmt.rs / arm_ident.rs / stmt.rs already handle carefully (#5251 shadowing, collision-rename, class self-aliases).

    The encouraging part

    The per-evaluation machinery already works for class expressions, including with heritage — verified on #6449:

    perry node
    per-evaluation statics (K1.s=1, K2.s=2) ✅ ✅
    new K1(42).x / .inst() ✅ ✅
    inherited field + method via super() ✅ ✅
    instanceof ✅ ✅

    So ClassExprFresh is a viable target for declarations too — the work is the binding/resolution rewiring, not the object model. (The arm_ident.rs claim that the path "drops static methods and SIGSEGVs new" is stale and should be corrected so it stops steering work away.)

    Suggested framing

    Retitle/split this issue as per-evaluation class identity for function-scoped classes, covering:

    1. class expressions with per-evaluation statics — done (perry-hir: class expressions hoisted to a single shared class — no per-evaluation identity or static-field state (blocks effect Schema #1758) #1772, fix(jsruntime): harden JS export snapshots #894, and fix(hir): per-evaluation statics for class expressions with heritage (#6438) #6449 for the heritage case);
    2. class declarations inside a function body — open (this comment): fresh identity per evaluation, so statics AND prototype mutations don't collapse;
    3. Object.getPrototypeOf(Child) === Parent returning false (node: true) — the same root surfacing directly.

    Until (2) lands, effect's Schema layer (and any library building classes in per-call factories) cannot run. logger.ts and forking.ts from the repro repo already run byte-identical to node; web.ts needs (2).

  5. proggeramlug commented on Jul 17, 2026

    @proggeramlug
    ContributorAuthor

    Fixed. The full HttpApi / web.ts startup-and-serve path now works end-to-end via the campaign PRs (#6449, #6459, #6460, #6466, #6470, #6474, #6496, #6499). All four effect-compiled-experiment apps — logger, forking, api, web — now run byte-identical to node --experimental-strip-types. The HttpApi server (api.ts) binds and serves /api/health correctly, and web.ts serves its homepage + JSON API. The startup TypeError: undefined is not iterable is gone. Closing as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions