diff --git a/openless-all/app/scripts/local-asr-polling-contract.test.mjs b/openless-all/app/scripts/local-asr-polling-contract.test.mjs new file mode 100644 index 00000000..91bbc3df --- /dev/null +++ b/openless-all/app/scripts/local-asr-polling-contract.test.mjs @@ -0,0 +1,32 @@ +import { readFile } from 'node:fs/promises'; + +const source = await readFile( + new URL('../src/pages/LocalAsr/index.tsx', import.meta.url), + 'utf-8', +); + +const refreshPolling = source.match( + /window\.setInterval\(\(\) => \{\s*void refresh\(\)\s*\}, 3000\)/g, +) ?? []; + +if (refreshPolling.length !== 1) { + throw new Error(`LocalAsr should have one refresh poller, found ${refreshPolling.length}`); +} + +if (!/if \(downloadDialogOpen\) return[\s\S]{0,200}window\.setInterval\(\(\) => \{\s*void refresh\(\)/.test(source)) { + throw new Error('LocalAsr refresh polling must stop while the download dialog is open'); +} + +for (const contract of [ + 'const downloadDialogOpenRef = useRef(downloadDialogOpen)', + 'const refreshGenerationRef = useRef(0)', + 'const makeRefreshGuard = (): RefreshGuard =>', + 'refreshGenerationRef.current += 1', + 'if (!isCurrent()) return', +]) { + if (!source.includes(contract)) { + throw new Error(`LocalAsr refresh guard contract is missing: ${contract}`); + } +} + +console.log('LocalAsr keeps one refresh poller and pauses it for the download dialog'); diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 2841bacc..f3905528 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -2736,15 +2736,27 @@ mod tests { let server = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); read_http_request(&mut stream); - let gap = std::time::Duration::from_millis(60); - let plan: Vec<(&[u8], std::time::Duration)> = - events.iter().map(|e| (e.as_slice(), gap)).collect(); + let gap = std::time::Duration::from_millis(150); + let plan: Vec<(&[u8], std::time::Duration)> = events + .iter() + .enumerate() + .map(|(index, event)| { + ( + event.as_slice(), + if index == 0 { + std::time::Duration::ZERO + } else { + gap + }, + ) + }) + .collect(); write_chunked_sse_response_with_delays(&mut stream, &plan); }); - // 总时长 ~300ms,远超 120ms 的首字预算;但每个 chunk 间隔 60ms < 空闲预算。 + // 总时长 ~600ms,超过 500ms 的首字预算;但每个 chunk 间隔 150ms < 空闲预算。 let timeouts = StreamingTimeouts { - first_token: std::time::Duration::from_millis(120), + first_token: std::time::Duration::from_millis(500), idle: std::time::Duration::from_millis(500), }; let out = streaming_test_provider(addr) diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index af68f92d..0979fbbf 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -153,6 +153,8 @@ interface LocalAsrProps { embedded?: boolean } +type RefreshGuard = () => boolean + export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const { t } = useTranslation() const { prefs, updatePrefs } = useHotkeySettings() @@ -228,6 +230,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { >({}) const [engineStatus, setEngineStatus] = useState(null) + const downloadDialogOpenRef = useRef(downloadDialogOpen) + const refreshGenerationRef = useRef(0) const refreshTimer = useRef(null) const foundryRefreshTimer = useRef(null) const sherpaRefreshTimer = useRef(null) @@ -243,6 +247,22 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const scrollGuardTimer = useRef(null) const scrollGuardCleanup = useRef<(() => void) | null>(null) + const setDownloadDialog = (open: boolean) => { + if (downloadDialogOpenRef.current !== open) { + downloadDialogOpenRef.current = open + refreshGenerationRef.current += 1 + } + setDownloadDialogOpen(open) + } + + // 清理 interval 只能阻止下一次 tick;generation 还要丢弃已经在途的异步结果。 + const makeRefreshGuard = (): RefreshGuard => { + const generation = refreshGenerationRef.current + return () => + generation === refreshGenerationRef.current && + !downloadDialogOpenRef.current + } + const restoreScrollGuard = () => { const guard = scrollGuard.current if (!guard) return @@ -318,8 +338,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refreshEngineStatus = async () => { + const isCurrent = makeRefreshGuard() try { const status = await getLocalAsrEngineStatus() + if (!isCurrent()) return setEngineStatus(status) } catch (err) { console.warn("[localAsr] engine status query failed", err) @@ -327,8 +349,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refreshFoundryStatus = async () => { + const isCurrent = makeRefreshGuard() try { const status = await getFoundryLocalAsrStatus() + if (!isCurrent()) return setFoundryStatus(status) if ( !foundrySelectionDirty.current && @@ -338,6 +362,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { void refreshFoundryModelDir(status.activeModel) } } catch (err) { + if (!isCurrent()) return const message = err instanceof Error ? err.message : String(err) setFoundryStatus({ providerId: "foundry-local-whisper", @@ -353,8 +378,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refreshFoundryCatalog = async () => { + const isCurrent = makeRefreshGuard() try { const catalog = await getFoundryLocalAsrCatalog() + if (!isCurrent()) return setFoundryCatalog(catalog) } catch (err) { console.warn("[localAsr] Foundry catalog query failed", err) @@ -364,8 +391,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const refreshFoundryModelDir = async ( modelAlias: FoundryLocalAsrModelAlias, ) => { + const isCurrent = makeRefreshGuard() try { const dir = await getFoundryLocalAsrModelDir(modelAlias) + if (!isCurrent()) return setFoundryModelDir((current) => { if (selectedFoundryAliasRef.current !== modelAlias) { return current @@ -379,6 +408,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } }) } catch (err) { + if (!isCurrent()) return console.warn("[localAsr] Foundry model dir query failed", err) setFoundryModelDir((current) => selectedFoundryAliasRef.current === modelAlias && @@ -390,8 +420,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refreshSherpaStatus = async () => { + const isCurrent = makeRefreshGuard() try { const status = await getSherpaOnnxAsrStatus() + if (!isCurrent()) return setSherpaStatus(status) if ( !sherpaSelectionDirty.current && @@ -401,6 +433,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { void refreshSherpaModelDir(status.activeModel) } } catch (err) { + if (!isCurrent()) return const message = err instanceof Error ? err.message : String(err) setSherpaStatus({ providerId: "sherpa-onnx-local", @@ -414,8 +447,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refreshSherpaCatalog = async () => { + const isCurrent = makeRefreshGuard() try { const catalog = await getSherpaOnnxAsrCatalog() + if (!isCurrent()) return setSherpaCatalog(catalog) } catch (err) { console.warn("[localAsr] Sherpa catalog query failed", err) @@ -423,8 +458,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refreshSherpaModelDir = async (modelAlias: string) => { + const isCurrent = makeRefreshGuard() try { const dir = await getSherpaOnnxAsrModelDir(modelAlias) + if (!isCurrent()) return setSherpaModelDir((current) => (current === dir ? current : dir)) } catch (err) { console.warn("[localAsr] Sherpa model dir query failed", err) @@ -432,18 +469,22 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { } const refresh = async () => { + const isCurrent = makeRefreshGuard() try { + if (!isCurrent()) return setError(null) const [s, list] = await Promise.all([ getLocalAsrSettings(), listLocalAsrModels(), ]) + if (!isCurrent()) return setSettings(s) setModels(list) void Promise.all( list.map(async (m) => { try { const dir = await getLocalAsrModelDir(m.id) + if (!isCurrent()) return setModelDirs((current) => current[m.id] === dir ? current @@ -475,11 +516,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }), ) } catch (e) { + if (!isCurrent()) return setError(e instanceof Error ? e.message : String(e)) } } const ensureRemoteSize = async (modelId: string, mirror: string) => { + const isCurrent = makeRefreshGuard() + if (!isCurrent()) return setRemoteSizes((prev) => { if (prev[modelId] && !prev[modelId].error) return prev return { @@ -494,6 +538,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }) try { const info = await fetchLocalAsrRemoteInfo(modelId, mirror) + if (!isCurrent()) return setRemoteSizes((prev) => ({ ...prev, [modelId]: { @@ -504,6 +549,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }, })) } catch (e) { + if (!isCurrent()) return setRemoteSizes((prev) => ({ ...prev, [modelId]: { @@ -546,6 +592,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { modelAlias: string, mirror: string, ) => { + const isCurrent = makeRefreshGuard() + if (!isCurrent()) return setSherpaRemoteSizes((prev) => { if (prev[modelAlias] && !prev[modelAlias].error) return prev return { @@ -560,6 +608,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }) try { const info = await fetchSherpaOnnxAsrRemoteInfo(modelAlias, mirror) + if (!isCurrent()) return setSherpaRemoteSizes((prev) => ({ ...prev, [modelAlias]: { @@ -570,6 +619,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }, })) } catch (e) { + if (!isCurrent()) return setSherpaRemoteSizes((prev) => ({ ...prev, [modelAlias]: { @@ -584,14 +634,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { useEffect(() => { void refresh() - // 3s 轮询磁盘状态:模型被外部删除 / 下载中断时前端自动跟随(删除后 - // 看板选中自动回落、下拉回到引擎级入口),不用等重开页面。qwen3 的 - // list 是本地 fs walk,很轻;远端尺寸有缓存不会重复请求。 - const pollTimer = window.setInterval(() => { - void refresh() - }, 3000) return () => { - window.clearInterval(pollTimer) if (scrollGuardCleanup.current) scrollGuardCleanup.current() } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -605,7 +648,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const pollTimer = window.setInterval(() => { void refresh() }, 3000) - return () => window.clearInterval(pollTimer) + return () => { + refreshGenerationRef.current += 1 + window.clearInterval(pollTimer) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [downloadDialogOpen]) @@ -1949,7 +1995,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { null if (!dialogEntry || dialogEntry.isDownloaded) return dispatchEntryAction(dialogEntry, "download") - setDownloadDialogOpen(false) + setDownloadDialog(false) } const selectedEntryRemote = selectedEntry @@ -2036,7 +2082,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // 立刻反映到列表与详情,不等 3s 轮询。 void refresh() }} - onOpenDownload={() => setDownloadDialogOpen(true)} + onOpenDownload={() => setDownloadDialog(true)} downloadDisabled={ busyModelId !== null || sherpaBusy !== null || @@ -2442,7 +2488,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { return { status: "ok" as const, card: state } }} onStart={startDownloadFromDialog} - onClose={() => setDownloadDialogOpen(false)} + onClose={() => setDownloadDialog(false)} /> )}