Repository navigation
cc 2.1.112 compiled from main dies at startup: every subcommand but --version throws "value is not a function" #11301
Description
Activity
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":=== 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 mainTwo 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
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
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
- The class-lowering suspects (fix(hir): a member heritage named like a JS built-in is not that built-in (#11139) #11146 member heritage, fix: keep prototype writes on a per-evaluation class expression local to that evaluation (#11134) #11143, fix(codegen): evaluate an assignment's member base once, before the RHS, for every base shape (#11150) #11173, fix(hir): a per-evaluation class declaration's own statics resolve to its evaluation, not the template (#11157) #11188/fix(hir): new/instanceof, async bodies and inlined factories use a class declaration's self-binding (#11142; stacked on #11188) #11190) are no longer indicated by the evidence. Nothing here exonerates them, but the mechanism now points at module init/cycles.
- Both minimal heritage reproducers passing is now expected rather than puzzling — the shape that matters is esbuild
__esmlazy init with a cyclic edge, which 20 lines of heritage cannot express. - Still consistent with earlier findings: not GC-sensitive (identical under
PERRY_GEN_GC=0andPERRY_GC_FORCE_EVACUATE=1), deterministic, HOME-independent, and--versionsurvives only because it exits before the offending init chain is entered.
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: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
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:site instruction perry_closure_…__17631(VY1)vmovq %xmm16,(%rax)— the storemainjs_gc_register_global_rootperry_closure_…__17630,perry_fn_…__ZQ5loads Breaking on the store shows what it writes:
value being stored (xmm16) = 0x7ffc000000000001 --- runtime printer --- undefinedSo the store faithfully stores
undefined:Math.maxevaluated toundefined. The lowering iscall 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 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
- Not a GC/lifetime bug. Identical under
PERRY_GEN_GC=0andPERRY_GC_FORCE_EVACUATE=1;report_dispatch_missnever 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 toglobalThis.Math/global.Math, no localMathbinding, notoStringTagshim in the bundle. Node runs this exact bundle fine. - Not the printed "empty Math" object.
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. - Not reproduced minimally — seven attempts pass, matching node exactly: member heritage (plain and built-in-named),
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.- Not a GC/lifetime bug. Identical under
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 -Sand a direct count agree:ref js_install_global_value_surfaces9f968de57(parent)0 references 63af6e8ff013 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.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_surfaces
Mathis 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: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") 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.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:symbol count js_math_max2 js_math_min2 js_json_stringify6 js_json_parse5 So 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.- added a commit that references this issue
on Sep 27, 2026
claude-code2.1.112 compiled frommaindies at startup: every subcommand except--versionfails withcc 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
Verified on
0bffaed264(v0.5.1654) on Linux x86_64.What the failure is and is not
--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.HOME.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.The construct, mapped to bundle source
Oqis bound asOq=yOq()whereyOq=p((…)=>{…})is esbuild's__commonJSwrapper — i.e. a lazily required CJS module namespace; the surrounding code is the AWS SDK / smithy client (resolveAwsRegionExtensionConfiguration,getHttpHandlerExtensionConfiguration).ZQ5is a lodash-shapedoverRestwhose 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
92fb0efcbb— the commit immediately before it — relinked with the same compiler, produces a distinct binary (64880244…vs2c1c8582…) that fails identically.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/*.jsonlfour-turn rows, base75b886a38), so the range is75b886a38..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:Once a compiler is stamped that way, every archive it links must be built with the same value.