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

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

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions