Repository navigation
fix(resolution): a large minified JS bundle no longer stalls resolving on Node 22 - #2424
Merged
Merged
Conversation
…g on Node 22 go-ethereum's `codegraph init` sat in "Resolving refs" for good on Node 22 (killed after 1 h 55 min and 9,203 CPU-seconds). It was not Go: two resolver pool workers each sat on one 500-ref chunk of graphql/internal/graphiql/graphiql.min.js, a 980 KB bundle on one line (18,388 refs, 2,234 functions). Whether a JS function binds a name itself (jsFunctionLocalScope, #2226) is read off its comment-stripped code with a parameter-list regex that starts with a `(?<!\b(?:if|while|for|switch|with)\s*)` lookbehind. Node 22's V8 compiles new regexes without optimization once a process has generated about a megabyte of regex code and holds 16 MB of executable memory, which a pool worker reaches after some 30,000 refs. Unoptimized, the lookbehind runs at every position and its `\s*` reads back through the whole run of blanks before it, so a run of n blanks costs n^2/2 steps. The comment stripper, which doesn't know regex literals, reads the `//` that closes `/Trident\//` as a comment and blanks the remaining 961,968 characters of the line. In a worker's state the regex took 36 ms over 16 K characters of that line, 5.1 s over 32 K and 54 s over 64 K, against 0.23 ms over 128 K in a fresh process. Compiling 20,000 throwaway regexes first reproduces it in a bare Node 22 process, and --no-regexp-optimization reproduces it anywhere. Node 24's V8, the runtime release bundles ship, kept optimizing after 60,000, so released installs finish: 1.6.2 indexes go-ethereum on its bundled Node 24 and stalls the same way on Node 22. The stall is as old as #2226. - A leading `(?=\()` keeps the lookbehind to where a parameter list opens. Every match starts with `(` anyway, so the regex matches exactly what it did. - The jsx-render pass split the file, sliced a function's lines and scanned them for tags once per function: 2,234 times over the bundle's line, 25 of the 28 s its linking passes took on Node 22 (9 of 13 on Node 24), for no edges. Functions that span the same lines now share one scan, and the file is split once. Graph: go-ethereum dumps (nodes, edges, unresolved refs, files) are byte-identical between main on Node 24 (main never finishes on Node 22) and this change on Node 22 and on Node 24. Main and this change also match byte for byte on bootstrap4, legend-state, takenote, mantis, excalidraw (482 jsx-render edges), qwik, gin, prometheus, etcd and hugo. Time, go-ethereum: on Node 22 main never finishes and this change takes 36 s; on Node 24 (three interleaved rounds, medians) 42.1 s -> 38.3 s wall, with resolution 17.7 s -> 12.7 s and the linking passes 6.5 s -> 1.5 s. Tests: js-local-binding-backtracking forces V8's unoptimized mode and indexes a function with a 60,000-character comment before a call (69 s on main, 1.4 s now); jsx-render-work counts the tag scans of 60 components on one line (60 on main, 1 now). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 7, 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.
Summary
On Node 22,
codegraph initon go-ethereum never finished "Resolving refs". I killed the main run after 1 h 55 min and 9,203 CPU-seconds. It isn't Go. Two resolver pool workers each sat on one 500-ref chunk ofgraphql/internal/graphiql/graphiql.min.js, a 980 KB minified bundle on one line (18,388 refs, 2,234 functions). The Go matchers named in the task (inferLocalReceiverType,resolveMethodOnType,goImplementsEdges,matchGoFieldChainCall) were never in the stalled stacks.Root cause.
jsFunctionLocalScope(#2226) asks whether a JS function binds a name itself. It runs a parameter-list regex over the function's comment-stripped code, and the regex starts with a(?<!\b(?:if|while|for|switch|with)\s*)lookbehind.\s*reads back through the whole run of blanks before it. A run of n blanks costs n²/2 steps.//that closes/Trident\//as a comment and blanks the remaining 961,968 characters of the line. The resulting run would take days to scan.Evidence:
Debugger.pausesamples of the stuck state all landed injsCodeBindsName→parameter.test(text).--no-regexp-optimizationreproduces it on any version.Regression or long-standing? Long-standing, and it depends on the Node version:
initNode 24's V8 kept optimizing regexes even after 60,000 warm-up regexes, so installs from npm (which bundle Node 24.16) finished. Running from source or on Node 22 stalled. The check has been there since #2226, which shipped in 1.6.2.
Changes
name-matcher.tsjsCodeBindsName: a leading(?=\()keeps the lookbehind to positions where a parameter list opens.param.sourcealways starts with\(and the lookbehind is zero-width, so the regex accepts exactly the same matches as before.callback-synthesizer.tsreactJsxChildEdges: the second hot path on the same file. The jsx-render pass split the whole file, sliced the function's lines and scanned them for tags once per function. That is 2,234 times over the bundle's line, taking 25 of the 28 s of linking on Node 22 (9 of 13 s on Node 24) for no edges. Functions that span the same lines now share one scan, and the file is split once. The tag set is a function of the span's text and keeps its insertion order, so the edges are the same.Validation
The graph is byte-identical (
scripts/dump-graph.mjs: nodes, edges, unresolved refs, files):jsx-renderedges, mantis 335 and takenote 51.Time, go-ethereum:
jsxEdgesTests:
js-local-binding-backtracking.test.tsforces V8's unoptimized mode (v8.setFlagsFromString, restored infinally) and indexes a function with a 60,000-character comment before a call. It fails on main (69 s against a 20 s bound) and passes in 1.4 s. It's timed because the cost sits inside a single regex execution; the bound is 15× the fix's cost.jsx-render-work.test.tscounts tag scans, not time: 60 components on one line are scanned 60 times on main and once now. A second case keeps different lines' tags apart.tscpasses. 22 related test files (117 tests) and the react-router suites pass.Overlap
#2420 (open) rewrites the same tag scan in
reactJsxChildEdges:namesbecomes aMap<name, asTag>, computed from the samesrc. The two will conflict textually. Whichever lands second should memoize that Map perstartLine:endLinespan instead of the Set.asTagreads onlysrc, which is a function of the span, so the memo stays graph-neutral.Not in this PR
bareCallReceiverstill joins the ref's line plus the next seven, then slices from the column, for every bare call. On this bundle that copies about 1 MB per ref: about 12 s of worker CPU on Node 22. It's slow, not a stall.strip-comments.ts) means the local-binding check only sees the first 14K characters of this bundle's line. Changing that would change graphs.🤖 Generated with Claude Code