Skip to content

[Bug]: Firefox: opening a WebSocket inside a Web Worker kills the page session with "Error: Assertion error" (regression in 1.61.0) #41742

Description

@tzuyi0817

Version

1.61.1

Steps to reproduce

  1. Save the spec below as repro.spec.ts (self-contained: it starts its own tiny
    WebSocket server with node:http, no extra dependencies, no playwright.config needed).
  2. npm init playwright@latest (defaults) or any project with @playwright/test@1.61.x.
  3. npx playwright test repro.spec.ts --project=firefox
import { createHash } from 'node:crypto';
import http from 'node:http';
import { test } from '@playwright/test';
import type { AddressInfo } from 'node:net';

const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

// Minimal WebSocket server: completes the 101 upgrade handshake so the
// client-side `open` event fires. No frames are exchanged.
function createWsServer(): Promise<{ port: number; close: () => void }> {
  const server = http.createServer((_, response) => {
    response.setHeader('content-type', 'text/html');
    response.end('<!DOCTYPE html><title>repro</title>');
  });

  server.on('upgrade', (request, socket) => {
    const key = request.headers['sec-websocket-key'] ?? '';
    const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64');

    socket.write(
      [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        `Sec-WebSocket-Accept: ${accept}`,
        '',
        '',
      ].join('\r\n'),
    );
  });

  return new Promise(resolve => {
    server.listen(0, '127.0.0.1', () => {
      const { port } = server.address() as AddressInfo;
      resolve({ port, close: () => server.close() });
    });
  });
}

test('WebSocket opened inside a dedicated worker crashes Firefox session', async ({ page }) => {
  const { port, close } = await createWsServer();
  try {
    await page.goto(`http://127.0.0.1:${port}/`);
    await page.evaluate(wsPort => {
      const code = `
        const ws = new WebSocket("ws://127.0.0.1:${wsPort}/");
        ws.addEventListener("open", () => self.postMessage("ws-open"));
      `;
      const worker = new Worker(URL.createObjectURL(new Blob([code], { type: 'text/javascript' })));
      worker.addEventListener('message', event => console.log('worker message:', event.data));
    }, port);

    // Firefox: fails here with a bare "Error: Assertion error"
    await page.waitForTimeout(5000);
  } finally {
    close();
  }
});

test('control: WebSocket opened in the main page works fine', async ({ page }) => {
  const { port, close } = await createWsServer();
  try {
    await page.goto(`http://127.0.0.1:${port}/`);
    await page.evaluate(wsPort => {
      const ws = new WebSocket(`ws://127.0.0.1:${wsPort}/`);
      ws.addEventListener('open', () => console.log('main ws-open'));
    }, port);
    await page.waitForTimeout(5000);
  } finally {
    close();
  }
});

Expected behavior

Both tests pass. A WebSocket opened from inside a dedicated Web Worker should not
affect the test. This is the case on chromium and webkit, and it was the case on
firefox up to Playwright 1.60.0.

Actual behavior

On firefox, the worker test dies with a bare protocol-level error as soon as the
WebSocket finishes opening inside the worker:

Error: Assertion error

Error: page.waitForTimeout: Test ended.

The control test (same WebSocket opened from the main page) passes.
Both classic workers and { type: "module" } workers reproduce it.
The worker's "open" event does fire (console shows "worker message: ws-open")
right before the session is torn down.

Additional context

Regression window (bisected):

@playwright/test bundled Firefox result
1.59.1 148.0.2 (r1511) ✅ pass
1.60.0 150.0.2 (r1522) ✅ pass
1.61.0 151.0 (r1532) ❌ Assertion error
1.61.1 151.0 (r1532) ❌ Assertion error
1.62.0-alpha-2026-07-10 152.0.4 (r1534) ❌ Assertion error

Protocol trace right before the teardown (DEBUG=pw:protocol):

◀ RECV {"method":"Page.webSocketOpened","params":{"frameId":"mainframe-10","requestId":"12","wsid":"1","effectiveURL":"ws://127.0.0.1:52914/"},...}
◀ RECV {"method":"Runtime.console","params":{"args":[{"value":"worker message:"},{"value":"ws-open"}],...}}
◀ RECV {"method":"Browser.detachedFromTarget","params":{...}}

Where the assert firesffPage.ts _onWebSocketOpened:

_onWebSocketOpened(event) {
  const request = this._webSocketRequests.get(event.requestId);
  assert(request);   // <-- throws: the worker's WS upgrade request was never
                     //     registered in _webSocketRequests
  ...
}

When the WebSocket originates from a dedicated worker, its upgrade request never
went through the code path that populates _webSocketRequests, so the assert
throws and the whole page session is torn down.

There is also this in the firefox process stderr around the same moment
(DEBUG=pw:browser), possibly secondary:

JavaScript error: chrome://juggler/content/Helper.js, line 82:
NS_ERROR_FAILURE: Component returned failure code: 0x80004005 (NS_ERROR_FAILURE)
[nsIWebProgress.removeProgressListener]

Real-world impact: any app tested against a Vite dev server where a Web Worker
loads Vite's HMR client is affected — in dev mode Vite injects
import ... from "/@vite/client" into worker scripts it serves, the client opens
its HMR WebSocket inside the worker, and every firefox test touching such a page
dies instantly. We hit this via the pdfjs-dist worker in a Vue 3 + Vite 8 app;
grep-ability keywords: pdf.js, "Setting up fake worker", vite HMR.

Environment

System:
  OS: macOS 26.5.1
  CPU: (8) arm64 Apple M2
Binaries:
  Node: 24.18.0
  npm: 11.16.0
  pnpm: 11.11.0
IDEs:
  Languages:
    Bash: 3.2.57
  npmPackages:
    @playwright/test: ^1.61.1 => 1.61.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions