Merge train: #9720, #9722 + stale TLS allowlist entry - #9727
Merged
Conversation
A closure created inside a `let`/`const` declarator's initializer that
references the binding that declarator introduces threw
`ReferenceError: <name> is not defined`. Node only has a TDZ window here,
and the closure body does not run until after initialization, so shapes
like `const off = ev.on(() => off())` and
`const { unmount } = await render({ onDone: () => unmount() })` are legal.
`pre_register_forward_captured_lets` recorded a declarator's initializer
into `seen_closure_refs` only after deciding whether that declarator's own
bindings were forward-captured. That ordering serves the later-declarator
case and nothing else, so the self-referential shape was never
pre-registered and the reference fell through to
`js_global_get_or_throw_unresolved`. Recording before the decision is a
strict superset — later declarators still see the same refs.
Reachable from every declarator form: the destructuring path has no
pre-registration of its own, and `ast_expr_contains_function_expr` (which
guards the simple-binding path) does not descend through `await`.
Found in claude-code's `install` subcommand, which is exactly this shape;
the uncaught ReferenceError there dropped the trailing newline and the
exit code (`B60_install_badtarget`, #9575).
Closes #9718
…9707) Module init recorded each closure body's rest/arity/length/arrow/strict/ async/generator/async-generator attributes and the trusted direct-call bodies into ten thread-local PtrHashMaps keyed by the same func_ptr, plus a dispatch-strategy memo map. Replace them with one `CLOSURE_BODY_REGISTRY` of 16-byte `ClosureBodyRecord`s (24-byte bucket, size-pinned), a dense `TRUSTED_TARGETS` side array indexed only by eligible arrows, and derive the dispatch strategy from the record on a miss instead of caching it. Census on a 20k-function fixture: 2,916,564 -> 1,638,416 bytes (-44 %) for 35,051 bodies, identical output. Projected on cc's recorded census counts: 7.24 MB -> 3.28 MB. Public registration/lookup signatures are unchanged. Claude-Session: https://claude.ai/code/session_01Dw8cFMegSvvXvGABjSXMQf
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge train: #9720, #9722, plus one gate fix.
#9720 — a declarator's own initializer can now see the binding it introduces (#9718).
const off = ev.on(() => off())andconst { unmount } = await render({ onDone: () => unmount() })threwReferenceError: <name> is not defined. The forward-capture pre-pass recorded a declarator's initializer intoseen_closure_refsonly after deciding whether that declarator's bindings needed a boxed, TDZ-seeded forward declaration — enough for the later-declarator case, but it left the self-referential shape unregistered, so the reference fell through tojs_global_get_or_throw_unresolved.Found in claude-code: the
installsubcommand is exactly this shape, soclaude install <bad-channel>threw an uncaughtReferenceError.Recording before the decision is a strict superset — the same
cic_expr(init, …), moved earlier in the loop iteration, with later declarators still seeing the refs. Registering more bindings also improves shadowing:let O = (() => O)()under an outerOpreviously fell through to global lookup and could return the outer value, and now correctly hits TDZ, which is what node does. The gap test covers every declarator form (plain, object pattern,{ key }shorthand, array, nested,let/const, with and withoutawait) plus a multi-declarator case pinning that the previously-working path did not regress.#9722 — ten closure-body registries packed into one 16-byte record (#9707). Module init recorded rest arity + kind, declared ABI arity, ECMAScript
.length, the arrow/strict/async/generator flags, and an eligible arrow's direct-call bodies into ten thread-localPtrHashMaps all keyed by the samefunc_ptr, plus an eleventh memoizing the dispatch strategy.Its
gc_runtime_root_holders.jsonedits are honest: the newCLOSURE_BODY_REGISTRYandTRUSTED_TARGETSverdicts carry forward the same reasoning the superseded entries held — afunc_ptris a code address the collector neither moves nor traces, and the record is three plain integers, a flags word, and an index — and the ten stale entries are deleted rather than left behind. Bothgc_runtime_root_holders.pyandgc_rekeyed_key_tables.pyagree.Gate fix (mine). #9697, which I landed in train115, removed
readline_helpers.rs's only rawthread_local!block but left its cold-allowlist entry behind, andcheck_thread_locals.pyfails a recorded entry that no longer matches the tree. That check runs intls-budget.yml, a satellite workflow outside the 64-gatelintset, so a green merge did not catch it.Removed by hand rather than with the script's
--update, which would also have swept five unrelated pre-existing failures (fs/deferred.rs,gc/census.rs,gc/idle_compact.rs,gc/idle_reclaim.rs,gc/oldgen_defrag.rs) into the allowlist as "cold" and hidden them. Those are other authors' rawthread_local!blocks and their call to make;tls-budgetstays red on them.Validation
64/64 lint gates; release build;
perry-runtimeandperry-hir(RUST_TEST_THREADS=1) — all green.