-
Notifications
You must be signed in to change notification settings - Fork 3.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
perf: cri client memory leak #29988
Merged
Merged
perf: cri client memory leak #29988
Changes from 25 commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
211ff8d
Refactor connection logic to cri-connection
cacieprins 87fd911
Merge branch 'develop' into perf/cri-client-memory-leak
cacieprins ab3aace
changelog
cacieprins bb85733
clean up event listeners before reconnecting
cacieprins 326fcdc
changelog
cacieprins 2cf27b4
changelog
cacieprins f441d36
changelog
cacieprins cf81eb9
unit tests
cacieprins 254c18e
lift out of the remote-interface dir
cacieprins 9726a97
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 17f5bcd
complete lift from /remote-interface
cacieprins 08428b1
further fix imports
cacieprins 44b888f
import
cacieprins 57d6c9e
fix disconnect behavior to make integration test pass
cacieprins 1175e47
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 66af7ed
Update packages/server/test/integration/cdp_spec.ts comment
cacieprins 284a210
fix snapgen
cacieprins df44433
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 3d537cc
improve debug
cacieprins 750ca37
further debug improvements
cacieprins e998290
do not attach crash handlers on browser level cri clients, add tests
cacieprins 9fbebdd
adds bindingCalled listeners discretely, rather than relying on 'even…
cacieprins 4855120
back out of main `event` listener, use discrete listeners, add integr…
cacieprins a5c0fc4
Revert "back out of main `event` listener, use discrete listeners, ad…
cacieprins 08ef221
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 3241976
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 4f61f3c
changelog
cacieprins 0295d35
adds integration test for various ways to add cdp event listeners
cacieprins c80aec8
note in changelog this is a cypress server fix
cacieprins ba3cc8b
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 1fb6a52
ensure enablement promises resolve after reconnect
cacieprins f10fd85
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins 6b82a7c
Update cli/CHANGELOG.md
cacieprins f056b90
Merge branch 'develop' into cacie/perf/cri-client-memory-leak
cacieprins File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains 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
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,256 @@ | ||
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping' | ||
import Debug from 'debug' | ||
import EventEmitter from 'events' | ||
import CDP from 'chrome-remote-interface' | ||
import type { CypressError } from '@packages/errors' | ||
import { debugCdpConnection, DebuggableCDPClient } from './debug-cdp-connection' | ||
import type { CdpEvent, CdpCommand } from './cdp_automation' | ||
import { CDPDisconnectedError, CDPTerminatedError, CDPAlreadyConnectedError } from './cri-errors' | ||
import { asyncRetry } from '../util/async_retry' | ||
import * as errors from '../errors' | ||
import type WebSocket from 'ws' | ||
|
||
const verboseDebugNs = 'cypress-verbose:server:browsers:cdp-connection' | ||
|
||
export type CDPListener<T extends keyof ProtocolMapping.Events> = (params: ProtocolMapping.Events[T][0], sessionId?: string) => void | ||
|
||
// CDPClient extends EventEmitter, but does not export that type information from its | ||
// definitelytyped module | ||
export type CdpClient = Exclude<EventEmitter, CDP.Client> & CDP.Client | ||
|
||
/** | ||
* There are three error messages we can encounter which should not be re-thrown, but | ||
* should trigger a reconnection attempt if one is not in progress, and enqueue the | ||
* command that errored. This regex is used in client.send to check for: | ||
* - WebSocket connection closed | ||
* - WebSocket not open | ||
* - WebSocket already in CLOSING or CLOSED state | ||
*/ | ||
const isWebsocketClosedErrorMessage = (message: string) => { | ||
return /^WebSocket (?:connection closed|is (?:not open|already in CLOSING or CLOSED state))/.test(message) | ||
} | ||
|
||
export type CDPConnectionOptions = { | ||
automaticallyReconnect: boolean | ||
} | ||
|
||
type CDPConnectionEventListeners = { | ||
'cdp-connection-reconnect-error': (err: CypressError) => void | ||
'cdp-connection-reconnect': () => void | ||
'cdp-connection-closed': () => void | ||
'cdp-connection-reconnect-attempt': (attemptNumber: number) => void | ||
} | ||
export type CDPConnectionEvent = keyof CDPConnectionEventListeners | ||
|
||
type CDPConnectionEventListener<T extends CDPConnectionEvent> = CDPConnectionEventListeners[T] | ||
|
||
export class CDPConnection { | ||
private _emitter: EventEmitter = new EventEmitter() | ||
private _connection: CdpClient | undefined | ||
private _autoReconnect: boolean | ||
private _terminated: boolean = false | ||
private _reconnection: Promise<void> | undefined | ||
private debug: Debug.Debugger | ||
private verboseDebug: Debug.Debugger | ||
private _bindingCalledListeners: { event: string, callback: CDPListener<any>}[] = [] | ||
|
||
constructor (private readonly _options: CDP.Options, connectionOptions: CDPConnectionOptions) { | ||
this._autoReconnect = connectionOptions.automaticallyReconnect | ||
this.debug = Debug(`cypress:server:browsers:cdp-connection:${_options.target}`) | ||
this.verboseDebug = Debug(`${verboseDebugNs}:${_options.target}`) | ||
} | ||
|
||
get terminated () { | ||
return this._terminated | ||
} | ||
|
||
get ws () { | ||
// this is reached into by browser-cri-client to detect close events - needs rethinking | ||
return (this._connection as { _ws?: WebSocket})._ws | ||
} | ||
|
||
on<T extends CdpEvent> (event: T, callback: CDPListener<T>) { | ||
this.debug('attaching event listener to cdp connection', event) | ||
|
||
if (event.startsWith('Runtime.bindingCalled')) { | ||
// these events do not get emitted by cdp clients primary 'event' hook, and must be | ||
// registered separately | ||
this._connection?.on(event, callback) | ||
this._bindingCalledListeners.push({ event, callback }) | ||
} else { | ||
this._emitter.on(event, callback) | ||
} | ||
} | ||
addConnectionEventListener<T extends CDPConnectionEvent> (event: T, callback: CDPConnectionEventListener<T>) { | ||
this.debug('adding connection event listener for ', event) | ||
this._emitter.on(event, callback) | ||
} | ||
off<T extends CdpEvent> (event: T, callback: CDPListener<T>) { | ||
if (event.startsWith('Runtime.bindingCalled')) { | ||
this._connection?.off(event, callback) | ||
this._bindingCalledListeners = this._bindingCalledListeners.filter((pair) => { | ||
return pair.callback !== callback && pair.event !== event | ||
}) | ||
} else { | ||
this._emitter.off(event, callback) | ||
} | ||
} | ||
removeConnectionEventListener<T extends CDPConnectionEvent> (event: T, callback: CDPConnectionEventListener<T>) { | ||
this._emitter.off(event, callback) | ||
} | ||
|
||
async connect (): Promise<void> { | ||
if (this._terminated) { | ||
throw new CDPTerminatedError(`Cannot connect to CDP. Client target ${this._options.target} has been terminated.`) | ||
} | ||
|
||
if (this._connection) { | ||
throw new CDPAlreadyConnectedError(`Cannot connect to CDP. Client target ${this._options.target} is already connected. Did you disconnect first?`) | ||
} | ||
|
||
this._connection = await CDP(this._options) as CdpClient | ||
|
||
debugCdpConnection(this.verboseDebug.namespace, this._connection as DebuggableCDPClient) | ||
|
||
this._connection.on('event', this._broadcastEvent) | ||
|
||
if (this._autoReconnect) { | ||
this._connection.on('disconnect', this._reconnect) | ||
} | ||
} | ||
|
||
async disconnect () { | ||
this.debug('disconnect of target %s requested.', this._options.target, { terminated: this._terminated, connection: !!this._connection, reconnection: !!this._reconnection }) | ||
if (this._terminated && !this._connection) { | ||
return | ||
} | ||
|
||
this._terminated = true | ||
|
||
if (this._connection) { | ||
await this._gracefullyDisconnect() | ||
this._emitter.emit('cdp-connection-closed') | ||
} | ||
} | ||
|
||
private _gracefullyDisconnect = async () => { | ||
this._connection?.off('event', this._broadcastEvent) | ||
this._connection?.off('disconnect', this._reconnect) | ||
while (this._bindingCalledListeners.length) { | ||
const pair = this._bindingCalledListeners.pop() | ||
|
||
if (pair) { | ||
this._connection?.off(pair.event, pair.callback) | ||
} | ||
} | ||
|
||
await this._connection?.close() | ||
this._connection = undefined | ||
} | ||
|
||
async send<T extends CdpCommand> ( | ||
command: T, | ||
data?: ProtocolMapping.Commands[T]['paramsType'][0], | ||
sessionId?: string, | ||
): Promise<ProtocolMapping.Commands[T]['returnType']> { | ||
if (this.terminated) { | ||
throw new CDPDisconnectedError(`${command} will not run as the CRI connection to Target ${this._options.target} has been terminated.`) | ||
} | ||
|
||
if (!this._connection) { | ||
throw new CDPDisconnectedError(`${command} will not run as the CRI connection to Target ${this._options.target} has not been established.`) | ||
} | ||
|
||
try { | ||
return await this._connection.send(command, data, sessionId) | ||
} catch (e) { | ||
// Clients may wish to determine if the command should be enqueued | ||
// should enqueue logic live in this class tho?? | ||
if (isWebsocketClosedErrorMessage(e.message)) { | ||
throw new CDPDisconnectedError(`${command} failed due to the websocket being disconnected.`, e) | ||
} | ||
|
||
throw e | ||
} | ||
} | ||
|
||
private _reconnect = async () => { | ||
this.debug('Reconnection requested') | ||
if (this._terminated) { | ||
return | ||
} | ||
|
||
if (this._reconnection) { | ||
return this._reconnection | ||
} | ||
|
||
if (this._connection) { | ||
try { | ||
await this._gracefullyDisconnect() | ||
} catch (e) { | ||
this.debug('Error cleaning up existing CDP connection before creating a new connection: ', e) | ||
} finally { | ||
this._connection = undefined | ||
} | ||
} | ||
|
||
let attempt = 0 | ||
|
||
this._reconnection = asyncRetry(async () => { | ||
attempt++ | ||
|
||
this.debug('Reconnection attempt %d for Target %s', attempt, this._options.target) | ||
|
||
if (this._terminated) { | ||
this.debug('Not reconnecting, connection to %s has been terminated', this._options.target) | ||
throw new CDPTerminatedError(`Cannot reconnect to CDP. Client target ${this._options.target} has been terminated.`) | ||
} | ||
|
||
this._emitter.emit('cdp-connection-reconnect-attempt', attempt) | ||
|
||
await this.connect() | ||
}, { | ||
maxAttempts: 20, | ||
retryDelay: () => { | ||
return 100 | ||
}, | ||
shouldRetry (err) { | ||
return !(err && CDPTerminatedError.isCDPTerminatedError(err)) | ||
}, | ||
})() | ||
|
||
try { | ||
await this._reconnection | ||
this._emitter.emit('cdp-connection-reconnect') | ||
} catch (err) { | ||
this.debug('error(s) on reconnecting: ', err) | ||
const significantError: Error = err.errors ? (err as AggregateError).errors[err.errors.length - 1] : err | ||
|
||
const retryHaltedDueToClosed = CDPTerminatedError.isCDPTerminatedError(err) || | ||
(err as AggregateError)?.errors?.find((predicate) => CDPTerminatedError.isCDPTerminatedError(predicate)) | ||
|
||
// if .disconnect() was called while trying to reconnect, there will be no active connection | ||
// so the .disconnect() method will not emit the connection closed event. However, we do | ||
// want to emit that once the reconnection attempts cease due to being closed. | ||
if (retryHaltedDueToClosed) { | ||
this._emitter.emit('cdp-connection-closed') | ||
} else { | ||
const cdpError = errors.get('CDP_COULD_NOT_RECONNECT', significantError) | ||
|
||
cdpError.isFatalApiErr = true | ||
this._emitter.emit('cdp-connection-reconnect-error', cdpError) | ||
} | ||
} | ||
|
||
this._reconnection = undefined | ||
} | ||
|
||
private _broadcastEvent = ({ method, params, sessionId }: { method: CdpEvent, params: Record<string, any>, sessionId?: string }) => { | ||
this.verboseDebug('rebroadcasting event', method, params, sessionId) | ||
if (method === 'Target.targetCrashed') { | ||
this.debug('broadcasting crash event') | ||
} | ||
|
||
this._emitter.emit(method, params, sessionId) | ||
} | ||
} |
This file contains 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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to call out that this is a server side leak? Maybe to help avoid confusion with some of the browser-side ones we've got going on.