Fix hosted job cancellation state
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user