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.
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, andinstanceofall 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.tsmakeException— the factory behind ALL of effect's core exception classes: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-experimentweb.ts, the actual startup failure (still unknown!) is masked asTimeoutException: An error has occurred— the real error's_tagwas overwritten and its message lost. EveryEffect.catchTag/isInterruptedException-style dispatch in any effect app is unreliable under this bug.Minimal repro (no effect)
Full matrix (node → perry):
r._tag/i._tag/t._tagnameper classObject.assign(proto, {kind})visible on instanceinstanceof_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.tsto node parity: layers so far — #6449 (class-expr statics) → #6460 (GetIterator on class decl) → #6463 (init-order back-edges) → this.