#775·camoufox

page.route_web_socket() silently does nothing: the WebSocket mock lands in the isolated world, so page-originated sockets are never intercepted

Author: JWriter20Created Sep 14, 2026Updated Sep 14, 2026

Summary

page.route_web_socket() does nothing on Camoufox. A socket the page's own script opens is never intercepted, the handler never fires, and the caller gets no error — the API reports success and silently does not work.

This is not a test artifact. It is what a real user gets against a real site.

Why

Playwright implements WebSocket routing by replacing window.WebSocket from an init script. From playwright-core/lib/server/dispatchers/webSocketRouteDispatcher.js, the feature has three legs, all of which have to reach the page:

Leg Mechanism Where it lands on Camoufox
__pwWebSocketBinding exposeBinding isolated world
the WebSocket mock addInitScript isolated world
globalThis.__pwWebSocketDispatch evaluateExpression isolated world

Camoufox runs the default world (name === '') as a sandbox rather than the page's own global — Frame._createIsolatedContext() in additions/juggler/content/FrameTree.js. So the replacement WebSocket is installed on the sandbox global, and a socket the page opens goes straight to the real constructor it still has. Nothing is intercepted.

This is isolation doing exactly what it exists to do. A page that can reach an automation binding can detect it, which is the reason this fork exists. The bug is not that isolation is on — it is that the feature fails silently and that we have no isolation-compatible implementation of it.

The same shape will recur for any Playwright feature implemented by installing something on the page's global, so it is worth fixing as a pattern rather than as one API.

Evidence

Measured against a build (run 34799668707, shard 3):

tests/async/test_route_web_socket.py::test_should_work_without_server
  isolated world   ->  Timeout
  main world       ->  PASSED

Measured directly with a page whose own script opens a socket — what a real site does — the handler fires in the main world and never fires isolated.

Why we need this

WebSockets are not a niche automation feature; they are how a large share of modern sites move data after load — live pricing, chat, order books, seat maps, match feeds, anything streaming. Scraping those sites means reading or shaping socket traffic. Today a Camoufox user who reaches for the documented Playwright API for that gets silence, and has no signal telling them to stop building on it.

The requirement is both halves at once: it has to work, and it has to stay undetectable. An implementation that trades one for the other is not worth shipping, which rules out the two obvious fixes below.

What not to do

Put the three legs in the main world. Mechanically this is close — the mw: init-script prefix already exists (_evaluateInitScript), and mainWorldContext() returns an ExecutionContext whose _contextGlobal is the page window, so addBinding there would export onto the real global. But it would leave __pwWebSocketBinding and __pwWebSocketDispatch enumerable on window and window.WebSocket failing a native-code toString() check. Object.keys(window).filter(k => k.startsWith('__pw')) defeats it in one line. Working and trivially detectable is the wrong trade for this fork.

Flip the default world. This already exists as disableWorldIsolation, and its own comment says it "gives up the property the fork exists for... it is not a scraping mode." It is there so the vendored conformance suite can run upstream semantics. The cost is not the launcher's init scripts — those are world-agnostic, protected by the seal/unseal window around camouUnsealFingerprintSetters() rather than by isolation. The cost is page.evaluate(), every binding, and Playwright's utility code all becoming page-visible at once.

Proposed fix

Intercept below the DOM object, where the page cannot see it:

  • hook WebSocket::Send (outbound) and WebSocket::CreateAndDispatchMessageEvent (inbound) in dom/websocket/WebSocket.cpp;
  • surface them through a Juggler protocol domain;
  • map route_web_socket onto that domain instead of the init-script mock.

The page's WebSocket is then the real native constructor: toString() is clean, the prototype is untouched, and nothing is added to window. This is the same approach the rest of the fork already takes — C++/Juggler rather than injected JS — and FrameTree.js already holds an nsIWebSocketEventService emitting webSocketCreated / WebSocketFrameSent / WebSocketFrameReceived. The observation half exists; what is missing is the ability to block and rewrite, which that service does not expose.

Known hard part: WebSocket::Send is synchronous from JS's point of view, so it cannot await a round trip to the Playwright server. Frames will need buffering and deferral. That shifts timing, which is acceptable — network timing varies anyway — but it is the part that makes this real work rather than a patch.

Interim

Until that lands, route_web_socket() should throw a clear "not supported under world isolation" error rather than silently doing nothing. A loud failure is strictly better than an API that reports success and drops every frame.

CI

These tests do not fail under isolation — they hang, and the hang cannot be bounded: pytest-timeout's signal fires and the sync API's greenlet never unwinds, so the process wedges with the timeout banner already printed. Four shards burned two hours each this way.

They are declared in ISOLATION_HANGS in ci/run_playwright.py, deselected from the isolated pass and run in the main world, where they pass. When this issue is fixed, that declaration should be removed — and the tests should then pass under isolation, which is the real acceptance criterion for the work.

Deliberately not in ci/skiplist.yml: that list means "fails in the most permissive world", and ci/run_skiplist_audit.py enforces it by running every entry with CI_WORLD=main and failing the build on any that pass. These pass there, so an entry would be rejected by the audit and would be untrue as written.

Generated with Claude Code

https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s