From 67f65bc3dfa9a782fbf0c50b88253f19696c9fd0 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Thu, 9 Jul 2026 19:24:06 +0530 Subject: [PATCH 1/2] Worker job queue + cancellation --- components/cipher/CipherLayout.tsx | 75 +++++++---- lib/hooks/useCipherWorker.ts | 143 ++++++++++++-------- package-lock.json | 64 ++++----- tests/unit/hooks/useCipherWorker.test.ts | 160 +++++++++++++++++++++++ 4 files changed, 328 insertions(+), 114 deletions(-) create mode 100644 tests/unit/hooks/useCipherWorker.test.ts diff --git a/components/cipher/CipherLayout.tsx b/components/cipher/CipherLayout.tsx index d276ffd..851188b 100644 --- a/components/cipher/CipherLayout.tsx +++ b/components/cipher/CipherLayout.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import type { CipherDefinition } from '../../lib/cipher/registry' import { useCipherWorker } from '../../lib/hooks/useCipherWorker' import StepAnimator from './StepAnimator' @@ -45,6 +45,7 @@ const isValidHistoryArray = (data: unknown): data is HistoryEntry[] => { export default function CipherLayout({ cipher }: CipherLayoutProps) { const { runCipher, loading, error: workerError } = useCipherWorker() + const abortControllerRef = useRef(null) const [input, setInput] = useState(cipher.defaultInput) const [key, setKey] = useState(cipher.defaultKey) @@ -65,6 +66,9 @@ export default function CipherLayout({ cipher }: CipherLayoutProps) { // Reset inputs when cipher changes useEffect(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } setInput(cipher.defaultInput) setKey(cipher.defaultKey) setResult(null) @@ -97,14 +101,27 @@ export default function CipherLayout({ cipher }: CipherLayoutProps) { if (opt.id === 'bobSecret') setBobSecret(opt.default) }) } + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } }, [cipher]) const handleRun = async () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + const controller = new AbortController() + abortControllerRef.current = controller + setError(null) try { // Gather options const options: any = { instrument: true, // Always request instrumented steps for visualizer + signal: controller.signal, } if (cipher.id === 'des' || cipher.id === '3des' || cipher.id === 'aes') { @@ -125,35 +142,45 @@ export default function CipherLayout({ cipher }: CipherLayoutProps) { const currentAction = cipher.id === 'dh' ? 'encrypt' : action const res = await runCipher(currentAction, cipher.id, input, key, options) - setResult(res) - setCurrentStep(0) - - if (res?.output !== undefined) { - const entry: HistoryEntry = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - input, - key, - action: currentAction, - output: String(res.output), - timestamp: new Date().toLocaleString(), - } + + if (!controller.signal.aborted) { + setResult(res) + setCurrentStep(0) + + if (res?.output !== undefined) { + const entry: HistoryEntry = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + input, + key, + action: currentAction, + output: String(res.output), + timestamp: new Date().toLocaleString(), + } - setHistory((prev) => { - const next = [entry, ...prev].slice(0, 5) - if (typeof window !== 'undefined') { - try { - window.localStorage.setItem(getHistoryStorageKey(cipher.id), JSON.stringify(next)) - } catch (e) { - // Silently fail if localStorage is unavailable (quota exceeded, disabled, private mode, etc.) - console.warn('Failed to save history:', e) + setHistory((prev) => { + const next = [entry, ...prev].slice(0, 5) + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(getHistoryStorageKey(cipher.id), JSON.stringify(next)) + } catch (e) { + // Silently fail if localStorage is unavailable (quota exceeded, disabled, private mode, etc.) + console.warn('Failed to save history:', e) + } } - } - return next - }) + return next + }) + } } } catch (err: any) { + if (err.name === 'AbortError') { + return + } setError(err.message || 'An error occurred during calculation.') setResult(null) + } finally { + if (abortControllerRef.current === controller) { + abortControllerRef.current = null + } } } diff --git a/lib/hooks/useCipherWorker.ts b/lib/hooks/useCipherWorker.ts index ca53826..ce024be 100644 --- a/lib/hooks/useCipherWorker.ts +++ b/lib/hooks/useCipherWorker.ts @@ -32,22 +32,45 @@ export function useCipherWorker() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - // Map to track active requests and resolve/reject promises + // Map to track active requests, resolve/reject callbacks, abort signals, and timeouts const activeRequestsRef = useRef< Map< string, { resolve: (value: CipherResult) => void reject: (reason: any) => void + signal?: AbortSignal + onAbort?: () => void + timeoutId?: NodeJS.Timeout } > >(new Map()) - useEffect(() => { - // Web Worker is client-side only - if (typeof window === 'undefined') return + // Helper to terminate the worker and reject all pending requests + const terminateWorkerAndRejectAll = useCallback((reason: Error) => { + if (workerRef.current) { + workerRef.current.terminate() + workerRef.current = null + } + for (const [, req] of activeRequestsRef.current.entries()) { + try { + if (req.timeoutId) clearTimeout(req.timeoutId) + if (req.signal && req.onAbort) { + req.signal.removeEventListener('abort', req.onAbort) + } + req.reject(reason) + } catch { + // Ignore secondary errors during teardown + } + } + activeRequestsRef.current.clear() + setLoading(false) + }, []) + + // Helper to create and initialize the web worker + const createWorker = useCallback(() => { + if (typeof window === 'undefined') return null - // Instantiate worker const worker = new Worker( new URL('../workers/cipher.worker.ts', import.meta.url) ) @@ -58,6 +81,13 @@ export function useCipherWorker() { const request = activeRequestsRef.current.get(id) if (request) { + if (request.timeoutId) { + clearTimeout(request.timeoutId) + } + if (request.signal && request.onAbort) { + request.signal.removeEventListener('abort', request.onAbort) + } + if (success && result) { request.resolve(result) } else { @@ -73,32 +103,23 @@ export function useCipherWorker() { worker.onerror = (err) => { console.error('Worker error:', err) - - const error = new Error('Web Worker initialization or runtime error.') - - // Reject all pending requests so consumers don't await forever. - for (const [, value] of activeRequestsRef.current.entries()) { - try { - value.reject(error) - } catch { - // Ignore secondary failures while handling a worker crash. - } - } - activeRequestsRef.current.clear() - - setError(error.message) - setLoading(false) - + const errorMsg = 'Web Worker initialization or runtime error.' + setError(errorMsg) + terminateWorkerAndRejectAll(new Error(errorMsg)) } + return worker + }, [terminateWorkerAndRejectAll]) - - workerRef.current = worker + useEffect(() => { + workerRef.current = createWorker() return () => { - worker.terminate() + if (workerRef.current) { + workerRef.current.terminate() + } } - }, []) + }, [createWorker]) const runCipher = useCallback( ( @@ -109,55 +130,73 @@ export function useCipherWorker() { options?: any ): Promise => { return new Promise((resolve, reject) => { + // Automatically cancel any previous running request to prevent overlap + if (activeRequestsRef.current.size > 0) { + terminateWorkerAndRejectAll(new DOMException('The user aborted a request.', 'AbortError')) + } + if (!workerRef.current) { - // Re-instantiate if terminated or not initialized yet - if (typeof window !== 'undefined') { - const worker = new Worker( - new URL('../workers/cipher.worker.ts', import.meta.url) - ) - - worker.onmessage = (event: MessageEvent) => { - const { id, success, result, error: workerError } = event.data - - const req = activeRequestsRef.current.get(id) - if (req) { - if (success && result) req.resolve(result) - else req.reject(new Error(workerError || 'Unknown worker error')) - activeRequestsRef.current.delete(id) - } - if (activeRequestsRef.current.size === 0) setLoading(false) - } - worker.onerror = (err) => { - console.error('Worker error:', err) - setError('Web Worker initialization or runtime error.') - setLoading(false) - } - workerRef.current = worker - } else { + workerRef.current = createWorker() + if (!workerRef.current) { return reject(new Error('Web Worker is not available on SSR.')) } } const id = Math.random().toString(36).substring(2, 11) - activeRequestsRef.current.set(id, { resolve, reject }) + + let onAbort: (() => void) | undefined + const signal = options?.signal as AbortSignal | undefined + + if (signal) { + if (signal.aborted) { + return reject(new DOMException('The user aborted a request.', 'AbortError')) + } + + onAbort = () => { + terminateWorkerAndRejectAll(new DOMException('The user aborted a request.', 'AbortError')) + } + signal.addEventListener('abort', onAbort) + } + + // 10-second timeout budget + const timeoutId = setTimeout(() => { + setError('WORKER_TIMEOUT') + terminateWorkerAndRejectAll(new Error('WORKER_TIMEOUT')) + }, 10000) + + activeRequestsRef.current.set(id, { + resolve, + reject, + signal, + onAbort, + timeoutId, + }) setLoading(true) setError(null) try { + // Strip AbortSignal from options since it's not JSON serializable + const { signal: _, ...serializableOptions } = options || {} const payloadStr = JSON.stringify({ id, action, cipherId, input, key, - options, + options: serializableOptions, }) const encoder = new TextEncoder() const payloadBuffer = encoder.encode(payloadStr) workerRef.current.postMessage(payloadBuffer, [payloadBuffer.buffer]) } catch (err: unknown) { + if (timeoutId) { + clearTimeout(timeoutId) + } + if (signal && onAbort) { + signal.removeEventListener('abort', onAbort) + } activeRequestsRef.current.delete(id) if (activeRequestsRef.current.size === 0) setLoading(false) const message = err instanceof Error ? err.message : String(err) @@ -166,7 +205,7 @@ export function useCipherWorker() { } }) }, - [] + [createWorker, terminateWorkerAndRejectAll] ) return { diff --git a/package-lock.json b/package-lock.json index 57d01e3..3afddc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,6 +139,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -459,6 +460,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -507,6 +509,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1779,29 +1782,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2178,7 +2158,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2199,7 +2178,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2275,8 +2253,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/bcryptjs": { "version": "2.4.6", @@ -2393,6 +2370,7 @@ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2403,6 +2381,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2413,6 +2392,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2468,6 +2448,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -3148,6 +3129,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3188,7 +3170,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3531,6 +3512,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4020,7 +4002,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -4053,8 +4034,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4335,6 +4315,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4520,6 +4501,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6331,7 +6313,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6488,6 +6469,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", @@ -6903,7 +6885,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6919,7 +6900,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6932,8 +6912,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -7000,6 +6979,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7009,6 +6989,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7020,13 +7001,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7093,7 +7076,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7918,6 +7902,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8136,6 +8121,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8317,6 +8303,7 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -8728,6 +8715,7 @@ "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/tests/unit/hooks/useCipherWorker.test.ts b/tests/unit/hooks/useCipherWorker.test.ts new file mode 100644 index 0000000..ba84819 --- /dev/null +++ b/tests/unit/hooks/useCipherWorker.test.ts @@ -0,0 +1,160 @@ +import { renderHook, act } from '@testing-library/react' +import { useCipherWorker } from '@/lib/hooks/useCipherWorker' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' + +// Mock Worker class +class MockWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((err: any) => void) | null = null + postMessage = vi.fn() + terminate = vi.fn() + + constructor(public url: string) { + MockWorker.instances.push(this) + } + + static instances: MockWorker[] = [] + static lastInstance(): MockWorker | null { + return MockWorker.instances[MockWorker.instances.length - 1] || null + } + static clearInstances() { + MockWorker.instances = [] + } +} + +describe('useCipherWorker', () => { + beforeEach(() => { + vi.stubGlobal('Worker', MockWorker) + MockWorker.clearInstances() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('initializes worker and handles successful message execution', async () => { + const { result } = renderHook(() => useCipherWorker()) + + expect(MockWorker.instances.length).toBe(1) + const worker = MockWorker.lastInstance()! + + // Start running cipher + let promise: Promise + act(() => { + promise = result.current.runCipher('encrypt', 'caesar', 'hello', '3') + }) + + // Expect message to be posted + expect(worker.postMessage).toHaveBeenCalled() + + // Decode message sent to postMessage + const firstCallArgs = worker.postMessage.mock.calls[0] + const sentBuffer = firstCallArgs[0] as Uint8Array + const decoder = new TextDecoder() + const parsedPayload = JSON.parse(decoder.decode(sentBuffer)) + + expect(parsedPayload.cipherId).toBe('caesar') + expect(parsedPayload.input).toBe('hello') + expect(parsedPayload.key).toBe('3') + + // Simulate worker success + act(() => { + worker.onmessage!({ + data: { + id: parsedPayload.id, + success: true, + result: { output: 'khoor', steps: [] } + } + } as MessageEvent) + }) + + const res = await promise! + expect(res.output).toBe('khoor') + }) + + it('aborts previous request automatically when a new request is started', async () => { + const { result } = renderHook(() => useCipherWorker()) + + let promise1: Promise + let promise2: Promise + + act(() => { + promise1 = result.current.runCipher('encrypt', 'caesar', 'hello', '3') + }) + + const firstWorker = MockWorker.lastInstance()! + + // Start a second cipher before the first one completes + act(() => { + promise2 = result.current.runCipher('encrypt', 'caesar', 'world', '3') + }) + + // Expect the first promise to reject with AbortError + await expect(promise1!).rejects.toThrowError(/aborted/) + + // The hook should terminate the first worker and spawn a new one + expect(firstWorker.terminate).toHaveBeenCalled() + expect(MockWorker.instances.length).toBe(2) + + const secondWorker = MockWorker.lastInstance()! + const secondCallArgs = secondWorker.postMessage.mock.calls[0] + const parsedPayload2 = JSON.parse(new TextDecoder().decode(secondCallArgs[0] as Uint8Array)) + expect(parsedPayload2.input).toBe('world') + + // Complete the second request successfully + act(() => { + secondWorker.onmessage!({ + data: { + id: parsedPayload2.id, + success: true, + result: { output: 'zruog', steps: [] } + } + } as MessageEvent) + }) + + const res2 = await promise2! + expect(res2.output).toBe('zruog') + }) + + it('handles aborting using an explicit AbortSignal', async () => { + const { result } = renderHook(() => useCipherWorker()) + const controller = new AbortController() + + let promise: Promise + act(() => { + promise = result.current.runCipher('encrypt', 'caesar', 'hello', '3', { signal: controller.signal }) + }) + + const worker = MockWorker.lastInstance()! + + // Trigger abort manually + act(() => { + controller.abort() + }) + + await expect(promise!).rejects.toThrowError(/aborted/) + expect(worker.terminate).toHaveBeenCalled() + }) + + it('triggers WORKER_TIMEOUT error after 10 seconds of inactivity', async () => { + const { result } = renderHook(() => useCipherWorker()) + + let promise: Promise + act(() => { + promise = result.current.runCipher('encrypt', 'caesar', 'hello', '3') + }) + + const worker = MockWorker.lastInstance()! + + // Fast-forward 10 seconds + act(() => { + vi.advanceTimersByTime(10000) + }) + + await expect(promise!).rejects.toThrowError('WORKER_TIMEOUT') + expect(worker.terminate).toHaveBeenCalled() + expect(result.current.error).toBe('WORKER_TIMEOUT') + }) +}) From 167ed1301df16c7ddd22d339392872844af6297d Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Thu, 9 Jul 2026 19:31:21 +0530 Subject: [PATCH 2/2] Typed message protocol between UI and worker --- lib/hooks/useCipherWorker.ts | 50 +++++++++--------------- lib/workers/cipher.worker.ts | 46 ++++++++++------------ tests/unit/hooks/useCipherWorker.test.ts | 19 +++++---- types/worker.ts | 32 +++++++++++++++ 4 files changed, 81 insertions(+), 66 deletions(-) create mode 100644 types/worker.ts diff --git a/lib/hooks/useCipherWorker.ts b/lib/hooks/useCipherWorker.ts index ce024be..02e5c6a 100644 --- a/lib/hooks/useCipherWorker.ts +++ b/lib/hooks/useCipherWorker.ts @@ -8,24 +8,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import type { CipherResult } from '../cipher/types' - -interface WorkerRequest { - id: string - action: 'encrypt' | 'decrypt' - cipherId: string - input: string - key: string - options?: any -} - -interface WorkerResponse { - id: string - success: boolean - result?: CipherResult - error?: string -} - -type WorkerResponseMessage = WorkerResponse | Uint8Array; +import type { WorkerRequest, WorkerResponse } from '../../types/worker' export function useCipherWorker() { const workerRef = useRef(null) @@ -76,9 +59,9 @@ export function useCipherWorker() { ) worker.onmessage = (event: MessageEvent) => { - const { id, success, result, error: workerError } = event.data + const { requestId, success, payload } = event.data - const request = activeRequestsRef.current.get(id) + const request = activeRequestsRef.current.get(requestId) if (request) { if (request.timeoutId) { @@ -88,12 +71,12 @@ export function useCipherWorker() { request.signal.removeEventListener('abort', request.onAbort) } - if (success && result) { - request.resolve(result) + if (success && payload.result) { + request.resolve(payload.result) } else { - request.reject(new Error(workerError || 'Unknown worker error')) + request.reject(new Error(payload.error || 'Unknown worker error')) } - activeRequestsRef.current.delete(id) + activeRequestsRef.current.delete(requestId) } if (activeRequestsRef.current.size === 0) { @@ -178,14 +161,17 @@ export function useCipherWorker() { try { // Strip AbortSignal from options since it's not JSON serializable const { signal: _, ...serializableOptions } = options || {} - const payloadStr = JSON.stringify({ - id, - action, - cipherId, - input, - key, - options: serializableOptions, - }) + const requestMessage: WorkerRequest = { + type: action, + requestId: id, + payload: { + cipherId, + input, + key, + options: serializableOptions, + }, + } + const payloadStr = JSON.stringify(requestMessage) const encoder = new TextEncoder() const payloadBuffer = encoder.encode(payloadStr) diff --git a/lib/workers/cipher.worker.ts b/lib/workers/cipher.worker.ts index eb992a9..3385aa6 100644 --- a/lib/workers/cipher.worker.ts +++ b/lib/workers/cipher.worker.ts @@ -25,38 +25,26 @@ import { encrypt as hmacEncrypt, decrypt as hmacDecrypt } from '../cipher/hash/h import { encrypt as bcryptEncrypt, decrypt as bcryptDecrypt } from '../cipher/hash/bcrypt' -interface WorkerRequest { - id: string - action: 'encrypt' | 'decrypt' - cipherId: string - input: string - key: string - options?: any -} - -interface WorkerResponse { - id: string - success: boolean - result?: any - error?: string -} +import type { WorkerRequest, WorkerResponse } from '../../types/worker' type WorkerRequestMessage = WorkerRequest | Uint8Array const workerScope = self as unknown as Worker workerScope.addEventListener('message', (event: MessageEvent) => { + const startTime = performance.now() let requestData: WorkerRequestMessage = event.data if (requestData instanceof Uint8Array) { const decoder = new TextDecoder() - requestData = JSON.parse(decoder.decode(requestData)) + requestData = JSON.parse(decoder.decode(requestData)) as WorkerRequest } - const { id, action, cipherId, input, key, options } = requestData as WorkerRequest + const { type, requestId, payload } = requestData as WorkerRequest + const { cipherId, input, key, options } = payload try { let result: any - const encryptMode = action === 'encrypt' + const encryptMode = type === 'encrypt' switch (cipherId) { case 'caesar': @@ -158,16 +146,22 @@ workerScope.addEventListener('message', (event: MessageEvent { const decoder = new TextDecoder() const parsedPayload = JSON.parse(decoder.decode(sentBuffer)) - expect(parsedPayload.cipherId).toBe('caesar') - expect(parsedPayload.input).toBe('hello') - expect(parsedPayload.key).toBe('3') + expect(parsedPayload.type).toBe('encrypt') + expect(parsedPayload.payload.cipherId).toBe('caesar') + expect(parsedPayload.payload.input).toBe('hello') + expect(parsedPayload.payload.key).toBe('3') // Simulate worker success act(() => { worker.onmessage!({ data: { - id: parsedPayload.id, + requestId: parsedPayload.requestId, success: true, - result: { output: 'khoor', steps: [] } + payload: { result: { output: 'khoor', steps: [] } }, + timings: { durationMs: 5 } } } as MessageEvent) }) @@ -101,15 +103,16 @@ describe('useCipherWorker', () => { const secondWorker = MockWorker.lastInstance()! const secondCallArgs = secondWorker.postMessage.mock.calls[0] const parsedPayload2 = JSON.parse(new TextDecoder().decode(secondCallArgs[0] as Uint8Array)) - expect(parsedPayload2.input).toBe('world') + expect(parsedPayload2.payload.input).toBe('world') // Complete the second request successfully act(() => { secondWorker.onmessage!({ data: { - id: parsedPayload2.id, + requestId: parsedPayload2.requestId, success: true, - result: { output: 'zruog', steps: [] } + payload: { result: { output: 'zruog', steps: [] } }, + timings: { durationMs: 8 } } } as MessageEvent) }) diff --git a/types/worker.ts b/types/worker.ts new file mode 100644 index 0000000..d34d6e4 --- /dev/null +++ b/types/worker.ts @@ -0,0 +1,32 @@ +import type { CipherResult } from '@/lib/cipher/types' + +export type WorkerRequestType = 'encrypt' | 'decrypt' + +export interface WorkerRequestPayload { + cipherId: string + input: string + key: string + options?: any +} + +export interface WorkerRequest { + type: WorkerRequestType + requestId: string + payload: WorkerRequestPayload +} + +export interface WorkerResponsePayload { + result?: CipherResult + error?: string +} + +export interface WorkerResponseTimings { + durationMs: number +} + +export interface WorkerResponse { + requestId: string + success: boolean + payload: WorkerResponsePayload + timings?: WorkerResponseTimings +}