|
| 1 | +/* global test, expect, afterEach, jest */ |
| 2 | +// NB's process-wide pressure registry (see ToolSocketNB.js) holds an entry per enhanced |
| 3 | +// socket and samples them on an interval while it has any. A socket leaves the registry when |
| 4 | +// its close surfaces, so every way an application ends a socket for good must surface one: |
| 5 | +// otherwise the entry, its socket, and the sampler outlive it for the rest of the process. |
| 6 | +const ToolSocket = require('./index.js'); |
| 7 | +const NB = require('./ToolSocketNB.js'); |
| 8 | + |
| 9 | +jest.setTimeout(20000); |
| 10 | + |
| 11 | +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); |
| 12 | +const waitFor = async (condition, timeoutMs = 5000, stepMs = 10) => { |
| 13 | + const start = Date.now(); |
| 14 | + while (Date.now() - start < timeoutMs) { |
| 15 | + if (condition()) { |
| 16 | + return true; |
| 17 | + } |
| 18 | + await sleep(stepMs); |
| 19 | + } |
| 20 | + return condition(); |
| 21 | +}; |
| 22 | + |
| 23 | +const registry = () => globalThis[Symbol.for('toolsocketnb.pressure.v1')]; |
| 24 | +const registered = () => NB.pressure.state().sockets; |
| 25 | + |
| 26 | +const running = []; |
| 27 | +afterEach(async () => { |
| 28 | + while (running.length) { |
| 29 | + try { |
| 30 | + running.pop().close(); |
| 31 | + } catch (_e) { /* already closed */ } |
| 32 | + } |
| 33 | + await waitFor(() => registered() === 0); |
| 34 | +}); |
| 35 | + |
| 36 | +function startServer() { |
| 37 | + const server = new ToolSocket.Server({ port: 0 }, 'server'); |
| 38 | + server.addEventListener('connection', socket => NB.enhance(socket, { reconnect: false })); |
| 39 | + running.push(server); |
| 40 | + return new Promise(resolve => server.server.on('listening', () => { |
| 41 | + resolve({ server, url: `ws://127.0.0.1:${server.server.address().port}` }); |
| 42 | + })); |
| 43 | +} |
| 44 | + |
| 45 | +function opened(socket) { |
| 46 | + return new Promise(resolve => socket.addEventListener('open', resolve)); |
| 47 | +} |
| 48 | + |
| 49 | +test('closing a server releases its connections from the registry', async () => { |
| 50 | + const { server, url } = await startServer(); |
| 51 | + // a legacy client, as most of the edge server's own are, and an NB one |
| 52 | + const legacy = new ToolSocket(new URL(url), 'testnet', 'web'); |
| 53 | + const nb = NB.connect(url, 'testnet', 'web', { reconnect: false }); |
| 54 | + await Promise.all([opened(legacy), opened(nb)]); |
| 55 | + // the server's two connections and the NB client |
| 56 | + expect(await waitFor(() => registered() === 3)).toBe(true); |
| 57 | + expect(registry().timer).not.toBe(null); |
| 58 | + |
| 59 | + const closed = [legacy, nb].map(socket => new Promise(resolve => socket.addEventListener('close', resolve))); |
| 60 | + server.close(); |
| 61 | + await Promise.all(closed); |
| 62 | + expect(await waitFor(() => registered() === 0)).toBe(true); |
| 63 | + expect(registry().timer).toBe(null); |
| 64 | +}); |
| 65 | + |
| 66 | +test('closing a server closes connections to peers without the session layer', async () => { |
| 67 | + const { server, url } = await startServer(); |
| 68 | + // a plain WebSocket never answers the session handshake, so it is never told __ts/bye |
| 69 | + const WebSocket = require('ws'); |
| 70 | + const peer = new WebSocket(url); |
| 71 | + peer.on('error', () => {}); |
| 72 | + await new Promise(resolve => peer.on('open', resolve)); |
| 73 | + expect(await waitFor(() => registered() === 1)).toBe(true); |
| 74 | + |
| 75 | + const peerClosed = new Promise(resolve => peer.on('close', resolve)); |
| 76 | + server.close(); |
| 77 | + await peerClosed; |
| 78 | + expect(await waitFor(() => registered() === 0)).toBe(true); |
| 79 | + expect(registry().timer).toBe(null); |
| 80 | +}); |
| 81 | + |
| 82 | +test('closing a server surfaces the close of a session held in grace', async () => { |
| 83 | + const server = new ToolSocket.Server({ port: 0, session: { graceMs: 60000 } }, 'server'); |
| 84 | + running.push(server); |
| 85 | + await new Promise(resolve => server.server.on('listening', resolve)); |
| 86 | + const url = `ws://127.0.0.1:${server.server.address().port}`; |
| 87 | + const connected = new Promise(resolve => server.addEventListener('connection', resolve)); |
| 88 | + const client = new ToolSocket(new URL(url), 'testnet', 'web'); |
| 89 | + running.push(client); |
| 90 | + const incoming = await connected; |
| 91 | + NB.enhance(incoming, { reconnect: false }); |
| 92 | + await opened(client); |
| 93 | + expect(await waitFor(() => incoming.__ssn.peer === true)).toBe(true); |
| 94 | + expect(registered()).toBe(1); |
| 95 | + |
| 96 | + // the transport drops without a toolsocket close(): the server holds the session for a |
| 97 | + // successor that, with the client's migration stopped, never comes |
| 98 | + client.__ssn.userClosed = true; |
| 99 | + client.socket.terminate(); |
| 100 | + expect(await waitFor(() => incoming.__ssn.inGrace())).toBe(true); |
| 101 | + |
| 102 | + const incomingClosed = new Promise(resolve => incoming.addEventListener('close', resolve)); |
| 103 | + server.close(); |
| 104 | + await incomingClosed; |
| 105 | + expect(registered()).toBe(0); |
| 106 | +}); |
| 107 | + |
| 108 | +test('a close listener that throws does not stop the server closing the rest', async () => { |
| 109 | + const server = new ToolSocket.Server({ port: 0, session: { graceMs: 60000 } }, 'server'); |
| 110 | + running.push(server); |
| 111 | + await new Promise(resolve => server.server.on('listening', resolve)); |
| 112 | + const url = `ws://127.0.0.1:${server.server.address().port}`; |
| 113 | + server.addEventListener('connection', socket => NB.enhance(socket, { reconnect: false })); |
| 114 | + const clients = []; |
| 115 | + for (let i = 0; i < 2; i++) { |
| 116 | + const client = new ToolSocket(new URL(url), 'testnet', 'web'); |
| 117 | + running.push(client); |
| 118 | + clients.push(client); |
| 119 | + await opened(client); |
| 120 | + } |
| 121 | + expect(await waitFor(() => server.sockets.length === 2 && server.sockets.every(s => s.__ssn.peer === true))).toBe(true); |
| 122 | + expect(registered()).toBe(2); |
| 123 | + |
| 124 | + // both sessions held in grace, so server.close() surfaces their closes, and so runs their |
| 125 | + // listeners, synchronously |
| 126 | + for (const client of clients) { |
| 127 | + client.__ssn.userClosed = true; |
| 128 | + client.socket.terminate(); |
| 129 | + } |
| 130 | + expect(await waitFor(() => server.sockets.every(s => s.__ssn.inGrace()))).toBe(true); |
| 131 | + |
| 132 | + const [first, second] = server.sockets; |
| 133 | + const listenerError = new Error('application close listener'); |
| 134 | + first.addEventListener('close', () => { |
| 135 | + throw listenerError; |
| 136 | + }); |
| 137 | + let secondClosed = false; |
| 138 | + second.addEventListener('close', () => { |
| 139 | + secondClosed = true; |
| 140 | + }); |
| 141 | + |
| 142 | + expect(() => server.close()).toThrow(listenerError); |
| 143 | + expect(secondClosed).toBe(true); |
| 144 | + expect(server.sockets.length).toBe(0); |
| 145 | + expect(registered()).toBe(0); |
| 146 | + expect(registry().timer).toBe(null); |
| 147 | + expect(server.server.address()).toBe(null); // stopped listening |
| 148 | +}); |
| 149 | + |
| 150 | +test('closing a reconnecting client releases it from the registry', async () => { |
| 151 | + const { url } = await startServer(); |
| 152 | + const client = NB.connect(url, 'testnet', 'web', { reconnect: true }); |
| 153 | + await opened(client); |
| 154 | + // once the session handshake is done, a close is sent as __ts/bye, so the server's end |
| 155 | + // closes at once rather than waiting out its grace for a successor |
| 156 | + expect(await waitFor(() => client.__ssn.peer === true)).toBe(true); |
| 157 | + expect(await waitFor(() => registered() === 2)).toBe(true); |
| 158 | + |
| 159 | + const clientClosed = new Promise(resolve => client.addEventListener('close', resolve)); |
| 160 | + client.close(); |
| 161 | + await clientClosed; |
| 162 | + // the server's end goes when the client's close reaches it |
| 163 | + expect(await waitFor(() => registered() === 0)).toBe(true); |
| 164 | + expect(registry().timer).toBe(null); |
| 165 | +}); |
0 commit comments