Skip to content

Commit 593c22e

Browse files
authored
perf: dispose mocha runner on run completion to prevent memory leak on rerun (#33631)
1 parent ddc05e5 commit 593c22e

4 files changed

Lines changed: 173 additions & 15 deletions

File tree

cli/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<!-- See ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
2-
32
## 15.14.1
43

5-
_Released 04/30/2026 (PENDING)_
4+
**Performance:**
5+
6+
- Fixed a memory leak in `cypress open` where each spec rerun accumulated an additional `uncaughtException` listener, preventing the previous Mocha runner — and all the objects it retained (commands, snapshots, logs) — from being garbage collected. Fixed in [#33631](https://github.com/cypress-io/cypress/pull/33631).
67

78
**Bugfixes:**
89

packages/driver/src/cypress/cy.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,27 @@ function __stackReplacementMarker (fn, ctx, args) {
6060

6161
declare let top: WindowProxy & { __alreadySetErrorHandlers__: boolean }
6262

63+
const _noopOnerrorGet = function () {}
64+
const _noopOnerrorSet = function () {}
65+
6366
// We only set top.onerror once since we make it configurable:false
64-
// but we update cy instance every run (page reload or rerun button)
67+
// but we update cy and Cypress instances every run (page reload or rerun button).
68+
// Both are module-level so that onTopError does NOT close over the Cypress
69+
// parameter from the first setTopOnError call — which would permanently retain
70+
// that Cypress instance (and everything it holds) via the top.addEventListener
71+
// event listener, preventing GC after reruns.
6572
let curCy: $Cy | null = null
73+
let curCypress: ICypress | null = null
6674
const setTopOnError = function (Cypress, cy: $Cy) {
6775
if (curCy) {
6876
curCy = cy
77+
curCypress = Cypress
6978

7079
return
7180
}
7281

7382
curCy = cy
83+
curCypress = Cypress
7484

7585
try {
7686
// prevent overriding top.onerror twice when loading more than one
@@ -89,7 +99,7 @@ const setTopOnError = function (Cypress, cy: $Cy) {
8999
// in some callbacks like for cy.intercept, we catch the errors and then
90100
// rethrow them, causing them to get caught by the top frame
91101
// but they came from the spec, so we need to differentiate them
92-
const isSpecError = $errUtils.isSpecError(Cypress.config('spec'), err)
102+
const isSpecError = $errUtils.isSpecError(curCypress!.config('spec'), err)
93103

94104
const handled = curCy!.onUncaughtException({
95105
err,
@@ -100,7 +110,7 @@ const setTopOnError = function (Cypress, cy: $Cy) {
100110

101111
debugErrors('uncaught top error: %o', originalErr)
102112

103-
$errUtils.logError(Cypress, handlerType, originalErr, handled)
113+
$errUtils.logError(curCypress!, handlerType, originalErr, handled)
104114

105115
// return undefined so the browser does its default
106116
// uncaught exception behavior (logging to console)
@@ -111,8 +121,8 @@ const setTopOnError = function (Cypress, cy: $Cy) {
111121

112122
// prevent Mocha from setting top.onerror
113123
Object.defineProperty(top, 'onerror', {
114-
set () { },
115-
get () { },
124+
set: _noopOnerrorSet,
125+
get: _noopOnerrorGet,
116126
configurable: false,
117127
enumerable: true,
118128
})

packages/driver/src/cypress/runner.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,10 +1414,6 @@ export default {
14141414
// and mocha may never fire this because our
14151415
// runnable may never finish
14161416
_runner.emit('end')
1417-
1418-
// remove all the listeners
1419-
// so no more events fire
1420-
_runner.removeAllListeners()
14211417
}
14221418

14231419
overrideRunnerHook(Cypress, _runner, getTestById, getTest, setTest, getTests, cy, abort)
@@ -1635,10 +1631,13 @@ export default {
16351631
// the run, then just set _runner.stopped to true here
16361632
_runner.stopped = true
16371633

1638-
// remove all the listeners
1639-
// so no more events fire
1640-
// since a test failure may 'leak' after a run completes
1641-
_runner.removeAllListeners()
1634+
// Dispose the mocha runner to remove all listeners so no additional events
1635+
// fire — a test failure may 'leak' after a run completes. This also ensures
1636+
// the Runner itself is properly released; otherwise each rerun retains the
1637+
// previous Runner (and everything it holds — Cypress, window, commands,
1638+
// snapshots, logs, etc.).
1639+
// @see https://github.com/cypress-io/cypress/pull/33631
1640+
_runner.dispose()
16421641

16431642
// TODO this functions is not correctly
16441643
// synchronized with the 'end' event that
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, it, expect, vi, afterEach } from 'vitest'
5+
import * as mocha from 'mocha'
6+
7+
import $Runner from '../../../src/cypress/runner'
8+
9+
// Match the import shape used by @packages/driver's cypress/mocha.ts
10+
// so we exercise the same Mocha constructor the driver consumes.
11+
const Mocha = (mocha as any).Mocha != null ? (mocha as any).Mocha : mocha
12+
const { Runner, Suite } = Mocha
13+
14+
describe('@packages/driver/src/cypress/runner', () => {
15+
const createdRealRunners: any[] = []
16+
17+
afterEach(() => {
18+
// Dispose any real Mocha runners created during a test so their `process`
19+
// listeners don't bleed into subsequent tests.
20+
while (createdRealRunners.length) {
21+
try {
22+
createdRealRunners.pop().dispose()
23+
} catch { /* noop */ }
24+
}
25+
})
26+
27+
// Minimal stubs for the arguments $Runner.create() expects. Each helper
28+
// returns just enough surface area for the factory to construct without
29+
// throwing; individual tests can override fields as needed.
30+
const makeCypressStub = () => {
31+
return {
32+
testingType: 'component',
33+
action: vi.fn(),
34+
emit: vi.fn(),
35+
emitThen: vi.fn(),
36+
config: vi.fn(() => false),
37+
env: vi.fn(() => undefined),
38+
state: vi.fn(),
39+
log: vi.fn(),
40+
isBrowser: vi.fn(() => false),
41+
browser: { family: 'chromium' },
42+
backend: vi.fn(),
43+
stop: vi.fn(),
44+
}
45+
}
46+
47+
const makeCyStub = () => {
48+
return {
49+
state: vi.fn(),
50+
onUncaughtException: vi.fn(),
51+
currentTest: null,
52+
stop: vi.fn(),
53+
}
54+
}
55+
56+
const makeStateStub = () => vi.fn()
57+
58+
const makeSpecWindow = () => ({ addEventListener: vi.fn() }) as unknown as Window
59+
60+
// Builds the `mocha` wrapper argument $Runner.create() expects, backed by
61+
// a real Mocha Runner so tests can observe real Mocha behavior.
62+
const makeMochaWrapper = () => {
63+
const suite = new Suite('root', {} as any)
64+
const runner = new Runner(suite)
65+
66+
createdRealRunners.push(runner)
67+
68+
return {
69+
wrapper: {
70+
getRunner: () => runner,
71+
getRootSuite: () => suite,
72+
},
73+
runner,
74+
suite,
75+
}
76+
}
77+
78+
it('calls dispose() on the underlying mocha runner when the run completes', () => {
79+
const { wrapper, runner } = makeMochaWrapper()
80+
const disposeSpy = vi.spyOn(runner, 'dispose')
81+
82+
const api = $Runner.create(
83+
makeSpecWindow(),
84+
wrapper,
85+
makeCypressStub(),
86+
makeCyStub(),
87+
makeStateStub(),
88+
)
89+
90+
api.run(() => {})
91+
92+
// Simulate mocha finishing the run by firing EVENT_RUN_END.
93+
// The callback registered by $Runner.run is what invokes dispose().
94+
runner.emit('end')
95+
96+
expect(disposeSpy).toHaveBeenCalledTimes(1)
97+
})
98+
99+
it('removes the uncaughtException listener from `process` after a run completes', () => {
100+
const { wrapper, runner } = makeMochaWrapper()
101+
const baseline = process.listenerCount('uncaughtException')
102+
103+
const api = $Runner.create(
104+
makeSpecWindow(),
105+
wrapper,
106+
makeCypressStub(),
107+
makeCyStub(),
108+
makeStateStub(),
109+
)
110+
111+
// api.run → _runner.run(cb): mocha synchronously adds the
112+
// `uncaughtException` listener on `process` (mocha's runner.js) and
113+
// registers the EVENT_RUN_END handler that will invoke cb.
114+
api.run(() => {})
115+
116+
expect(process.listenerCount('uncaughtException')).toBe(baseline + 1)
117+
118+
// Simulate run completion: fires EVENT_RUN_END, which invokes our
119+
// callback, which calls _runner.dispose(), which removes the listener.
120+
runner.emit('end')
121+
122+
expect(process.listenerCount('uncaughtException')).toBe(baseline)
123+
})
124+
125+
it('does not accumulate process listeners across multiple run/end cycles', () => {
126+
// Simulates the Cypress rerun lifecycle: each "rerun" creates a new
127+
// $Runner, calls api.run(), then ends. After each cycle, the process
128+
// listener count should return to baseline.
129+
const baseline = process.listenerCount('uncaughtException')
130+
131+
for (let i = 0; i < 5; i++) {
132+
const { wrapper, runner } = makeMochaWrapper()
133+
134+
const api = $Runner.create(
135+
makeSpecWindow(),
136+
wrapper,
137+
makeCypressStub(),
138+
makeCyStub(),
139+
makeStateStub(),
140+
)
141+
142+
api.run(() => {})
143+
runner.emit('end')
144+
}
145+
146+
expect(process.listenerCount('uncaughtException')).toBe(baseline)
147+
})
148+
})

0 commit comments

Comments
 (0)