diff --git a/lib/drivers/__tests__/boundedWait.spec.ts b/lib/drivers/__tests__/boundedWait.spec.ts new file mode 100644 index 0000000..f3e962d --- /dev/null +++ b/lib/drivers/__tests__/boundedWait.spec.ts @@ -0,0 +1,88 @@ +import { BoundedWait } from '../boundedWait' + +/** + * The single bounded wait every wait on the Socket is built from. What each of + * those waits does with it lives in the socket specs; this file is only about + * ending once, the deadline, and the release. + */ +describe('BoundedWait', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => jest.useRealTimers()) + + it('resolves with the value it is settled with', async () => { + const wait = new BoundedWait() + wait.resolve('ready') + + await expect(wait.promise).resolves.toBe('ready') + }) + + it('rejects with the error it is failed with', async () => { + const wait = new BoundedWait() + wait.reject(new Error('gone')) + + await expect(wait.promise).rejects.toThrow('gone') + }) + + it('keeps the first settle when a second follows', async () => { + const wait = new BoundedWait() + wait.resolve('first') + wait.reject(new Error('second')) + + await expect(wait.promise).resolves.toBe('first') + }) + + it('ends on its deadline', async () => { + const wait = new BoundedWait(100, () => wait.resolve('expired')) + jest.advanceTimersByTime(100) + + await expect(wait.promise).resolves.toBe('expired') + }) + + it('does not reach its deadline once it is settled', async () => { + const onDeadline = jest.fn() + const wait = new BoundedWait(100, onDeadline) + wait.resolve('answered') + jest.advanceTimersByTime(1000) + + await expect(wait.promise).resolves.toBe('answered') + expect(onDeadline).not.toHaveBeenCalled() + }) + + it('releases what it attached, once, when it settles', () => { + const release = jest.fn() + const wait = new BoundedWait() + wait.release(release) + + wait.resolve(undefined) + wait.resolve(undefined) + + expect(release).toHaveBeenCalledTimes(1) + }) + + it('releases every release it was given', () => { + const first = jest.fn() + const second = jest.fn() + const wait = new BoundedWait() + wait.release(first) + wait.release(second) + + wait.resolve(undefined) + + expect(first).toHaveBeenCalled() + expect(second).toHaveBeenCalled() + }) + + it('leaves the promise to another settler when it is cancelled', async () => { + const release = jest.fn() + const onDeadline = jest.fn() + const wait = new BoundedWait(100, onDeadline) + wait.release(release) + + wait.cancel() + jest.advanceTimersByTime(1000) + + expect(release).toHaveBeenCalled() + expect(onDeadline).not.toHaveBeenCalled() + await expect(Promise.race([wait.promise, Promise.resolve('pending')])).resolves.toBe('pending') + }) +}) diff --git a/lib/drivers/boundedWait.ts b/lib/drivers/boundedWait.ts new file mode 100644 index 0000000..78f06d0 --- /dev/null +++ b/lib/drivers/boundedWait.ts @@ -0,0 +1,49 @@ +/** + * @module BoundedWait + * One wait that ends exactly once — on the first settle, on its deadline, or on + * a cancel — releasing whatever it attached before the promise settles. + */ +export class BoundedWait { + readonly promise: Promise + private settlePromise!: (value: T) => void + private failPromise!: (err: any) => void + private releases: Array<() => void> = [] + private deadline?: NodeJS.Timer | number + private settled = false + + constructor (deadlineMs?: number, onDeadline?: (wait: BoundedWait) => void) { + this.promise = new Promise((settle, fail) => { + this.settlePromise = settle + this.failPromise = fail + }) + + if (deadlineMs !== undefined && onDeadline) { + this.deadline = setTimeout(() => onDeadline(this), deadlineMs) + } + } + + release = (release: () => void) => { + this.releases.push(release) + } + + resolve = (value: T) => { + if (this.end()) this.settlePromise(value) + } + + reject = (err: any) => { + if (this.end()) this.failPromise(err) + } + + /** End the wait, releasing it, and leave the promise to another settler. */ + cancel = () => { + this.end() + } + + private end = () => { + if (this.settled) return false + this.settled = true + clearTimeout(this.deadline as any) + this.releases.forEach((release) => release()) + return true + } +} diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index 98507ca..c6558dc 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -27,6 +27,7 @@ import { ILogger } from '../../interfaces' +import { BoundedWait } from './boundedWait' import { DDPError, toError } from './ddpError' import { hostToWS } from '../util' import { sha256 } from 'js-sha256' @@ -257,45 +258,39 @@ export class Socket extends SDKEventEmitter { * `close` event: a close emitted for the connection that replaced this one * says nothing about the socket being closed here. */ - private waitForClose = (connection: WebSocket, deadlineMs: number) => - new Promise((resolve) => { - let settled = false - const driverOnClose = connection.onclose - - const settle = () => { - if (settled) return - settled = true - clearTimeout(deadline as any) - resolve() - } + private waitForClose = (connection: WebSocket, deadlineMs: number) => { + const driverOnClose = connection.onclose + + const answerCloseOurselves = (reason: string) => { + // Null rather than restore: a transport close that lands after this + // would otherwise re-enter onClose, emit a second close and arm a + // reopen for a socket the driver is already letting go. + if (connection.onclose === onTransportClose) connection.onclose = null as any + this.onClose({ code: userDisconnectCloseCode, reason, wasClean: false }, connection) + wait.resolve(undefined) + } - const onTransportClose = (e: any) => { - driverOnClose?.(e) - settle() - } + const wait = new BoundedWait( + deadlineMs, + () => answerCloseOurselves('the transport did not answer the close') + ) - const answerCloseOurselves = (reason: string) => { - // Null rather than restore: a transport close that lands after this - // would otherwise re-enter onClose, emit a second close and arm a - // reopen for a socket the driver is already letting go. - if (connection.onclose === onTransportClose) connection.onclose = null as any - this.onClose({ code: userDisconnectCloseCode, reason, wasClean: false }, connection) - settle() - } + const onTransportClose = (e: any) => { + driverOnClose?.(e) + wait.resolve(undefined) + } - connection.onclose = onTransportClose - const deadline = setTimeout( - () => answerCloseOurselves('the transport did not answer the close'), - deadlineMs - ) + connection.onclose = onTransportClose - try { - connection.close(userDisconnectCloseCode) - } catch (err) { - this.logger.debug(`[ddp] close: the transport refused to close: ${(err as Error).message}`) - answerCloseOurselves('the transport refused to close') - } - }) + try { + connection.close(userDisconnectCloseCode) + } catch (err) { + this.logger.debug(`[ddp] close: the transport refused to close: ${(err as Error).message}`) + answerCloseOurselves('the transport refused to close') + } + + return wait.promise + } /** * Disconnect the DDP from server and forget every subscription locally: the @@ -388,30 +383,25 @@ export class Socket extends SDKEventEmitter { return this.reopenPromise } - this.reopenPromise = new Promise(resolve => { - this.cancelScheduledReopen() - this.lastPing = 0 - this.emit('disconnected') - - let settled = false - const cleanup = () => { - if (settled) return - settled = true - this.off('open', cleanup) - if (timeout) clearTimeout(timeout as any) - delete this.reopenPromise - delete this.settleReopen - resolve() - } - - this.settleReopen = cleanup - this.once('open', cleanup) + this.cancelScheduledReopen() + this.lastPing = 0 + this.emit('disconnected') - this.createConnection().catch(() => {}) + const wait = new BoundedWait(this.config.timeout, () => wait.resolve(undefined)) + const onOpen = () => wait.resolve(undefined) - const timeout = setTimeout(() => cleanup(), this.config.timeout) + wait.release(() => { + this.off('open', onOpen) + delete this.reopenPromise + delete this.settleReopen }) + this.settleReopen = onOpen + this.once('open', onOpen) + this.reopenPromise = wait.promise + + this.createConnection().catch(() => {}) + return this.reopenPromise } @@ -420,39 +410,24 @@ export class Socket extends SDKEventEmitter { * the socket is open and the server answers the ping within the deadline. */ probe = (deadlineMs = socketDeadlineMs): Promise => { - return new Promise(resolve => { - const connection = this.connection - if (!connection || connection.readyState !== socketOpen) { - return resolve(false) - } - - let settled = false - const cleanup = () => { - if (settled) return - settled = true - this.off('pong', onPong) - if (timeout) clearTimeout(timeout as any) - } + const connection = this.connection + if (!connection || connection.readyState !== socketOpen) { + return Promise.resolve(false) + } - const onPong = () => { - cleanup() - resolve(true) - } + const wait = new BoundedWait(deadlineMs, () => wait.resolve(false)) + const onPong = () => wait.resolve(true) - this.once('pong', onPong) + wait.release(() => this.off('pong', onPong)) + this.once('pong', onPong) - const timeout = setTimeout(() => { - cleanup() - resolve(false) - }, deadlineMs) + try { + connection.send(JSON.stringify({ msg: 'ping' })) + } catch { + wait.resolve(false) + } - try { - connection.send(JSON.stringify({ msg: 'ping' })) - } catch { - cleanup() - resolve(false) - } - }) + return wait.promise } get transportOpen () { @@ -477,24 +452,16 @@ export class Socket extends SDKEventEmitter { * expires as the reconnect begins and every send issued at a drop fails. */ private waitForOpen = (deadlineMs = this.config.reopen * 2): Promise => { - return new Promise((resolve, reject) => { - const cleanup = () => { - this.off('open', onOpen) - clearTimeout(timeout as any) - } + const wait = new BoundedWait( + deadlineMs, + () => wait.reject(new Error('[ddp] timed out waiting for the connection to open')) + ) + const onOpen = () => wait.resolve(undefined) - const onOpen = () => { - cleanup() - resolve() - } - - this.once('open', onOpen) + wait.release(() => this.off('open', onOpen)) + this.once('open', onOpen) - const timeout = setTimeout(() => { - cleanup() - reject(new Error('[ddp] timed out waiting for the connection to open')) - }, deadlineMs) - }) + return wait.promise } /** @@ -526,54 +493,53 @@ export class Socket extends SDKEventEmitter { if (connection.readyState !== socketOpen) throw new AbandonedWait(abandonedByClose) } - return new Promise((resolve, reject) => { - const id = obj.id || `ddp-${ this.sent }` - this.sent += 1 - const data = { ...obj, ...(/connect|ping|pong/.test(obj.msg) ? {} : { id }) } - const stringdata = JSON.stringify(data) - const listener = (data.msg === 'ping' && 'pong') || (data.msg === 'connect' && 'connected') || data.id - this.logger.debug(`[ddp] sending message: ${stringdata}`) + const wait = new BoundedWait() + const id = obj.id || `ddp-${ this.sent }` + this.sent += 1 + const data = { ...obj, ...(/connect|ping|pong/.test(obj.msg) ? {} : { id }) } + const stringdata = JSON.stringify(data) + const listener = (data.msg === 'ping' && 'pong') || (data.msg === 'connect' && 'connected') || data.id + this.logger.debug(`[ddp] sending message: ${stringdata}`) - try { - connection.send(stringdata) - } catch (err) { - this.logger.error(`[ddp] the transport failed to write the message: ${stringdata}`); - return reject(err) - } - - // Before any listener is attached: a DDP message with no response to wait for — - // `pong` — would otherwise strand a `disconnected` listener forever. - if (!listener) { - return resolve(undefined) - } + try { + connection.send(stringdata) + } catch (err) { + this.logger.error(`[ddp] the transport failed to write the message: ${stringdata}`); + wait.reject(err) + return wait.promise + } - // The DDP response can only arrive on the connection this message went out - // on, so every event that ends that connection ends this wait. - const abandonListeners = [ - { event: 'disconnected', message: abandonedByReopen }, - { event: 'connecting', message: abandonedByReopen }, - { event: 'close', message: abandonedByClose } - ].map(({ event, message }) => ({ - event, - onAbandon: () => { - removeListeners() - reject(new AbandonedRequest(id, message)) - } - })) + // Before any listener is attached: a DDP message with no response to wait for — + // `pong` — would otherwise strand a `disconnected` listener forever. + if (!listener) { + wait.resolve(undefined) + return wait.promise + } - const removeListeners = () => { - this.off(listener, onResponse) - abandonListeners.forEach(({ event, onAbandon }) => this.off(event, onAbandon)) - } + // The DDP response can only arrive on the connection this message went out + // on, so every event that ends that connection ends this wait. + const abandonListeners = [ + { event: 'disconnected', message: abandonedByReopen }, + { event: 'connecting', message: abandonedByReopen }, + { event: 'close', message: abandonedByClose } + ].map(({ event, message }) => ({ + event, + onAbandon: () => wait.reject(new AbandonedRequest(id, message)) + })) + + const onResponse = (result: any) => (result.error + ? wait.reject(toError(result.error)) + : wait.resolve({ ...(/connect|ping|pong/.test(obj.msg) ? {} : { id }) , ...result })) + + wait.release(() => { + this.off(listener, onResponse) + abandonListeners.forEach(({ event, onAbandon }) => this.off(event, onAbandon)) + }) - const onResponse = (result: any) => { - removeListeners() - return (result.error ? reject(toError(result.error)) : resolve({ ...(/connect|ping|pong/.test(obj.msg) ? {} : { id }) , ...result })) - } + abandonListeners.forEach(({ event, onAbandon }) => this.once(event, onAbandon)) + this.once(listener, onResponse) - abandonListeners.forEach(({ event, onAbandon }) => this.once(event, onAbandon)) - this.once(listener, onResponse) - }) + return wait.promise } /** Send ping, record time, re-open if nothing comes back, repeat */ @@ -585,18 +551,15 @@ export class Socket extends SDKEventEmitter { // sends, and without a deadline of its own the chain stops here and // `reopen` is never reached. The deadline lives in `ping` rather than in // `send`, so no other caller inherits a reply timeout. - let deadline: NodeJS.Timer | number | undefined - const answered = new Promise((_, expire) => { - deadline = setTimeout( - () => expire(new Error('[ddp] ping went unanswered')), - this.config.ping - ) - }) + const answered = new BoundedWait( + this.config.ping, + () => answered.reject(new Error('[ddp] ping went unanswered')) + ) - Promise.race([this.send({ msg: 'ping' }), answered]) + Promise.race([this.send({ msg: 'ping' }), answered.promise]) .then(() => this.ping()) .catch(this.reopenUnlessAbandoned) - .finally(() => clearTimeout(deadline as any)) + .finally(() => answered.cancel()) }, this.config.ping) }