perf: dispose mocha runner on run completion to prevent memory leak on rerun#33631
Merged
Conversation
- 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
|
||||||||||||||||||||||||||||||||||||||||
| Project |
cypress
|
| Branch Review |
claude/jovial-mahavira
|
| Run status |
|
| Run duration | 16m 45s |
| Commit |
|
| Committer | Matt Schile |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
1
|
|
|
10
|
|
|
0
|
|
|
658
|
| View all changes introduced in this branch ↗︎ | |
UI Coverage
0%
|
|
|---|---|
|
|
4
|
|
|
0
|
Accessibility
100%
|
|
|---|---|
|
|
0 critical
0 serious
0 moderate
0 minor
|
|
|
0
|
…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.
AtofStryker
approved these changes
Apr 20, 2026
Collaborator
AtofStryker
left a comment
There was a problem hiding this comment.
Great work on this! Really encouraging after looking at the heap snapshots.
Updated changelog for version 15.14.1 with release date and performance fix details.
Removed redundant mention of 'process' in memory leak fix description.
Contributor
|
Released in This comment thread has been locked. If you are still experiencing this issue after upgrading to |
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Additional details
Problem
In
cypress openmode, repeatedly rerunning a spec caused memory to grow monotonically and eventually crash the renderer process withV8 process OOM. The leak got worse with each rerun.Root causes
1.
processuncaughtExceptionlistener accumulation (every rerun)At the end of a Mocha run,
packages/driver/src/cypress/runner.tswas calling_runner.removeAllListeners(). That only clears listeners on the Runner emitter itself — it does not remove theuncaughtExceptionlistener Mocha installs onprocessduring_runner.run()(seemocha/lib/runner.js:1035):self.uncaughtis a bound function whose[[BoundThis]]slot holds the Runner. Becauseprocesspersists 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.
Cypressinstance retention viatoperror listeners (first rerun only)setTopOnErrorinpackages/driver/src/cypress/cy.tsruns its full body only once (becausetop.onerrorisconfigurable: false). TheonTopErrorclosure it creates captured theCypressparameter from that first call. Because thetop.addEventListener('error')andtop.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 toObject.defineProperty(top, 'onerror')shared a V8 closure context withonTopError, creating a second retention path viatop.onerror's[[Get]]slot.Fixes
runner.ts: Call_runner.dispose()instead of_runner.removeAllListeners().dispose()walks Mocha's internal_eventListenersbookkeeping to remove each externally-installed listener from its target — including theuncaughtExceptionlistener onprocess. Also removed a redundant_runner.removeAllListeners()call from theabort()path.cy.ts: Mirror the existingcurCypattern by adding a module-levelcurCypressvariable that is updated on every run. Replace the closed-overCypressparameter inonTopErrorwithcurCypress, so the closure no longer permanently capturesCypress₀. Also move the nooponerrorgetter/setter to module scope so they do not share a V8 closure context withonTopError.Steps to test
cypress open.A unit regression test is included at
packages/driver/test/unit/cypress/runner.spec.tsthat:$Runner.create(...).run(...)path.process.listenerCount('uncaughtException')returns to baseline after a run ends.Run with:
yarn workspace @packages/driver test test/unit/cypress/runner.spec.ts.How has the user experience changed?
cypress openreruns no longer leak memory across rerun cycles.Before (5 runs):

After (6 runs):

PR Tasks
cypress-documentation?type definitions?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 openfrom leaking memory across spec reruns by disposing the underlying MochaRunneron run completion (switching from_runner.removeAllListeners()to_runner.dispose()), ensuring Mocha’sprocess-leveluncaughtExceptionlistener is removed and prior runner state can be garbage collected.Updates
setTopOnErrorincy.tsto stop closures from permanently capturing the firstCypressinstance by using module-levelcurCypress(and module-scoped nooptop.onerroraccessors), so top-frameerror/unhandledrejectionhandlers don’t retain old run data. Adds a new unit spec (runner.spec.ts) assertingdispose()is called andprocesslisteners don’t accumulate, and documents the fix in thecli/CHANGELOG.md.Reviewed by Cursor Bugbot for commit 1d83fa3. Bugbot is set up for automated code reviews on this repo. Configure here.