Skip to content

perf: dispose mocha runner on run completion to prevent memory leak on rerun#33631

Merged
mschile merged 15 commits into
developfrom
claude/jovial-mahavira
Apr 20, 2026
Merged

perf: dispose mocha runner on run completion to prevent memory leak on rerun#33631
mschile merged 15 commits into
developfrom
claude/jovial-mahavira

Conversation

@mschile
Copy link
Copy Markdown
Collaborator

@mschile mschile commented Apr 18, 2026

Additional details

Problem

In cypress open mode, repeatedly rerunning a spec caused memory to grow monotonically and eventually crash the renderer process with V8 process OOM. The leak got worse with each rerun.

Root causes

1. process uncaughtException listener accumulation (every rerun)

At the end of a Mocha run, packages/driver/src/cypress/runner.ts was calling _runner.removeAllListeners(). That only clears listeners on the Runner emitter itself — it does not remove the uncaughtException listener Mocha installs on process during _runner.run() (see mocha/lib/runner.js:1035):

self._addEventListener(process, 'uncaughtException', self.uncaught);

self.uncaught is a bound function whose [[BoundThis]] slot holds the Runner. Because process persists across reruns, the handler list accumulated one entry per rerun — each anchoring an old Runner and everything it retains (suite tree, tests, command queue, snapshots, logs).

2. Cypress instance retention via top error listeners (first rerun only)

setTopOnError in packages/driver/src/cypress/cy.ts runs its full body only once (because top.onerror is configurable: false). The onTopError closure it creates captured the Cypress parameter from that first call. Because the top.addEventListener('error') and top.addEventListener('unhandledrejection') listeners added in that first call can never be removed, Cypress₀ — and all the test data it retained — was permanently anchored in memory.

Additionally, the inline noop get()/set() functions passed to Object.defineProperty(top, 'onerror') shared a V8 closure context with onTopError, creating a second retention path via top.onerror's [[Get]] slot.

Fixes

runner.ts: Call _runner.dispose() instead of _runner.removeAllListeners(). dispose() walks Mocha's internal _eventListeners bookkeeping to remove each externally-installed listener from its target — including the uncaughtException listener on process. Also removed a redundant _runner.removeAllListeners() call from the abort() path.

cy.ts: Mirror the existing curCy pattern by adding a module-level curCypress variable that is updated on every run. Replace the closed-over Cypress parameter in onTopError with curCypress, so the closure no longer permanently captures Cypress₀. Also move the noop onerror getter/setter to module scope so they do not share a V8 closure context with onTopError.

Steps to test

  1. Open any memory-intensive project with cypress open.
  2. Open a spec and let it run to completion.
  3. Click Rerun several times.
  4. Observe DevTools Memory — before the fix, heap grew monotonically with each rerun. After the fix it should plateau.

A unit regression test is included at packages/driver/test/unit/cypress/runner.spec.ts that:

  • Exercises the real $Runner.create(...).run(...) path.
  • Asserts process.listenerCount('uncaughtException') returns to baseline after a run ends.
  • Simulates 5 rerun cycles and asserts listener count stays at baseline (no accumulation).

Run with: yarn workspace @packages/driver test test/unit/cypress/runner.spec.ts.

How has the user experience changed?

  • cypress open reruns no longer leak memory across rerun cycles.
  • Long-running open-mode sessions no longer accumulate heap and crash the renderer.

Before (5 runs):
Screenshot 2026-04-18 at 8 36 32 AM

After (6 runs):
Screenshot 2026-04-18 at 3 37 03 PM

PR Tasks


Note

Medium Risk
Touches core runner lifecycle and global error handling; mistakes could cause missed/duplicated error reporting or unexpected runner teardown behavior during open-mode reruns.

Overview
Prevents cypress open from leaking memory across spec reruns by disposing the underlying Mocha Runner on run completion (switching from _runner.removeAllListeners() to _runner.dispose()), ensuring Mocha’s process-level uncaughtException listener is removed and prior runner state can be garbage collected.

Updates setTopOnError in cy.ts to stop closures from permanently capturing the first Cypress instance by using module-level curCypress (and module-scoped noop top.onerror accessors), so top-frame error/unhandledrejection handlers don’t retain old run data. Adds a new unit spec (runner.spec.ts) asserting dispose() is called and process listeners don’t accumulate, and documents the fix in the cli/CHANGELOG.md.

