| 1 | import assert from 'node:assert/strict'; |
| 2 | import { readFile } from 'node:fs/promises'; |
| 3 | import test from 'node:test'; |
| 4 | |
| 5 | const opened = []; |
| 6 | class WebSocket { |
| 7 | static OPEN = 1; |
| 8 | constructor() { |
| 9 | this.readyState = WebSocket.OPEN; |
| 10 | this.bufferedAmount = 0; |
| 11 | this.sent = []; |
| 12 | this.closes = 0; |
| 13 | opened.push(this); |
| 14 | } |
| 15 | send(bytes) { |
| 16 | if (this.failure) throw this.failure; |
| 17 | this.sent.push(bytes.slice()); |
| 18 | this.bufferedAmount += bytes.length; |
| 19 | } |
| 20 | close() { |
| 21 | this.closes++; |
| 22 | this.readyState = 3; |
| 23 | } |
| 24 | } |
| 25 | globalThis.WebSocket = WebSocket; |
| 26 | const source = await readFile(new URL('../../crates/notebook/src/live/web.js', import.meta.url), 'utf8'); |
| 27 | const { liveConnect, liveSend, liveClose } = await import( |
| 28 | `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` |
| 29 | ); |
| 30 | |
| 31 | for (const accepted of [0, 1]) { |
| 32 | test(`backpressure after ${accepted} chunks retires the socket before notifying`, () => { |
| 33 | let disconnected = 0; |
| 34 | const id = liveConnect('ws://disposable.invalid', () => {}, () => { |
| 35 | disconnected++; |
| 36 | assert.throws(() => liveSend(id, new Uint8Array(1)), /Relay disconnected/); |
| 37 | }); |
| 38 | const socket = opened.at(-1); |
| 39 | const onclose = socket.onclose; |
| 40 | const chunk = new Uint8Array(64 << 10); |
| 41 | socket.bufferedAmount = accepted ? 1048576 - chunk.length + 1 : 1048577; |
| 42 | if (accepted) liveSend(id, chunk); |
| 43 | assert.throws(() => liveSend(id, chunk), /Relay cannot keep up/); |
| 44 | assert.equal(socket.sent.length, accepted); |
| 45 | assert.equal(socket.closes, 1); |
| 46 | assert.equal(disconnected, 1); |
| 47 | assert.equal(socket.onmessage, null); |
| 48 | assert.equal(socket.onclose, null); |
| 49 | onclose(); |
| 50 | liveClose(id); |
| 51 | assert.equal(socket.closes, 1); |
| 52 | assert.equal(disconnected, 1); |
| 53 | |
| 54 | const replacement = liveConnect('ws://disposable.invalid', () => {}, () => disconnected++); |
| 55 | liveSend(replacement, chunk); |
| 56 | assert.throws(() => liveSend(id, chunk), /Relay disconnected/); |
| 57 | onclose(); |
| 58 | assert.equal(opened.at(-1).sent.length, 1); |
| 59 | assert.equal(opened.at(-1).closes, 0); |
| 60 | assert.equal(disconnected, 1); |
| 61 | liveClose(replacement); |
| 62 | }); |
| 63 | } |
| 64 | |
| 65 | test('a WebSocket send exception retires the socket and preserves the error', () => { |
| 66 | let disconnected = 0; |
| 67 | const id = liveConnect('ws://disposable.invalid', () => {}, () => disconnected++); |
| 68 | const socket = opened.at(-1); |
| 69 | socket.failure = new Error('Transport failed'); |
| 70 | assert.throws(() => liveSend(id, new Uint8Array(1)), error => error === socket.failure); |
| 71 | assert.equal(socket.sent.length, 0); |
| 72 | assert.equal(socket.closes, 1); |
| 73 | assert.equal(disconnected, 1); |
| 74 | }); |
| 75 | |
| 76 | test('a non-open socket is retired, while deliberate close never reconnects', () => { |
| 77 | let disconnected = 0; |
| 78 | const id = liveConnect('ws://disposable.invalid', () => {}, () => disconnected++); |
| 79 | const socket = opened.at(-1); |
| 80 | socket.readyState = 2; |
| 81 | assert.throws(() => liveSend(id, new Uint8Array(1)), /Relay disconnected/); |
| 82 | assert.equal(socket.closes, 1); |
| 83 | assert.equal(disconnected, 1); |
| 84 | const next = liveConnect('ws://disposable.invalid', () => {}, () => disconnected++); |
| 85 | const onclose = opened.at(-1).onclose; |
| 86 | liveClose(next); |
| 87 | onclose(); |
| 88 | assert.equal(opened.at(-1).closes, 1); |
| 89 | assert.equal(disconnected, 1); |
| 90 | }); |