page.route_web_socket() silently does nothing: the WebSocket mock lands in the isolated world, so page-originated sockets are never intercepted
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 -> PASSEDMeasured 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) andWebSocket::CreateAndDispatchMessageEvent(inbound) indom/websocket/WebSocket.cpp; - surface them through a Juggler protocol domain;
- map
route_web_socketonto 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
Source: daijro/camoufox