[ Bug]: BiDi cached browsing context is never invalidated on contextDestroyed; classic commands mask it and switchToParentFrame() cannot recover
Environment
- WebdriverIO: 9.30.1 (
webdriveriostandalone, no CLI/framework needed) - Node.js: 24.15.0
- Chrome / chromedriver: 152.0.7977.76 / matching
- OS: Windows 11
- Protocol: WebDriver BiDi (
webSocketUrl: true)
What happens
ContextManager caches the current browsing-context id and passes it as target.context to
script.callFunction (packages/webdriverio/src/session/context.ts). When that context is
destroyed by the page rather than by a WebdriverIO command, the cache is never invalidated,
so every subsequent browser.execute() fails permanently:
WebDriver Bidi command "script.callFunction" failed with error:
no such frame - Context 5D50FE58A74D41D74F22CEF6031213AF not foundThree things make this hard to diagnose and impossible to recover from in user land:
The browser announces the destruction and WebdriverIO ignores it. The constructor subscribes only to
browsingContext.navigationStarted. I verified thatbrowsingContext.contextDestroyedfires with exactly the id that is about to go stale — the information is already on the wire.Classic commands keep succeeding and mask the broken state.
getTitle()/getUrl()go over classic WebDriver against "whatever context is current", so they return normally while every BiDi script call fails. A health-check liketry { await browser.getTitle() } catch { /* re-anchor */ }can therefore never detect this.switchToParentFrame()does not recover. It is listed inCOMMANDS_REQUIRING_RESET, but#onCommandreturns early in its own branch:#onCommand(event) { if (event.command === 'switchToParentFrame') { if (!this.#currentContext) return return this.#browser.browsingContextGetTree({}).then(...) // <-- returns here } ... if (COMMANDS_REQUIRING_RESET.includes(event.command)) { this.#currentContext = undefined // <-- never reached } }And
findParentContext(#currentContext, contexts)cannot find a parent for a context that no longer exists in the tree, so it returns without setting anything. The cache stays stale.
Reproducible example
npm i [email protected] && node repro.mjs — deterministic, asserts each claim, ~10 lines of output.
/**
* Repro: WebdriverIO caches a browsing-context id and never learns it was destroyed.
*
* npm i [email protected] && node repro.mjs
*
* Steps: switch into an iframe, let the page remove that iframe, then keep using the browser.
* Expected: WebdriverIO notices the context is gone and re-anchors (or at least surfaces a
* recoverable error, and switchToParentFrame() recovers).
* Actual: every browser.execute() fails forever with
* script.callFunction failed with error: no such frame - Context <id> not found
* while CLASSIC commands (getTitle/getUrl) keep succeeding and mask the problem,
* and switchToParentFrame() does NOT recover.
*/
import { remote } from 'webdriverio'
import assert from 'node:assert'
const PAGE = 'data:text/html,' + encodeURIComponent(
'<h1>parent</h1><iframe id="f" src="https://example.com/"></iframe>')
const browser = await remote({
logLevel: 'silent',
capabilities: {
browserName: 'chrome',
webSocketUrl: true, // BiDi
'goog:chromeOptions': { args: ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage'] }
}
})
// The browser DOES announce the destruction - WebdriverIO just does not subscribe to it.
const destroyedEvents = []
await browser.sessionSubscribe({ events: ['browsingContext.contextDestroyed'] })
browser.on('browsingContext.contextDestroyed', (e) => destroyedEvents.push(e.context))
await browser.url(PAGE)
await browser.switchFrame(await browser.$('#f'))
const iframeCtx = (await browser.browsingContextGetTree({})).contexts[0].children[0].context
await browser.execute(() => 1) // works while the iframe is alive
console.log(`iframe context ${iframeCtx} - execute() ok`)
// The page removes the iframe. No WebdriverIO command is involved, as in a real SPA re-render.
const topCtx = (await browser.browsingContextGetTree({})).contexts[0].context
await browser.scriptCallFunction({
functionDeclaration: 'function(){ document.getElementById("f").remove() }',
awaitPromise: true,
target: { context: topCtx }
})
await new Promise((r) => setTimeout(r, 800))
// 1. The browser told us. WebdriverIO ignored it.
assert.ok(destroyedEvents.includes(iframeCtx),
'browser did not emit contextDestroyed for the iframe')
console.log(`browsingContext.contextDestroyed fired for ${iframeCtx} - cache still points at it`)
// 2. Classic commands still succeed, hiding the broken state.
console.log(`getTitle() [classic] -> ${JSON.stringify(await browser.getTitle())} <-- masks the failure`)
// 3. Every BiDi script call fails.
let bidiError
try {
await browser.execute(() => 1)
} catch (e) {
bidiError = e
console.log(`execute() [BiDi] -> ${e.message.split('\n')[0]}`)
}
assert.ok(bidiError, 'expected execute() to fail against the destroyed context')
// 4. switchToParentFrame() is in COMMANDS_REQUIRING_RESET but does not recover, because
// ContextManager#onCommand returns early for it and never reaches the reset.
await browser.switchToParentFrame()
try {
await browser.execute(() => 1)
console.log('switchToParentFrame() -> RECOVERED')
} catch (e) {
console.log(`switchToParentFrame() -> STILL BROKEN: ${e.message.split('\n')[0]}`)
}
// 5. These do recover (usable workarounds).
await browser.switchFrame(null)
await browser.execute(() => 1)
console.log('switchFrame(null) -> RECOVERED')
await browser.deleteSession()Output:
iframe context 5D50FE58A74D41D74F22CEF6031213AF - execute() ok
browsingContext.contextDestroyed fired for 5D50FE58A74D41D74F22CEF6031213AF - cache still points at it
getTitle() [classic] -> "" <-- masks the failure
execute() [BiDi] -> WebDriver Bidi command "script.callFunction" failed with error: no such frame - Context 5D50FE58A74D41D74F22CEF6031213AF not found
switchToParentFrame() -> STILL BROKEN: WebDriver Bidi command "script.callFunction" failed with error: no such frame - Context 5D50FE58A74D41D74F22CEF6031213AF not found
switchFrame(null) -> RECOVEREDExpected
Any of:
- Subscribe to
browsingContext.contextDestroyedand clear/re-anchor#currentContextwhen the cached context is the one destroyed; or - have
script.callFunctionclear the cache and retry once when the driver reports the target context as missing; and - fix
#onCommandsoswitchToParentFramestill hits theCOMMANDS_REQUIRING_RESETreset, so the documented escape hatch works.
Workarounds (verified)
switchFrame(null), switchToWindow(handles[0]) and refresh() all recover.
switchToParentFrame() does not.
Note on triggers
I could only reproduce this deterministically via a destroyed nested context (iframe).
Two other candidate triggers do not cause it: a cross-origin navigation keeps the same
context id, and a page-initiated window.close() is recovered from correctly. A renderer crash
(chrome://crash) breaks classic commands too, so it is a different failure mode.
This may be the same underlying defect as #14177, which was closed as "Reproducible Example Missing".
Source: webdriverio/webdriverio