Repository navigation
codegen: a string's .codePointAt in cc's hottest loop lowers to NativeMethodCall{module:"child_process", class_name:"Instance"} #9847
Description
Activity
Mechanism found, with a minimal reproducer and a one-rename A/B
The native-instance tag is keyed by identifier NAME and scoped to the whole
module. Achild_processhandle namedOin one function makes every other
Oin the module achild_process::Instance— including afor…of
destructuring binding holding a grapheme string in an unrelated function.The reproducer — two files differing by ONE identifier rename
import * as cp from "child_process"; export function unrelatedSpawner(): any { let O: any; // <-- ARM B; ARM A renames this to notO try { O = cp.spawn("true", []); } catch (M) { O = null; } return O; } const rR_ = new Intl.Segmenter("en", { granularity: "grapheme" }); const oR_: any = /^[̀-ͯ]$/u; const g54: any = { default: () => /[\u{1F300}-\u{1F5FF}]/gu }; export function widthLike(q: string): number { let Y = 0; for (let { segment: O } of rR_.segment(q)) { // the SAME name, different function let w = O.codePointAt(0)!; // <-- this is what gets mislowered if (oR_.test(O)) continue; if (g54.default().test(O)) { Y += 2; continue; } Y += w >= 0x1100 ? 2 : 1; } return Y; }
perry compile --no-auto-optimize <file>.ts --print-hir, and look atwidthLike:arm the spawner's variable O.codePointAt(0)inwidthLikelowers toA notOCall(PropertyGet(LocalGet(O), "codePointAt"), [0])— correctB ONativeMethodCall { module: "child_process", class_name: Some("Instance"), object: Some(LocalGet(O)), method: "codePointAt" }Nothing else differs. The spawner is never called from the loop and the two
bindings are in different functions with different scopes.Where it comes from
lower/expr_assign.rs:186-205— for a plain assignmentX = <native module>.<method>(...):let class_name = match (module_name, method_ident.sym.as_ref()) { ("mongodb", "connect") => Some("MongoClient"), ("pg", "connect") => Some("Client"), ("readline", "createInterface") => Some("Interface"), _ => Some("Instance"), // catch-all }; ctx.push_module_native_instance((var_name.clone(), module_name.to_string(), class_name.to_string()));
push_module_native_instancekeys onvar_name— the identifier text — and the
registration is module-wide. The catch-all arm means any method on any
native module, assigned to a variable, tags that name.Note the shape matters: it is the assignment path (
let O; … O = cp.spawn(…)),
not the declaration path (let O = cp.spawn(…)), and it requires the module to
be a recognised native import — a CJSrequire()in a.jsfile does not
create the tag, which is why two earlier attempts at this reproducer came back
negative.Blast radius: BROAD, not one local
cli_2.1.112.jscompiles as one module (Found 1 module(s)), it contains
let O; try { O = fA1.spawn(z.file, z.args, z.options) }, and the identifier
Ois a binding 5,381 times in that file. So every one of those is tagged
child_process::Instancefor the whole 13 MB program. Minified bundles reuse
single letters everywhere, so any name that is ever a native handle poisons all
its homonyms.This is consistent with the segment-view counter reporting
code_point_at=0on
all ninefor…of-over-.segment()sites in the bundle, not justN$6,
though I have not confirmed each of those nine individually.Correctness
cc runs and renders correctly, so the mislowered dispatch is falling through to
a generic path at runtime rather than calling a wrong function — this looks like
a missed-optimisation and mis-typing defect, not a wrong-answer defect. I have
not verified that, and "it currently degrades gracefully" is not the same as
"it is safe": achild_process::Instancemethod table that ever gains a
codePointAt,lengthortestentry would silently capture string calls.Blast radius, measured: it is not just
O— the eight commonest minified identifiers are all taggedIndependent confirmation of the mechanism from the registry side, and it is
worse than the A/B above suggested. Instrumenting the two entry points through
which any native-instance tag must pass (register_native_instanceand
push_module_native_instanceinlower/context.rs) and compiling
cli_2.1.112.js:total REGISTER lines: 795 (104 push_module, 691 register) most-registered identifiers: 71 "Y" 54 "A" 65 "z" 52 "O" 65 "K" 37 "w" 65 "_" 35 "q"Every one of them is a single letter, i.e. exactly what a minifier emits,
and several are registered under mutually exclusive classes.Oalone:[nativeinst] REGISTER push_module name="O" -> stream::Instance [nativeinst] REGISTER push_module name="O" -> child_process::Instance [nativeinst] REGISTER register name="O" -> transform_stream::TransformStream [nativeinst] REGISTER register name="O" -> readable_stream::ReadableStreamand
child_process::Instanceby itself is registered for the names
O,w,z,Y,_,K.So in a 13 MB bundle that compiles as one module, the tag table is keyed by
identifier text with module-wide scope, and the commonest short identifiers are
each claimed by several unrelated native classes. Every method call on any local
with one of those names is then lowered as a native-instance method call of
whichever class won.That is the same defect as the reproducer above, but it means the segment loop
is an example rather than the case: the mis-typing is spread across the whole
program, andcodePointAton a grapheme is simply where a compile-time counter
happened to notice it.Reproducing the census:
PERRY_NATIVEINST_DIAG=1(a diagnostic on those two
entry points, excluded from the build-level cache) plus
perry compile --no-auto-optimize <bundle> -o /tmp/out; the report prints
during HIR lowering, ~30 s, no LLVM needed. I can open that instrumentation as
a PR if it is wanted as a permanent diagnostic; it is currently a local commit
on a lane branch.One caveat on the numbers:
registerlines include entries with an empty
module::class, which I have not investigated — they are probably the
tombstone/shadow path (shadow_native_instance). Thepush_modulecount (104)
and the class-bearing lines are the unambiguous part.- added 3 commits that reference this issue
on Sep 6, 2026
What the compiler emits
In
cli_2.1.112.js(claude-code),N$6isstring-width. Its loop is thesingle hottest thing in a claude-code turn: the allocation census ranks it
1/2/3 by count (172,032 segment records + 247,808 substrings per 400-character
reply, 58 % of the top-30 allocation count), and a
sampleputs 60-85 % ofactive main-thread CPU inside the subtree it sits in (ink
wrapText→ JSwrap-ansi →
string-width).Source:
Ois a grapheme — a string. Here is what it lowers to:Repro — one minute, no LLVM build
Find
__destruct_118613in the dump; theLetforwtwo statements later isthe node above. The whole-bundle HIR pass is ~1 minute, so this is cheap to
re-check.
Cause: UNKNOWN. One hypothesis was tested and refuted
I am reporting what the compiler emits, not why. My one hypothesis was that
lookup_native_instanceis keyed by identifier name(
HashMap<String, (String, String)>,lower/context.rs:1587), and that in aminified bundle the name
Ocollides:cli_2.1.112.jshas threeO = <child_process>.spawn/exec(…)bindings and 5,381 bindings namedO.That hypothesis does not reproduce. Controlled experiment: a small probe in
the bundle's module shape whose segment loop classifies correctly, plus one
added, unrelated, never-executed
function __unrelated_spawner(){ let O = require("child_process").spawn("true"); }.The lowering was unchanged — the segment loop's
codePointAtstayed ageneric
Call(PropertyGet(O, "codePointAt"), [0]). So whatever tags local118611 as a
child_processInstance, it is not that route.Note there is also an id-keyed twin of that table
(
local_id_native_instances: HashMap<LocalId, …>injs_transform/cross_module_natives.rs), which I did not investigate.The way to settle it is a diagnostic at the decision site — which local, which
binding it resolved through, and what evidence it used — not another
hypothesis.
Both halves of this call are already known to be expensive
cc runs correctly, so this dispatch must be falling through to a generic path
on a string receiver — once per grapheme, in the dominant loop. That
fall-through has a cost independent of anything else here.
#9795 ("perf(runtime): dispatch
String.prototype.codePointAtnatively —99k String wrappers per reply", open) is the runtime half of this same call.
This issue is the codegen half: the call does not even arrive at the runtime
labelled as a string method. Whoever picks up the resolver should know both
exist.
Why this was found, and the constraint it puts on any fix
Found by a compile-time counter for the segment-view for-of tier (#9843), whose
per-use classifier reconciles against a sound
collect_local_refs_stmtcountand books every unrecognised occurrence as "must materialise". It reported
code_point_at=0, materialise=1for this loop, which is what pointed at thenode.
A fix must not be "teach the classifier to match
NativeMethodCall{module:"child_process", method:"codePointAt"}". That wouldlaunder a misclassification into a fast path. The lowering is the thing to fix.