Skip to content
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

fix: Ensure orphaned browser instance is killed before connecting to a new one #25898

Merged
merged 6 commits into from
Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 12.7.0

_Released 03/1/2023 (PENDING)_
_Released 02/28/2023 (PENDING)_

**Features:**

Expand All @@ -14,9 +14,10 @@ _Released 03/1/2023 (PENDING)_
- Fixed an issue where cookies were being duplicated with the same hostname, but a prepended dot. Fixed an issue where cookies may not be expiring correctly. Fixes [#25174](https://github.com/cypress-io/cypress/issues/25174), [#25205](https://github.com/cypress-io/cypress/issues/25205) and [#25495](https://github.com/cypress-io/cypress/issues/25495).
- Fixed an issue where cookies weren't being synced when the application was stable. Fixed in [#25855](https://github.com/cypress-io/cypress/pull/25855). Fixes [#25835](https://github.com/cypress-io/cypress/issues/25835).
- Added missing TypeScript type definitions for the [`cy.reload()`](https://docs.cypress.io/api/commands/reload) command. Addressed in [#25779](https://github.com/cypress-io/cypress/pull/25779).
- Ensure Angular components are mounted inside the correct element. Fixes [#24385](https://github.com/cypress-io/cypress/issues/24385)
- Fix a bug where files outside the project root in a monorepo are not correctly served when using Vite. Addressed in [#25801](https://github.com/cypress-io/cypress/pull/25801)
- Ensure Angular components are mounted inside the correct element. Fixes [#24385](https://github.com/cypress-io/cypress/issues/24385).
- Fix a bug where files outside the project root in a monorepo are not correctly served when using Vite. Addressed in [#25801](https://github.com/cypress-io/cypress/pull/25801).
- Fixed an issue where using [`cy.intercept`](https://docs.cypress.io/api/commands/intercept)'s `req.continue()` with a non-function parameter would not provide an appropriate error message. Fixed in [#25884](https://github.com/cypress-io/cypress/pull/25884).
- Fixed an issue where Cypress would erroneously launch and connect to multiple browser instances. Fixes [#24377](https://github.com/cypress-io/cypress/issues/24377).

**Misc:**

Expand Down
79 changes: 66 additions & 13 deletions packages/server/lib/browsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,39 @@ const debug = Debug('cypress:server:browsers')
const isBrowserFamily = check.oneOf(BROWSER_FAMILY)

let instance: BrowserInstance | null = null
let instanceId = 0
mschile marked this conversation as resolved.
Show resolved Hide resolved

const kill = function (unbind = true, isProcessExit = false) {
// Clean up the instance when the browser is closed
if (!instance) {
interface KillOptions {
instance?: BrowserInstance
isProcessExit?: boolean
nullOut?: boolean
unbind?: boolean
}

const kill = (options: KillOptions = {}) => {
options = _.defaults({}, options, {
instance,
isProcessExit: false,
unbind: true,
nullOut: true,
})

const instanceToKill = options.instance

if (!instanceToKill) {
debug('browsers.kill called with no active instance')

return Promise.resolve()
}

const _instance = instance

instance = null
if (options.nullOut) {
instance = null
}

return new Promise<void>((resolve) => {
_instance.once('exit', () => {
if (unbind) {
_instance.removeAllListeners()
instanceToKill.once('exit', () => {
if (options.unbind) {
instanceToKill.removeAllListeners()
}

debug('browser process killed')
Expand All @@ -41,9 +57,9 @@ const kill = function (unbind = true, isProcessExit = false) {

debug('killing browser process')

_instance.isProcessExit = isProcessExit
instanceToKill.isProcessExit = options.isProcessExit

_instance.kill()
instanceToKill.kill()
})
}

Expand Down Expand Up @@ -86,7 +102,7 @@ async function getBrowserLauncher (browser: Browser, browsers: FoundBrowser[]):
return utils.throwBrowserNotFound(browser.name, browsers)
}

process.once('exit', () => kill(true, true))
process.once('exit', () => kill({ isProcessExit: true }))

export = {
ensureAndGetByNameOrPath: utils.ensureAndGetByNameOrPath,
Expand Down Expand Up @@ -136,7 +152,16 @@ export = {
},

async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx): Promise<BrowserInstance | null> {
await kill(true)
// this global helps keep track of which launch attempt is the latest one
instanceId++

// we capture the instance id for this launch attempt, so that if the global
// one changes in the course of launching, we know another attempt has been
// made that should supercede it. see the long comment below for more details
const thisInstanceId = instanceId

// kill any currently open browser instance before launching a new one
await kill()

_.defaults(options, {
onBrowserOpen () {},
Expand All @@ -155,6 +180,34 @@ export = {

debug('browser opened')

// in most cases, we'll kill any running browser instance before launching
// a new one when we call `await kill()` on early in this function.
// however, the code that calls this sets a timeout and, if that timeout
// hits, it catches the timeout error and retries launching the browser by
// calling this function again. that means any attempt to launch the browser
// isn't necessarily canceled, we just ignore its success. it's possible an
// original attempt to launch the browser eventually does succeed after
// we've already called this function again on retry. if the 1st
// (now timed-out) browser launch succeeds after this attempt to kill it,
// the 1st instance gets created but then orphaned when we override the
// `instance` singleton after the 2nd attempt succeeds. subsequent code
// expects only 1 browser to be connected at a time, so this causes wonky
// things to occur because we end up connected to and receiving messages
// from 2 browser instances.
//
// to counteract this potential race condition, we use the `instanceId`
// global to essentially track which browser launch attempt is the latest
// one. the latest one should always be the correct one we want to connect
// to, so if the `instanceId` global has changed in the course of launching
// this browser, it means it has been orphaned and should be terminated.
//
// https://github.com/cypress-io/cypress/issues/24377
if (thisInstanceId !== instanceId) {
mschile marked this conversation as resolved.
Show resolved Hide resolved
await kill({ instance: _instance, nullOut: false })

return null
}

instance = _instance
instance.browser = browser

Expand Down
10 changes: 10 additions & 0 deletions packages/server/test/support/helpers/deferred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const deferred = () => {
let reject
let resolve
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})

return { promise, resolve, reject }
}
108 changes: 108 additions & 0 deletions packages/server/test/unit/browsers/browsers_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const { exec } = require('child_process')
const util = require('util')
const { createTestDataContext } = require('@packages/data-context/test/unit/helper')
const electron = require('../../../lib/browsers/electron')
const chrome = require('../../../lib/browsers/chrome')
const Promise = require('bluebird')
const { deferred } = require('../../support/helpers/deferred')

const normalizeSnapshot = (str) => {
return snapshot(stripAnsi(str))
Expand Down Expand Up @@ -156,6 +158,112 @@ describe('lib/browsers/index', () => {
expect(err).to.have.property('message').to.contain(`Browser: ${chalk.yellow('foo-bad-bang')} was not found on your system`)
})
})

// https://github.com/cypress-io/cypress/issues/24377
it('terminates orphaned browser if it connects while launching another instance', async () => {
const browserOptions = [{
family: 'chromium',
}, {
url: 'http://example.com',
onBrowserOpen () {},
}, null, ctx]

const launchBrowser1 = deferred()
const browserInstance1 = new EventEmitter()

browserInstance1.kill = sinon.stub()
sinon.stub(chrome, 'open').onCall(0).returns(launchBrowser1.promise)

// attempt to launch browser
const openBrowser1 = browsers.open(...browserOptions)
const launchBrowser2 = deferred()
const browserInstance2 = new EventEmitter()

browserInstance2.kill = sinon.stub()
chrome.open.onCall(1).returns(launchBrowser2.promise)

// original browser launch times out, so we retry launching the browser
const openBrowser2 = browsers.open(...browserOptions)

// in the meantime, the 1st browser launches
launchBrowser1.resolve(browserInstance1)
// allow time for 1st browser to set instance before allowing 2nd
// browser launch to move forward
await Promise.delay(10)
// the 2nd browser launches
launchBrowser2.resolve(browserInstance2)
// if we exit too soon, it will clear the instance in `open`'s exit
// handler and not trigger the condition we're looking for
await Promise.delay(10)
// finishes killing the 1st browser
browserInstance1.emit('exit')

await openBrowser1
await openBrowser2

const currentInstance = browsers.getBrowserInstance()

// clear out instance or afterEach hook will try to kill it and
// it won't resolve. make sure this is before the assertions or
// a failing one will prevent it from happening
browsers._setInstance(null)

expect(browserInstance1.kill).to.be.calledOnce
expect(currentInstance).to.equal(browserInstance2)
})

// https://github.com/cypress-io/cypress/issues/24377
it('terminates orphaned browser if it connects after another instance launches', async () => {
const browserOptions = [{
family: 'chromium',
}, {
url: 'http://example.com',
onBrowserOpen () {},
}, null, ctx]

const launchBrowser1 = deferred()
const browserInstance1 = new EventEmitter()

browserInstance1.kill = sinon.stub()
sinon.stub(chrome, 'open').onCall(0).returns(launchBrowser1.promise)

// attempt to launch browser
const openBrowser1 = browsers.open(...browserOptions)
const launchBrowser2 = deferred()
const browserInstance2 = new EventEmitter()

browserInstance2.kill = sinon.stub()
chrome.open.onCall(1).returns(launchBrowser2.promise)

// original browser launch times out, so we retry launching the browser
const openBrowser2 = browsers.open(...browserOptions)

// the 2nd browser launches
launchBrowser2.resolve(browserInstance2)

await openBrowser2

// but then the 1st browser launches
launchBrowser1.resolve(browserInstance1)

// wait a tick for exit listener to be set up, then send 'exit'
await Promise.delay(10)
// it should be killed (asserted below)
// this finishes killing the 1st browser
browserInstance1.emit('exit')

await openBrowser1

const currentInstance = browsers.getBrowserInstance()

// clear out instance or afterEach hook will try to kill it and
// it won't resolve. make sure this is before the assertions or
// a failing one will prevent it from happening
browsers._setInstance(null)

expect(browserInstance1.kill).to.be.calledOnce
expect(currentInstance).to.equal(browserInstance2)
})
})

context('.extendLaunchOptionsFromPlugins', () => {
Expand Down
12 changes: 1 addition & 11 deletions packages/server/test/unit/plugins/child/run_plugins_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,11 @@ const resolve = require(`../../../../lib/util/resolve`)
const browserUtils = require(`../../../../lib/browsers/utils`)
const Fixtures = require('@tooling/system-tests')
const { RunPlugins } = require(`../../../../lib/plugins/child/run_plugins`)
const { deferred } = require('../../../support/helpers/deferred')

const colorCodeRe = /\[[0-9;]+m/gm
const pathRe = /\/?([a-z0-9_-]+\/)*[a-z0-9_-]+\/([a-z_]+\.\w+)[:0-9]+/gmi

const deferred = () => {
let reject
let resolve
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})

return { promise, resolve, reject }
}

const withoutColorCodes = (str) => {
return str.replace(colorCodeRe, '<color-code>')
}
Expand Down