Repository navigation
cc 2.1.112 compiled from main dies at startup: every subcommand but --version throws "value is not a function" #11301
Description
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 hiall die identically. Only--versionsurvives, 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=0and underPERRY_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…vs2c1c8582…) 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-lessclass 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 onmainand 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
proggeramlug commentedon Sep 25, 2026
Root cause narrowed: an esbuild
__esmlazy-init binding is read before its initializer ran — not class heritageRetracting 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 dD1is merely the outermost frame; the failure is a plainundefinedreaching a call, several module-init frames deeper.The callee is literally
undefinedBuilt with
PERRY_KEEP_SYMBOLS=1(not a cache key — one relink, ~8 min, not a recompile) and broken onthrow_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":Two things worth noting: it throws during microtask drain, which is why it presents as
Uncaught (in promise); andreport_dispatch_missis never reached, so this is a genuine user-levelundefined, not a dynamic-dispatch tower fallthrough.The undefined value, named
Lis esbuild's__esmlazy-module-init wrapper.EQ5readsDD6at call time, andDD6is only ever assigned byRO8's thunk. So the failure is:EQ5ran beforeRO8's body had completed,DD6was stillundefined,ZQ5receivedundefinedas itstransform, and calling it threw.The JS stack has three
Lframes (at L … at q6 … at L … at new dD1), i.e. nested__esminitializers. So this is a module-initialization order / cycle problem: esbuild's__esmdeliberately returns a partially-initialized module on cyclic re-entry ((fn, res) => () => (fn && (res = fn(fn = 0)), res)clearsfnbefore 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
__esmlazy init with a cyclic edge, which 20 lines of heritage cannot express.PERRY_GEN_GC=0andPERRY_GC_FORCE_EVACUATE=1), deterministic, HOME-independent, and--versionsurvives only because it exits before the offending init chain is entered.proggeramlug commentedon Sep 25, 2026
Narrowed to one instruction:
Math.maxread as a value yieldsundefinedin this buildCorrecting my previous comment as well as the issue body. It is not class heritage, and it is not an
__esminit-order/cycle problem either. The undefined value isMath.max.The chain, read off the binary
The throwing callee is the module slot
ZQ5loads forAm7, and in the bundle:AJ8is the only route toZQ5and is assigned afterAm7, so reachingZQ5proves theAm7store executed.objdumpconfirms exactly one store to that slot, in theVY1thunk, at the same TLS offsetZQ5loads — so there is no duplicate binding:perry_closure_…__17631(VY1)vmovq %xmm16,(%rax)— the storemainjs_gc_register_global_rootperry_closure_…__17630,perry_fn_…__ZQ5Breaking on the store shows what it writes:
So the store faithfully stores
undefined:Math.maxevaluated toundefined. The lowering isand at that call both operands are valid — receiver
0x55556a2832e8, key prints asmax— yet the result is0x7ffc000000000001. Callingjs_object_get_field_by_name_f64(recv, key)directly from the debugger returns undefined too. The receiver'sjs_object_get_class_idis 0.What this is not
PERRY_GEN_GC=0andPERRY_GC_FORCE_EVACUATE=1;report_dispatch_missnever fires, so it is not a dispatch-tower fallthrough either.92fb0efcbb(the commit before it) fails identically, distinct binary.Math. Zero writes toglobalThis.Math/global.Math, no localMathbinding, notoStringTagshim in the bundle. Node runs this exact bundle fine.Mathprints as{ Symbol(Symbol.toStringTag): 'Math' }in a working perry program too, wheretypeof Math.max === "function"andhasOwnProperty('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.Math.maxaliasing at top level, aliasing a hoisted function declaration, an__esmthunk assigning a local function, an__esmthunk assigning four intrinsic members (Math.max,Math.min,Object.assign,Array.prototype.slice), andMathas a value under both the plain and the wasm-host runtimes.Where I would look
Something in this build makes the global
Mathresolve to an object whose native member fallback does not answer — while a directMath.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.xreads 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 andvar m = Math.maxdoes not. Any fix should pin both forms.proggeramlug commentedon Sep 27, 2026
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:That failure is itself the evidence.
git log -Sand a direct count agree:js_install_global_value_surfaces9f968de57(parent)63af6e8ff00bffaed264(my base)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.rsstates the new contract outright: a module's value-export resolver lives inNM_CONST_REGISTRYand is "linked only when itsjs_nm_install_<module>()is". On the codegen side,emit_global_value_installsemits an installer call for exactly:process→js_nm_install_processconsole→js_nm_install_consoleglobalThis/global/self/window/frames→js_install_global_value_surfacesMathis 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 ofMath.maxarrives asGlobalGet(0).maxand is routed by property name against a hand-written set (env, the Promise statics,revocable, the Error statics) that does not containmax. It therefore falls through to the generic path —js_get_global_this_builtin_value("Math")thenjs_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), result0x7ffc000000000001, i.e.undefined. The value is then stored into lodash'snativeMaxalias and called later, which is theTypeError: 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.maxresolves statically, and a directMath.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,Intlat minimum. Two forms must be pinned per namespace, because only the second is broken:Note
#11114("localeCompare no longer links the Intl namespace") and5fde6f6e09("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.proggeramlug commentedon Sep 27, 2026
Retraction: my first-bad attribution to
63af6e8ff0was 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
globalThissetup, gated by theglobal-math/global-json/global-reflectbuild features. In the failing cc binary theglobalThissetup that actually ran came from the runtime copy bundled intolibperry_stdlib.aby the no-auto HTTP rebuild (cc importshttp), which was built without perry-runtime's default features — and the link takes stdlib first with duplicate symbols allowed. That is #11174, fixed byc025870294+256925ac46, both after my base0bffaed264.Where my reasoning went wrong
I relinked cc's objects against the runtime at
63af6e8ff0^and it failed with manyundefined reference to 'js_install_global_value_surfaces'. I then confirmed that symbol is introduced by63af6e8ff0(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
Mathat all. The installer-list argument I built on top (emit_global_value_installscovering onlyprocess/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
nmshows no Math/JSON member functions. In my failing binary (ccA_sym, base0bffaed264,--no-auto-optimize --enable-wasm-runtime) they are present:js_math_maxjs_math_minjs_json_stringifyjs_json_parseSo in this build the member functions are linked but never attached to the namespace object. That is consistent with the #11174 mechanism — a
globalThissetup compiled withoutglobal-math/global-jsonsimply 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
Mathobject had onlySymbol(Symbol.toStringTag): 'Math'and no own members, both operands of the failing lookup were valid, andjs_object_get_field_by_name_f64(Math, "max")returnedundefinedwhen called directly from the debugger.The regression test shape in #11469 (the lodash
__esmshape plus every namespace, pinned underPERRY_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 importedhttp, so none triggered the no-auto stdlib rebuild that swaps in the feature-less runtime copy.test(parity): pin builtin namespace member values under the no-auto H…