Fix hosted job cancellation state

This commit is contained in:
velixio
2026-08-15 15:13:46 +05:30
parent c818d235fb
commit 37c8be6bfe
6 changed files with 144 additions and 7 deletions
+4
View File
@@ -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}
+13
View File
@@ -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<number> {
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<boolean> {
return false;
}
export async function listHistory(): Promise<unknown> {
return apiJson('/history');
}
+13 -4
View File
@@ -48,6 +48,8 @@ export default function ActionBar({
outputPlaying,
isGenerating,
handleGenerate,
cancelGeneration,
cancelAllPendingJobs,
generationTime,
wasGeneratingRef,
}) {
@@ -265,13 +267,12 @@ export default function ActionBar({
<Button
variant="primary"
block
loading={isGenerating}
onClick={handleGenerate}
leading={!isGenerating && <Play size={14} />}
onClick={isGenerating ? cancelGeneration : handleGenerate}
leading={isGenerating ? <Square size={14} /> : <Play size={14} />}
className="mt-[6px]"
>
{isGenerating
? t('clone.synthesizing', { seconds: generationTime })
? 'Cancel job'
: t('clone.synthesize')}
</Button>
)}
@@ -283,6 +284,14 @@ export default function ActionBar({
className="mt-[6px]"
/>
)}
<Button
variant="ghost"
block
onClick={cancelAllPendingJobs}
className="mt-[4px]"
>
Cancel all pending jobs
</Button>
{/* 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
+40 -2
View File
@@ -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,
};
}
+4
View File
@@ -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}
/>
+70 -1
View File
@@ -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 });