Skip to content

Commit 382e0b5

Browse files
mjhenkesemilyrohrboughryanthemanuel
authored
fix: detect chrome browser process and tab crashes to no longer hang in CI (#24338)
* Handle chrome tab and browser crashes. * handle process crashes and add system tests * handle crashing on windows * updating comment * update unit tests * Update packages/server/lib/browsers/index.ts Co-authored-by: Emily Rohrbough <emilyrohrbough@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Ryan Manuel <ryanm@cypress.io> * fix flaky system test probably Co-authored-by: Emily Rohrbough <emilyrohrbough@users.noreply.github.com> Co-authored-by: Ryan Manuel <ryanm@cypress.io>
1 parent 208efbf commit 382e0b5

18 files changed

+546
-21
lines changed

packages/errors/__snapshot-html__/BROWSER_CRASHED.html

+48
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/errors/__snapshot-html__/RENDERER_CRASHED.html

+1-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/errors/src/errors.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,6 @@ export const AllCypressErrors = {
570570
This can happen for a number of different reasons:
571571
572572
- You wrote an endless loop and you must fix your own code
573-
- There is a memory leak in Cypress (unlikely but possible)
574573
- You are running Docker (there is an easy fix for this: see link below)
575574
- You are running lots of tests on a memory intense application
576575
- You are running in a memory starved VM environment
@@ -581,6 +580,20 @@ export const AllCypressErrors = {
581580
582581
https://on.cypress.io/renderer-process-crashed`
583582
},
583+
BROWSER_CRASHED: (browser: string, code: string | number, signal: string) => {
584+
return errTemplate`\
585+
We detected that the ${fmt.highlight(browser)} process just crashed with code '${fmt.highlight(code)}' and signal '${fmt.highlight(signal)}'.
586+
587+
We have failed the current test and have relaunched ${fmt.highlight(browser)}.
588+
589+
This can happen for many different reasons:
590+
591+
- You wrote an endless loop and you must fix your own code
592+
- You are running lots of tests on a memory intense application
593+
- You are running in a memory starved VM environment
594+
- There are problems with your GPU / GPU drivers
595+
- There are browser bugs`
596+
},
584597
AUTOMATION_SERVER_DISCONNECTED: () => {
585598
return errTemplate`The automation client disconnected. Cannot continue running tests.`
586599
},

packages/errors/test/unit/visualSnapshotErrors_spec.ts

+5
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,11 @@ describe('visual error templates', () => {
646646
default: [],
647647
}
648648
},
649+
BROWSER_CRASHED: () => {
650+
return {
651+
default: ['Chrome', 'code', 'signal'],
652+
}
653+
},
649654
AUTOMATION_SERVER_DISCONNECTED: () => {
650655
return {
651656
default: [],

packages/server/lib/browsers/chrome.ts

+25-5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { fs } from '../util/fs'
1515
import { CdpAutomation, screencastOpts } from './cdp_automation'
1616
import * as protocol from './protocol'
1717
import utils from './utils'
18+
import * as errors from '../errors'
1819
import type { Browser, BrowserInstance } from './types'
1920
import { BrowserCriClient } from './browser-cri-client'
2021
import type { CriClient } from './cri-client'
@@ -561,6 +562,17 @@ export = {
561562
return args
562563
},
563564

565+
/**
566+
* Clear instance state for the chrome instance, this is normally called in on kill or on exit.
567+
*/
568+
clearInstanceState () {
569+
debug('closing remote interface client')
570+
571+
// Do nothing on failure here since we're shutting down anyway
572+
browserCriClient?.close().catch()
573+
browserCriClient = undefined
574+
},
575+
564576
async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) {
565577
debug('connecting to new chrome tab in existing instance with url and debugging port', { url: options.url })
566578

@@ -595,6 +607,18 @@ export = {
595607
async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) {
596608
const browserCriClient = this._getBrowserCriClient()
597609

610+
// Handle chrome tab crashes.
611+
pageCriClient.on('Inspector.targetCrashed', () => {
612+
const err = errors.get('RENDERER_CRASHED')
613+
614+
if (!options.onError) {
615+
errors.log(err)
616+
throw new Error('Missing onError in attachListeners')
617+
}
618+
619+
options.onError(err)
620+
})
621+
598622
if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners')
599623

600624
debug('attaching listeners to chrome %o', { url, options })
@@ -700,11 +724,7 @@ export = {
700724
launchedBrowser.browserCriClient = browserCriClient
701725

702726
launchedBrowser.kill = (...args) => {
703-
debug('closing remote interface client')
704-
705-
// Do nothing on failure here since we're shutting down anyway
706-
browserCriClient?.close().catch()
707-
browserCriClient = undefined
727+
this.clearInstanceState()
708728

709729
debug('closing chrome')
710730

packages/server/lib/browsers/electron.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,10 @@ export = {
146146
onCrashed () {
147147
const err = errors.get('RENDERER_CRASHED')
148148

149-
errors.log(err)
150-
151-
if (!options.onError) throw new Error('Missing onError in onCrashed')
149+
if (!options.onError) {
150+
errors.log(err)
151+
throw new Error('Missing onError in onCrashed')
152+
}
152153

153154
options.onError(err)
154155
},
@@ -471,6 +472,11 @@ export = {
471472
})
472473
},
473474

475+
/**
476+
* Clear instance state for the electron instance, this is normally called in on kill or on exit for electron there isn't state to clear.
477+
*/
478+
clearInstanceState () {},
479+
474480
async connectToNewSpec (browser: Browser, options: ElectronOpts, automation: Automation) {
475481
if (!options.url) throw new Error('Missing url in connectToNewSpec')
476482

packages/server/lib/browsers/firefox.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,17 @@ export function _createDetachedInstance (browserInstance: BrowserInstance, brows
371371
return detachedInstance
372372
}
373373

374+
/**
375+
* Clear instance state for the chrome instance, this is normally called in on kill or on exit.
376+
*/
377+
export function clearInstanceState () {
378+
debug('closing remote interface client')
379+
if (browserCriClient) {
380+
browserCriClient.close().catch()
381+
browserCriClient = undefined
382+
}
383+
}
384+
374385
export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) {
375386
await firefoxUtil.connectToNewSpec(options, automation, browserCriClient)
376387
}
@@ -552,13 +563,8 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
552563
const originalBrowserKill = browserInstance.kill
553564

554565
browserInstance.kill = (...args) => {
555-
debug('closing remote interface client')
556-
557566
// Do nothing on failure here since we're shutting down anyway
558-
if (browserCriClient) {
559-
browserCriClient.close().catch()
560-
browserCriClient = undefined
561-
}
567+
clearInstanceState()
562568

563569
debug('closing firefox')
564570

packages/server/lib/browsers/index.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import _ from 'lodash'
22
import Bluebird from 'bluebird'
33
import Debug from 'debug'
44
import utils from './utils'
5+
import * as errors from '../errors'
56
import check from 'check-more-types'
67
import { exec } from 'child_process'
78
import util from 'util'
@@ -155,13 +156,36 @@ export = {
155156
// TODO: normalizing opening and closing / exiting
156157
// so that there is a default for each browser but
157158
// enable the browser to configure the interface
158-
instance.once('exit', () => {
159+
instance.once('exit', async (code, signal) => {
159160
ctx.browser.setBrowserStatus('closed')
160161
// TODO: make this a required property
161162
if (!options.onBrowserClose) throw new Error('onBrowserClose did not exist in interactive mode')
162163

164+
const browserDisplayName = instance?.browser?.displayName || 'unknown'
165+
163166
options.onBrowserClose()
167+
browserLauncher.clearInstanceState()
164168
instance = null
169+
170+
// We are being very narrow on when to restart the browser here. The only case we can reliably test the 'SIGTRAP' signal.
171+
// We want to avoid adding signals in here that may intentionally be caused by a user.
172+
// For example exiting firefox through either force quitting or quitting via cypress will fire a 'SIGTERM' event which
173+
// would result in constantly relaunching the browser when the user actively wants to quit.
174+
// On windows the crash produces 2147483651 as an exit code. We should add to the list of crashes we handle as we see them.
175+
// In the future we may consider delegating to the browsers to determine if an exit is a crash since it might be different
176+
// depending on what browser has crashed.
177+
if (code === null && ['SIGTRAP', 'SIGABRT'].includes(signal) || code === 2147483651 && signal === null) {
178+
const err = errors.get('BROWSER_CRASHED', browserDisplayName, code, signal)
179+
180+
if (!options.onError) {
181+
errors.log(err)
182+
throw new Error('Missing onError in attachListeners')
183+
}
184+
185+
await options.onError(err)
186+
187+
await options.relaunchBrowser!()
188+
}
165189
})
166190

167191
// TODO: instead of waiting an arbitrary

packages/server/lib/browsers/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,8 @@ export type BrowserLauncher = {
3535
* Used in Cypress-in-Cypress tests to connect to the existing browser instance.
3636
*/
3737
connectToExisting: (browser: Browser, options: BrowserLaunchOpts, automation: Automation) => void | Promise<void>
38+
/**
39+
* Used to clear instance state after the browser has been exited.
40+
*/
41+
clearInstanceState: () => void
3842
}

packages/server/lib/browsers/webkit.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export async function connectToNewSpec (browser: Browser, options: BrowserNewTab
2525
})
2626
}
2727

28+
/**
29+
* Clear instance state for the webkit instance, this is normally called in on kill or on exit.
30+
*/
31+
export function clearInstanceState () {
32+
wkAutomation = undefined
33+
}
34+
2835
export function connectToExisting () {
2936
throw new Error('Cypress-in-Cypress is not supported for WebKit.')
3037
}
@@ -120,7 +127,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
120127
async kill () {
121128
debug('closing pwBrowser')
122129
await pwBrowser.close()
123-
wkAutomation = undefined
130+
clearInstanceState()
124131
}
125132

126133
/**

packages/server/lib/open_project.ts

+2
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ export class OpenProject {
186186
return await browsers.connectToNewSpec(browser, { onInitializeNewBrowserTab, ...options }, automation)
187187
}
188188

189+
options.relaunchBrowser = this.relaunchBrowser
190+
189191
return await browsers.open(browser, options, automation, this._ctx)
190192
}
191193

packages/server/lib/project-base.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ export class ProjectBase<TServer extends Server> extends EE {
103103
this.options = {
104104
report: false,
105105
onFocusTests () {},
106-
onError () {},
106+
onError (error) {
107+
errors.log(error)
108+
},
107109
onWarning: this.ctx.onWarning,
108110
...options,
109111
}

packages/types/src/server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type BrowserLaunchOpts = {
2020
isTextTerminal: boolean
2121
onBrowserClose?: (...args: unknown[]) => void
2222
onBrowserOpen?: (...args: unknown[]) => void
23+
relaunchBrowser?: () => Promise<any>
2324
} & Partial<OpenProjectLaunchOpts> // TODO: remove the `Partial` here by making it impossible for openProject.launch to be called w/o OpenProjectLaunchOpts
2425
& Pick<ReceivedCypressOptions, 'userAgent' | 'proxyUrl' | 'socketIoRoute' | 'chromeWebSecurity' | 'downloadsFolder' | 'experimentalSessionAndOrigin' | 'experimentalModifyObstructiveThirdPartyCode' | 'experimentalWebKitSupport'>
2526

0 commit comments

Comments
 (0)