Skip to content

Commit 282ca22

Browse files
authored
fix: cleanup protocol listeners (#33391)
1 parent fb6de82 commit 282ca22

5 files changed

Lines changed: 133 additions & 7 deletions

File tree

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ _Released 02/17/2026 (PENDING)_
1313

1414
- Fixed an issue where a cancelled or incomplete login attempt would not properly open a browser window or tab, and required a restart of Cypress to enable a new login attempt. Fixed in [#33366](https://github.com/cypress-io/cypress/pull/33366). Fixes [#33350](https://github.com/cypress-io/cypress/issues/33350).
1515
- Fixed an issue on Windows where extracting the Studio or Prompt bundle could fail with `EPERM: operation not permitted` when renaming extracted files. The extract step now retries on EPERM/EACCES with a short delay to handle transient file locks. Addressed in [#33330](https://github.com/cypress-io/cypress/pull/33330).
16+
- The capture protocol is now properly cleaned up when the protocol is re-initialized or when the run closes, ensuring CDP client listeners and resources are removed. Addressed in [#33391](https://github.com/cypress-io/cypress/pull/33391).
1617

1718
**Misc:**
1819

packages/server/lib/cloud/protocol.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export class ProtocolManager implements ProtocolManagerShape {
102102
debug('setting up protocol')
103103

104104
try {
105+
// Cleanup the previous protocol
106+
this.cleanup()
107+
105108
if (!this.AppCaptureProtocol || !this.options) {
106109
throw new Error('Cannot setup protocol without a prepared protocol')
107110
}
@@ -126,11 +129,15 @@ export class ProtocolManager implements ProtocolManagerShape {
126129
}
127130

128131
async connectToBrowser (cdpClient: CDPClient) {
129-
// Wrap the cdp client listeners so that we can be notified of any errors that may occur
132+
// Keyed by event name then by original listener so that the same function
133+
// registered for multiple events doesn't collide and leak wrappers.
134+
const listenerMap = new Map<string, Map<Function, Function>>()
135+
130136
const newCdpClient: CDPClient = {
131137
...cdpClient,
132138
on: (event, listener) => {
133-
cdpClient.on(event, async (message) => {
139+
// Wrap the cdp client listeners so that we can be notified of any errors that may occur
140+
const wrapper = async (message) => {
134141
try {
135142
await listener(message)
136143
} catch (error) {
@@ -141,7 +148,26 @@ export class ProtocolManager implements ProtocolManagerShape {
141148
throw error
142149
}
143150
}
144-
})
151+
}
152+
153+
if (!listenerMap.has(event)) {
154+
listenerMap.set(event, new Map())
155+
}
156+
157+
listenerMap.get(event)!.set(listener, wrapper)
158+
cdpClient.on(event, wrapper)
159+
},
160+
off: (event, listener) => {
161+
const eventListeners = listenerMap.get(event)
162+
const wrapper = eventListeners?.get(listener)
163+
164+
if (wrapper) {
165+
cdpClient.off(event, wrapper as any)
166+
eventListeners!.delete(listener)
167+
if (eventListeners!.size === 0) {
168+
listenerMap.delete(event)
169+
}
170+
}
145171
},
146172
}
147173

@@ -166,7 +192,7 @@ export class ProtocolManager implements ProtocolManagerShape {
166192
this._beforeSpec(spec)
167193
} catch (error) {
168194
// Clear out protocol since we will not have a valid state when spec has failed
169-
this._protocol = undefined
195+
this.cleanup()
170196

171197
if (CAPTURE_ERRORS) {
172198
this.captureError({ captureMethod: 'beforeSpec', fatal: true, error, args: [spec], runnableId: this._runnableId })
@@ -491,6 +517,11 @@ export class ProtocolManager implements ProtocolManagerShape {
491517
this._specName = undefined
492518
this._runId = undefined
493519
this._errors = []
520+
this.cleanup()
521+
}
522+
523+
cleanup (): void {
524+
this.invokeSync('cleanup', { isEssential: false })
494525
this._protocol = undefined
495526
}
496527

packages/server/test/support/fixtures/cloud/protocol/test-protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,5 @@ export class AppCaptureProtocol implements AppCaptureProtocolInterface {
4242

4343
pageLoading (input: any): void {}
4444
resetTest (testId: string): void {}
45+
cleanup (): void {}
4546
}

packages/server/test/unit/cloud/protocol_spec.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import fs from 'fs-extra'
99
import type { SinonStub } from 'sinon'
1010

1111
class TestClient extends EventEmitter {
12-
send: sinon.SinonStub = sinon.stub()
12+
send: SinonStub = sinon.stub()
1313
}
1414

1515
const mockDb = sinon.stub()
@@ -93,6 +93,86 @@ describe('lib/cloud/protocol', () => {
9393
])
9494
})
9595

96+
it('should unregister listener when off() is called on wrapped CDP client', async () => {
97+
const mockCdpClient = new TestClient()
98+
99+
sinon.stub(protocol, 'connectToBrowser').resolves()
100+
101+
await protocolManager.connectToBrowser(mockCdpClient as any)
102+
103+
const newCdpClient = (protocol.connectToBrowser as SinonStub).getCall(0).args[0]
104+
const listener = sinon.stub()
105+
106+
newCdpClient.on('Page.loadEventFired', listener)
107+
mockCdpClient.emit('Page.loadEventFired')
108+
expect(listener).to.have.been.calledOnce
109+
110+
newCdpClient.off('Page.loadEventFired', listener)
111+
mockCdpClient.emit('Page.loadEventFired')
112+
expect(listener).to.have.been.calledOnce
113+
})
114+
115+
it('uses event+listener composite key so same listener on multiple events does not leak wrappers', async () => {
116+
const mockCdpClient = new TestClient()
117+
const onCalls: Array<{ event: string, listener: Function }> = []
118+
const offCalls: Array<{ event: string, listener: Function }> = []
119+
120+
const originalOn = mockCdpClient.on.bind(mockCdpClient)
121+
const originalOff = mockCdpClient.off.bind(mockCdpClient)
122+
123+
mockCdpClient.on = function (event: string, listener: Function) {
124+
onCalls.push({ event, listener })
125+
126+
return originalOn(event, listener)
127+
}
128+
129+
mockCdpClient.off = function (event: string, listener: Function) {
130+
offCalls.push({ event, listener })
131+
132+
return originalOff(event, listener)
133+
}
134+
135+
let capturedWrappedClient: any
136+
137+
sinon.stub(protocolManager as any, 'invokeAsync').callsFake(async (_method: string, _opts: any, cdpClient: any) => {
138+
capturedWrappedClient = cdpClient
139+
})
140+
141+
await protocolManager.connectToBrowser(mockCdpClient as any)
142+
143+
expect(capturedWrappedClient).to.exist
144+
145+
const sharedListener = sinon.stub()
146+
147+
capturedWrappedClient.on('Page.frameAttached', sharedListener)
148+
capturedWrappedClient.on('Page.frameDetached', sharedListener)
149+
150+
expect(onCalls).to.have.length(2)
151+
152+
const wrapperForAttached = onCalls.find((c) => c.event === 'Page.frameAttached')!.listener
153+
const wrapperForDetached = onCalls.find((c) => c.event === 'Page.frameDetached')!.listener
154+
155+
expect(wrapperForAttached).to.not.equal(wrapperForDetached)
156+
157+
capturedWrappedClient.off('Page.frameAttached', sharedListener)
158+
expect(offCalls).to.have.length(1)
159+
expect(offCalls[0].event).to.equal('Page.frameAttached')
160+
expect(offCalls[0].listener).to.equal(wrapperForAttached)
161+
162+
capturedWrappedClient.off('Page.frameDetached', sharedListener)
163+
expect(offCalls).to.have.length(2)
164+
expect(offCalls[1].event).to.equal('Page.frameDetached')
165+
expect(offCalls[1].listener).to.equal(wrapperForDetached)
166+
})
167+
168+
it('should call cleanup on existing protocol when setupProtocol is called again', () => {
169+
const cleanupStub = sinon.stub(protocol, 'cleanup')
170+
171+
protocolManager.setupProtocol()
172+
173+
expect(cleanupStub).to.have.been.calledOnce
174+
})
175+
96176
it('should be able to initialize a new spec', () => {
97177
sinon.stub(protocol, 'beforeSpec')
98178

@@ -364,6 +444,20 @@ describe('lib/cloud/protocol', () => {
364444
expect(protocolManager['_errors']).to.be.empty
365445
expect(protocolManager['_protocol']).to.be.undefined
366446
})
447+
448+
it('calls cleanup on protocol before clearing it', () => {
449+
const cleanupStub = sinon.stub(protocol, 'cleanup')
450+
451+
protocolManager['_db'] = { close: sinon.stub() }
452+
protocolManager['_dbPath'] = '/path/to/db'
453+
protocolManager['_archivePath'] = '/path/to/archive'
454+
sinon.stub(fs, 'unlink').resolves()
455+
456+
protocolManager.close()
457+
458+
expect(cleanupStub).to.have.been.calledOnce
459+
expect(protocolManager['_protocol']).to.be.undefined
460+
})
367461
})
368462

369463
describe('.dbPath', () => {

packages/types/src/protocol.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ export interface CDPClient {
1717
off (eventName: string, cb: (event: any) => void): void
1818
}
1919

20-
// TODO(protocol): This is basic for now but will evolve as we progress with the protocol work
21-
2220
export interface AppCaptureProtocolCommon {
2321
cdpReconnect (): Promise<void>
2422
addRunnables (runnables: any): void
@@ -42,6 +40,7 @@ export interface AppCaptureProtocolInterface extends AppCaptureProtocolCommon {
4240
beforeSpec ({ spec, workingDirectory, archivePath, dbPath, db }: { spec: FoundSpec & { instanceId: string }, workingDirectory: string, archivePath: string, dbPath: string, db: Database.Database }): void
4341
uploadStallSamplingInterval: () => number
4442
connectToBrowser (cdpClient: CDPClient): Promise<void>
43+
cleanup (): void
4544
}
4645

4746
export type ProtocolCaptureMethod = keyof AppCaptureProtocolInterface | 'setupProtocol' | 'prepareProtocol' | 'uploadCaptureArtifact' | 'getCaptureProtocolScript' | 'cdpClient.on' | 'getZippedDb' | 'UNKNOWN' | 'createProtocolArtifact' | 'protocolUploadUrl'

0 commit comments

Comments
 (0)