#15570·webdriverio

[ Bug]: BiDi cached browsing context is never invalidated on contextDestroyed; classic commands mask it and switchToParentFrame() cannot recover

Author: mccmrunalCreated Sep 7, 2026Updated Sep 7, 2026
LabelsBug 🐛help wantedgood first pickProtocol Related

Environment

  • WebdriverIO: 9.30.1 (webdriverio standalone, 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 found

Three things make this hard to diagnose and impossible to recover from in user land:

  1. The browser announces the destruction and WebdriverIO ignores it. The constructor subscribes only to browsingContext.navigationStarted. I verified that browsingContext.contextDestroyed fires with exactly the id that is about to go stale — the information is already on the wire.

  2. 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 like try { await browser.getTitle() } catch { /* re-anchor */ } can therefore never detect this.

  3. switchToParentFrame() does not recover. It is listed in COMMANDS_REQUIRING_RESET, but #onCommand returns early in its own branch:

    javascript
    #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.mjs
javascript
/**
 * 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)     -> RECOVERED

Expected

Any of:

  • Subscribe to browsingContext.contextDestroyed and clear/re-anchor #currentContext when the cached context is the one destroyed; or
  • have script.callFunction clear the cache and retry once when the driver reports the target context as missing; and
  • fix #onCommand so switchToParentFrame still hits the COMMANDS_REQUIRING_RESET reset, 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".