Skip to content

Commit 8eb60b6

Browse files
fix: Ensure orphaned browser instance is killed before connecting to a new one (#25898)
1 parent 4a47081 commit 8eb60b6

File tree

5 files changed

+189
-27
lines changed

5 files changed

+189
-27
lines changed

cli/CHANGELOG.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
22
## 12.7.0
33

4-
_Released 03/1/2023 (PENDING)_
4+
_Released 02/28/2023 (PENDING)_
55

66
**Features:**
77

@@ -14,9 +14,10 @@ _Released 03/1/2023 (PENDING)_
1414
- 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).
1515
- 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).
1616
- 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).
17-
- Ensure Angular components are mounted inside the correct element. Fixes [#24385](https://github.com/cypress-io/cypress/issues/24385)
18-
- 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)
17+
- Ensure Angular components are mounted inside the correct element. Fixes [#24385](https://github.com/cypress-io/cypress/issues/24385).
18+
- 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).
1919
- 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).
20+
- Fixed an issue where Cypress would erroneously launch and connect to multiple browser instances. Fixes [#24377](https://github.com/cypress-io/cypress/issues/24377).
2021

2122
**Misc:**
2223

packages/server/lib/browsers/index.ts

+66-13
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,39 @@ const debug = Debug('cypress:server:browsers')
1515
const isBrowserFamily = check.oneOf(BROWSER_FAMILY)
1616

1717
let instance: BrowserInstance | null = null
18+
let launchAttempt = 0
1819

19-
const kill = function (unbind = true, isProcessExit = false) {
20-
// Clean up the instance when the browser is closed
21-
if (!instance) {
20+
interface KillOptions {
21+
instance?: BrowserInstance
22+
isProcessExit?: boolean
23+
nullOut?: boolean
24+
unbind?: boolean
25+
}
26+
27+
const kill = (options: KillOptions = {}) => {
28+
options = _.defaults({}, options, {
29+
instance,
30+
isProcessExit: false,
31+
unbind: true,
32+
nullOut: true,
33+
})
34+
35+
const instanceToKill = options.instance
36+
37+
if (!instanceToKill) {
2238
debug('browsers.kill called with no active instance')
2339

2440
return Promise.resolve()
2541
}
2642

27-
const _instance = instance
28-
29-
instance = null
43+
if (options.nullOut) {
44+
instance = null
45+
}
3046

3147
return new Promise<void>((resolve) => {
32-
_instance.once('exit', () => {
33-
if (unbind) {
34-
_instance.removeAllListeners()
48+
instanceToKill.once('exit', () => {
49+
if (options.unbind) {
50+
instanceToKill.removeAllListeners()
3551
}
3652

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

4258
debug('killing browser process')
4359

44-
_instance.isProcessExit = isProcessExit
60+
instanceToKill.isProcessExit = options.isProcessExit
4561

46-
_instance.kill()
62+
instanceToKill.kill()
4763
})
4864
}
4965

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

89-
process.once('exit', () => kill(true, true))
105+
process.once('exit', () => kill({ isProcessExit: true }))
90106

91107
export = {
92108
ensureAndGetByNameOrPath: utils.ensureAndGetByNameOrPath,
@@ -136,7 +152,16 @@ export = {
136152
},
137153

138154
async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx): Promise<BrowserInstance | null> {
139-
await kill(true)
155+
// this global helps keep track of which launch attempt is the latest one
156+
launchAttempt++
157+
158+
// capture the launch attempt number for this attempt, so that if the global
159+
// one changes in the course of launching, we know another attempt has been
160+
// made that should supercede it. see the long comment below for more details
161+
const thisLaunchAttempt = launchAttempt
162+
163+
// kill any currently open browser instance before launching a new one
164+
await kill()
140165

141166
_.defaults(options, {
142167
onBrowserOpen () {},
@@ -155,6 +180,34 @@ export = {
155180

156181
debug('browser opened')
157182

183+
// in most cases, we'll kill any running browser instance before launching
184+
// a new one when we call `await kill()` early in this function.
185+
// however, the code that calls this sets a timeout and, if that timeout
186+
// hits, it catches the timeout error and retries launching the browser by
187+
// calling this function again. that means any attempt to launch the browser
188+
// isn't necessarily canceled; we just ignore its success. it's possible an
189+
// original attempt to launch the browser eventually does succeed after
190+
// we've already called this function again on retry. if the 1st
191+
// (now timed-out) browser launch succeeds after this attempt to kill it,
192+
// the 1st instance gets created but then orphaned when we override the
193+
// `instance` singleton after the 2nd attempt succeeds. subsequent code
194+
// expects only 1 browser to be connected at a time, so this causes wonky
195+
// things to occur because we end up connected to and receiving messages
196+
// from 2 browser instances.
197+
//
198+
// to counteract this potential race condition, we use the `launchAttempt`
199+
// global to essentially track which browser launch attempt is the latest
200+
// one. the latest one should always be the correct one we want to connect
201+
// to, so if the `launchAttempt` global has changed in the course of launching
202+
// this browser, it means it has been orphaned and should be terminated.
203+
//
204+
// https://github.com/cypress-io/cypress/issues/24377
205+
if (thisLaunchAttempt !== launchAttempt) {
206+
await kill({ instance: _instance, nullOut: false })
207+
208+
return null
209+
}
210+
158211
instance = _instance
159212
instance.browser = browser
160213

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const deferred = () => {
2+
let reject
3+
let resolve
4+
const promise = new Promise((_resolve, _reject) => {
5+
resolve = _resolve
6+
reject = _reject
7+
})
8+
9+
return { promise, resolve, reject }
10+
}

packages/server/test/unit/browsers/browsers_spec.js

+108
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ const { exec } = require('child_process')
1212
const util = require('util')
1313
const { createTestDataContext } = require('@packages/data-context/test/unit/helper')
1414
const electron = require('../../../lib/browsers/electron')
15+
const chrome = require('../../../lib/browsers/chrome')
1516
const Promise = require('bluebird')
17+
const { deferred } = require('../../support/helpers/deferred')
1618

1719
const normalizeSnapshot = (str) => {
1820
return snapshot(stripAnsi(str))
@@ -156,6 +158,112 @@ describe('lib/browsers/index', () => {
156158
expect(err).to.have.property('message').to.contain(`Browser: ${chalk.yellow('foo-bad-bang')} was not found on your system`)
157159
})
158160
})
161+
162+
// https://github.com/cypress-io/cypress/issues/24377
163+
it('terminates orphaned browser if it connects while launching another instance', async () => {
164+
const browserOptions = [{
165+
family: 'chromium',
166+
}, {
167+
url: 'http://example.com',
168+
onBrowserOpen () {},
169+
}, null, ctx]
170+
171+
const launchBrowser1 = deferred()
172+
const browserInstance1 = new EventEmitter()
173+
174+
browserInstance1.kill = sinon.stub()
175+
sinon.stub(chrome, 'open').onCall(0).returns(launchBrowser1.promise)
176+
177+
// attempt to launch browser
178+
const openBrowser1 = browsers.open(...browserOptions)
179+
const launchBrowser2 = deferred()
180+
const browserInstance2 = new EventEmitter()
181+
182+
browserInstance2.kill = sinon.stub()
183+
chrome.open.onCall(1).returns(launchBrowser2.promise)
184+
185+
// original browser launch times out, so we retry launching the browser
186+
const openBrowser2 = browsers.open(...browserOptions)
187+
188+
// in the meantime, the 1st browser launches
189+
launchBrowser1.resolve(browserInstance1)
190+
// allow time for 1st browser to set instance before allowing 2nd
191+
// browser launch to move forward
192+
await Promise.delay(10)
193+
// the 2nd browser launches
194+
launchBrowser2.resolve(browserInstance2)
195+
// if we exit too soon, it will clear the instance in `open`'s exit
196+
// handler and not trigger the condition we're looking for
197+
await Promise.delay(10)
198+
// finishes killing the 1st browser
199+
browserInstance1.emit('exit')
200+
201+
await openBrowser1
202+
await openBrowser2
203+
204+
const currentInstance = browsers.getBrowserInstance()
205+
206+
// clear out instance or afterEach hook will try to kill it and
207+
// it won't resolve. make sure this is before the assertions or
208+
// a failing one will prevent it from happening
209+
browsers._setInstance(null)
210+
211+
expect(browserInstance1.kill).to.be.calledOnce
212+
expect(currentInstance).to.equal(browserInstance2)
213+
})
214+
215+
// https://github.com/cypress-io/cypress/issues/24377
216+
it('terminates orphaned browser if it connects after another instance launches', async () => {
217+
const browserOptions = [{
218+
family: 'chromium',
219+
}, {
220+
url: 'http://example.com',
221+
onBrowserOpen () {},
222+
}, null, ctx]
223+
224+
const launchBrowser1 = deferred()
225+
const browserInstance1 = new EventEmitter()
226+
227+
browserInstance1.kill = sinon.stub()
228+
sinon.stub(chrome, 'open').onCall(0).returns(launchBrowser1.promise)
229+
230+
// attempt to launch browser
231+
const openBrowser1 = browsers.open(...browserOptions)
232+
const launchBrowser2 = deferred()
233+
const browserInstance2 = new EventEmitter()
234+
235+
browserInstance2.kill = sinon.stub()
236+
chrome.open.onCall(1).returns(launchBrowser2.promise)
237+
238+
// original browser launch times out, so we retry launching the browser
239+
const openBrowser2 = browsers.open(...browserOptions)
240+
241+
// the 2nd browser launches
242+
launchBrowser2.resolve(browserInstance2)
243+
244+
await openBrowser2
245+
246+
// but then the 1st browser launches
247+
launchBrowser1.resolve(browserInstance1)
248+
249+
// wait a tick for exit listener to be set up, then send 'exit'
250+
await Promise.delay(10)
251+
// it should be killed (asserted below)
252+
// this finishes killing the 1st browser
253+
browserInstance1.emit('exit')
254+
255+
await openBrowser1
256+
257+
const currentInstance = browsers.getBrowserInstance()
258+
259+
// clear out instance or afterEach hook will try to kill it and
260+
// it won't resolve. make sure this is before the assertions or
261+
// a failing one will prevent it from happening
262+
browsers._setInstance(null)
263+
264+
expect(browserInstance1.kill).to.be.calledOnce
265+
expect(currentInstance).to.equal(browserInstance2)
266+
})
159267
})
160268

161269
context('.extendLaunchOptionsFromPlugins', () => {

packages/server/test/unit/plugins/child/run_plugins_spec.js

+1-11
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,11 @@ const resolve = require(`../../../../lib/util/resolve`)
1010
const browserUtils = require(`../../../../lib/browsers/utils`)
1111
const Fixtures = require('@tooling/system-tests')
1212
const { RunPlugins } = require(`../../../../lib/plugins/child/run_plugins`)
13+
const { deferred } = require('../../../support/helpers/deferred')
1314

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

17-
const deferred = () => {
18-
let reject
19-
let resolve
20-
const promise = new Promise((_resolve, _reject) => {
21-
resolve = _resolve
22-
reject = _reject
23-
})
24-
25-
return { promise, resolve, reject }
26-
}
27-
2818
const withoutColorCodes = (str) => {
2919
return str.replace(colorCodeRe, '<color-code>')
3020
}

0 commit comments

Comments
 (0)