Skip to content

perry-runtime: Effect framework throws TypeError: value is not a function during ParseResult.ts__initclass X extends TaggedError(...)<...> (Effect end-to-end blocker, post-#711) #740

@proggeramlug

Description

@proggeramlug

Summary

After #711 closed the pipe is not a function throw in Schema.ts__init at v0.5.862, the Effect end-to-end DoD repro from #321 progresses past Schema.ts on perry 0.5.893 — but now trips earlier in the init chain on effect/src/ParseResult.ts__init:

TypeError: value is not a function
    at <anonymous>

Strict improvement (Schema.ts no longer the blocker; ParseResult.ts is reached and then fails on a class extends FactoryCall<...> pattern) but #321 still blocked.

Repro

mkdir effect-321 && cd effect-321
cat > package.json <<'JSON'
{ "name": "effect-321", "version": "0.0.0", "type": "module",
  "perry": { "compilePackages": ["effect"] } }
JSON
bun add effect@3.21.2

cat > test_bare.ts <<'TS'
console.log("[1] before");
import {} from "effect";
console.log("[2] after");
TS

perry compile test_bare.ts -o /tmp/out
/tmp/out
# stdout:  (nothing)
# stderr:  TypeError: value is not a function
#              at <anonymous>
# exit:    1

Bisection

lldb -o "b _exit" -o "run" -o "bt" -o "quit" --batch backtrace:

  * frame #0: libsystem_kernel.dylib`__exit
    frame #1: libsystem_c.dylib`exit + 68
    frame #2-#5: throw machinery (4 frames)
    frame #6: out_b`<unnamed> + 32
    frame #7: out_b`<unnamed> + 280
    frame #8: out_b`<unnamed> + 1140         ← inside ParseResult.ts__init body
    frame #9: out_b`_main + 1052             ← `bl @ _main + 1048` = unlinked offset 0x6A8
   frame #10: dyld`start

_main + 1048 → unlinked offset 0x290 + 0x418 = 0x6A8 → relocation:

0x6a8 ARM64_RELOC_BRANCH26  _node_modules_effect_src_ParseResult_ts__init

So the crash is during effect/src/ParseResult.ts__init — a DIFFERENT module than the four prior sub-issues (Utils.ts → HashMap.ts → Schema.ts → Schema.ts → ParseResult.ts), and earlier in the topological chain than where v0.5.862 was failing (Schema.ts depends on ParseResult.ts).

Throw-site analysis

Message format value is not a function (no (kind). prefix, with the literal placeholder "value") traces to crates/perry-runtime/src/closure.rs:773-781throw_not_callable() invoked from js_closure_callN when func_ptr validation fails:

#[cold]
#[inline(never)]
fn throw_not_callable() -> ! {
    crate::error::js_throw_type_error_not_a_function(
        std::ptr::null(),
        0,
        b"value".as_ptr(),
        5,
    )
}

Same throw shape as the original #671 (HashMap.ts). The difference: there, the bad func_ptr came from a cross-module namespace-member getter; here, ParseResult.ts has a much more constrained candidate set.

Almost certainly: class ParseError extends TaggedError("ParseError")<...>

ParseResult.ts:230 has the canonical class-extends-factory pattern:

import { TaggedError } from "./Data.js"
// ...
export class ParseError extends TaggedError("ParseError")<{ readonly issue: ParseIssue }> {
  readonly [ParseErrorTypeId] = ParseErrorTypeId
  get message() { return this.toString() }
  toString() { return TreeFormatter.formatIssueSync(this.issue) }
  toJSON() { return { _id: "ParseError", message: this.toString() } }
  [Inspectable.NodeInspectSymbol]() { return this.toJSON() }
}

TaggedError is defined in effect/src/Data.ts:755-765:

export const TaggedError = <Tag extends string>(tag: Tag) => {
  const O = {
    BaseEffectError: class extends Error<{}> {
      readonly _tag = tag
    }
  }
  ;(O.BaseEffectError.prototype as any).name = tag
  return O.BaseEffectError as any
}

So at module init, perry needs to:

  1. Call TaggedError("ParseError") — a closure invocation that creates O, mutates its prototype, returns the class. If this closure's func_ptr was registered with a stale/invalid pointer (cross-module function-export from Data.ts), it throws here.
  2. Use the returned class as the base for ParseError's extends clause.
  3. Evaluate ParseError's body (computed-key field init [ParseErrorTypeId] = ParseErrorTypeId, getter get message(), methods).

The throw fires somewhere in this chain. Note this is conceptually identical to #711's class extends String$.pipe(...) — class extending a factory-call result — but here the factory itself is a cross-module function import (TaggedError from ./Data.js), so the candidate failure surface includes:

  • Cross-module function-pointer registration for TaggedError returning an invalid closure descriptor in the importer's module-init context
  • Object-literal-with-inner-class-decl evaluation: { BaseEffectError: class extends Error<{}> { ... } } — codegen for an object literal containing a class declaration may not register the inner class's vtable correctly when the literal is evaluated at call time rather than module-init time
  • .prototype write on the inner class: (O.BaseEffectError.prototype as any).name = tag — if codegen doesn't track .prototype for classes that were declared inside an object literal, this write goes to nowhere and the resulting class's vtable lookup later fails

What I tried for a standalone repro

A minimal mimic that reproduces the surface shape (TaggedError factory returning a class declared inside an object literal, class extending the factory's return value with computed-key field init):

class BaseError { constructor(opts: { readonly issue: any }) { this.issue = opts.issue } }
function TaggedError<Tag extends string>(tag: Tag) {
  const O = { BaseEffectError: class extends BaseError { readonly _tag = tag } }
  ;(O.BaseEffectError.prototype as any).name = tag
  return O.BaseEffectError
}
const ParseErrorTypeId: unique symbol = Symbol.for("test/ParseError")
class ParseError extends TaggedError("ParseError")<{ readonly issue: any }> {
  readonly [ParseErrorTypeId] = ParseErrorTypeId
  // ...
}

…actually hits a different TypeError standalone (Cannot read properties of undefined (reading 'issue') during the new ParseError(...) step), but the same general class-extends-factory shape. The fact that perry already errors on this minimal pattern (rather than working clean as the prior sub-issue repros did) suggests this whole "class extends factory-returning-inner-class" path is fragile and may be addressable with a focused unit test.

Status snapshot for #321

Phase v0.5.795 v0.5.809 v0.5.862 v0.5.893
Boots into main
Crash module HashMap.ts (#26) Schema.ts Schema.ts (deeper) ParseResult.ts
Throw shape invalid closure (number).slice (real-obj).pipe invalid closure
result: 42

The crash has finally moved out of Schema.ts. With #711's class-extends-pipe fix, Schema.ts is now reachable past the previous throw point — but the init chain immediately hits an earlier-bound module (ParseResult.ts is a Schema.ts dependency) where a parallel pattern fails.

Notes for whoever picks this up

  • The standalone repro above fails standalone — much faster iteration than the full Effect repro. Whatever fix lands needs to make BOTH the standalone repro (printing [3] ParseError defined, [4] pe._tag: ParseError, [5] pe.message: ...) AND the Effect DoD work.
  • Object-literal-containing-class-declaration is the most novel shape vs. prior sub-issues. The inner class's prototype gets mutated immediately after the literal evaluates — if perry's class-registration runs eagerly at literal-creation time and the prototype write doesn't propagate to vtable, that's the lead.
  • Effect's Data.ts defines several factory functions with this same inner-class-in-object-literal shape (TaggedClass, TaggedEnum, Class, ...) — a fix here likely unblocks several downstream modules at once.

Refs #321, #711.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions