From 37c8be6bfe112551441b92229b1bfec878c7bdca Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:13:46 +0530 Subject: [PATCH] Fix hosted job cancellation state --- frontend/src/App.jsx | 4 ++ frontend/src/api/generate.ts | 13 ++++ frontend/src/components/clone/ActionBar.jsx | 17 +++-- frontend/src/hooks/useTTS.js | 42 ++++++++++- frontend/src/pages/CloneDesignTab.jsx | 4 ++ frontend/src/test/useTTSAutoplayPref.test.jsx | 71 ++++++++++++++++++- 6 files changed, 144 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 32d16314..0c141078 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -412,6 +412,8 @@ function App() { insertTag, applyPreset, handleGenerate, + cancelGeneration, + cancelAllPendingJobs, } = useTTS({ selectedProfile, setSelectedProfile, loadHistory, profiles }); const handleSaveProfile = () => _handleSaveProfile(refAudio, refText, instruct, language); @@ -1717,6 +1719,8 @@ function App() { handleSaveProfile={handleSaveProfile} handleSaveDesignProfile={handleSaveDesignProfile} handleGenerate={handleGenerate} + cancelGeneration={cancelGeneration} + cancelAllPendingJobs={cancelAllPendingJobs} startRecording={startRecording} stopRecording={stopRecording} ingestRefAudio={ingestRefAudio} diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts index 65a1fb0a..73d68d31 100644 --- a/frontend/src/api/generate.ts +++ b/frontend/src/api/generate.ts @@ -55,6 +55,19 @@ export async function generateSpeech( } } +// Hosted builds replace this module and provide tenant-scoped durable Job +// cancellation. Local VoiceStudio has no durable hosted Job queue, so its +// equivalent is intentionally a no-op rather than a cloud dependency. +export async function cancelPendingHostedJobs(): Promise { + return 0; +} + +// The local backend has no durable hosted Job to cancel. Returning false lets +// the caller stop its local request directly. +export async function cancelActiveHostedJob(_signal: AbortSignal): Promise { + return false; +} + export async function listHistory(): Promise { return apiJson('/history'); } diff --git a/frontend/src/components/clone/ActionBar.jsx b/frontend/src/components/clone/ActionBar.jsx index ed8a41f3..e6382a5a 100644 --- a/frontend/src/components/clone/ActionBar.jsx +++ b/frontend/src/components/clone/ActionBar.jsx @@ -48,6 +48,8 @@ export default function ActionBar({ outputPlaying, isGenerating, handleGenerate, + cancelGeneration, + cancelAllPendingJobs, generationTime, wasGeneratingRef, }) { @@ -265,13 +267,12 @@ export default function ActionBar({ )} @@ -283,6 +284,14 @@ export default function ActionBar({ className="mt-[6px]" /> )} + {/* 10x P4 a11y (spec §3): persistent polite live region — screen readers hear generation start AND finish in-workspace, without relying on the FloatingPill. sr-only keeps it out of the diff --git a/frontend/src/hooks/useTTS.js b/frontend/src/hooks/useTTS.js index 55f83fde..fb0b84d6 100644 --- a/frontend/src/hooks/useTTS.js +++ b/frontend/src/hooks/useTTS.js @@ -1,6 +1,6 @@ import { useState, useRef, useCallback } from 'react'; import { useAppStore } from '../store'; -import { generateSpeech } from '../api/generate'; +import { cancelActiveHostedJob, cancelPendingHostedJobs, generateSpeech } from '../api/generate'; import { pickDesignSeed } from '../utils/seed'; import { playBlobAudio, playPing } from '../utils/media'; import { @@ -67,6 +67,8 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor const [generationTime, setGenerationTime] = useState(0); const timerRef = useRef(null); const textAreaRef = useRef(null); + const generationAbortRef = useRef(null); + const canceledByUserRef = useRef(false); const ingestRefAudio = useCallback( async (file) => { @@ -109,11 +111,43 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor [text, insertTag], ); + const cancelGeneration = useCallback(async () => { + // Hosted synthesis observes this signal after admission and sends the + // durable cancel command for the queued Job. Before admission it simply + // stops staging, so no unsubmitted work is left behind. + const controller = generationAbortRef.current; + if (!controller) return; + try { + // Hosted cancellation is durable. Do not change the UI until the server + // accepted it; a conflict leaves the active Job visible for retry. + await cancelActiveHostedJob(controller.signal); + canceledByUserRef.current = true; + controller.abort(); + } catch (error) { + toastErrorWithReport(`Could not cancel the job: ${error.message}`, error); + } + }, []); + + const cancelAllPendingJobs = useCallback(async () => { + try { + const canceled = await cancelPendingHostedJobs(); + if (canceled > 0) { + toast.success(`Canceled ${canceled} pending ${canceled === 1 ? 'job' : 'jobs'}.`); + } else { + toast('No pending hosted jobs to cancel.'); + } + await loadHistory(); + } catch (error) { + toastErrorWithReport(`Could not cancel pending jobs: ${error.message}`, error); + } + }, [loadHistory]); + const handleGenerate = useCallback(async () => { if (!text.trim()) return toast.error(t('tts_errors.enter_text')); if (defineMethod === 'audio' && !refAudio && !selectedProfile) return toast.error(t('tts_errors.upload_or_select')); addBreadcrumb(`generate:start (${defineMethod})`); + canceledByUserRef.current = false; setIsGenerating(true); setGenerationTime(0); const st = Date.now(); @@ -213,6 +247,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor // is unreachable. The ceiling sits just above the backend's load timeout // so the backend's descriptive error wins in the normal case. const ac = new AbortController(); + generationAbortRef.current = ac; abortTimer = setTimeout(() => ac.abort(), 21 * 60 * 1000); // #1330 — one voice for both delivery paths. A dropped chunk is not an @@ -369,13 +404,14 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor // Timeouts are user-recoverable (retry / shorter input) — plain toast. // Real generation failures get the "Report this bug" action. if (err?.name === 'AbortError') { - toast.error(t('tts_errors.timeout')); + toast.error(canceledByUserRef.current ? 'Job canceled.' : t('tts_errors.timeout')); } else if (modelNotDownloadedPayload(err)) { toastModelNotDownloaded(modelNotDownloadedPayload(err)); } else { toastErrorWithReport(t('tts_errors.error_prefix', { message: err.message }), err); } } finally { + generationAbortRef.current = null; if (abortTimer) clearTimeout(abortTimer); clearInterval(timerRef.current); setIsGenerating(false); @@ -418,5 +454,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor insertTag, applyPreset, handleGenerate, + cancelGeneration, + cancelAllPendingJobs, }; } diff --git a/frontend/src/pages/CloneDesignTab.jsx b/frontend/src/pages/CloneDesignTab.jsx index 3c41d93e..e045cee5 100644 --- a/frontend/src/pages/CloneDesignTab.jsx +++ b/frontend/src/pages/CloneDesignTab.jsx @@ -74,6 +74,8 @@ export default function CloneDesignTab(props) { handleSaveProfile, handleSaveDesignProfile, handleGenerate, + cancelGeneration, + cancelAllPendingJobs, startRecording, stopRecording, ingestRefAudio, @@ -444,6 +446,8 @@ export default function CloneDesignTab(props) { outputPlaying={outputPlaying} isGenerating={isGenerating} handleGenerate={handleGenerate} + cancelGeneration={cancelGeneration} + cancelAllPendingJobs={cancelAllPendingJobs} generationTime={generationTime} wasGeneratingRef={wasGeneratingRef} /> diff --git a/frontend/src/test/useTTSAutoplayPref.test.jsx b/frontend/src/test/useTTSAutoplayPref.test.jsx index 89bc6524..90d86d84 100644 --- a/frontend/src/test/useTTSAutoplayPref.test.jsx +++ b/frontend/src/test/useTTSAutoplayPref.test.jsx @@ -8,7 +8,7 @@ import { streamGenerateSpeech, supportsStreamingPreview, } from '../utils/streamingTts'; -import { generateSpeech } from '../api/generate'; +import { cancelActiveHostedJob, generateSpeech } from '../api/generate'; import toast from 'react-hot-toast'; // #1032: Settings → Appearance "Auto-play preview" ("play the output as soon @@ -31,6 +31,8 @@ vi.mock('../api/generate', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + cancelActiveHostedJob: vi.fn().mockResolvedValue(false), + cancelPendingHostedJobs: vi.fn().mockResolvedValue(0), generateSpeech: vi.fn().mockImplementation(async () => { let served = false; return { @@ -113,6 +115,73 @@ describe('useTTS auto-play pref (#1032)', () => { }); }); +describe('useTTS hosted cancellation', () => { + it('aborts an in-flight hosted synthesis when the user cancels it', async () => { + let rejectGeneration; + vi.mocked(generateSpeech).mockImplementationOnce((_formData, { signal }) => + new Promise((_resolve, reject) => { + rejectGeneration = reject; + signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + const { result } = renderHook(() => useTTS(hookProps())); + let generation; + await act(async () => { + generation = result.current.handleGenerate(); + await Promise.resolve(); + }); + + expect(result.current.isGenerating).toBe(true); + await act(async () => { + await result.current.cancelGeneration(); + await generation; + }); + + expect(rejectGeneration).toBeTypeOf('function'); + expect(result.current.isGenerating).toBe(false); + }); + + it('keeps the job active when the cancellation API rejects it', async () => { + let resolveGeneration; + vi.mocked(generateSpeech).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGeneration = resolve; + }), + ); + vi.mocked(cancelActiveHostedJob).mockRejectedValueOnce( + new Error('The Job cancellation could not be accepted.'), + ); + + const { result } = renderHook(() => useTTS(hookProps())); + let generation; + await act(async () => { + generation = result.current.handleGenerate(); + await Promise.resolve(); + }); + + await act(async () => { + await result.current.cancelGeneration(); + }); + + expect(result.current.isGenerating).toBe(true); + + await act(async () => { + resolveGeneration({ + body: { + getReader: () => ({ + read: async () => ({ done: true, value: undefined }), + }), + }, + headers: { get: () => null }, + }); + await generation; + }); + }); +}); + describe('useTTS delivery path vs the chosen GPU', () => { beforeEach(() => { useAppStore.setState({ autoPlayPreview: true });