Skip to content

fix(hir): scope a bare-assignment native-instance tag to the binding, not the name (#9847) - #9857

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9847-native-instance-scope
Closed

fix(hir): scope a bare-assignment native-instance tag to the binding, not the name (#9847)#9857
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9847-native-instance-scope

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #9847.

What the compiler was emitting, in one table

Every call site in cli_2.1.112.js (claude-code) that lowered to
NativeMethodCall { module: "child_process", class_name: Some("Instance"), … },
counted from the whole-bundle HIR — 194 sites, 76 distinct method names, and
not one of them is a ChildProcess method
:

count method count method
26 slice 5 isFile
19 startsWith 5 isDirectory
16 endsWith 4 substring
13 includes 4 map
11 toLowerCase 3 isSymbolicLink
11 indexOf 3 toUpperCase, replace, next, get
10 trim 2 getAttribute, keys, join, some
8 split, bind 1 cloneNode, isEqualNode, normalize, …
6 match 1 codePointAt
5 test

String methods, fs.Stats predicates and DOM calls, dispatched through a
child_process instance method table. The single codePointAt is
string-width's grapheme loop — the loop that dominates a claude-code turn,
executing that mislowered dispatch once per grapheme.

Mechanism

lower_assign handles X = <native module>.<method>(...). Its class-name match
ends in a catch-all (_ => Some("Instance")), and it registered the result with
push_module_native_instancekeyed on the identifier text, scoped to the
whole module, never truncated
. So any method on any recognised native module,
assigned to a variable, claimed that spelling for the rest of the program.

Minified bundles reuse single letters everywhere, which makes the collision the
normal case rather than a corner. cli_2.1.112.js compiles as one module, does
import fA1 from "node:child_process", contains

function WU(q, K, _) {  let O; try { O = fA1.spawn(z.file, z.args, z.options) } catch  }

and binds the name O 5,381 times. Every O lowered after WU was typed
child_process::Instance — including the for (let { segment: O } of … )
binding that holds a grapheme string.

The shape matters, which is why this took two failed reproductions to pin: it is
the assignment form (let O; … O = cp.spawn(…)), not the declaration form,
and the module must be a recognised native import — a CJS require() never
creates the tag, so the earlier probes could not produce the precondition they
were testing.

Blast radius, and the control

Whole-bundle count of class-bearing NativeMethodCall nodes
(perry compile --no-auto-optimize --trace hir on cli_2.1.112.js), 410
total before the fix:

module::class before after
child_process::Instance 194 0
path::Instance 92 15
fs::Instance 40 0
https::Instance 9 8
stream::Instance 5 5
catch-all ::Instance subtotal 340 28
Headers::Headers 21 21
net::Socket 12 12
http::ClientRequest 9 9
http::HttpServer 8 8ERVER
events::EventEmitter 5 5
net::Server 4 4
net::BlockList 3 3
readline::Interface 2 2
http::IncomingMessage 2 2
fetch::Response 2 2
stream::PassThrough 1 1
FormData::FormData 1 1
named-class subtotal (control) 70 70
total 410 98

The 70 named-class rows are a registered control: they come from
registration paths this PR does not touch, and the prediction that they would
not move was written down before the fixed compiler was built.

From the other direction, the keystroke lane's registry instrumentation counted
795 native-instance registrations on the same bundle, the most-registered
identifiers all single letters (Y 71, z 65, K 65, _ 65, A 54, O 52,
w 37, q 35), with several names claimed by mutually exclusive classes — O
alone is registered as stream::Instance, child_process::Instance,
transform_stream::TransformStream and readable_stream::ReadableStream. Note
these are two different denominators: 795 registrations against the 410
call-site nodes counted here, not the same quantity. (The empty-module::class
register lines in that instrumentation — probably the shadow_native_instance
tombstone path — are uninvestigated and are not claimed either way.)

The fix

The tag now keys on the LocalId the assignment target resolves to.

This is the same correction #7775 already made in this file for new Proxy
bindings — proxy_locals (name, module-wide, scope-blind) became
proxy_local_ids (the resolved binding) after a proxy bound to a in one
function made every other function's a.prop lower to js_proxy_get. Same
defect class, same table shape, same fix.

Scope-truncating the assignment path would not have worked, and that is why
the fix is id-keying rather than something narrower: the module-wide table
exists for the cross-function case — a module-level let client; assigned
inside init() and read inside handler() — and truncating at scope exit would
have dropped exactly that. Keyed on the binding it reaches just as far, because
both functions resolve client to the same LocalId, while a same-named
binding in another scope is simply a different binding.

lookup_native_instance gains an id-keyed arm ahead of the module-wide one,
short-circuited when the module has no bare-assignment handle at all (the arm
sits on the miss path of every identifier property access).

Known hole, stated plainly

A target that resolves to no local — a bare global with no binding — still
registers, and resolves, by name. That is the hole #7775 documented for proxies,
kept for the same reason: dropping it would regress genuine handles that reach
the lowering only through a name, and it is strictly no worse than the previous
behaviour, which used that path for every assignment.

Deliberately not changed

  • The catch-all arm (_ => Some("Instance")) stays. It is over-broad, but
    that is a separate question from scoping, and narrowing it would change which
    calls dispatch natively.
  • Two sibling forms in the same function still register module-wide by name:
    x = new NativeClass(...) (which at least also registers scoped) and the
    variable-to-variable x = y propagation. Same shape as this defect. Left
    alone because codegen: a string's .codePointAt in cc's hottest loop lowers to NativeMethodCall{module:"child_process", class_name:"Instance"} #9847's A/B does not exercise them and widening would put
    behaviour outside the evidence — flagging for a reviewer's call.
  • Nothing pattern-matches child_process or codePointAt. The mislowered
    call disappears because the tag never reaches that binding.

Evidence

All four were registered before the fixed compiler existed.

1. The issue's one-identifier A/B. Two files differing only in whether the
spawner's variable is spelled O. On the pre-fix binary arm A lowered
Call{PropertyGet(O,"codePointAt")} and arm B lowered
NativeMethodCall{module:"child_process", class_name:Some("Instance"), method:"codePointAt"}. On the fixed binary both arms lower
PropertyGet { object: LocalGet(13), property: "codePointAt" }
— same node,
same local id. Encoded as a test that compares the two lowered bodies with only
source byte_offset normalised (the rename shifts every offset in the file);
the structural diff is zero lines.

2. N$6 in the real 13 MB bundle. perry compile --no-auto-optimize --trace hir --focus 'N$6' cli_2.1.112.js. The issue reported

Let { id: 118612, name: "w", … init: Some(NativeMethodCall {
        module: "child_process", class_name: Some("Instance"),
        object: Some(LocalGet(118611)), method: "codePointAt", args: [Integer(0)] }) }

and the same node now reads

name: "w", ty: Any, mutable: true, init: Some(Call { callee: PropertyGet { object: LocalGet(118611), …

Same binding (118611), correct node.

3. A real handle and an unrelated same-named local in one module. One
fixture carries all three cases; on the fixed binary the module-level
cross-function handle still lowers NativeMethodCall{child_process, "kill"},
the function-local handle still lowers NativeMethodCall{child_process, "kill"}, and only the unrelated for…of binding changes, to
PropertyGet{…,"codePointAt"}.

4. cargo test -p perry-hir -p perry-codegen — 74 suites, 2,535 passed, 0
failed, 9 ignored.

What did NOT go to zero, stated plainly

28 catch-all ::Instance sites survive. About half look genuine — stream::on
(4), stream::pipe, stream::once, https::on (3), https::destroy (3),
https::write, https::end are all plausible real handles. The other 14 are
not fixed by this PR and I am not claiming they are: path::startsWith (7),
path::slice (3), path::replace (2), path::toLowerCase, path::split,
path::match — string methods on a module that has no instances at all. Those
reach their binding either through the unresolvable-name fallback above or
through the two sibling registration forms this PR deliberately leaves alone.
So the honest number is 312 of 326 wrong sites removed, not a clean sweep.

Why the existing test did not catch this

crates/perry-hir/tests/destructured_binding_native_hygiene.rs pins the
opposite contract — a childProcess.spawnSync handle named z must not make
a destructured const { install: z } inherit the tag — and it passes both
before and after this change. It is protected by the tombstone path
(shadow_native_instance_if_present), which a const/let binding goes
through and a for…of destructuring head does not. So the guard existed, it
just had a hole shaped exactly like the hot loop. The new test uses the for…of
form for that reason.

The order dependence, and a test that could not have failed

The tag only reaches functions lowered after the assignment that creates it.
My first fixture put the spawner second — it lowered correctly on the unfixed
compiler, i.e. it could not have failed. The shipped fixture puts the spawner
first and was verified to leak on a pre-fix binary before anything was asserted
about it; the doc comment says so, so nobody reorders it for readability.

Correctness

This is a mis-typing / missed-optimisation fix, not a wrong-answer fix: the
mislowered calls fall through to a generic path on a string receiver, so
claude-code renders correctly today. But "it currently degrades gracefully" is
not "it is safe" — a child_process::Instance method table that ever gained a
slice, startsWith, test or codePointAt entry would have silently
captured 194 string, fs and DOM calls.

There is also a possible runtime connection worth naming without claiming: the
non-GC profile of a claude-code turn puts js_native_call_method, and
get_accessor_descriptor beneath it, at the top of the genuinely non-GC list. A
program-wide population of mis-typed native-instance calls falling through to
generic dispatch is a plausible producer of that. This PR does not measure it —
a runtime measurement after this lands is the follow-up.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected native-instance type tracking so bindings are identified by their actual scope rather than variable name alone.
    • Prevented unrelated variables with the same name from receiving incorrect native-module behavior.
    • Preserved native dispatch for valid native instances while ensuring ordinary bindings use standard property dispatch.
  • Tests

    • Added coverage for local, shared, renamed, and same-named bindings.

…erryTS#9847)

`lower_assign` handles `X = <native module>.<method>(...)`. Its class-name
match ends in a catch-all, and it registered the result through
`push_module_native_instance` — keyed on the identifier TEXT and scoped to
the whole module, never truncated. So any method on any recognised native
module, assigned to a variable, claimed that spelling for the rest of the
program.

Minified bundles reuse single letters everywhere, which makes the collision
the normal case rather than a corner. `cli_2.1.112.js` (claude-code) compiles
as one module, imports `child_process` as `fA1`, contains
`let O; try { O = fA1.spawn(z.file, z.args, z.options) }` inside one helper,
and binds the name `O` 5,381 times. Every later `O` was typed
`child_process::Instance` — including the `for (let { segment: O } of ...)`
binding in `string-width` that holds a grapheme STRING, whose
`O.codePointAt(0)` lowered as
`NativeMethodCall { module: "child_process", class_name: Some("Instance") }`
and reached the right answer only because native-instance dispatch falls
through to a generic path on a string receiver — once per grapheme, in the
loop that dominates a claude-code turn.

The tag now keys on the `LocalId` the assignment target resolves to. This is
the same correction PerryTS#7775 already made in this file for `new Proxy` bindings
(`proxy_locals` -> `proxy_local_ids`) after a proxy bound to `a` in one
function made every other function's `a.prop` lower to `js_proxy_get`.

Scope-truncating the assignment path would NOT have worked: the module-wide
table exists for the cross-function case — a module-level `let client;`
assigned inside `init()` and read inside `handler()` — and truncating at
scope exit would have dropped exactly that. Keyed on the binding it reaches
just as far, because both functions resolve `client` to the same `LocalId`,
while a same-named binding in another scope is simply a different binding.

`lookup_native_instance` gains an id-keyed arm ahead of the module-wide one,
short-circuited when the module has no bare-assignment handle at all (the arm
sits on the miss path of every identifier property access). A target that
resolves to no local — a bare global — still registers and resolves by name;
that is the same hole PerryTS#7775 documented, kept for the same reason and strictly
no worse than the previous behaviour, which used it for every assignment.

Nothing pattern-matches `child_process` or `codePointAt`: the mislowered call
disappears because the tag never reaches that binding.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 17863103-cbbd-42d7-98eb-d03802e483e7

📥 Commits

Reviewing files that changed from the base of the PR and between f96a6c3 and 47b5e7c.

📒 Files selected for processing (5)
  • changelog.d/native-instance-binding-scope-9847.md
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/tests/native_instance_binding_scope.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The compiler now keys native-instance tags by resolved LocalId for local assignments. Unresolved global assignments retain name-based fallback behavior. Regression tests verify scope isolation and native dispatch preservation.

Changes

Native instance binding scope

Layer / File(s) Summary
LocalId tracking and lookup
crates/perry-hir/src/lower/context.rs, crates/perry-hir/src/lower/lowering_context.rs
LoweringContext stores native-instance tags by LocalId, resolves them during lookup, and retains name-based fallback for unresolved targets.
Assignment registration
crates/perry-hir/src/lower/expr_assign.rs
Native-module assignments register resolved local targets by LocalId; unresolved targets use the existing name-keyed registration.
Regression coverage and changelog
crates/perry-hir/tests/native_instance_binding_scope.rs, changelog.d/native-instance-binding-scope-9847.md
Tests cover local scope, module-level handles, homonymous bindings, and renamed spawn handles. The changelog documents the fix.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 47b5e

Native-instance lowering now follows the resolved binding rather than identifier spelling, preventing unrelated minified locals from receiving native dispatch while retaining valid native-handle dispatch. No current merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: scoping bare-assignment native-instance tags to resolved bindings instead of identifier names.
Description check ✅ Passed The description is detailed and directly addresses the fix, affected behavior, linked issue, implementation, evidence, limitations, and tests. It does not use the repository template headings or check…
Linked Issues check ✅ Passed The change satisfies issue #9847 by fixing native-instance classification at the lowering stage, using resolved LocalId bindings, preserving valid cross-function behavior, and adding regression covera…
Out of Scope Changes check ✅ Passed The code changes remain within the linked issue scope. They update native-instance tracking, lowering behavior, and regression tests. The runtime performance discussion is explicitly presented as foll…
Full details: Docstring Coverage

Explanation

Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up filed as #9858: the two sibling registration forms in the same lower_assign prologue (x = new NativeClass(...) and the x = y propagation), plus this PR's unresolvable-name fallback, are still name-keyed and module-wide.

It carries the split of the 28 ::Instance sites that survive this change — ~14 that look like genuine stream/https handles, and 14 path::{startsWith,slice,replace,toLowerCase,split,match} that are string methods on a module with no instances and are therefore still mis-typed. Which of the three routes produces those 14 is not yet determined, so the same LocalId treatment is recorded there as the obvious shape but explicitly as an untested hypothesis, not a plan.

Also from the keystroke lane's segment-view counter, run against this branch: N$6 moves code_point_at 0→1, materialise 1→0, v2_ready→yes, fires stays 9, and no other site moves — one node, one number.

proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Sep 6, 2026
…y, and its NAMES

Perrymaster's round-1 run refuted two readings and left one question, and
this answers the question rather than re-asking it.

REFUTED, and recorded here because the retraction is the useful part:

  * EXTERNAL_CARRIER-never-cleared is dead. Every examined id is retired
    (~51%) or skip_keep (~49%); skip_cache, skip_external, skip_both and
    skip_norecord are ZERO in all four draws and the five sum to examined.
    No id is un-retirable. The flag may indeed never be cleared; it is
    simply not what fills this family.
  * retire_owned_shape_siblings is NOT the caller. retire_len_max = 16 in
    every draw -- retirement never sees the big list. The removals come
    from the dead-owner prune, which is the chain the original profile
    named; the earlier attribution came from this file's own counter block
    landing in retire's loop.

THE OPEN QUESTION is that bytes moved does not decide the mode: one draw
moved 335 GB and was as fast as one that moved 16 GB, while 848 and 643 GB
draws cost 4.5 s more. Since elems_moved is already the tail handed to the
memmove per removal (len - 1 - pos, not the list length), the definition is
not the escape and the live hypothesis is WHEN. So:

  * a per-removal TAIL histogram (0, 1, 2-15, ... 1M+), which says whether
    the bytes arrive as many small shifts or few enormous ones;
  * the first family to cross 100k is latched by KEY, and every removal
    from it is charged separately -- "is it all one list?" becomes a
    number rather than an inference from len_max;
  * removals from that family PER MINOR, printed and then reset, with the
    peak kept. A total spread evenly across a turn and the same total
    delivered in a few bursts are different costs, and only the per-minor
    figure separates them.

And the crossing lines now print the KEY NAMES of the array. One keys array
carrying ~500k descriptors that all share its key list is the signature of
PerryTS#9847's mis-typed native-instance objects -- a fresh descriptor minted per
call over the same keys -- which is why PerryTS#9857 removes the whole thing. If
the names say so, the growth is EXPLAINED and belongs in an issue ("a family
of N descriptors with identical keys should not exist -- shape dedupe"),
not in a fix.

cargo fmt clean; not compiled (disk).

Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9883. Validated as a tree: 64/64 lint gates, and perry-runtime/codegen/hir/stdlib all green (5,910 tests, 0 failures). Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codegen: a string's .codePointAt in cc's hottest loop lowers to NativeMethodCall{module:"child_process", class_name:"Instance"}

1 participant