Reconnect to CDP on WebSocket failure (#6532)

* wip: reconnect to CDP automatically

i think we should not do this, see:
https://github.com/cypress-io/cypress/issues/5685\#issuecomment-589732584

* reconnect to CDP automatically

* cleanup

* fix unit tests

* update snapshot

* replace automation client disconnected line
This commit is contained in:
Zach Bloomquist
2020-02-28 15:28:48 -05:00
committed by GitHub
parent 778321786f
commit 483d494557
10 changed files with 322 additions and 28 deletions

View File

@@ -43,3 +43,66 @@ Error: connect ECONNREFUSED 127.0.0.1:7777
`
exports['e2e cdp / handles disconnections as expected'] = `
====================================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 1.2.3 │
│ Browser: FooBrowser 88 │
│ Specs: 1 found (spec.ts) │
│ Searched: cypress/integration/spec.ts │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: spec.ts (1 of 1)
e2e remote debugging disconnect
✓ reconnects as expected
Error: There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.
connect ECONNREFUSED 127.0.0.1:7777
[stack trace lines]
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 0 │
│ Passing: 0 │
│ Failing: 1 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: true │
│ Duration: X seconds │
│ Spec Ran: spec.ts │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
(Video)
- Started processing: Compressing to 32 CRF
- Finished processing: /XXX/XXX/XXX/cypress/videos/spec.ts.mp4 (X second)
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✖ spec.ts XX:XX - - 1 - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✖ 1 of 1 failed (100%) XX:XX - - 1 - -
`

View File

@@ -240,7 +240,7 @@ const _disableRestorePagesPrompt = function (userDir) {
// After the browser has been opened, we can connect to
// its remote interface via a websocket.
const _connectToChromeRemoteInterface = function (port) {
const _connectToChromeRemoteInterface = function (port, onError) {
// @ts-ignore
la(check.userPort(port), 'expected port number to connect CRI to', port)
@@ -250,7 +250,7 @@ const _connectToChromeRemoteInterface = function (port) {
.then((wsUrl) => {
debug('received wsUrl %s for port %d', wsUrl, port)
return CriClient.create(wsUrl)
return CriClient.create(wsUrl, onError)
})
}
@@ -452,7 +452,7 @@ export = {
// SECOND connect to the Chrome remote interface
// and when the connection is ready
// navigate to the actual url
const criClient = await this._connectToChromeRemoteInterface(port)
const criClient = await this._connectToChromeRemoteInterface(port, options.onError)
la(criClient, 'expected Chrome remote interface reference', criClient)

View File

@@ -5,12 +5,14 @@ import _ from 'lodash'
const chromeRemoteInterface = require('chrome-remote-interface')
const errors = require('../errors')
const debugVerbose = debugModule('cypress-verbose:server:browsers:cri-client')
const debug = debugModule('cypress:server:browsers:cri-client')
// debug using cypress-verbose:server:browsers:cri-client:send:*
const debugVerboseSend = debugModule('cypress-verbose:server:browsers:cri-client:send:[-->]')
// debug using cypress-verbose:server:browsers:cri-client:recv:*
const debugVerboseReceive = debugModule('cypress-verbose:server:browsers:cri-client:recv:[<--]')
const WEBSOCKET_NOT_OPEN_RE = /^WebSocket is not open/
/**
* Url returned by the Chrome Remote Interface
*/
@@ -28,9 +30,8 @@ namespace CRI {
'Page.navigate' |
'Page.startScreencast'
export enum EventNames {
export type EventName =
'Page.screencastFrame'
}
}
/**
@@ -41,7 +42,7 @@ interface CRIWrapper {
/**
* Get the `protocolVersion` supported by the browser.
*/
getProtocolVersion (): Bluebird<string>
getProtocolVersion (): Bluebird<Version>
/**
* Rejects if `protocolVersion` is less than the current version.
* @param protocolVersion CDP version string (ex: 1.3)
@@ -56,7 +57,7 @@ interface CRIWrapper {
* Registers callback for particular event.
* @see https://github.com/cyrus-and/chrome-remote-interface#class-cdp
*/
on (eventName: CRI.EventNames, cb: Function): void
on (eventName: CRI.EventName, cb: Function): void
/**
* Calls underlying remote interface client close
*/
@@ -128,17 +129,73 @@ const maybeDebugCdpMessages = (cri) => {
*/
export { chromeRemoteInterface }
export const create = Bluebird.method((debuggerUrl: websocketUrl): Bluebird<CRIWrapper> => {
return chromeRemoteInterface({
target: debuggerUrl,
local: true,
})
.then((cri) => {
maybeDebugCdpMessages(cri)
type DeferredPromise = { resolve: Function, reject: Function }
cri.send = Bluebird.promisify(cri.send, { context: cri })
cri.close = Bluebird.promisify(cri.close, { context: cri })
export const create = Bluebird.method((target: websocketUrl, onAsynchronousError: Function): Bluebird<CRIWrapper> => {
const subscriptions: {eventName: CRI.EventName, cb: Function}[] = []
let enqueuedCommands: {command: CRI.Command, params: any, p: DeferredPromise }[] = []
let closed = false // has the user called .close on this?
let connected = false // is this currently connected to CDP?
let cri
let client: CRIWrapper
const reconnect = () => {
debug('disconnected, attempting to reconnect... %o', { closed })
connected = false
if (closed) {
return
}
return connect()
.then(() => {
debug('restoring subscriptions + running queued commands... %o', { subscriptions, enqueuedCommands })
subscriptions.forEach((sub) => {
cri.on(sub.eventName, sub.cb)
})
enqueuedCommands.forEach((cmd) => {
cri.send(cmd.command, cmd.params)
.then(cmd.p.resolve, cmd.p.reject)
})
enqueuedCommands = []
})
.catch((err) => {
const err2 = new Error(`There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.\n\n${err.message}`)
onAsynchronousError(err2)
})
}
const connect = () => {
cri?.close()
debug('connecting %o', { target })
return chromeRemoteInterface({
target,
local: true,
})
.then((newCri) => {
cri = newCri
connected = true
maybeDebugCdpMessages(cri)
cri.send = Bluebird.promisify(cri.send, { context: cri })
cri.close = Bluebird.promisify(cri.close, { context: cri })
// @see https://github.com/cyrus-and/chrome-remote-interface/issues/72
cri._notifier.on('disconnect', reconnect)
})
}
return connect()
.then(() => {
const ensureMinimumProtocolVersion = (protocolVersion: string) => {
return getProtocolVersion()
.then((actual) => {
@@ -151,7 +208,7 @@ export const create = Bluebird.method((debuggerUrl: websocketUrl): Bluebird<CRIW
}
const getProtocolVersion = _.memoize(() => {
return cri.send('Browser.getVersion')
return client.send('Browser.getVersion')
// could be any version <= 1.2
.catchReturn({ protocolVersion: '0.0' })
.then(({ protocolVersion }) => {
@@ -159,22 +216,44 @@ export const create = Bluebird.method((debuggerUrl: websocketUrl): Bluebird<CRIW
})
})
/**
* Wrapper around Chrome remote interface client
* that logs every command sent.
*/
const client: CRIWrapper = {
client = {
ensureMinimumProtocolVersion,
getProtocolVersion,
send: Bluebird.method((command: CRI.Command, params?: object) => {
return cri.send(command, params)
const enqueue = () => {
return new Bluebird((resolve, reject) => {
enqueuedCommands.push({ command, params, p: { resolve, reject } })
})
}
if (connected) {
return cri.send(command, params)
.catch((err) => {
if (!WEBSOCKET_NOT_OPEN_RE.test(err.message)) {
throw err
}
debug('encountered closed websocket on send %o', { command, params, err })
const p = enqueue()
reconnect()
return p
})
}
return enqueue()
}),
on (eventName: CRI.EventNames, cb: Function) {
debugVerbose('registering CDP on event %o', { eventName })
on (eventName: CRI.EventName, cb: Function) {
subscriptions.push({ eventName, cb })
debug('registering CDP on event %o', { eventName })
return cri.on(eventName, cb)
},
close () {
closed = true
return cri.close()
},
}

View File

@@ -118,6 +118,8 @@ const moduleFactory = () => {
}
}
options.onError = openProject.options.onError
relaunchBrowser = () => {
debug(
'launching browser: %o, spec: %s',

View File

@@ -24,4 +24,17 @@ describe('e2e cdp', function () {
expectedExitCode: 1,
snapshot: true,
})
// https://github.com/cypress-io/cypress/issues/5685
e2e.it('handles disconnections as expected', {
project: Fixtures.projectPath('remote-debugging-disconnect'),
spec: 'spec.ts',
browser: 'chrome',
expectedExitCode: 1,
snapshot: true,
onStdout: (stdout) => {
// the location of this warning is non-deterministic
return stdout.replace('The automation client disconnected. Cannot continue running tests.\n', '')
},
})
})

View File

@@ -0,0 +1,41 @@
describe('e2e remote debugging disconnect', () => {
it('reconnects as expected', () => {
// 1 probing connection and 1 real connection should have been made during startup
cy.task('get:stats')
.should('include', {
totalConnectionCount: 2,
currentConnectionCount: 1,
})
// now, kill all CDP sockets
cy.task('kill:active:connections')
// this will attempt to run a CDP command, realize the socket is dead, enqueue it,
// and start the reconnection process
cy.wrap(Cypress)
// @ts-ignore
.invoke('automation', 'remote:debugger:protocol', {
command: 'Browser.getVersion',
})
.should('have.keys', ['protocolVersion', 'product', 'revision', 'userAgent', 'jsVersion'])
// evidence of a reconnection:
cy.task('get:stats')
.should('include', {
totalConnectionCount: 3,
currentConnectionCount: 1,
})
})
it('errors if CDP connection cannot be reestablished', () => {
cy.task('destroy:server')
cy.task('kill:active:connections')
// this will cause a project-level error once we realize we can't talk to CDP anymore
cy.wrap(Cypress)
// @ts-ignore
.invoke('automation', 'remote:debugger:protocol', {
command: 'Browser.getVersion',
})
})
})

View File

@@ -0,0 +1,90 @@
/* eslint-disable no-console */
const la = require('lazy-ass')
const net = require('net')
const realPort = process.env.CYPRESS_REMOTE_DEBUGGING_PORT
const fakePort = 17171
let currentConnectionCount = 0
let totalConnectionCount = 0
let server
// this is a transparent TCP proxy for Chrome's debugging port
// it can kill all existing connections or shut the port down independently of Chrome or Cypress
const startTcpProxy = () => {
return new Promise((resolve, reject) => {
server = net.createServer((socket) => {
const { remotePort } = socket
console.error('received connection from ', { remotePort })
const upstreamSocket = net.connect(fakePort, () => {
console.error('hooked to upstream', { remotePort })
totalConnectionCount++
currentConnectionCount++
server.on('kill-active-connections', () => {
console.error('destroying', { remotePort })
socket.destroy()
})
socket.on('close', () => {
currentConnectionCount--
})
socket.pipe(upstreamSocket)
upstreamSocket.pipe(socket)
})
upstreamSocket.on('error', (err) => {
console.error('error on upstream', { remotePort })
socket.destroy()
})
})
server.listen(realPort, resolve)
server.on('error', reject)
})
}
module.exports = (on) => {
on('before:browser:launch', (browser = {}, options) => {
la(browser.family === 'chromium', 'this test can only be run with a chromium-family browser')
// set debugging port to go through our lil TCP proxy
const newArgs = options.args.filter((arg) => !arg.startsWith('--remote-debugging-port='))
newArgs.push(`--remote-debugging-port=${fakePort}`)
la(newArgs.length === options.args.length, 'arg list length should stay the same length')
options.args = newArgs
return startTcpProxy()
.then(() => {
return options
})
})
on('task', {
'get:stats' () {
return {
currentConnectionCount,
totalConnectionCount,
}
},
'kill:active:connections' () {
server.emit('kill-active-connections')
return null
},
'destroy:server' () {
console.error('closing server')
server.close()
return null
},
})
}

View File

@@ -1,5 +1,6 @@
import Bluebird from 'bluebird'
import { create } from '../../../lib/browsers/cri-client'
import EventEmitter from 'events'
const { expect, proxyquire, sinon } = require('../../spec_helper')
@@ -11,22 +12,24 @@ describe('lib/browsers/cri-client', function () {
}
let send: sinon.SinonStub
let criImport: sinon.SinonStub
let onError: sinon.SinonStub
function getClient () {
return criClient.create(DEBUGGER_URL)
return criClient.create(DEBUGGER_URL, onError)
}
beforeEach(function () {
sinon.stub(Bluebird, 'promisify').returnsArg(0)
send = sinon.stub()
onError = sinon.stub()
criImport = sinon.stub()
.withArgs({
target: DEBUGGER_URL,
local: true,
})
.resolves({ send })
.resolves({ send, _notifier: new EventEmitter() })
criClient = proxyquire('../lib/browsers/cri-client', {
'chrome-remote-interface': criImport,

View File

@@ -33,6 +33,8 @@ describe('lib/open_project', () => {
context('#launch', () => {
beforeEach(function () {
openProject.getProject().options = {}
this.spec = {
absolute: 'path/to/spec',
}