Reviewed by Cursor Bugbot for commit 1d83fa3. Bugbot is set up for automated code reviews on this repo. Configure here.

mschile added 3 commits April 17, 2026 19:43
- Add unit test verifying $Runner.create().run() calls dispose() on the
  underlying mocha runner, and that the process-level uncaughtException
  listener is removed after a run completes. Simulates 5 rerun cycles
  and asserts process.listenerCount returns to baseline.
- Remove redundant _runner.removeAllListeners() call from abort(); it is
  already covered by the dispose() invoked through the emit('end') →
  run-callback chain.
- Expand the comment on _runner.dispose() to explain why dispose is
  required over removeAllListeners.
@cypress
Copy link
Copy Markdown

cypress Bot commented Apr 18, 2026

cypress    Run #70025

Run Properties:  status check passed Passed #70025  •  git commit 1d83fa3ad7: Update CHANGELOG for version 15.14.1
Project cypress
Branch Review claude/jovial-mahavira
Run status status check passed Passed #70025
Run duration 16m 45s
Commit git commit 1d83fa3ad7: Update CHANGELOG for version 15.14.1
Committer Matt Schile
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 1
Tests that did not run due to a developer annotating a test with .skip  Pending 10
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 658
View all changes introduced in this branch ↗︎
UI Coverage  0%
  Untested elements 4  
  Tested elements 0  
Accessibility  100%
  Failed rules  0 critical   0 serious   0 moderate   0 minor
  Failed elements 0  

@mschile mschile self-assigned this Apr 18, 2026
@mschile mschile changed the title fix(driver): dispose mocha runner on run completion to prevent memory leak on rerun perf: dispose mocha runner on run completion to prevent memory leak on rerun Apr 18, 2026
mschile added 8 commits April 18, 2026 13:27
…revent memory leak

The noop get()/set() functions passed to Object.defineProperty(top, 'onerror')
were defined inline inside setTopOnError, sharing a V8 closure context with
onTopError which captures the first Cypress instance (Cypress₀).

Because top.onerror is configurable:false it can never be redefined, so its
[[Get]] function — and the shared closure context containing Cypress₀ — is
permanently retained. This caused the first Cypress instance (and all Test
objects, snapshots, and logs it retained) to stay in memory after the first
rerun.

Moving the noop functions to module scope gives them their own context that
does not include Cypress, breaking the retainer chain.
…ope to prevent memory leak"

This reverts commit 8713cde.
…op error listeners

setTopOnError runs its full body only once (top.onerror is configurable:false).
The onTopError closure captured the Cypress parameter from that first call,
permanently anchoring Cypress₀ — and everything it retains — in memory via the
top.addEventListener('error') and top.addEventListener('unhandledrejection')
handlers, which can never be removed.

Fix by mirroring the existing curCy pattern: add a module-level curCypress
variable updated on every run, and replace the closed-over Cypress parameter
with curCypress in onTopError. Also move the noop onerror getter/setter to
module scope so they do not share a V8 closure context with onTopError.
Copy link
Copy Markdown
Collaborator

@AtofStryker AtofStryker left a comment

Choose a reason for hiding this comment

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

Great work on this! Really encouraging after looking at the heap snapshots.

Comment thread packages/driver/src/cypress/cy.ts
Comment thread packages/driver/src/cypress/cy.ts
Comment thread packages/driver/src/cypress/runner.ts
mschile added 4 commits April 20, 2026 08:39
Updated changelog for version 15.14.1 with release date and performance fix details.
Removed redundant mention of 'process' in memory leak fix description.
@mschile mschile merged commit 593c22e into develop Apr 20, 2026
92 of 94 checks passed
@mschile mschile deleted the claude/jovial-mahavira branch April 20, 2026 17:34
@cypress-bot
Copy link
Copy Markdown
Contributor

cypress-bot Bot commented Apr 21, 2026

Released in 15.14.1.

This comment thread has been locked. If you are still experiencing this issue after upgrading to
Cypress v15.14.1, please open a new issue.

@cypress-bot cypress-bot Bot locked as resolved and limited conversation to collaborators Apr 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants