Skip to content

fix(resolution): a large minified JS bundle no longer stalls resolving on Node 22 - #2424

Merged
colbymchenry merged 4 commits into
mainfrom
claude/musing-meninsky-b9f288
Oct 7, 2026
Merged

colbymchenry merged 4 commits into
mainfrom
claude/musing-meninsky-b9f288

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

On Node 22, codegraph init on 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 of graphql/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.

  • Node 22's V8 compiles new regexes without optimization once a process has generated about 1 MB of regex code and holds 16 MB of executable memory. A pool worker gets there after roughly 30,000 refs.
  • Unoptimized, the lookbehind is tried at every position, and its \s* reads back through the whole run of blanks before it. A run of n blanks costs n²/2 steps.
  • The comment stripper doesn't know regex literals. In this file it reads the // 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:

  • CDP Debugger.pause samples of the stuck state all landed in jsCodeBindsName → parameter.test(text).
  • A V8 tick profile put 62% of all ticks in that one regex.
  • I captured the exact regex and text from the stuck call. In the worker's state the regex took 36 ms over 16K characters, 5.1 s over 32K and 54 s over 64K. In a fresh process it took 0.23 ms over 128K.
  • A bare Node 22 process reproduces it after compiling 20,000 throwaway regexes, and --no-regexp-optimization reproduces it on any version.

Regression or long-standing? Long-standing, and it depends on the Node version:

go-ethereum init Node 22 (system) Node 24 (bundled runtime)
v1.6.2 stalls at batch 30, killed at 30 min finishes, 198 s
main (02d22ae) stalls at batch 30, killed after 1 h 55 min finishes, 129 s
this PR finishes, 36 s finishes

Node 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.ts jsCodeBindsName: a leading (?=\() keeps the lookbehind to positions where a parameter list opens. param.source always starts with \( and the lookbehind is zero-width, so the regex accepts exactly the same matches as before.
  • callback-synthesizer.ts reactJsxChildEdges: 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):

  • go-ethereum: main on Node 24 (main can't finish on Node 22) matches this change on Node 22 and on Node 24. That held on 02d22ae and again after rebasing onto 31c3328: 38,706 nodes, 155,824 edges, 125,913 refs and 1,607 files.
  • main and this change, both on Node 22, match on bootstrap4 (6 minified bundles), legend-state, takenote, mantis, excalidraw, qwik, gin, prometheus, etcd and hugo, on both bases. JSX-heavy ones: excalidraw has 482 jsx-render edges, mantis 335 and takenote 51.

Time, go-ethereum:

  • Node 22: main never finishes; this change takes 36 s.
  • Node 24, three interleaved rounds (medians):
wall user CPU resolution phase linking passes jsxEdges
main 42.1 s 66.6 s 17.7 s 6.5 s 5.8 s
this PR 38.3 s 61.8 s 12.7 s 1.5 s 0.39 s

Tests:

  • js-local-binding-backtracking.test.ts forces V8's unoptimized mode (v8.setFlagsFromString, restored in finally) 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.ts counts 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.
  • tsc passes. 22 related test files (117 tests) and the react-router suites pass.

Overlap

#2420 (open) rewrites the same tag scan in reactJsxChildEdges: names becomes a Map<name, asTag>, computed from the same src. The two will conflict textually. Whichever lands second should memoize that Map per startLine:endLine span instead of the Set. asTag reads only src, which is a function of the span, so the memo stays graph-neutral.

Not in this PR

  • bareCallReceiver still 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.
  • The comment stripper's regex-literal blind spot (documented in strip-comments.ts) means the local-binding check only sees the first 14K characters of this bundle's line. Changing that would change graphs.
  • Other regexes may slow down in long-lived workers once Node 22's V8 stops optimizing them. This was the one that turned quadratic on real input.

🤖 Generated with Claude Code

…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>
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.

1 participant