From a893737594b7ab6984731cbe7edd8fe8b5ebf801 Mon Sep 17 00:00:00 2001 From: Daniel Desjardins Date: Mon, 14 Sep 2026 15:24:51 -0400 Subject: [PATCH 1/2] Add a real WebSocket builtin module pythonmonkey has never had a WebSocket implementation. dcp-client's own SocketIOTransport.buildOptions() explicitly special-cases this: every platform except pythonmonkey gets transports: ['websocket', 'polling'] and upgrades to a persistent WebSocket within the first round trip or two; pythonmonkey is hard-restricted to ['polling'] forever, since `typeof WebSocket` was previously `undefined`. That forces pythonmonkey to be the only client that ever *sustains* HTTP long-polling for an entire connection's lifetime against services like DCP's package manager - a code path essentially unexercised by any other real client, where a real, reproducible session-routing bug lives (a freshly issued session id doesn't reliably route to the same backend on the very next polling request; confirmed independent of pythonmonkey/dcp-client with plain curl, aiohttp, and Node https). Rather than work around that bug, this gives pythonmonkey a real WebSocket implementation - a JS class matching the DOM API (builtin_modules/WebSocket.js), backed by a Python aiohttp-based implementation (builtin_modules/WebSocket-internal.py), following the exact existing pattern XMLHttpRequest already uses (loaded dynamically via require() at pythonmonkey import time, no C++ rebuild needed). Two real bugs were found and fixed while building it (documented inline in WebSocket.js/WebSocket-internal.py's own docstrings): - A race between the 'open' event firing and send/close functions being available, since real clients often send their first message immediately on 'open'. - engine.io-client's real WS transport unconditionally calling `this.ws._socket.unref()` (a Node `ws`-library-shaped assumption that doesn't hold for a browser-style WebSocket), worked around with a harmless no-op `_socket` property. This alone doesn't change any existing behavior - dcp-client's platform check still needs its pythonmonkey exclusion relaxed on its own side for anything to actually use this. Verified via a real end-to-end DCP job (job.exec(), real network dispatch to the demo/dcp compute group) after also relaxing that check locally. Co-Authored-By: Claude Sonnet 5 --- python/pythonmonkey/__init__.py | 1 + .../builtin_modules/WebSocket-internal.py | 85 +++++++ .../pythonmonkey/builtin_modules/WebSocket.js | 214 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 python/pythonmonkey/builtin_modules/WebSocket-internal.py create mode 100644 python/pythonmonkey/builtin_modules/WebSocket.js diff --git a/python/pythonmonkey/__init__.py b/python/pythonmonkey/__init__.py index 957713ff..b101ea8f 100644 --- a/python/pythonmonkey/__init__.py +++ b/python/pythonmonkey/__init__.py @@ -15,3 +15,4 @@ require("timers") require("url") require("XMLHttpRequest") +require("WebSocket") diff --git a/python/pythonmonkey/builtin_modules/WebSocket-internal.py b/python/pythonmonkey/builtin_modules/WebSocket-internal.py new file mode 100644 index 00000000..bdd73b3d --- /dev/null +++ b/python/pythonmonkey/builtin_modules/WebSocket-internal.py @@ -0,0 +1,85 @@ +# @file WebSocket-internal.py +# @brief internal helper functions for WebSocket, backed by aiohttp +# @author (local patch, this session) +# @date September 2026 + +import asyncio +import aiohttp +from typing import Callable, Union, List + + +async def wsConnect( + url: str, + protocols: List[str], + headers: dict, + onOpen: Callable[[Callable, Callable, Callable], None], + onMessage: Callable[[Union[str, bytearray], bool], None], + onError: Callable[[str], None], + onClose: Callable[[int, str], None], + debug: Callable[[str], Callable[..., None]], + / +): + """ + NOTE: onOpen is called with (sendText, sendBinary, close) as arguments, + rather than having this whole function return them once the coroutine + finishes -- the JS side needs those functions available in the SAME + synchronous callback that fires the 'open' event, not one microtask + later. A real client (dcp-client included) commonly sends its first + message immediately in reaction to 'open'; if the send/close functions + only became available via this async function's eventual return value + (resolved on a later microtask), that first send would silently race + against them not being wired up yet and get dropped -- confirmed + empirically as a silent hang (no error, no crash, connection just never + progresses) the first time this was tried without this fix. + """ + session = aiohttp.ClientSession() + + try: + ws = await session.ws_connect(url, protocols=tuple(protocols) if protocols else (), headers=headers or {}) + except Exception as e: + try: + onError(str(e)) + finally: + await session.close() + return + + async def sendText(data: str): + if not ws.closed: + await ws.send_str(data) + + async def sendBinary(data): + if not ws.closed: + await ws.send_bytes(bytes(data)) + + async def closeConn(code: int, reason: str): + if not ws.closed: + await ws.close(code=code or 1000, message=(reason or '').encode('utf-8')) + + onOpen(sendText, sendBinary, closeConn) + + close_code = 1006 + close_reason = '' + try: + async for msg in ws: + debug('ws:io')('received', msg.type, 'len=', len(msg.data) if hasattr(msg.data, '__len__') else None) + if msg.type == aiohttp.WSMsgType.TEXT: + onMessage(msg.data, False) + elif msg.type == aiohttp.WSMsgType.BINARY: + onMessage(bytearray(msg.data), True) + elif msg.type == aiohttp.WSMsgType.ERROR: + onError(str(ws.exception())) + elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED): + break + close_code = ws.close_code if ws.close_code is not None else 1000 + except Exception as e: + onError(str(e)) + finally: + try: + await session.close() + except Exception: + pass + onClose(close_code, close_reason) + + +# Module exports +exports['wsConnect'] = wsConnect # type: ignore diff --git a/python/pythonmonkey/builtin_modules/WebSocket.js b/python/pythonmonkey/builtin_modules/WebSocket.js new file mode 100644 index 00000000..1763b6bc --- /dev/null +++ b/python/pythonmonkey/builtin_modules/WebSocket.js @@ -0,0 +1,214 @@ +/** + * @file WebSocket.js + * Implement the WebSocket API, backed by Python's aiohttp + * WebSocket client (WebSocket-internal.py). + * + * LOCAL PATCH (this session): pythonmonkey has never had a + * WebSocket implementation, which is why dcp-client's + * SocketIOTransport.buildOptions() hard-codes + * `transports: platform !== 'pythonmonkey' ? ['websocket','polling'] : ['polling']` + * -- every other platform upgrades to a persistent WebSocket + * almost immediately, while pythonmonkey is forced to sustain + * long-polling for the entire connection lifetime. That + * sustained-polling code path is essentially unexercised by any + * other real client, and packages.distributed.computer's own + * backend has a real, reproducible session-routing bug in it + * (a freshly-issued session id 404s on the very next polling + * request, confirmed with plain curl/aiohttp/Node https/a bare + * engine.io-client, no dcp-client or pythonmonkey involved). + * Giving pythonmonkey a real WebSocket lets it take the exact + * same well-exercised upgrade path every other platform takes, + * sidestepping that bug entirely instead of working around it. + * + * @date September 2026 + */ +'use strict'; + +const { EventTarget, Event } = require('event-target'); +const { DOMException } = require('dom-exception'); +const { URL } = require('url'); +const { wsConnect } = require('WebSocket-internal'); +const debug = globalThis.python.eval('__import__("pythonmonkey").bootstrap.require')('debug'); + +// exposed +class MessageEvent extends Event +{ + constructor(type, eventInitDict = {}) + { + super(type); + this.data = eventInitDict.data; + } +} + +// exposed +class CloseEvent extends Event +{ + constructor(type, eventInitDict = {}) + { + super(type); + this.code = eventInitDict.code ?? 1000; + this.reason = eventInitDict.reason ?? ''; + this.wasClean = eventInitDict.wasClean ?? true; + } +} + +/** + * Implement the `WebSocket` API according to the spec, backed by aiohttp. + * @see https://websockets.spec.whatwg.org/ + */ +class WebSocket extends EventTarget +{ + /** @readonly */ static CONNECTING = 0; + /** @readonly */ static OPEN = 1; + /** @readonly */ static CLOSING = 2; + /** @readonly */ static CLOSED = 3; + + /** @readonly */ CONNECTING = 0; + /** @readonly */ OPEN = 1; + /** @readonly */ CLOSING = 2; + /** @readonly */ CLOSED = 3; + + // event handlers -- EventTarget#dispatchEvent auto-invokes these + onopen = null; + onmessage = null; + onerror = null; + onclose = null; + + #readyState = WebSocket.CONNECTING; + #conn = null; + #url; + #protocol = ''; + #sendBuffer = []; // messages queued before the underlying connection is ready + + // engine.io-client's WS transport (addEventListeners()) unconditionally + // does `this.ws._socket.unref()` when its `autoUnref` option is set -- + // real Node `ws` library sockets expose the underlying raw net.Socket as + // `._socket`, but a browser-style WebSocket has no such concept. dcp-client + // sets `autoUnref: true` for pythonmonkey (it's in env.js's + // referencedTimerPlatformList), so without this dummy property that call + // throws "can't access property unref, this.ws._socket is undefined" the + // instant the connection opens. A no-op unref() is exactly correct here: + // there is nothing OS-level for pythonmonkey to unref in the first place. + _socket = { unref() {}, ref() {} }; + + /** + * @param {string | URL} url + * @param {string | string[]} [protocols] + */ + constructor(url, protocols) + { + super(); + const parsedURL = new URL(url); + if (!['ws:', 'wss:'].includes(parsedURL.protocol)) + throw new DOMException(`Invalid WebSocket URL scheme "${parsedURL.protocol}"`, 'SyntaxError'); + this.#url = parsedURL.href; + + const protoArray = protocols ? (Array.isArray(protocols) ? protocols : [protocols]) : []; + + // aiohttp's ws_connect() expects a plain http(s):// URL, not ws(s):// + const httpURL = this.#url.replace(/^ws/, 'http'); + + debug('ws:connect')(`connecting to ${httpURL}`); + + wsConnect( + httpURL, + protoArray, + {}, + (sendText, sendBinary, closeFn) => // onOpen + { + // Wired up in the SAME synchronous callback that fires 'open' -- + // see WebSocket-internal.py's docstring for why this matters (a + // message sent in reaction to 'open', which real clients commonly + // do, must never race ahead of these being available). + this.#conn = { sendText, sendBinary, close: closeFn }; + this.#readyState = WebSocket.OPEN; + debug('ws:open')(`connected to ${this.#url}`); + for (const queued of this.#sendBuffer) + this.#doSend(queued); + this.#sendBuffer = []; + this.dispatchEvent(new Event('open')); + }, + (data, isBinary) => // onMessage + { + const payload = isBinary ? new Uint8Array(data).buffer : data; + this.dispatchEvent(new MessageEvent('message', { data: payload })); + }, + (message) => // onError + { + debug('ws:error')(message); + this.dispatchEvent(new Event('error')); + }, + (code, reason) => // onClose + { + this.#readyState = WebSocket.CLOSED; + debug('ws:close')(`closed, code=${code} reason=${reason}`); + this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean: code === 1000 })); + }, + debug, + ).catch((e) => + { + this.#readyState = WebSocket.CLOSED; + debug('ws:error')(String(e)); + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new CloseEvent('close', { code: 1006, reason: String(e), wasClean: false })); + }); + } + + get readyState() { return this.#readyState; } + get url() { return this.#url; } + get protocol() { return this.#protocol; } + get bufferedAmount() { return 0; } // not tracked + + /** + * @param {string | ArrayBuffer | ArrayBufferView} data + */ + send(data) + { + if (this.#readyState === WebSocket.CONNECTING) + throw new DOMException('WebSocket is still connecting (readyState CONNECTING)', 'InvalidStateError'); + if (this.#readyState !== WebSocket.OPEN) + return; // per spec: silently discard if not OPEN + this.#doSend(data); + } + + #doSend(data) + { + if (!this.#conn) + return; + if (typeof data === 'string') + this.#conn.sendText(data); + else if (data instanceof ArrayBuffer) + this.#conn.sendBinary(new Uint8Array(data)); + else + this.#conn.sendBinary(data); // TypedArray/DataView + } + + /** + * @param {number} [code] + * @param {string} [reason] + */ + close(code = 1000, reason = '') + { + if (this.#readyState === WebSocket.CLOSING || this.#readyState === WebSocket.CLOSED) + return; + this.#readyState = WebSocket.CLOSING; + if (this.#conn) + this.#conn.close(code, reason); + } +} + +/* A side-effect of loading this module is to add WebSocket and related + * symbols to the global object, matching XMLHttpRequest.js's convention, + * so real code (like dcp-client, once its platform check is relaxed) can + * use `new WebSocket(...)` directly with no require() needed. + */ +if (!globalThis.WebSocket) + globalThis.WebSocket = WebSocket; +if (!globalThis.MessageEvent) + globalThis.MessageEvent = MessageEvent; +if (!globalThis.CloseEvent) + globalThis.CloseEvent = CloseEvent; + +exports.WebSocket = WebSocket; +exports.MessageEvent = MessageEvent; +exports.CloseEvent = CloseEvent; From 52437264a6d72af080789e42ed56b6b6c5c25858 Mon Sep 17 00:00:00 2001 From: Daniel Desjardins Date: Wed, 7 Oct 2026 06:47:43 -0400 Subject: [PATCH 2/2] WebSocket: fix connection-failure and early-close state, add test - A refused connection now fires 'close' (1006) after 'error' and ends in readyState CLOSED instead of staying CONNECTING forever. - close() while still connecting is honoured once the socket opens, instead of being ignored and 'open' firing anyway. - Surface the negotiated subprotocol and the server's close reason. - wasClean is false only for 1006 (no close handshake), not for any non-1000 code. - Send errors are reported via 'error' instead of unhandled rejections; send() accepts any ArrayBufferView and rejects other types. - Drop the unused pre-open send buffer and headers parameter; trim file headers and comments to the why; add WebSocket-internal.d.ts. - Add tests/python/test_websocket.py covering the above against a local aiohttp server. Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + .../builtin_modules/WebSocket-internal.d.ts | 30 +++++ .../builtin_modules/WebSocket-internal.py | 86 +++++++------- .../pythonmonkey/builtin_modules/WebSocket.js | 81 +++++-------- tests/python/test_websocket.py | 106 ++++++++++++++++++ 5 files changed, 208 insertions(+), 96 deletions(-) create mode 100644 python/pythonmonkey/builtin_modules/WebSocket-internal.d.ts create mode 100644 tests/python/test_websocket.py diff --git a/README.md b/README.md index 31e6efac..a16ba775 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ js_eval("console.log")('hello, world') - [done] CommonJS module system .py loader, loads Python modules for use by JS - [done] Python host environment supplies event loop, including EventEmitter, setTimeout, etc. - [done] Python host environment supplies XMLHttpRequest +- [done] Python host environment supplies WebSocket - [done] Python TypedArrays coerce to JS TypeArrays - [done] JS TypedArrays coerce to Python TypeArrays - [done] Python lists coerce to JS Arrays diff --git a/python/pythonmonkey/builtin_modules/WebSocket-internal.d.ts b/python/pythonmonkey/builtin_modules/WebSocket-internal.d.ts new file mode 100644 index 00000000..9c3cafe1 --- /dev/null +++ b/python/pythonmonkey/builtin_modules/WebSocket-internal.d.ts @@ -0,0 +1,30 @@ +/** + * @file WebSocket-internal.d.ts + * @brief TypeScript type declarations for the internal WebSocket helpers + * @author Dan Desjardins + * @date September 2026 + * + * @copyright Copyright (c) 2026 Distributive Corp. + */ + +/** + * Open a WebSocket connection and pump its messages into the callbacks. + * Resolves once the connection has closed; `onClose` is called exactly once. + */ +export declare function wsConnect( + url: string, + protocols: string[], + // called before the 'open' event, with the negotiated subprotocol and the send/close functions + onOpen: ( + protocol: string, + sendText: (data: string) => Promise, + sendBinary: (data: Uint8Array) => Promise, + close: (code: number, reason: string) => Promise, + ) => void, + onMessage: (data: string | Uint8Array, isBinary: boolean) => void, + onError: (message: string) => void, + onClose: (code: number, reason: string) => void, + // the debug logging function + /** See `pm.bootstrap.require("debug")` */ + debug: (selector: string) => ((...args: string[]) => void), +): Promise; diff --git a/python/pythonmonkey/builtin_modules/WebSocket-internal.py b/python/pythonmonkey/builtin_modules/WebSocket-internal.py index bdd73b3d..e7164152 100644 --- a/python/pythonmonkey/builtin_modules/WebSocket-internal.py +++ b/python/pythonmonkey/builtin_modules/WebSocket-internal.py @@ -1,18 +1,17 @@ # @file WebSocket-internal.py # @brief internal helper functions for WebSocket, backed by aiohttp -# @author (local patch, this session) +# @author Dan Desjardins # @date September 2026 +# @copyright Copyright (c) 2026 Distributive Corp. -import asyncio import aiohttp -from typing import Callable, Union, List +from typing import Callable, List, Union async def wsConnect( url: str, protocols: List[str], - headers: dict, - onOpen: Callable[[Callable, Callable, Callable], None], + onOpen: Callable[[str, Callable, Callable, Callable], None], onMessage: Callable[[Union[str, bytearray], bool], None], onError: Callable[[str], None], onClose: Callable[[int, str], None], @@ -20,65 +19,68 @@ async def wsConnect( / ): """ - NOTE: onOpen is called with (sendText, sendBinary, close) as arguments, - rather than having this whole function return them once the coroutine - finishes -- the JS side needs those functions available in the SAME - synchronous callback that fires the 'open' event, not one microtask - later. A real client (dcp-client included) commonly sends its first - message immediately in reaction to 'open'; if the send/close functions - only became available via this async function's eventual return value - (resolved on a later microtask), that first send would silently race - against them not being wired up yet and get dropped -- confirmed - empirically as a silent hang (no error, no crash, connection just never - progresses) the first time this was tried without this fix. + Open a WebSocket and pump its messages into the JS-side callbacks. + + onOpen receives the negotiated subprotocol and the send/close functions in + the same synchronous call that precedes the JS 'open' event, so a message + sent from an 'open' handler can never race the functions being wired up. + Every path ends with exactly one onClose call. """ + log = debug('ws:io') session = aiohttp.ClientSession() try: - ws = await session.ws_connect(url, protocols=tuple(protocols) if protocols else (), headers=headers or {}) + ws = await session.ws_connect(url, protocols=tuple(protocols)) except Exception as e: - try: - onError(str(e)) - finally: - await session.close() + onError(str(e)) + await session.close() + onClose(1006, '') return - async def sendText(data: str): - if not ws.closed: - await ws.send_str(data) - - async def sendBinary(data): - if not ws.closed: - await ws.send_bytes(bytes(data)) + def guard(fn): + # JS calls these without awaiting the returned promise, so failures are + # reported through onError instead of becoming unhandled rejections. + async def wrapper(*args): + if ws.closed: + return + try: + await fn(*args) + except Exception as e: + onError(str(e)) + return wrapper - async def closeConn(code: int, reason: str): - if not ws.closed: - await ws.close(code=code or 1000, message=(reason or '').encode('utf-8')) + sendText = guard(ws.send_str) + sendBinary = guard(lambda data: ws.send_bytes(bytes(data))) + closeConn = guard(lambda code, reason: ws.close(code=code, message=reason.encode('utf-8'))) - onOpen(sendText, sendBinary, closeConn) + onOpen(ws.protocol or '', sendText, sendBinary, closeConn) - close_code = 1006 close_reason = '' try: - async for msg in ws: - debug('ws:io')('received', msg.type, 'len=', len(msg.data) if hasattr(msg.data, '__len__') else None) + while True: + # receive() rather than `async for`, which swallows the CLOSE frame's reason + msg = await ws.receive() + log('received', msg.type.name) if msg.type == aiohttp.WSMsgType.TEXT: onMessage(msg.data, False) elif msg.type == aiohttp.WSMsgType.BINARY: onMessage(bytearray(msg.data), True) elif msg.type == aiohttp.WSMsgType.ERROR: onError(str(ws.exception())) - elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED): break - close_code = ws.close_code if ws.close_code is not None else 1000 + elif msg.type == aiohttp.WSMsgType.CLOSE: + close_reason = msg.extra or '' + break + elif msg.type == aiohttp.WSMsgType.CLOSED: + break + # CLOSING means our own close() is in flight; the next receive() finishes + # the handshake and reports CLOSED with the real close code except Exception as e: onError(str(e)) finally: - try: - await session.close() - except Exception: - pass - onClose(close_code, close_reason) + await session.close() + # close_code is unset when the connection dropped without a close frame + onClose(ws.close_code or 1006, close_reason) # Module exports diff --git a/python/pythonmonkey/builtin_modules/WebSocket.js b/python/pythonmonkey/builtin_modules/WebSocket.js index 1763b6bc..db374a5c 100644 --- a/python/pythonmonkey/builtin_modules/WebSocket.js +++ b/python/pythonmonkey/builtin_modules/WebSocket.js @@ -2,25 +2,10 @@ * @file WebSocket.js * Implement the WebSocket API, backed by Python's aiohttp * WebSocket client (WebSocket-internal.py). - * - * LOCAL PATCH (this session): pythonmonkey has never had a - * WebSocket implementation, which is why dcp-client's - * SocketIOTransport.buildOptions() hard-codes - * `transports: platform !== 'pythonmonkey' ? ['websocket','polling'] : ['polling']` - * -- every other platform upgrades to a persistent WebSocket - * almost immediately, while pythonmonkey is forced to sustain - * long-polling for the entire connection lifetime. That - * sustained-polling code path is essentially unexercised by any - * other real client, and packages.distributed.computer's own - * backend has a real, reproducible session-routing bug in it - * (a freshly-issued session id 404s on the very next polling - * request, confirmed with plain curl/aiohttp/Node https/a bare - * engine.io-client, no dcp-client or pythonmonkey involved). - * Giving pythonmonkey a real WebSocket lets it take the exact - * same well-exercised upgrade path every other platform takes, - * sidestepping that bug entirely instead of working around it. - * + * @author Dan Desjardins * @date September 2026 + * + * @copyright Copyright (c) 2026 Distributive Corp. */ 'use strict'; @@ -78,17 +63,10 @@ class WebSocket extends EventTarget #conn = null; #url; #protocol = ''; - #sendBuffer = []; // messages queued before the underlying connection is ready - - // engine.io-client's WS transport (addEventListeners()) unconditionally - // does `this.ws._socket.unref()` when its `autoUnref` option is set -- - // real Node `ws` library sockets expose the underlying raw net.Socket as - // `._socket`, but a browser-style WebSocket has no such concept. dcp-client - // sets `autoUnref: true` for pythonmonkey (it's in env.js's - // referencedTimerPlatformList), so without this dummy property that call - // throws "can't access property unref, this.ws._socket is undefined" the - // instant the connection opens. A no-op unref() is exactly correct here: - // there is nothing OS-level for pythonmonkey to unref in the first place. + + // engine.io-client's WebSocket transport calls `this.ws._socket.unref()` on + // open when its autoUnref option is set, a Node `ws`-library detail that a + // browser-style WebSocket doesn't have. Nothing here needs unref'ing. _socket = { unref() {}, ref() {} }; /** @@ -113,23 +91,22 @@ class WebSocket extends EventTarget wsConnect( httpURL, protoArray, - {}, - (sendText, sendBinary, closeFn) => // onOpen + (protocol, sendText, sendBinary, closeFn) => // onOpen { - // Wired up in the SAME synchronous callback that fires 'open' -- - // see WebSocket-internal.py's docstring for why this matters (a - // message sent in reaction to 'open', which real clients commonly - // do, must never race ahead of these being available). this.#conn = { sendText, sendBinary, close: closeFn }; + if (this.#readyState === WebSocket.CLOSING) // close() was called while connecting + { + closeFn(1000, ''); + return; + } + this.#protocol = protocol; this.#readyState = WebSocket.OPEN; debug('ws:open')(`connected to ${this.#url}`); - for (const queued of this.#sendBuffer) - this.#doSend(queued); - this.#sendBuffer = []; this.dispatchEvent(new Event('open')); }, (data, isBinary) => // onMessage { + // copy binary payloads out of the Python bytearray into a JS-owned ArrayBuffer const payload = isBinary ? new Uint8Array(data).buffer : data; this.dispatchEvent(new MessageEvent('message', { data: payload })); }, @@ -142,13 +119,16 @@ class WebSocket extends EventTarget { this.#readyState = WebSocket.CLOSED; debug('ws:close')(`closed, code=${code} reason=${reason}`); - this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean: code === 1000 })); + // 1006 is reserved for connections that dropped without a close handshake + this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean: code !== 1006 })); }, debug, - ).catch((e) => + ).catch((e) => // only reachable if a callback above threw past the Python side { - this.#readyState = WebSocket.CLOSED; debug('ws:error')(String(e)); + if (this.#readyState === WebSocket.CLOSED) + return; + this.#readyState = WebSocket.CLOSED; this.dispatchEvent(new Event('error')); this.dispatchEvent(new CloseEvent('close', { code: 1006, reason: String(e), wasClean: false })); }); @@ -168,19 +148,14 @@ class WebSocket extends EventTarget throw new DOMException('WebSocket is still connecting (readyState CONNECTING)', 'InvalidStateError'); if (this.#readyState !== WebSocket.OPEN) return; // per spec: silently discard if not OPEN - this.#doSend(data); - } - - #doSend(data) - { - if (!this.#conn) - return; if (typeof data === 'string') this.#conn.sendText(data); else if (data instanceof ArrayBuffer) this.#conn.sendBinary(new Uint8Array(data)); + else if (ArrayBuffer.isView(data)) + this.#conn.sendBinary(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); else - this.#conn.sendBinary(data); // TypedArray/DataView + throw new TypeError('WebSocket.send() data must be a string, ArrayBuffer or ArrayBufferView'); } /** @@ -192,15 +167,13 @@ class WebSocket extends EventTarget if (this.#readyState === WebSocket.CLOSING || this.#readyState === WebSocket.CLOSED) return; this.#readyState = WebSocket.CLOSING; - if (this.#conn) + if (this.#conn) // otherwise still connecting; onOpen closes it this.#conn.close(code, reason); } } -/* A side-effect of loading this module is to add WebSocket and related - * symbols to the global object, matching XMLHttpRequest.js's convention, - * so real code (like dcp-client, once its platform check is relaxed) can - * use `new WebSocket(...)` directly with no require() needed. +/* A side-effect of loading this module is to add WebSocket and related symbols to the global + * object, matching XMLHttpRequest.js, so code written for browsers works without a require(). */ if (!globalThis.WebSocket) globalThis.WebSocket = WebSocket; diff --git a/tests/python/test_websocket.py b/tests/python/test_websocket.py new file mode 100644 index 00000000..0c9d6d5d --- /dev/null +++ b/tests/python/test_websocket.py @@ -0,0 +1,106 @@ +import asyncio +import socket +import aiohttp +import aiohttp.web +import pythonmonkey as pm + + +def free_port(): + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +async def ws_handler(request): + ws = aiohttp.web.WebSocketResponse(protocols=('chat',)) + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if msg.data == 'close-me': + await ws.close(code=4000, message=b'bye') + else: + await ws.send_str('echo:' + msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await ws.send_bytes(bytes(reversed(msg.data))) + return ws + + +def test_websocket(): + async def async_fn(): + port = free_port() + app = aiohttp.web.Application() + app.router.add_get('/ws', ws_handler) + runner = aiohttp.web.AppRunner(app) + await runner.setup() + await aiohttp.web.TCPSite(runner, '127.0.0.1', port).start() + + # text and binary round trips, subprotocol negotiation, server-initiated close + log = await pm.eval(""" + (port) => new Promise((resolve, reject) => { + const log = []; + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, ['chat']); + log.push('state=' + ws.readyState); + ws.onopen = () => { + log.push(`open protocol=${ws.protocol} state=${ws.readyState}`); + ws.send('hello'); + }; + ws.onmessage = (ev) => { + if (typeof ev.data === 'string') { + log.push('text:' + ev.data); + ws.send(new Uint8Array([1, 2, 3])); + } else { + log.push('binary:' + Array.from(new Uint8Array(ev.data)).join(',')); + ws.send('close-me'); + } + }; + ws.onerror = () => log.push('error'); + ws.onclose = (ev) => { + log.push(`close code=${ev.code} reason=${ev.reason} clean=${ev.wasClean} state=${ws.readyState}`); + resolve(log); + }; + setTimeout(() => reject(new Error('timeout: ' + log.join(' | '))), 5000); + }) + """)(port) + assert list(log) == [ + 'state=0', + 'open protocol=chat state=1', + 'text:echo:hello', + 'binary:3,2,1', + 'close code=4000 reason=bye clean=true state=3', + ] + + # refused connection fires error then close, and ends CLOSED + log = await pm.eval(""" + (port) => new Promise((resolve, reject) => { + const log = []; + const ws = new WebSocket(`ws://127.0.0.1:${port}/`); + ws.onopen = () => log.push('open'); + ws.onerror = () => log.push('error state=' + ws.readyState); + ws.onclose = (ev) => { + log.push(`close code=${ev.code} clean=${ev.wasClean} state=${ws.readyState}`); + resolve(log); + }; + setTimeout(() => reject(new Error('timeout: ' + log.join(' | '))), 5000); + }) + """)(free_port()) + assert list(log) == ['error state=0', 'close code=1006 clean=false state=3'] + + # close() while still connecting never fires 'open' + log = await pm.eval(""" + (port) => new Promise((resolve, reject) => { + const log = []; + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + ws.close(); + log.push('state=' + ws.readyState); + ws.onopen = () => log.push('open'); + ws.onclose = (ev) => { + log.push(`close code=${ev.code} state=${ws.readyState}`); + resolve(log); + }; + setTimeout(() => reject(new Error('timeout: ' + log.join(' | '))), 5000); + }) + """)(port) + assert list(log) == ['state=2', 'close code=1000 state=3'] + + await runner.cleanup() + asyncio.run(async_fn())