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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions