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

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 commit is contained in:
Colby Mchenry
2026-10-07 08:42:06 +00:00
committed by GitHub
parent c54b72692c
commit 8f0766cc50
5 changed files with 188 additions and 7 deletions
+1
View File
@@ -38,6 +38,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, an exported type written without a package, like `Node` in `func Walk(v Visitor, node Node)`, now links to the type of that name in its own package, whether it is a struct, an interface or another kind of type. Before, it could link to a struct of the same name in an unrelated package, so prometheus's PromQL parser functions pointed at the Kubernetes discovery `Node` struct instead of the parser's own `Node` interface, or, for a result type like `Appender` in `func (f *fanout) Appender(…) Appender`, to the method itself. A type from a package outside your project, like `apiv1.Node` or `http.Handler`, no longer links to a struct of the same name in the file that uses it; a variadic parameter like `...storage.Filter` now links to that package's type; a route handler written as a method value, like `h.Follow` in an Echo or Gin app, links to that method rather than a model struct named `Follow`; and a method called on another call's result or on an indexed value, like `err[i].Error()` or a chained `.String()`, is no longer recorded as creating a struct of the same name. Re-index Go projects after upgrading.
- In Go, a function or method passed as a value, like `sync.Pool{New: wm.new}`, now shows the function that passes it among its callers and impact even when it is named `new`, `nil`, `None`, `self` or a handful of similar words, or when a line break or comment sits beside the dot. Before, such a value was skipped, so a method used only that way looked unused. Re-index Go projects after upgrading.
- Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334)
- Indexing a project that ships a large minified JavaScript file, such as the GraphiQL bundle inside go-ethereum, no longer stalls at "Resolving refs" when CodeGraph runs on Node 22, and linking such a file is faster on any Node version. The graph it builds is unchanged.
- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334)
- In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
@@ -0,0 +1,73 @@
/**
* Whether a JS function binds a name itself (jsFunctionLocalScope, #2226) is
* read off the function's comment-stripped code with a parameter-list regex.
* Its `(?<!\b(?:if|while|for|switch|with)\s*)` lookbehind is cheap only while
* V8 optimizes the regex, and Node 22's V8 stops optimizing new regexes once a
* process has generated about a megabyte of regex code — which a resolver pool
* worker does early, compiling patterns per receiver and per name. Unoptimized,
* the lookbehind runs at every position and reads back through each whitespace
* run before it, so a long blanked comment cost time in its length squared.
*
* go-ethereum's bundled `graphiql.min.js` is one 980 KB line, and the
* stripper, which doesn't know regex literals, read the `//` that ends
* `/Trident\//` as a comment and blanked the remaining 962 KB of it. Two pool
* workers sat on one such check each until the run was killed (`codegraph
* init` never finished resolving).
*
* The cost lives inside one regex execution, where there is nothing to count,
* so this is timed — with V8's unoptimized mode forced, on any Node version,
* and a bound far above the fix's cost and far below the quadratic one.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as v8 from 'v8';
import { CodeGraph } from '../src';
describe('JS local-binding check on a long comment (go-ethereum graphiql.min.js)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
tmpDir = undefined;
});
const callees = (graph: CodeGraph, name: string): string[] => {
const from = graph.getNodesByName(name).find((n) => n.kind === 'function')!;
return graph.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls')
.map((e) => graph.getNode(e.target)!.name);
};
it('stays linear when V8 stops optimizing regexes', async () => {
// Blanked by the comment stripper into one 60,000-character run of spaces.
const comment = `/* ${'word '.repeat(12_000)} */`;
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-lookbehind-'));
fs.writeFileSync(path.join(tmpDir, 'paint.js'), [
'function paintWidget() {\n return 1;\n}',
// Calls the file's own paintWidget: the check scans all of host's code and finds no binding.
`function host() {\n ${comment}\n return paintWidget();\n}`,
// Calls its parameter, which shadows the file's paintWidget.
`function shadow(paintWidget) {\n ${comment}\n return paintWidget();\n}`,
'',
].join('\n'));
v8.setFlagsFromString('--no-regexp-optimization');
const started = Date.now();
try {
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
} finally {
v8.setFlagsFromString('--regexp-optimization');
}
const elapsed = Date.now() - started;
expect(callees(cg, 'host')).toEqual(['paintWidget']);
expect(callees(cg, 'shadow')).toEqual([]);
// About a second with the fix; minutes when the lookbehind reads back through the run at every position.
expect(elapsed).toBeLessThan(20_000);
}, 300_000);
});
+84
View File
@@ -0,0 +1,84 @@
/**
* The jsx-render pass reads each function's own lines for the component tags
* it renders. Every function of a minified bundle spans the bundle's one line,
* so it split, sliced and scanned the whole file once per function: 2,234
* times over go-ethereum's 980 KB `graphiql.min.js`, 25 of the 28 seconds its
* linking passes took on Node 22 (9 of 13 on Node 24). Functions that span
* the same lines render the same tags, so they share one scan. Counted, never
* timed.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
/** The source of the pass's tag pattern, to count its scans by. */
const JSX_TAG_SOURCE = String.raw`<([A-Z][A-Za-z0-9_]*)[\s/>]`;
describe('jsx-render pass work', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
const originalExec = RegExp.prototype.exec;
afterEach(() => {
RegExp.prototype.exec = originalExec;
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
tmpDir = undefined;
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-work-'));
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
for (const [file, content] of Object.entries(files)) fs.writeFileSync(path.join(tmpDir, file), content);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
return cg;
}
/** What each named function renders, by jsx-render edge. */
const renders = (graph: CodeGraph, name: string): string[] => {
const from = graph.getNodesByName(name).find((n) => n.kind === 'function' || n.kind === 'component')!;
return graph.getOutgoingEdges(from.id)
.filter((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'jsx-render')
.map((e) => graph.getNode(e.target)!.name);
};
it('scans a line that many components share once, not once per component', async () => {
const COMPONENTS = 60;
const bundle = Array.from({ length: COMPONENTS }, (_, i) => `function Card${i}(){return <Badge/>}`).join('');
// A scan of the bundle's line: the tag pattern run over it from the start.
let scans = 0;
RegExp.prototype.exec = function (this: RegExp, input: string) {
if (this.source === JSX_TAG_SOURCE && this.lastIndex === 0 && typeof input === 'string' &&
input.includes(`Card${COMPONENTS - 1}(`)) scans++;
return originalExec.call(this, input);
};
const graph = await index({
'bundle.jsx': `${bundle}\n`,
'badge.jsx': 'export function Badge(){return null}\n',
});
RegExp.prototype.exec = originalExec;
for (const i of [0, 31, COMPONENTS - 1]) expect(renders(graph, `Card${i}`)).toEqual(['Badge']);
expect(scans).toBeGreaterThan(0);
expect(scans).toBeLessThan(COMPONENTS / 4);
});
it('keeps the tags of functions on different lines apart', async () => {
const graph = await index({
'page.jsx': [
'export function Page() { return <Header/>; }',
'export function Footer() {',
' return <Links/>;',
'}',
'function Header(){return null} function Links(){return null}',
'',
].join('\n'),
});
expect(renders(graph, 'Page')).toEqual(['Header']);
expect(renders(graph, 'Footer')).toEqual(['Links']);
});
});
+21 -6
View File
@@ -1432,13 +1432,28 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
let imports: Map<string, string> | null = null;
const importsOf = () =>
(imports ??= importedFrom(ctx, file, parents[0]!.language));
// A function renders the tags its own lines hold, so functions that span
// the same lines share them: every function of a minified bundle spans
// its one line, and reading that line again per function took 9 to 25 s
// over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The
// file is split once, not once per function.
let lines: string[] | null = null;
const tagsBySpan = new Map<string, Set<string>>();
for (const parent of parents) {
const src = sliceLines(content, parent.startLine, parent.endLine);
if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
const names = new Set<string>();
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
if (!parent.startLine || !parent.endLine) continue;
const span = `${parent.startLine}:${parent.endLine}`;
let names = tagsBySpan.get(span);
if (!names) {
names = new Set<string>();
lines ??= content.split('\n');
const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n');
if (src.includes('</') || src.includes('/>')) {
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
}
tagsBySpan.set(span, names);
}
let added = 0;
for (const name of names) {
if (added >= MAX_JSX_CHILDREN) break;
+9 -1
View File
@@ -6846,7 +6846,15 @@ function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: Re
const declared = new RegExp(`\\b(?:const|let|var)\\s+${n}\\b(?!\\s*[,\\]}])`).test(text);
// A parameter list — never a control-flow head (`if (openMarkerClose) {`).
// A return type stays on its line, never a ternary's `: data.slice()` below `filter(canRowExpand)`.
const parameter = new RegExp(`(?<!\\b(?:if|while|for|switch|with)\\s*)${param.source.replace('(?::[^=;{]*)?', '(?::[^=;{}()\\n]*)?')}`);
// The leading `(?=\()` (every match opens a list) keeps the lookbehind to
// where one opens. Node 22's V8 stops optimizing the regexes a process
// compiles once it has generated about a megabyte of regex code, which a
// resolver pool worker soon has, and then tried the lookbehind at every
// position, each time reading back through the run of blanks before it:
// go-ethereum's graphiql.min.js, a 980 KB line whose last 962 KB the
// stripper blanks (it reads the `//` closing `/Trident\//` as a comment),
// never resolved.
const parameter = new RegExp(`(?=\\()(?<!\\b(?:if|while|for|switch|with)\\s*)${param.source.replace('(?::[^=;{]*)?', '(?::[^=;{}()\\n]*)?')}`);
binds = declared || parameter.test(text);
memo.set(key, binds);
}