You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
effect: HttpApi server dies at startup with "TypeError: undefined is not iterable" (logger/fork already work) #6438
An @effect/platformHttpApi 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).
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.
$ 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.
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.
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.
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.
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(), ...};letmut seq:Vec<Expr> = Vec::new();ifletSome(p) = parent_expr {
seq.push(Expr::RegisterClassParentDynamic{class_name: synthetic_name,parent_expr: p });}
seq.extend(computed_member_registrations);if seq.is_empty(){returnOk(fresh_expr);}
seq.push(fresh_expr);returnOk(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.
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:
exportconstTaggedError=(identifier?)=>(tag,fieldsOr,annotations)=>{classBaseextendsdata_.Error{}// class DECLARATION inside a function body;(Base.prototypeasany).name=tag// per-call prototype mutation
...
classTaggedErrorClassextendsmakeClass({ ... })...}
@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:
arm_class.rs — allow ClassExprFresh for class expressions with heritage (drop parent_expr.is_none()), sequencing RegisterClassParentDynamic ahead of the fresh object.
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
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.
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:
exportconstTaggedError=(identifier?)=>(tag,fieldsOr,annotations)=>{classBaseextendsdata_.Error{}// class DECLARATION in a fn body;(Base.prototypeasany).name=tag// per-call prototype mutation
...
classTaggedErrorClassextendsmakeClass({ ... }){// class DECLARATION in a fn bodystatic_tag=tag// per-call static capturing `tag`}returnTaggedErrorClass}
@effect/platform/src/Error.ts calls this twice (BadArgument, SystemError) then Schema.Union(BadArgument, SystemError).
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 ClassRefis 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:
class declarations inside a function body — open (this comment): fresh identity per evaluation, so statics AND prototype mutations don't collapse;
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).
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.
Activity
proggeramlug commentedon Jul 15, 2026
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
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, andextends-shape are all red herrings — I ruled each out individually.Why
perry-codegen/src/codegen/module_globals_emit.rs(Phase E):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:
typeParameters) are not in scope at all.Object.getPrototypeOf(Child) === Parentis alsofalseunder 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:makeDeclareClassis called many times duringSchema.tsmodule init. Instrumenting Schema.ts side-by-side (identical probes insrc/for Perry anddist/esm/for node):Identical control flow;
this.typeParametersis 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_latepath 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 fullcargo build --release.proggeramlug commentedon Jul 15, 2026
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):ClassExprFreshis exactly the right machinery — its own doc cites this case:and #1772's comment states the failure mode verbatim:
But
parent_expr.is_none()excludes class expressions with heritage — which is precisely effect'sclass DeclareClass extends make(ast) { static typeParameters = [...typeParameters] }. So it takes the shared-templateClassRefpath and hits the exact "shares one template class and.astis undefined/clobbered" outcome #1772 describes.Verified by HIR trace:
Partial fix (works, but incomplete)
Dropping
parent_expr.is_none()and sequencing the dynamic-parent registration ahead of the fresh class object:Results:
M1 -> [1],M5 -> [5], previously bothundefined);TypeError: undefined is not iterableis 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:
@effect/platform/src/Error.tshasPlatformError = Schema.Union(BadArgument, SystemError)— the union seeingBadArgumenttwice meansSystemErrorandBadArgumentcollapsed onto the same template.Cause:
RegisterClassParentDynamickeys the parent edge by class_name / template class_id (js_register_class_parent_dynamicis 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 eachDeclareClassa different parent (make(ast)returns a freshSchemaClassper call), so all instances collapse onto the final parent.What the complete fix needs
ClassExprFreshalready 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, andinstanceofconsult the object's own parent when the receiver is a fresh class object — instead of the template-keyedCLASS_REGISTRYedge.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) === Parentisfalseunder Perry (node:true) — same root: class-object identity/edges are modeled per source class, not per evaluation.proggeramlug commentedon Jul 15, 2026
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:effect/src/Schema.ts:@effect/platform/src/Error.tscalls this twice —BadArgumentandSystemError— thenPlatformError = Schema.Union(BadArgument, SystemError). Each call must produce a distinct class carrying its ownprototype.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 seesBadArgumenttwice.So the general statement is broader than #1772/#6438's static-field framing:
ClassExprFreshis 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: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:
arm_class.rs— allowClassExprFreshfor class expressions with heritage (dropparent_expr.is_none()), sequencingRegisterClassParentDynamicahead of the fresh object.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:
*mut ObjectHeaderand dereferencing is an instant SIGSEGV;Result: the minimal repro and the multi-level factory chain now match node exactly
and effect's
TypeError: undefined is not iterableis gone. But the server still does not start, becauseTaggedError's function-scoped class declarations remain shared. Fixing #6438 end-to-end therefore needs per-evaluation class identity, not just per-evaluation statics.proggeramlug commentedon Jul 16, 2026
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:Source —
effect/src/Schema.ts,TaggedError:@effect/platform/src/Error.tscalls this twice (BadArgument,SystemError) thenSchema.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 singleClassRef:so every evaluation writes the same
@perry_static_…__TaggedErrorClass___tagglobal. Two calls, one identity, one slot →BadArgumentandSystemErrorend up with the same_tag. Same forBase.prototype.name = tag: one shared prototype, last-wins.Why there is no narrow fix
A
ClassRefis 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 everynew C(), static call,instanceof, and export site — the machinerybody_stmt.rs/arm_ident.rs/stmt.rsalready 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:
new K1(42).x/.inst()super()instanceofSo
ClassExprFreshis a viable target for declarations too — the work is the binding/resolution rewiring, not the object model. (Thearm_ident.rsclaim that the path "drops static methods and SIGSEGVsnew" 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:
Object.getPrototypeOf(Child) === Parentreturningfalse(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.tsandforking.tsfrom the repro repo already run byte-identical to node;web.tsneeds (2).fix(runtime): inherited statics on a per-evaluation class object must…
fix(hir): per-evaluation statics for class expressions with heritage (#…
fix(hir): per-evaluation captures for class declarations in function …
proggeramlug commentedon Jul 17, 2026
Fixed. The full HttpApi /
web.tsstartup-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 tonode --experimental-strip-types. The HttpApi server (api.ts) binds and serves/api/healthcorrectly, andweb.tsserves its homepage + JSON API. The startupTypeError: undefined is not iterableis gone. Closing as resolved.fix(runtime,codegen): capture-class construction args, statics mirror…
fix(runtime,codegen): bundled-zod capture-class family — statics disp…
fix(runtime): per-evaluation inherited static wins over last-wins reg…