Skip to content

[Bug]: unrouteAll({ behavior: 'wait' }) sends other in-flight routes to the network when the first handler finishes #43878

Closed

Description

Version

1.64.1 (also reproduced on 1.60.0)

Steps to reproduce

  1. In an empty directory: npm init -y && npm pkg set type=module && npm i @playwright/test@1.64.1
  2. Save this as repro.mjs and run node repro.mjs. It uses the installed Chrome; set CHANNEL=chromium to use Playwright's Chromium instead.
import { chromium } from '@playwright/test'

const browser = await chromium.launch({ channel: process.env.CHANNEL || 'chrome' })
const context = await browser.newContext()
const page = await context.newPage()
const delays = { '/a': 50, '/b': 400 }
let started = 0
await context.route('**/*', async (route) => {
  const url = new URL(route.request().url())
  if (url.pathname === '/') return route.fulfill({ body: '<p>ok</p>', contentType: 'text/html' })
  started++
  await new Promise((resolve) => setTimeout(resolve, delays[url.pathname]))
  await route.fulfill({ body: url.pathname }).then(
    () => console.log('fulfilled', url.pathname),
    (error) => console.log('fulfill failed', url.pathname, error.message),
  )
})
context.on('requestfailed', (request) =>
  console.log('request failed', new URL(request.url()).pathname, request.failure()?.errorText),
)
await page.goto('http://repro.test/')
void page.evaluate(() => Promise.allSettled(['/a', '/b'].map((path) => fetch(path))))
while (started < 2) await new Promise((resolve) => setTimeout(resolve, 5))
console.log('unrouteAll with /a and /b in flight')
const result = await Promise.race([
  context.unrouteAll({ behavior: 'wait' }).then(() => 'unrouteAll resolved'),
  new Promise((resolve) => setTimeout(() => resolve('unrouteAll still pending after 3s'), 3000)),
])
console.log(result)
await browser.close()

Expected behavior

unrouteAll({ behavior: 'wait' }) waits for both in-flight handlers, both fulfills succeed, and unrouteAll resolves:

unrouteAll with /a and /b in flight
fulfilled /a
fulfilled /b
unrouteAll resolved

Actual behavior

unrouteAll with /a and /b in flight
fulfilled /a
request failed /b net::ERR_NAME_NOT_RESOLVED
fulfill failed /b route.fulfill: Route is already handled!
unrouteAll still pending after 3s

As soon as the first handler (/a) finishes, /b is sent to the real network while its handler is still running. Its later route.fulfill throws Route is already handled!. If the handler lets that error propagate, it surfaces as an unhandled rejection (in the test runner, a failed test). If the handler catches it, as above, unrouteAll never resolves.

Additional context

This looks like a remaining multi-handler case of #36585, which #36588 fixed for a single in-flight handler. In _onRoute (both the page and the context versions), after a handler returns:

const handled = await routeHandler.handle(route);
if (!this._routes.length)
  this._updateInterceptionPatterns({ internal: true }).catch(() => {});

unrouteAll has already emptied _routes, so the first handler to finish sends setNetworkInterceptionPatterns with no patterns while _unrouteInternal is still awaiting stop('wait') on the others. The server then drops the interceptor and continues the remaining intercepted requests, so their handlers can no longer fulfill them.

A possible fix: skip that update while a behavior: 'wait' unroute is pending, and let _unrouteInternal update the patterns once every removed handler has stopped (it already does).

Workaround: track your own in-flight handlers, wait for them to settle, then close the pages instead of calling unrouteAll.

We hit this in CI as an intermittent Route is already handled! failure during fixture teardown that called context.unrouteAll({ behavior: 'wait' }) while two mocked requests were in flight.

Environment

OS: macOS 26.3.1 (Darwin arm64); also seen on ubuntu-latest in GitHub Actions
Node.js: 22.23.0
@playwright/test: 1.64.1 and 1.60.0
Browser: Chrome 156.0.8078.80 (channel: 'chrome'); CI used chromium-headless-shell

Activity

  1. darshpatel1052 commented on Nov 3, 2026

    @darshpatel1052

    I’d like to work on this. I’d add a regression with two delayed in-flight routes, then trace when page and context handlers update interception patterns during unrouteAll({ behavior: 'wait' }). I’d make sure both handlers finish before interception is removed, while checking the other unroute behaviours. Is there a preferred test location for covering both page and context variants?

  2. github-actions commented on Nov 3, 2026

    @github-actions
  3. microsoft-playwright-automation commented on Nov 3, 2026

    @microsoft-playwright-automation
    Contributor

    Hi, I'm the Playwright bot. I reproduced this on Linux.

    Reproduced on current main (1.65.0-next, 3cd8a42c5ee15e559c193f0face72d237af0fc72) in Chromium, Firefox and WebKit, for both page and context routes. The first completed handler disables interception while unrouteAll({ behavior: 'wait' }) is still waiting for the other handler. The proposed fix leaves that update to unrouteAll() when it has replaced the route list.

    Reproduction and verification

    Extended existing tests RO-200 and RO-201 in tests/library/unroute-behavior.spec.ts. This is the context case; the page case uses page.route() and page.unrouteAll():

    const routePromise = new ManualPromise();
    let started = 0;
    await page.goto(server.EMPTY_PAGE);
    await context.route('**/*', async route => {
      if (++started === 2)
        routePromise.resolve();
      await routePromise;
      await new Promise(f => setTimeout(f, route.request().url().endsWith('/a') ? 100 : 1000));
      const response = await route.fetch();
      await route.fulfill({ response, body: 'mocked' });
    });
    const fetchPromise = page.evaluate(() => Promise.all(['/a', '/b'].map(async path => (await fetch(path)).text())));
    await routePromise;
    const [, bodies] = await Promise.all([context.unrouteAll({ behavior: 'wait' }), fetchPromise]);
    expect(bodies).toEqual(['mocked', 'mocked']);

    ManualPromise comes from iso in packages/playwright-core/lib/coreBundle; page, context and server are the standard library-test fixtures.

    Linux, headless: Chromium 158.0.8127.2 (build 1253), Firefox 159.0 (build 1572), WebKit 27.2 (build 2388). Both cases failed on all three browsers without the fix: the second response came from the server instead of the mock, and its handler threw Route is already handled!. All six cases pass with the fix. No browser-specific difference was observed.

    Commands:

    npm run test -- tests/library/unroute-behavior.spec.ts --grep 'RO-20[01]' --project=chromium-library --project=firefox-library --project=webkit-library --retries=0 --workers=3 --reporter=dot
    npm run build
    npm run flint
    npm run test -- tests/library/unroute-behavior.spec.ts tests/library/browsercontext-route.spec.ts tests/page/page-route.spec.ts tests/page/interception.spec.ts --project=chromium-library --project=firefox-library --project=webkit-library --project=chromium-page --project=firefox-page --project=webkit-page --retries=0 --workers=4 --reporter=dot

    The regression command failed in all six cases without the fix. Build and lint passed with the fix. The final four-file run passed: 325 passed, 8 existing skips.

    Draft PR: #43889

    Triaged on Linux by the Playwright bot - agent run

  4. darshpatel1052 commented on Nov 4, 2026

    @darshpatel1052

    Thanks, I see this is fixed in #43889.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions