Skip to content

cc 2.1.112 compiled from main dies at startup: every subcommand but --version throws "value is not a function" #11301

Description

@proggeramlug

claude-code 2.1.112 compiled from main dies at startup: every subcommand except --version fails with

Uncaught (in promise) TypeError: value is not a function
    at ZQ5 … at KPA … at q6 … at new dD1 (<anonymous>)

cc is the corpus the GC/tenuring constants were tuned on, so this blocks any cc-based A/B (it is currently blocking the #9949 four-turn re-run).

Reproduce

# bundle: secret-tests/claude-code-build/cli_2.1.112.js (13 MB vendored pristine)
cargo build --release -p perry
cargo build --release -p perry-runtime-static -p perry-stdlib-static -p perry-wasm-host \
      --features perry-runtime/wasm-host
PERRY_RUNTIME_DIR=$PWD/target/release \
  ./target/release/perry compile --no-auto-optimize --enable-wasm-runtime cli_2.1.112.js -o cc
HOME=/tmp/cc_home ./cc --help     # fails
HOME=/tmp/cc_home ./cc --version  # works -> 2.1.112 (Claude Code)

Verified on 0bffaed264 (v0.5.1654) on Linux x86_64.

What the failure is and is not

  • Not command-specific. --help, doctor, config list, mcp list, -p hi all die identically. Only --version survives, which is consistent with it exiting before the offending module is evaluated — the module in question is reached through a lazy CJS require.
  • Not config-dependent. Same failure with a pristine empty HOME.
  • Not GC-sensitive. Byte-identical first line under PERRY_GEN_GC=0 and under PERRY_GC_FORCE_EVACUATE=1. A stale-pointer bug of the GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154 family should move under at least one of those, so lifetime/rooting causes are unlikely.
  • Deterministic — same message, same point, every run.

The construct, mapped to bundle source

class XP extends Oq.Client { config; constr…   // member heritage across a lazy CJS require
class dD1 extends XP {}                         // CTOR-LESS subclass, empty body
Oq.createAggregatedClient(IA9, dD1);

Oq is bound as Oq=yOq() where yOq=p((…)=>{…}) is esbuild's __commonJS wrapper — i.e. a lazily required CJS module namespace; the surrounding code is the AWS SDK / smithy client (resolveAwsRegionExtensionConfiguration, getHttpHandlerExtensionConfiguration). ZQ5 is a lodash-shaped overRest whose tail is an apply, so a value reaching an apply is not a function while the inherited constructor chain of a ctor-less subclass runs.

This is the shape of #10300 ("a ctor-less class extending a cross-module native base loses the base").

Already ruled out

  • fix(runtime): root yoga measure callbacks and the measure result across moving collections #11195 (yoga measure-callback rooting) is not the cause. A runtime built at 92fb0efcbb — the commit immediately before it — relinked with the same compiler, produces a distinct binary (64880244… vs 2c1c8582…) that fails identically.
  • Two minimal reproducers do NOT reproduce it (perry matches node exactly): a lazily-required CJS namespace with class XP extends NS.Client {} + ctor-less class dD1 extends XP {}; and the same with member names that are real built-ins (NS.Event, NS.Request). So the bare heritage shape is fine on main and something else in the bundle's context is required. This does not clear the class-lowering commits — it only means a 20-line repro is not enough.

Bisect range and a method caveat

cc ran on this rig in September (/root/rig9831/*.jsonl four-turn rows, base 75b886a38), so the range is 75b886a38..0bffaed264.

Caveat for whoever bisects: a runtime-only relink (same compiler, vary PERRY_RUNTIME_DIR) is 7 min versus 46 for a full compile, but it can only convict or clear runtime commits — an HIR/codegen change is invisible to it, because every arm shares the one compiler. Suspects named so far by the class-lowering lane: #11146 (member heritage named like a built-in), #11143 (prototype writes on per-evaluation class expression), #11173, #11188/#11190.

Toolchain trap: do not pass a short sha to PERRY_BUILD_COMMIT. The compiler's own auto-rebuilt archive stamps the full sha, and the guard then refuses with what reads as a stale-archive error:

library build: v0.5.1654 (commit 0bffaed26466)
Perry build:   v0.5.1654 (commit 0bffaed264)

Once a compiler is stamped that way, every archive it links must be built with the same value.

Activity

  1. proggeramlug commented on Sep 25, 2026

    @proggeramlug
    ContributorAuthor

    Root cause narrowed: an esbuild __esm lazy-init binding is read before its initializer ran — not class heritage

    Retracting the framing in the issue body. The class dD1 extends XP / ctor-less-subclass story was where the JS stack pointed, and it is wrong. new dD1 is merely the outermost frame; the failure is a plain undefined reaching a call, several module-init frames deeper.

    The callee is literally undefined

    Built with PERRY_KEEP_SYMBOLS=1 (not a cache key — one relink, ~8 min, not a recompile) and broken on throw_not_callable. Note there are two copies of that symbol in the binary; breaking on the wrong one silently never fires, which reads exactly like "the bug is elsewhere":

    === callee bits in xmm0: 0x7ffc000000000001 ===
    tag(top16)=0x7ffc      (0x7ffd would be a pointer)
    --- runtime printer ---
    undefined
    #0  throw_not_callable
    #1  js_closure_unbox_callee_checked
    #2  perry_fn_cli_2_1_112_js__ZQ5
    #3  perry_fn_cli_2_1_112_js__EQ5
    #4  js_closure_call1_receiverless
    #5  perry_fn_cli_2_1_112_js__LQ5
    #6  js_closure_call1_receiverless
    #7  perry_closure_cli_2_1_112_js
    …
    #21 perry_runtime::promise::microtasks::pump_protected
    #24 perry_runtime::promise::microtasks::run_microtasks
    #25 main
    

    Two things worth noting: it throws during microtask drain, which is why it presents as Uncaught (in promise); and report_dispatch_miss is never reached, so this is a genuine user-level undefined, not a dynamic-dispatch tower fallthrough.

    The undefined value, named

    function EQ5(q,K){ return OJ8(AJ8(q,K,DD6), q+"") }   // lodash baseRest; AJ8 = ZQ5 (overRest)
    function DZ5(q){ return q }                            // lodash identity
    var DD6;
    var RO8 = L(() => { DD6 = DZ5 });                      // DD6 is assigned ONLY inside a lazy init
    var Mm7 = L(() => { RO8(); VY1(); kY1(); Xm7 = EQ5 }); // and RO8() is called from Mm7's init

    L is esbuild's __esm lazy-module-init wrapper. EQ5 reads DD6 at call time, and DD6 is only ever assigned by RO8's thunk. So the failure is: EQ5 ran before RO8's body had completed, DD6 was still undefined, ZQ5 received undefined as its transform, and calling it threw.

    The JS stack has three L frames (at L … at q6 … at L … at new dD1), i.e. nested __esm initializers. So this is a module-initialization order / cycle problem: esbuild's __esm deliberately returns a partially-initialized module on cyclic re-entry ((fn, res) => () => (fn && (res = fn(fn = 0)), res) clears fn before the body finishes), so entering the cycle at a different edge than node does yields a binding that is still unassigned.

    That puts this in the family of #10278 (a dynamically imported module in an import cycle never inited its partner), not in the class-lowering lane.

    Consequences for the bisect

  2. proggeramlug commented on Sep 25, 2026

    @proggeramlug
    ContributorAuthor

    Narrowed to one instruction: Math.max read as a value yields undefined in this build

    Correcting my previous comment as well as the issue body. It is not class heritage, and it is not an __esm init-order/cycle problem either. The undefined value is Math.max.

    The chain, read off the binary

    The throwing callee is the module slot ZQ5 loads for Am7, and in the bundle:

    var Am7, AJ8;
    var VY1 = L(() => { Ym7(); Am7 = Math.max; AJ8 = ZQ5 });   // Am7 assigned BEFORE AJ8
    function ZQ5(q,K,_){ return K = Am7(K === void 0 ? q.length-1 : K, 0), function(){ … } }
    function EQ5(q,K){ return OJ8(AJ8(q,K,DD6), q+"") }         // AJ8 === ZQ5

    AJ8 is the only route to ZQ5 and is assigned after Am7, so reaching ZQ5 proves the Am7 store executed. objdump confirms exactly one store to that slot, in the VY1 thunk, at the same TLS offset ZQ5 loads — so there is no duplicate binding:

    site instruction
    perry_closure_…__17631 (VY1) vmovq %xmm16,(%rax) — the store
    main js_gc_register_global_root
    perry_closure_…__17630, perry_fn_…__ZQ5 loads

    Breaking on the store shows what it writes:

    value being stored (xmm16) = 0x7ffc000000000001
    --- runtime printer --- undefined
    

    So the store faithfully stores undefined: Math.max evaluated to undefined. The lowering is

    call js_get_global_this_builtin_value      ; global "Math"
    lea  -0x181aa0(%rax)                      ; key string, from a TLS slot
    call js_object_get_field_by_name_f64      ; Math[key]
    

    and at that call both operands are valid — receiver 0x55556a2832e8, key prints as max — yet the result is 0x7ffc000000000001. Calling js_object_get_field_by_name_f64(recv, key) directly from the debugger returns undefined too. The receiver's js_object_get_class_id is 0.

    What this is not

    • Not a GC/lifetime bug. Identical under PERRY_GEN_GC=0 and PERRY_GC_FORCE_EVACUATE=1; report_dispatch_miss never fires, so it is not a dispatch-tower fallthrough either.
    • Not fix(runtime): root yoga measure callbacks and the measure result across moving collections #11195. A runtime at 92fb0efcbb (the commit before it) fails identically, distinct binary.
    • Not the bundle shimming Math. Zero writes to globalThis.Math/global.Math, no local Math binding, no toStringTag shim in the bundle. Node runs this exact bundle fine.
    • Not the printed "empty Math" object. Math prints as { Symbol(Symbol.toStringTag): 'Math' } in a working perry program too, where typeof Math.max === "function" and hasOwnProperty('max') is true. The members come from a native fallback the printer does not show, so that shape is normal and was a red herring of mine.
    • Not reproduced minimally — seven attempts pass, matching node exactly: member heritage (plain and built-in-named), Math.max aliasing at top level, aliasing a hoisted function declaration, an __esm thunk assigning a local function, an __esm thunk assigning four intrinsic members (Math.max, Math.min, Object.assign, Array.prototype.slice), and Math as a value under both the plain and the wasm-host runtimes.

    Where I would look

    Something in this build makes the global Math resolve to an object whose native member fallback does not answer — while a direct Math.max(1,2) call still works, because that lowers statically without the object. It is scale- or context-dependent, so the interesting candidates are the mechanisms that behave differently in a 47,000-closure program: the intrinsic/global static surface (cf. #10303, global.x reads collapsing to the intrinsic static surface), and per-module class-id allocation (the receiver's class id is 0; cf. the known unfixed class-id collision across modules).

    A one-line check for whoever picks this up: in cc, Math.max(1,2) works and var m = Math.max does not. Any fix should pin both forms.

  3. proggeramlug commented on Sep 27, 2026

    @proggeramlug
    ContributorAuthor

    First-bad: 63af6e8ff0 — "perf(size): stop the always-live native-module hubs from pinning module surfaces" (#11135)

    Attribution is by symbol provenance plus source mechanism, not by a green pre-commit arm. The distinction matters, so it is stated first: I could not produce a working cc on the pre-commit runtime, because that commit changes the codegen↔runtime ABI. Relinking cc's objects (compiled by the v0.5.1654 compiler) against the runtime at 63af6e8ff0^ fails to link:

    undefined reference to `js_install_global_value_surfaces'   (× many)
    

    That failure is itself the evidence. git log -S and a direct count agree:

    ref js_install_global_value_surfaces
    9f968de57 (parent) 0 references
    63af6e8ff0 13
    0bffaed264 (my base) 13

    So the installer is introduced by this commit, and a runtime-only bisect across it is impossible by construction.

    The mechanism

    The commit converts always-live native-module surfaces into on-demand installs. native_module_registry.rs states the new contract outright: a module's value-export resolver lives in NM_CONST_REGISTRY and is "linked only when its js_nm_install_<module>() is". On the codegen side, emit_global_value_installs emits an installer call for exactly:

    • process → js_nm_install_process
    • console → js_nm_install_console
    • globalThis / global / self / window / frames → js_install_global_value_surfaces

    Math is not in that list, nor is any other builtin namespace. And per the same file's doc comment, "the receiver name has been collapsed during HIR lowering", so a value read of Math.max arrives as GlobalGet(0).max and is routed by property name against a hand-written set (env, the Promise statics, revocable, the Error statics) that does not contain max. It therefore falls through to the generic path — js_get_global_this_builtin_value("Math") then js_object_get_field_by_name_f64(Math, "max") — which needs a surface nothing installed.

    Observed exactly that in the built binary: both operands valid (receiver a real pointer, key prints as max), result 0x7ffc000000000001, i.e. undefined. The value is then stored into lodash's nativeMax alias and called later, which is the TypeError: value is not a function.

    Why small programs do not reproduce it

    Seven minimal reproducers pass, and that is consistent rather than puzzling: a simple var m = Math.max resolves statically, and a direct Math.max(1,2) never touches the object at all. Only the dynamic fallback needs the installed surface, and cc reaches it because the read sits inside a deep closure with the receiver collapsed.

    Suggested fix shape

    Emit the installer (or restore always-live registration) for value reads of the builtin-namespace family, not just process/console/globalThis: Math, Object, JSON, Reflect, Array.prototype, Intl at minimum. Two forms must be pinned per namespace, because only the second is broken:

    Math.max(1, 2)      // works — lowers statically
    var m = Math.max    // undefined — needs the installed surface

    Note #11114 ("localeCompare no longer links the Intl namespace") and 5fde6f6e09 ("route process metadata through its registry") are the same campaign and worth auditing together.

    Ownership: the authoring session (binary-size1) is no longer running, so this lane is unowned.

  4. proggeramlug commented on Sep 27, 2026

    @proggeramlug
    ContributorAuthor

    Retraction: my first-bad attribution to 63af6e8ff0 was WRONG. The cause is #11174.

    The correct diagnosis is the one from the #11469 fix agent: Math's members never came from codegen's installers at all. The runtime creates them during globalThis setup, gated by the global-math / global-json / global-reflect build features. In the failing cc binary the globalThis setup that actually ran came from the runtime copy bundled into libperry_stdlib.a by the no-auto HTTP rebuild (cc imports http), which was built without perry-runtime's default features — and the link takes stdlib first with duplicate symbols allowed. That is #11174, fixed by c025870294 + 256925ac46, both after my base 0bffaed264.

    Where my reasoning went wrong

    I relinked cc's objects against the runtime at 63af6e8ff0^ and it failed with many undefined reference to 'js_install_global_value_surfaces'. I then confirmed that symbol is introduced by 63af6e8ff0 (0 refs at the parent, 13 at the commit) and treated that as the attribution.

    It is not. All that proves is that the commit changed the codegen↔runtime ABI, so a runtime-only relink cannot cross it. I turned "this commit introduced the symbol my relink could not find" into "this commit caused the bug", which does not follow — the link never got far enough to say anything about Math at all. The installer-list argument I built on top (emit_global_value_installs covering only process/console/globalThis) was a real observation about a real mechanism that simply is not the one in play here.

    One data point that refines the fix framing

    The #11469 description says nm shows no Math/JSON member functions. In my failing binary (ccA_sym, base 0bffaed264, --no-auto-optimize --enable-wasm-runtime) they are present:

    symbol count
    js_math_max 2
    js_math_min 2
    js_json_stringify 6
    js_json_parse 5

    So in this build the member functions are linked but never attached to the namespace object. That is consistent with the #11174 mechanism — a globalThis setup compiled without global-math/global-json simply does not register them — and it sharpens the fix: the failure mode is registration, not linking, and the two can present identically from the JS side. Worth checking which of the two your binary shows, since a test that asserts only on the presence of symbols would pass on mine while the bug is live.

    My earlier gdb evidence corroborates the registration reading: the resolved Math object had only Symbol(Symbol.toStringTag): 'Math' and no own members, both operands of the failing lookup were valid, and js_object_get_field_by_name_f64(Math, "max") returned undefined when called directly from the debugger.

    The regression test shape in #11469 (the lodash __esm shape plus every namespace, pinned under PERRY_NO_AUTO_OPTIMIZE=1, sabotage-verified against a pre-#11174 stdlib archive) is the right guard, and it covers what my seven minimal reproducers could not: they all passed precisely because none of them imported http, so none triggered the no-auto stdlib rebuild that swaps in the feature-less runtime copy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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