|
| 1 | +/** |
| 2 | + * @vitest-environment jsdom |
| 3 | + */ |
| 4 | + |
| 5 | +import { describe, expect, it } from 'vitest'; |
| 6 | +import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler'; |
| 7 | +import type { WorkerResponse } from '../../../src/types'; |
| 8 | + |
| 9 | +/** |
| 10 | + * Minimal Worker stub that lets tests control when responses dispatch and |
| 11 | + * track how many 'message' listeners are attached at any time. Real workers |
| 12 | + * are async; we model that with a queue we drain manually so the test can |
| 13 | + * assert on the listener count while requests are in flight. |
| 14 | + */ |
| 15 | +class MockWorker implements Pick<Worker, 'addEventListener' | 'removeEventListener' | 'postMessage' | 'terminate'> { |
| 16 | + public listenerCount = 0; |
| 17 | + public terminated = false; |
| 18 | + |
| 19 | + private _listeners = new Map<string, Set<EventListenerOrEventListenerObject>>(); |
| 20 | + private _pendingRequests: Array<{ id: number; method: string }> = []; |
| 21 | + |
| 22 | + public addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { |
| 23 | + if (!this._listeners.has(type)) this._listeners.set(type, new Set()); |
| 24 | + this._listeners.get(type)!.add(listener); |
| 25 | + if (type === 'message') this.listenerCount++; |
| 26 | + } |
| 27 | + |
| 28 | + public removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { |
| 29 | + const set = this._listeners.get(type); |
| 30 | + if (set?.delete(listener) && type === 'message') this.listenerCount--; |
| 31 | + } |
| 32 | + |
| 33 | + public postMessage(data: unknown): void { |
| 34 | + const { id, method } = data as { id: number; method: string }; |
| 35 | + this._pendingRequests.push({ id, method }); |
| 36 | + } |
| 37 | + |
| 38 | + public terminate(): void { |
| 39 | + this.terminated = true; |
| 40 | + } |
| 41 | + |
| 42 | + /** Dispatch the queued response for a given id (FIFO order otherwise). */ |
| 43 | + public flushOne(overrides?: Partial<WorkerResponse>): void { |
| 44 | + const next = this._pendingRequests.shift(); |
| 45 | + if (!next) return; |
| 46 | + const response: WorkerResponse = { |
| 47 | + id: next.id, |
| 48 | + method: next.method, |
| 49 | + success: true, |
| 50 | + response: `result-${next.id}`, |
| 51 | + ...overrides, |
| 52 | + }; |
| 53 | + this._dispatch('message', { data: response } as MessageEvent); |
| 54 | + } |
| 55 | + |
| 56 | + public flushAll(): void { |
| 57 | + while (this._pendingRequests.length > 0) this.flushOne(); |
| 58 | + } |
| 59 | + |
| 60 | + /** Dispatch a message that doesn't correspond to a queued request. */ |
| 61 | + public dispatchRaw(response: Partial<WorkerResponse>): void { |
| 62 | + this._dispatch('message', { data: response } as MessageEvent); |
| 63 | + } |
| 64 | + |
| 65 | + public get pendingCount(): number { |
| 66 | + return this._pendingRequests.length; |
| 67 | + } |
| 68 | + |
| 69 | + private _dispatch(type: string, event: MessageEvent): void { |
| 70 | + const set = this._listeners.get(type); |
| 71 | + if (!set) return; |
| 72 | + for (const listener of set) { |
| 73 | + if (typeof listener === 'function') listener(event); |
| 74 | + else listener.handleEvent(event); |
| 75 | + } |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +const makeHandler = () => { |
| 80 | + const worker = new MockWorker(); |
| 81 | + const handler = new WorkerHandler(worker as unknown as Worker); |
| 82 | + return { worker, handler }; |
| 83 | +}; |
| 84 | + |
| 85 | +describe('Unit | eventBuffer | WorkerHandler', () => { |
| 86 | + it('does not attach a new message listener per postMessage call (regression: #20547)', async () => { |
| 87 | + const { worker, handler } = makeHandler(); |
| 88 | + |
| 89 | + // One listener is attached at construction time. |
| 90 | + expect(worker.listenerCount).toBe(1); |
| 91 | + |
| 92 | + // Fire a burst of in-flight requests. The pre-fix implementation attached |
| 93 | + // one listener per call, growing linearly; this would dispatch every |
| 94 | + // response to all attached listeners (O(n^2) main-thread work). |
| 95 | + const promises = Array.from({ length: 100 }, (_, i) => handler.postMessage('addEvent', `arg-${i}`)); |
| 96 | + |
| 97 | + expect(worker.listenerCount).toBe(1); |
| 98 | + expect(worker.pendingCount).toBe(100); |
| 99 | + |
| 100 | + worker.flushAll(); |
| 101 | + await Promise.all(promises); |
| 102 | + |
| 103 | + // Listener count is still 1 after the burst drains. |
| 104 | + expect(worker.listenerCount).toBe(1); |
| 105 | + }); |
| 106 | + |
| 107 | + it('resolves concurrent postMessage calls with the correct response per id', async () => { |
| 108 | + const { worker, handler } = makeHandler(); |
| 109 | + |
| 110 | + const p0 = handler.postMessage<string>('addEvent', 'a'); |
| 111 | + const p1 = handler.postMessage<string>('addEvent', 'b'); |
| 112 | + const p2 = handler.postMessage<string>('addEvent', 'c'); |
| 113 | + |
| 114 | + worker.flushAll(); |
| 115 | + |
| 116 | + await expect(p0).resolves.toBe('result-0'); |
| 117 | + await expect(p1).resolves.toBe('result-1'); |
| 118 | + await expect(p2).resolves.toBe('result-2'); |
| 119 | + }); |
| 120 | + |
| 121 | + it('rejects when the worker reports success: false', async () => { |
| 122 | + const { worker, handler } = makeHandler(); |
| 123 | + |
| 124 | + const promise = handler.postMessage('addEvent', 'a'); |
| 125 | + worker.flushOne({ success: false, response: 'boom' }); |
| 126 | + |
| 127 | + await expect(promise).rejects.toThrow('Error in compression worker'); |
| 128 | + }); |
| 129 | + |
| 130 | + it('rejects and cleans up the pending entry when worker.postMessage throws synchronously', async () => { |
| 131 | + const { worker, handler } = makeHandler(); |
| 132 | + const error = new Error('DataCloneError'); |
| 133 | + worker.postMessage = () => { |
| 134 | + throw error; |
| 135 | + }; |
| 136 | + |
| 137 | + await expect(handler.postMessage('addEvent', 'a')).rejects.toBe(error); |
| 138 | + |
| 139 | + // A subsequent successful call should still work — the previous failure |
| 140 | + // didn't leave a stale entry behind. |
| 141 | + worker.postMessage = MockWorker.prototype.postMessage.bind(worker); |
| 142 | + const promise = handler.postMessage<string>('addEvent', 'b'); |
| 143 | + worker.flushOne(); |
| 144 | + await expect(promise).resolves.toBe('result-1'); |
| 145 | + }); |
| 146 | + |
| 147 | + it('ignores messages without a numeric id (e.g. the worker init message)', async () => { |
| 148 | + const { worker, handler } = makeHandler(); |
| 149 | + |
| 150 | + const promise = handler.postMessage<string>('addEvent', 'a'); |
| 151 | + |
| 152 | + // Simulate the init message the worker emits on load. Should be ignored |
| 153 | + // and not crash. |
| 154 | + worker.dispatchRaw({ id: undefined, method: 'init', success: true }); |
| 155 | + |
| 156 | + // The legitimate response still resolves. |
| 157 | + worker.flushOne(); |
| 158 | + await expect(promise).resolves.toBe('result-0'); |
| 159 | + }); |
| 160 | + |
| 161 | + it('destroy() rejects pending requests and detaches the listener', async () => { |
| 162 | + const { worker, handler } = makeHandler(); |
| 163 | + |
| 164 | + const p1 = handler.postMessage('addEvent', 'a'); |
| 165 | + const p2 = handler.postMessage('addEvent', 'b'); |
| 166 | + |
| 167 | + handler.destroy(); |
| 168 | + |
| 169 | + await expect(p1).rejects.toThrow('Worker destroyed'); |
| 170 | + await expect(p2).rejects.toThrow('Worker destroyed'); |
| 171 | + expect(worker.terminated).toBe(true); |
| 172 | + expect(worker.listenerCount).toBe(0); |
| 173 | + }); |
| 174 | +}); |
0 commit comments