Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 51 additions & 24 deletions components/cipher/CipherLayout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<AbortController | null>(null)

const [input, setInput] = useState(cipher.defaultInput)
const [key, setKey] = useState(cipher.defaultKey)
Expand All @@ -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)
Expand Down Expand Up @@ -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') {
Expand All @@ -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
}
}
}

Expand Down
143 changes: 91 additions & 52 deletions lib/hooks/useCipherWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,45 @@ export function useCipherWorker() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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)
)
Expand All @@ -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 {
Expand All @@ -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(
(
Expand All @@ -109,55 +130,73 @@ export function useCipherWorker() {
options?: any
): Promise<CipherResult> => {
return new Promise<CipherResult>((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<WorkerResponse>) => {
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)
Comment on lines +162 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Timeout rejects/sets a raw error code instead of a user-facing message, and bypasses the CipherError contract.

The hook rejects with new Error('WORKER_TIMEOUT') and sets error to the literal 'WORKER_TIMEOUT'. CipherLayout renders {error || workerError} verbatim, so users see the raw code WORKER_TIMEOUT rather than a readable message. Since errors.ts already exposes a CipherError(code, message) type carrying a WORKER_TIMEOUT code, prefer surfacing a CipherError with a friendly message so consumers can both display a sensible string and branch on code.

🛠️ Proposed change
-        const timeoutId = setTimeout(() => {
-          setError('WORKER_TIMEOUT')
-          terminateWorkerAndRejectAll(new Error('WORKER_TIMEOUT'))
-        }, 10000)
+        const timeoutId = setTimeout(() => {
+          const timeoutError = new CipherError(
+            'WORKER_TIMEOUT',
+            'The computation timed out after 10 seconds.'
+          )
+          setError(timeoutError.message)
+          terminateWorkerAndRejectAll(timeoutError)
+        }, 10000)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const timeoutId = setTimeout(() => {
setError('WORKER_TIMEOUT')
terminateWorkerAndRejectAll(new Error('WORKER_TIMEOUT'))
}, 10000)
const timeoutId = setTimeout(() => {
const timeoutError = new CipherError(
'WORKER_TIMEOUT',
'The computation timed out after 10 seconds.'
)
setError(timeoutError.message)
terminateWorkerAndRejectAll(timeoutError)
}, 10000)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/hooks/useCipherWorker.ts` around lines 162 - 165, The timeout handling in
useCipherWorker is using a raw WORKER_TIMEOUT string and a plain Error, which
skips the CipherError contract and exposes an unfriendly message to
CipherLayout. Update the timeout callback to create and propagate a CipherError
from errors.ts with code WORKER_TIMEOUT and a user-facing message, and set hook
error state to that message rather than the literal code. Make sure
terminateWorkerAndRejectAll and any callers continue to receive the structured
error so they can branch on code while displaying a readable string.


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)
Expand All @@ -166,7 +205,7 @@ export function useCipherWorker() {
}
})
},
[]
[createWorker, terminateWorkerAndRejectAll]
)

return {
Expand Down
Loading