Skip to content

class declarations in function bodies share one class across evaluations — effect's makeException masks every error as TimeoutException #6465

Description

@proggeramlug

Summary

A class declaration inside a function body gets one shared compile-time class — every factory evaluation returns the same class object, so captured field initializers, Object.assign(C.prototype, ...) writes, and instanceof all cross-contaminate between evaluations. #6449 fixed exactly this for class expressions (const C = class {...}, ClassExprFresh); the declaration form has the same disease.

Where it bites

effect/src/internal/core.ts makeException — the factory behind ALL of effect's core exception classes:

const makeException = (proto, tag) => {
  class Base extends YieldableError {
    readonly _tag = tag              // captured field init
  }
  Object.assign(Base.prototype, proto) // per-kind TypeId symbol etc.
  ;(Base.prototype as any).name = tag
  return Base as any
}
export const RuntimeException      = makeException({...}, "RuntimeException")
export const InterruptedException  = makeException({...}, "InterruptedException")
...
export const TimeoutException      = makeException({...}, "TimeoutException")

Under perry every one of these classes is the SAME class: any error raised through them reports _tag/name "TimeoutException" (last evaluation wins). In the effect-compiled-experiment web.ts, the actual startup failure (still unknown!) is masked as TimeoutException: An error has occurred — the real error's _tag was overwritten and its message lost. Every Effect.catchTag/isInterruptedException-style dispatch in any effect app is unreliable under this bug.

Minimal repro (no effect)

class YieldableErr extends Error {}
const makeException = (proto: object, tag: string): any => {
  class Base extends YieldableErr { readonly _tag = tag }
  Object.assign(Base.prototype, proto)
  ;(Base.prototype as any).name = tag
  return Base
}
const RuntimeException = makeException({ kindR: true }, "RuntimeException")
const InterruptedException = makeException({ kindI: true }, "InterruptedException")
const TimeoutException = makeException({ kindT: true }, "TimeoutException")
const r: any = new RuntimeException()
console.log(r._tag)                                              // node: RuntimeException | perry: TimeoutException
console.log(RuntimeException.prototype !== TimeoutException.prototype) // node: true | perry: false
console.log(r instanceof TimeoutException)                       // node: false | perry: true
console.log(Object.keys(r).join("|"))                            // node: _tag | perry: _tag|__perry_cap_8

Full matrix (node → perry):

check node perry
r._tag / i._tag / t._tag Runtime / Interrupted / Timeout all TimeoutException
name per class per-tag all TimeoutException
protos distinct true false
Object.assign(proto, {kind}) visible on instance true false (all kinds lost)
cross-instanceof false true
instance own keys _tag _tag|__perry_cap_8 (internal capture slot leaks as a property)

Relation to #6449

#6449 (ClassExprFresh) already builds per-evaluation class objects for class expressions with statics/heritage/captured args. Class declarations in function scope need the same routing — they are hoisted within their block, but each evaluation of the enclosing function must produce a fresh class object with its own prototype identity and its own captured-arg environment.

Found while driving effect-compiled-experiment web.ts to node parity: layers so far — #6449 (class-expr statics) → #6460 (GetIterator on class decl) → #6463 (init-order back-edges) → this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions