Skip to content

fix(build): tree-shake React reconciler dev build to prevent PerformanceMeasure leak#4462

Merged
huww98 merged 1 commit into
QwenLM:mainfrom
huww98:fix/tree-shake-react-dev-build
May 23, 2026
Merged

fix(build): tree-shake React reconciler dev build to prevent PerformanceMeasure leak#4462
huww98 merged 1 commit into
QwenLM:mainfrom
huww98:fix/tree-shake-react-dev-build

Conversation

@huww98

@huww98 huww98 commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Set process.env.NODE_ENV to "production" in the esbuild define map so the React reconciler's development build is tree-shaken from the production bundle.
  • Why it changed: The ink 6→7 upgrade (v0.15.11, PR chore(deps): upgrade ink 6.2.3 → 7.0.2 + bump Node engine to 22 #3860) pulled in react-reconciler 0.33, whose development build calls performance.measure() on every Ink component render. Since NODE_ENV was never defined at build time, esbuild bundled both dev and prod builds and selected dev at runtime — leaking PerformanceMeasure objects into the global measureEntryBuffer indefinitely. Heap snapshot analysis shows this single buffer retains ~148 MB (45% of heap) after moderate usage, contributing to OOM crashes at the 4 GB limit.
  • Reviewer focus: The one-line define addition in esbuild.config.js. Verify no code relies on process.env.NODE_ENV !== "production" at runtime in the bundled output.
image

Validation

  • Commands run:
    # Before fix
    npm run bundle
    grep -c 'performance\.measure' dist/cli.js          # 17
    grep -c 'process\.env\.NODE_ENV' dist/cli.js        # 22
    wc -l < dist/cli.js                                 # 170,636
    
    # After fix
    npm run bundle
    grep -c 'performance\.measure' dist/cli.js          # 0
    grep -c 'process\.env\.NODE_ENV' dist/cli.js        # 0
    wc -l < dist/cli.js                                 # 154,798
    node dist/cli.js --version                           # 0.16.0
  • Also confirmed by downloading published npm packages:
    • @qwen-code/qwen-code@0.15.10 (ink 6, react-reconciler 0.31): 0 performance.measure calls — no leak
    • @qwen-code/qwen-code@0.15.11 (ink 7, react-reconciler 0.33): 17 performance.measure calls — leak introduced
  • Heap snapshot evidence (Chrome DevTools):
    • measureEntryBuffer retains 148,173 KB (45% of 331 MB heap) after 3 parallel Explore agents + bash commands
    • 150,719 PerformanceMeasure objects accumulated, never cleared
  • Expected result: No performance.measure calls in bundle; dev build fully eliminated
  • Observed result: Bundle shrinks by 15,838 lines / 700 KB; zero React profiling overhead at runtime
  • Quickest reviewer verification path: npm run bundle && grep -c 'performance\.measure' dist/cli.js → should be 0

Scope / Risk

  • Main risk or tradeoff: Any code that checks process.env.NODE_ENV at runtime in the bundled output will now see "production" instead of undefined. This is standard practice for Node.js CLI tools and all affected packages (react, react-reconciler, react-dom, react-jsx-runtime) are designed for this.
  • Not covered / not validated: The performance.measure leak also affects running from source (node packages/cli/dist/index.js) since tests/dev use the unbundled node_modules which still do the runtime NODE_ENV check. A separate fix (e.g., setting NODE_ENV=production in the sandbox launcher) could address that.
  • Breaking changes / migration notes: None.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️ ⚠️
Docker ⚠️ ⚠️ ⚠️
Podman ⚠️ N/A N/A
Seatbelt ⚠️ N/A N/A

Testing matrix notes:

  • Verified bundle build + --version + --help on macOS arm64

Linked Issues / Bugs

Partially addresses #4185 — this fix eliminates one major contributor (~45% of heap) to the OOM crashes in long sessions. The remaining memory pressure from conversation history growth (#4184, #4185) is a separate concern.

The ink 6→7 upgrade landed in v0.15.11 (May 13) via PR #3860. A wave of OOM reports appeared immediately after:


🤖 Generated with Claude Code

…nceMeasure leak

The ink 6→7 upgrade (v0.15.11) pulled in react-reconciler 0.33, whose
development build calls performance.measure() on every component render.
Since NODE_ENV was never set to "production" in the esbuild define map,
the bundle shipped both dev and prod builds and selected dev at runtime,
causing an unbounded measureEntryBuffer leak (~45% of heap after moderate
use, confirmed via heap snapshots).

Set process.env.NODE_ENV to "production" at build time so esbuild
statically resolves the conditional require and tree-shakes the entire
15k-line dev build. Bundle shrinks by ~700 KB / 15,800 lines.
@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR adds process.env.NODE_ENV definition to the esbuild configuration to tree-shake React reconciler's development build, preventing PerformanceMeasure object leaks that cause ~148 MB heap retention and potential OOM crashes. The one-line change is well-documented, thoroughly validated with empirical evidence, and follows standard Node.js CLI bundling practices.

🔍 General Feedback

  • Excellent problem diagnosis with heap snapshot evidence and quantitative before/after metrics
  • Comprehensive validation script provided in the PR description makes verification straightforward
  • The fix follows established patterns for React-based production builds
  • Clear explanation of the root cause (ink 6→7 upgrade pulling in react-reconciler 0.33)
  • Comment in code appropriately explains the why and impact for future maintainers

🎯 Specific Feedback

🔴 Critical

No critical issues identified.

🟡 High

No high priority issues identified.

🟢 Medium

No medium priority issues identified.

🔵 Low

  • esbuild.config.js:115-118 - Consider adding a linked issue reference in the comment once one is created. The PR description mentions "No linked issues" but this memory leak fix seems significant enough to warrant a tracking issue for future reference. This would help connect the code change to any related incident reports or user-facing bug reports.

✅ Highlights

  • Outstanding validation methodology: bundle size reduction (15,838 lines / 700 KB), grep verification of removed performance.measure calls, and runtime version check
  • Proactive heap analysis identifying the specific leak source (measureEntryBuffer retaining 45% of heap)
  • Clear risk assessment acknowledging the standard practice of setting NODE_ENV=production for Node.js CLI tools
  • Thorough testing matrix documentation
  • The comment added to the code is exemplary - it explains the problem, the cause, and the solution in 4 concise lines

@huww98

huww98 commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

searched and linked issues. bot, please review again.

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR addresses a significant memory leak (~148 MB / 45% of heap) introduced by the ink 6→7 upgrade by adding a single-line define configuration to tree-shake React's development build from the production bundle. The fix is well-researched, thoroughly validated with heap snapshot evidence, and follows standard Node.js CLI bundling practices.

🔍 General Feedback

  • Excellent investigation: The PR description provides compelling evidence with heap snapshots, grep counts, and line-count comparisons that clearly demonstrate both the problem and the fix's effectiveness
  • Minimal, targeted change: A one-line addition with comprehensive comments explaining the why and how
  • Strong validation: Multiple verification methods (bundle analysis, npm package comparison, heap snapshots) provide high confidence
  • Clear reviewer guidance: Specific commands provided for quick verification
  • Proper risk assessment: Acknowledges the tradeoff and explains why it's acceptable for this codebase

🎯 Specific Feedback

🔵 Low

Suggested addition:

// react-reconciler ≥0.33 (ink 7, PR #3860) gates its dev build behind NODE_ENV

✅ Highlights

  • Outstanding debugging work: The heap snapshot analysis identifying measureEntryBuffer as retaining 45% of heap is exactly the kind of evidence-based debugging that leads to confident fixes
  • Comprehensive validation: The before/after comparison using grep -c 'performance\.measure' and line count reduction (170,636 → 154,798 lines) provides clear, verifiable proof
  • Proactive risk mitigation: The comment about unbundled dev/test mode (node packages/cli/dist/index.js) shows forward-thinking about edge cases
  • Standard practice: Setting NODE_ENV=production at build time is the canonical approach for React-based tooling and aligns with ecosystem expectations
  • Clear testing matrix: Honest about what was verified (macOS arm64) vs. what needs broader testing

🧪 Verification Commands

For the reviewer to quickly validate:

npm run bundle && grep -c 'performance\.measure' dist/cli.js  # Should be 0
node dist/cli.js --version  # Should output 0.16.0

Recommendation: ✅ Approve — This is a low-risk, high-impact fix for a well-documented memory leak. The single-line change follows established patterns and the validation evidence is compelling.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found. LGTM! ✅

The change correctly sets process.env.NODE_ENV to "production" in the esbuild define block, which tree-shakes the react-reconciler dev build (~19,737 lines). Verified: performance.measure count in dist/cli.js = 0; build and CLI startup both pass.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@huww98 huww98 merged commit 61d91ad into QwenLM:main May 23, 2026
24 of 26 checks passed
@doudouOUC

Copy link
Copy Markdown
Collaborator

Validation from parallel investigation

Posting this to help maintainers prioritize the release that includes this PR. Over the past 10 days I've been tracking the OOM cluster from the user-report side; the heap snapshot finding here lines up perfectly with the symptoms in 12+ reports and explains why upgrading to v0.16.0 (which contains PR #4286 for structuredClone) did not stop the OOMs.

Reports plausibly explained by this fix

From the investigation thread (full details in code_agent/docs/investigation-oom-series.md):

Issue What happened Why this PR likely explains it
#4116 (Windows) 50 min idle → 2 GB heap Active Ink renders even when idle (footer / spinner / status line)
#4116 @Kieaer comment 45 GB heap, 7.2 h Long session × --max-old-space-size=8192 × continuous renders
#4116 @maxinteresa-ops 6 GB heap, 107 min, on v0.16.0 Upgrading didn't stop it — this PR is the missing piece
#4167 /compress crash, glm-5 Compression workflow has many renders
#4315 19 h session, typing-triggered Long-running session, heavy render accumulation
#4322 (Windows) 7.1 h OOM Same pattern
#2868 (100 sec) Linux, fast crash measureEntryBuffer grows rapidly under load

Three commenters on #2868 (@supercargotim-rgb, @kstepyra, @gkubon) reported "happens multiple times a day" and "happens constantly" within days of the v0.15.11 release window — frequency spike timing matches.

My investigation got close but missed the bullseye

I had identified the regression window (v0.15.10 → v0.15.11) and flagged the ink 7 upgrade (#3860) as a suspect, but I misread the mechanism. From the stack frames (OpenSSL symbols like X509_STORE_set_cleanup, BIO_ssl_shutdown, ReportExternalAllocationLimitReached) I extrapolated "TLS native pool leak", and even built a mock-SSE V3 reproduction that showed ~195 KB/iter RSS growth. That leak is real — but it's secondary (~60 MB/min at 5 streams/sec), nowhere near what the heap snapshot in this PR found (148 MB / 45% of heap).

I should have led with process.report.writeReport() or a heap snapshot like you did. Lesson noted.

Why the symptom looked like a native leak

PerformanceMeasure objects ARE in the V8 heap, but the user-visible symptom (RSS approaching limit while context % stays low) made me suspect native memory. Plus the OpenSSL symbols in the stack are large-offset anchors that don't actually mean the OOM occurred during TLS shutdown — they just happen to be the nearest exported symbol to where V8 was when it ran out.

Suggestion: ship this fast

This PR fixes a measurable 45% of heap retention. Combined with PR #4286 (already in v0.16.0) it should resolve the bulk of the OOM reports. The reporters on the issues above have been suffering for ~10 days with no good workaround beyond "restart frequently / raise --max-old-space-size and pray."

I've posted a correction comment on #4116 walking back my B-TLS hypothesis and pointing readers to this PR.

Excellent investigation work — heap snapshots win over stack-frame speculation every time. 👏

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.

4 participants