mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
refactor(speech): narrow progress churn fix
This commit is contained in:
@@ -275,27 +275,6 @@ describe('renderer breadcrumb IPC routing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: each window emits its own churn notice with a different payload, so
|
||||
// coalescing by name is what keeps a download storm to one ring slot. Without
|
||||
// the registration these carry no message and fall through uncoalesced —
|
||||
// evicting the pre-crash trail this breadcrumb exists to sit beside.
|
||||
it('coalesces speech model-state churn by name across windows', () => {
|
||||
emitRendererBreadcrumb({
|
||||
name: 'speech_model_state_churn',
|
||||
data: { refreshes: 250, noOpRefreshes: 249, windowMs: 4200 }
|
||||
})
|
||||
emitRendererBreadcrumb({
|
||||
name: 'speech_model_state_churn',
|
||||
data: { refreshes: 250, noOpRefreshes: 12, windowMs: 3100 }
|
||||
})
|
||||
|
||||
expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled()
|
||||
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2)
|
||||
for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) {
|
||||
expect(call[0]).toMatchObject({ coalesceKey: 'speech_model_state_churn' })
|
||||
}
|
||||
})
|
||||
|
||||
// Why kind-scoped: a routine post-wake atlas reset must never suppress the
|
||||
// context-loss crumb that says the driver gave up on this renderer.
|
||||
it('coalesces WebGL diagnostics per kind so one kind cannot mask another', () => {
|
||||
|
||||
@@ -332,7 +332,6 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
|
||||
'renderer_unhandled_rejection',
|
||||
'terminal_park_verdict_churn',
|
||||
'terminal_safe_fit_retry_exhausted',
|
||||
'speech_model_state_churn',
|
||||
DUPLICATE_TAB_OWNER_BREADCRUMB,
|
||||
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB
|
||||
])
|
||||
@@ -346,14 +345,9 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
|
||||
// mounted pane within ~60ms. Windows crash F0BKR84AHEH lost 26-90% of its
|
||||
// 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count
|
||||
// — the only signal these carry — in one slot.
|
||||
//
|
||||
// speech_model_state_churn: a model download that outruns its coalescing fires this
|
||||
// once per 5s window for as long as the storm lasts. The rate is the signal, so one
|
||||
// entry plus `suppressedSinceLast` preserves it without evicting the crash itself.
|
||||
const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set([
|
||||
'terminal_park_verdict_churn',
|
||||
'terminal_safe_fit_retry_exhausted',
|
||||
'speech_model_state_churn'
|
||||
'terminal_safe_fit_retry_exhausted'
|
||||
])
|
||||
|
||||
function rendererBreadcrumbCoalesceKey(
|
||||
|
||||
@@ -59,58 +59,14 @@ describe('ModelManager progress callbacks', () => {
|
||||
internals.updateState('model-a', 'downloading', chunk / 8_000)
|
||||
}
|
||||
|
||||
// Exact, not a ceiling: a bound alone passes when coalescing swallows every
|
||||
// step after the first and the progress bar sits at 0% for the whole download.
|
||||
expect(listener.mock.calls.map(([, progress]) => Math.round(progress * 100))).toEqual(
|
||||
Array.from({ length: 101 }, (_unused, percent) => percent)
|
||||
expect(listener.mock.calls.map(([, progress]) => progress)).toEqual(
|
||||
Array.from({ length: 101 }, (_unused, percent) => percent / 100)
|
||||
)
|
||||
// Status transitions must still reach the UI unconditionally.
|
||||
const afterDownload = listener.mock.calls.length
|
||||
internals.updateState('model-a', 'extracting')
|
||||
internals.updateState('model-a', 'ready')
|
||||
expect(listener.mock.calls.length).toBe(afterDownload + 2)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('re-emits a repeated ready so a stale pane still resyncs', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
|
||||
try {
|
||||
const manager = new ModelManager(dir)
|
||||
const internals = manager as unknown as ModelManagerInternals
|
||||
const listener = vi.fn()
|
||||
manager.setProgressCallback(listener)
|
||||
|
||||
// downloadModel's already-downloaded branch: this lone 'ready' is the only
|
||||
// notification the requesting window receives before the handler returns.
|
||||
internals.updateState('model-a', 'ready')
|
||||
internals.updateState('model-a', 'ready')
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Why this matters: the renderer discards the progress event payload and re-polls
|
||||
// getModelState, so coalescing only the fan-out leaves the polled path raw.
|
||||
it('reports whole-percent progress to pollers during a download', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
|
||||
try {
|
||||
const manager = new ModelManager(dir)
|
||||
const internals = manager as unknown as ModelManagerInternals
|
||||
const seen = new Set<number | undefined>()
|
||||
|
||||
for (let chunk = 0; chunk < 8000; chunk += 1) {
|
||||
internals.updateState('whisper-tiny', 'downloading', chunk / 8000)
|
||||
seen.add((await manager.getModelState('whisper-tiny')).progress)
|
||||
}
|
||||
|
||||
// Exact, not a ceiling: a bound alone passes when every poll reports 0%.
|
||||
expect([...seen].sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual(
|
||||
Array.from({ length: 101 }, (_unused, percent) => percent / 100)
|
||||
)
|
||||
expect(listener.mock.calls.length).toBe(afterDownload + 3)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -47,18 +47,6 @@ type DownloadTotals = {
|
||||
}
|
||||
type ContentRange = { start: number; end: number; totalBytes?: number }
|
||||
|
||||
// Why: -1 stands in for "no progress reported" so undefined never equals 0%.
|
||||
function toWholeProgressPercent(progress: number | undefined): number {
|
||||
return progress === undefined ? -1 : Math.round(progress * 100)
|
||||
}
|
||||
|
||||
/** The precision the UI actually renders, so polled replies match the fan-out. */
|
||||
function toWholePercentState(state: SpeechModelState): SpeechModelState {
|
||||
return state.progress === undefined
|
||||
? state
|
||||
: { ...state, progress: toWholeProgressPercent(state.progress) / 100 }
|
||||
}
|
||||
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
|
||||
// Why: flaky networks/proxies often kill long CDN transfers near the end; Range-resume lets them finish.
|
||||
const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
@@ -224,10 +212,7 @@ export class ModelManager {
|
||||
await this.migrationReady
|
||||
const cached = this.modelStates.get(modelId)
|
||||
if (cached && (cached.status === 'downloading' || cached.status === 'extracting')) {
|
||||
// Why quantise here too: the renderer ignores the progress event payload and
|
||||
// re-polls, so returning the raw sub-percent cache would hand it a new value
|
||||
// per chunk and defeat the whole-percent coalescing the fan-out already does.
|
||||
return toWholePercentState(cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const manifest = getCatalogModel(modelId)
|
||||
@@ -403,25 +388,23 @@ export class ModelManager {
|
||||
error?: string
|
||||
): void {
|
||||
const previous = this.modelStates.get(modelId)
|
||||
const state: SpeechModelState = { id: modelId, status, progress, error }
|
||||
this.modelStates.set(modelId, state)
|
||||
// Why: notify on every state change (not just progress) so extracting/ready/error transitions reach the UI.
|
||||
const progressValue = progress ?? (status === 'extracting' ? 0.95 : -1)
|
||||
// Why: downloads call this once per HTTP chunk (thousands per model), and each
|
||||
// notification costs the renderer an IPC round-trip plus a full re-render of the
|
||||
// open speech-model menu. The UI only ever shows whole percent, so emitting at
|
||||
// that granularity keeps every visible transition and drops the rest.
|
||||
// Why only 'downloading': every other transition is one-shot, and the
|
||||
// already-ready branch of downloadModel is the sole notification that window
|
||||
// gets — deduplicating it strands a stale pane on a dead click.
|
||||
// Whole-percent state matches the UI and prevents chunk-level IPC/poll churn.
|
||||
const reportedProgress =
|
||||
status === 'downloading' && progress !== undefined
|
||||
? Math.round(progress * 100) / 100
|
||||
: progress
|
||||
if (
|
||||
status === 'downloading' &&
|
||||
previous?.status === 'downloading' &&
|
||||
previous.error === error &&
|
||||
toWholeProgressPercent(previous.progress) === toWholeProgressPercent(progress)
|
||||
previous.progress === reportedProgress
|
||||
) {
|
||||
return
|
||||
}
|
||||
const state: SpeechModelState = { id: modelId, status, progress: reportedProgress, error }
|
||||
this.modelStates.set(modelId, state)
|
||||
// Repeated non-download states can be the requesting window's only resync signal.
|
||||
const progressValue = reportedProgress ?? (status === 'extracting' ? 0.95 : -1)
|
||||
for (const callback of this.progressCallbacks) {
|
||||
callback(modelId, progressValue)
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create, type StateCreator } from 'zustand'
|
||||
import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { getDefaultVoiceSettings } from '../../../../shared/constants'
|
||||
import {
|
||||
createDictationSlice,
|
||||
SPEECH_MODEL_STATE_CHURN_BREADCRUMB
|
||||
} from '../../store/slices/dictation'
|
||||
import type { AppState } from '../../store/types'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
type DictationTestStore = Pick<
|
||||
AppState,
|
||||
'modelStates' | 'refreshModelStates' | 'setModelStates' | 'dictationState'
|
||||
>
|
||||
|
||||
// Why: drive the production dictation slice so modelStates identity churn is real, not modelled.
|
||||
const dictationSlice = createDictationSlice as unknown as StateCreator<DictationTestStore>
|
||||
const useDictationTestStore = create<DictationTestStore>(dictationSlice)
|
||||
const initialDictationState = useDictationTestStore.getState()
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: <T,>(selector: (state: DictationTestStore) => T): T =>
|
||||
useDictationTestStore(selector)
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Ctrl+Shift+Y' }))
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), message: vi.fn(), success: vi.fn(), info: vi.fn() }
|
||||
}))
|
||||
|
||||
const MODEL_ID = 'whisper-small'
|
||||
const CATALOG_ENTRY: SpeechModelManifest = {
|
||||
id: MODEL_ID,
|
||||
label: 'Whisper Small',
|
||||
description: 'Local streaming model',
|
||||
type: 'whisper',
|
||||
provider: 'local',
|
||||
language: 'en',
|
||||
sizeBytes: 500_000_000,
|
||||
sampleRate: 16_000,
|
||||
streaming: true,
|
||||
recommended: true
|
||||
}
|
||||
|
||||
let emitDownloadProgress: ((payload: { modelId: string; progress: number }) => void) | null = null
|
||||
let reportedProgress = 0
|
||||
|
||||
function installWindowApi(): void {
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
developerPermissions: { request: vi.fn() },
|
||||
crashReports: { recordBreadcrumb: vi.fn() },
|
||||
speech: {
|
||||
getCatalog: vi.fn(async () => [{ ...CATALOG_ENTRY }]),
|
||||
getOpenAiApiKeyStatus: vi.fn(async () => ({ configured: false })),
|
||||
saveOpenAiApiKey: vi.fn(async () => ({ configured: true })),
|
||||
clearOpenAiApiKey: vi.fn(async () => ({ configured: false })),
|
||||
// Why: every IPC reply is a fresh array, exactly like structured clone across the bridge.
|
||||
getModelStates: vi.fn(
|
||||
async (): Promise<SpeechModelState[]> => [
|
||||
{ id: MODEL_ID, status: 'downloading', progress: reportedProgress }
|
||||
]
|
||||
),
|
||||
downloadModel: vi.fn(async () => {}),
|
||||
deleteModel: vi.fn(async () => {}),
|
||||
onDownloadProgress: vi.fn(
|
||||
(callback: (payload: { modelId: string; progress: number }) => void) => {
|
||||
emitDownloadProgress = callback
|
||||
return () => {
|
||||
emitDownloadProgress = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: Radix Popper/DismissableLayer need DOM APIs happy-dom does not implement.
|
||||
function installDomShims(): void {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
)
|
||||
Element.prototype.hasPointerCapture ??= (): boolean => false
|
||||
Element.prototype.setPointerCapture ??= (): void => {}
|
||||
Element.prototype.releasePointerCapture ??= (): void => {}
|
||||
Element.prototype.scrollIntoView ??= (): void => {}
|
||||
}
|
||||
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
reportedProgress = 0
|
||||
emitDownloadProgress = null
|
||||
useDictationTestStore.setState(initialDictationState, true)
|
||||
installWindowApi()
|
||||
installDomShims()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the real VoicePane with the speech-model dropdown open — the state a
|
||||
* download deliberately leaves the menu in — and replays a download-progress
|
||||
* burst, counting how many times modelStates subscribers are forced to re-render.
|
||||
*/
|
||||
async function runDownloadProgressBurst(events: number): Promise<number> {
|
||||
const { VoicePane } = await import('./VoicePane')
|
||||
let subscriberRenders = 0
|
||||
|
||||
function ModelStatesSubscriber(): null {
|
||||
// Mirrors how VoicePane and the open menu read the store.
|
||||
useDictationTestStore((state) => state.modelStates)
|
||||
subscriberRenders += 1
|
||||
return null
|
||||
}
|
||||
|
||||
const settings = {
|
||||
voice: { ...getDefaultVoiceSettings(), enabled: true, sttModel: '' }
|
||||
} as GlobalSettings
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
<VoicePane settings={settings} updateSettings={vi.fn()} />
|
||||
<ModelStatesSubscriber />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const trigger = container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
|
||||
if (!trigger) {
|
||||
throw new Error('speech model dropdown trigger was not rendered')
|
||||
}
|
||||
await act(async () => {
|
||||
for (const type of ['pointerdown', 'mousedown', 'click']) {
|
||||
trigger.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0 }))
|
||||
}
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!document.querySelector('[role="menu"]')) {
|
||||
throw new Error('speech model dropdown did not open')
|
||||
}
|
||||
|
||||
const rendersBeforeBurst = subscriberRenders
|
||||
for (let index = 0; index < events; index += 1) {
|
||||
// Mirrors what main now returns: getModelState quantises the polled reply, so
|
||||
// raw sub-percent progress never reaches the renderer. Without that, this mock
|
||||
// would flatter the renderer-side stabilisation it is meant to measure.
|
||||
reportedProgress = Math.floor((index / events) * 100) / 100
|
||||
await act(async () => {
|
||||
emitDownloadProgress?.({ modelId: MODEL_ID, progress: reportedProgress })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
return subscriberRenders - rendersBeforeBurst
|
||||
}
|
||||
|
||||
describe('speech model download progress storm (render pressure)', () => {
|
||||
it('does not re-render the open speech-model menu once per download chunk', async () => {
|
||||
// A 500MB model over a 64KB chunk stream emits thousands of these; the menu
|
||||
// stays open by design while a download runs, so each one re-renders the
|
||||
// whole Radix portal/Presence tree the #185 reports crashed inside.
|
||||
const EVENTS = 200
|
||||
const renders = await runDownloadProgressBurst(EVENTS)
|
||||
|
||||
// 200 chunks carry 100 distinct whole percents and the mount refresh already
|
||||
// applied the first, so exactly 99 of these are real work. Asserting the floor
|
||||
// too: over-holding freezes the progress bar, which a ceiling alone calls a pass.
|
||||
expect(renders).toBe(99)
|
||||
expect(useDictationTestStore.getState().modelStates[0]?.progress).toBe(reportedProgress)
|
||||
})
|
||||
|
||||
it('records the churn breadcrumb when refreshes outrun coalescing', async () => {
|
||||
// Why pin Date.now: the threshold is per 5s window, so a loaded machine that
|
||||
// takes longer than one window to run the loop would measure itself, not the rule.
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
|
||||
// Own store instance: the churn window is per-slice state the other tests advance.
|
||||
const churnStore = create<DictationTestStore>(dictationSlice)
|
||||
for (let refresh = 0; refresh < 250; refresh += 1) {
|
||||
await churnStore.getState().refreshModelStates()
|
||||
}
|
||||
|
||||
const recordBreadcrumb = window.api.crashReports.recordBreadcrumb as ReturnType<typeof vi.fn>
|
||||
const churn = recordBreadcrumb.mock.calls
|
||||
.map((call) => call[0] as { name: string; data?: Record<string, number> })
|
||||
.find((entry) => entry.name === SPEECH_MODEL_STATE_CHURN_BREADCRUMB)
|
||||
expect(churn?.data).toMatchObject({ refreshes: 250, noOpRefreshes: 249 })
|
||||
// Registered in COALESCED/NAME_ONLY sets in src/main/ipc/crash-reporting.ts.
|
||||
expect(SPEECH_MODEL_STATE_CHURN_BREADCRUMB).toBe('speech_model_state_churn')
|
||||
})
|
||||
})
|
||||
@@ -1,185 +1,62 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create, type StateCreator } from 'zustand'
|
||||
import type { SpeechModelState } from '../../../../shared/speech-types'
|
||||
import type { AppState } from '../types'
|
||||
import { createDictationSlice, SPEECH_MODEL_STATE_CHURN_BREADCRUMB } from './dictation'
|
||||
import { createDictationSlice } from './dictation'
|
||||
|
||||
type DictationTestStore = Pick<AppState, 'modelStates' | 'refreshModelStates' | 'setModelStates'>
|
||||
const dictationSlice = createDictationSlice as unknown as StateCreator<DictationTestStore>
|
||||
|
||||
let reply: SpeechModelState[] = []
|
||||
let recordBreadcrumb: ReturnType<typeof vi.fn>
|
||||
|
||||
// Mirrors CHURN_WINDOW_MS in ./dictation; enough to roll the window.
|
||||
const PAST_CHURN_WINDOW_MS = 5_001
|
||||
|
||||
// Why a fresh store per test: the churn window is per-slice closure state.
|
||||
function newStore(): UseBoundStore<StoreApi<DictationTestStore>> {
|
||||
return create<DictationTestStore>(dictationSlice)
|
||||
}
|
||||
|
||||
// Why pin Date.now: the churn rule is "per window", so a test that lets real time
|
||||
// pass measures the machine, not the rule.
|
||||
function pinClock(): { advance: (ms: number) => void } {
|
||||
let now = 1_000_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
return {
|
||||
advance: (ms) => {
|
||||
now += ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function churnBreadcrumbs(): { name: string; data?: Record<string, number> }[] {
|
||||
return recordBreadcrumb.mock.calls
|
||||
.map((call) => call[0] as { name: string; data?: Record<string, number> })
|
||||
.filter((entry) => entry.name === SPEECH_MODEL_STATE_CHURN_BREADCRUMB)
|
||||
}
|
||||
let reply: SpeechModelState[]
|
||||
|
||||
beforeEach(() => {
|
||||
recordBreadcrumb = vi.fn()
|
||||
reply = []
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
crashReports: { recordBreadcrumb },
|
||||
// Why a fresh clone per call: structured clone across the bridge never
|
||||
// preserves identity, which is the condition the stabilisation exists for.
|
||||
speech: { getModelStates: vi.fn(async () => reply.map((state) => ({ ...state }))) }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('dictation model-state stabilisation', () => {
|
||||
it('keeps the previous array when every field matches', async () => {
|
||||
it('does not publish an unchanged reply', async () => {
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }]
|
||||
const store = newStore()
|
||||
const store = create<DictationTestStore>(dictationSlice)
|
||||
await store.getState().refreshModelStates()
|
||||
const first = store.getState().modelStates
|
||||
const previous = store.getState()
|
||||
const subscriber = vi.fn()
|
||||
store.subscribe(subscriber)
|
||||
|
||||
await store.getState().refreshModelStates()
|
||||
|
||||
expect(store.getState().modelStates).toBe(first)
|
||||
expect(store.getState()).toBe(previous)
|
||||
expect(subscriber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Guards the failure mode the stabilisation itself creates: over-holding leaves the
|
||||
// Voice pane frozen on a stale download, and no render-pressure ceiling catches it.
|
||||
it.each([
|
||||
['progress', { id: 'whisper-tiny', status: 'downloading', progress: 0.43 }],
|
||||
['status', { id: 'whisper-tiny', status: 'ready', progress: 0.42 }],
|
||||
['error', { id: 'whisper-tiny', status: 'downloading', progress: 0.42, error: 'boom' }],
|
||||
['id', { id: 'parakeet-tdt-0.6b-v3-int8', status: 'downloading', progress: 0.42 }]
|
||||
] as [string, SpeechModelState][])('adopts a reply whose %s changed', async (_, changed) => {
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }]
|
||||
const store = newStore()
|
||||
await store.getState().refreshModelStates()
|
||||
const first = store.getState().modelStates
|
||||
{ changed: [{ id: 'whisper-tiny', status: 'downloading', progress: 0.43 }] },
|
||||
{ changed: [{ id: 'whisper-tiny', status: 'ready' }] },
|
||||
{ changed: [{ id: 'whisper-tiny', status: 'error', error: 'boom' }] },
|
||||
{
|
||||
changed: [{ id: 'parakeet-tdt-0.6b-v3-int8', status: 'downloading', progress: 0.42 }]
|
||||
},
|
||||
{
|
||||
changed: [
|
||||
{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 },
|
||||
{ id: 'parakeet-tdt-0.6b-v3-int8', status: 'not-downloaded' }
|
||||
]
|
||||
}
|
||||
] as { changed: SpeechModelState[] }[])('publishes a changed reply', async ({ changed }) => {
|
||||
const store = create<DictationTestStore>(dictationSlice)
|
||||
store.getState().setModelStates([{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }])
|
||||
const subscriber = vi.fn()
|
||||
store.subscribe(subscriber)
|
||||
|
||||
reply = [changed]
|
||||
reply = changed
|
||||
await store.getState().refreshModelStates()
|
||||
|
||||
expect(store.getState().modelStates).not.toBe(first)
|
||||
expect(store.getState().modelStates).toEqual([changed])
|
||||
})
|
||||
|
||||
it('adopts a reply that added a model', async () => {
|
||||
reply = [{ id: 'whisper-tiny', status: 'ready' }]
|
||||
const store = newStore()
|
||||
await store.getState().refreshModelStates()
|
||||
|
||||
reply = [
|
||||
{ id: 'whisper-tiny', status: 'ready' },
|
||||
{ id: 'parakeet-tdt-0.6b-v3-int8', status: 'not-downloaded' }
|
||||
]
|
||||
await store.getState().refreshModelStates()
|
||||
|
||||
expect(store.getState().modelStates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies every step of a whole-percent download', async () => {
|
||||
const store = newStore()
|
||||
const rendered: (number | undefined)[] = []
|
||||
for (let percent = 0; percent <= 90; percent += 1) {
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: percent / 100 }]
|
||||
await store.getState().refreshModelStates()
|
||||
rendered.push(store.getState().modelStates[0]?.progress)
|
||||
}
|
||||
|
||||
expect(rendered).toEqual(Array.from({ length: 91 }, (_unused, percent) => percent / 100))
|
||||
})
|
||||
|
||||
// Main clamps progress at 0.9 and emits per whole percent, so a fast 56MB download
|
||||
// puts its whole ~91-refresh run in one window. Firing there would be a false alarm
|
||||
// and would evict real evidence from the 30-entry breadcrumb ring.
|
||||
it('does not report churn for one healthy download inside a single window', async () => {
|
||||
const store = newStore()
|
||||
for (let percent = 0; percent <= 90; percent += 1) {
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: percent / 100 }]
|
||||
await store.getState().refreshModelStates()
|
||||
}
|
||||
|
||||
expect(recordBreadcrumb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports churn once the renderer is driven far past whole-percent pacing', async () => {
|
||||
pinClock()
|
||||
const store = newStore()
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }]
|
||||
for (let refresh = 0; refresh < 250; refresh += 1) {
|
||||
await store.getState().refreshModelStates()
|
||||
}
|
||||
|
||||
const churn = churnBreadcrumbs()
|
||||
expect(churn).toHaveLength(1)
|
||||
expect(churn[0].data).toMatchObject({ refreshes: 250, noOpRefreshes: 249 })
|
||||
})
|
||||
|
||||
// Why spread across the window: a burst-only test passes for any window length,
|
||||
// including one too short for a real storm to ever accumulate the threshold. The
|
||||
// window has to be wide enough to hold a sustained storm, not just an instant one.
|
||||
it('still reports a storm that is spread across most of one window', async () => {
|
||||
const clock = pinClock()
|
||||
const store = newStore()
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }]
|
||||
for (let refresh = 0; refresh < 250; refresh += 1) {
|
||||
await store.getState().refreshModelStates()
|
||||
clock.advance(19)
|
||||
}
|
||||
|
||||
const churn = churnBreadcrumbs()
|
||||
expect(churn).toHaveLength(1)
|
||||
expect(churn[0].data).toMatchObject({ refreshes: 250, windowMs: 249 * 19 })
|
||||
})
|
||||
|
||||
// Stopping at exactly the threshold cannot tell "fires once" from "fires on every
|
||||
// refresh after", and the second floods the 30-entry ring this breadcrumb is
|
||||
// coalesced into to protect.
|
||||
it('records the churn breadcrumb at most once per window', async () => {
|
||||
pinClock()
|
||||
const store = newStore()
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }]
|
||||
for (let refresh = 0; refresh < 400; refresh += 1) {
|
||||
await store.getState().refreshModelStates()
|
||||
}
|
||||
|
||||
expect(churnBreadcrumbs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The threshold is a rate, not a lifetime total. Without a per-window reset three
|
||||
// healthy downloads (3 x 91) cross 250 and the breadcrumb cries wolf again.
|
||||
it('counts per window, so healthy downloads never accumulate into a false alarm', async () => {
|
||||
const clock = pinClock()
|
||||
const store = newStore()
|
||||
reply = [{ id: 'whisper-tiny', status: 'downloading', progress: 0.42 }]
|
||||
for (let download = 0; download < 4; download += 1) {
|
||||
for (let refresh = 0; refresh <= 90; refresh += 1) {
|
||||
await store.getState().refreshModelStates()
|
||||
}
|
||||
clock.advance(PAST_CHURN_WINDOW_MS)
|
||||
}
|
||||
|
||||
expect(churnBreadcrumbs()).toEqual([])
|
||||
expect(store.getState().modelStates).toEqual(changed)
|
||||
expect(subscriber).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { DictationState, SpeechModelState } from '../../../../shared/speech-types'
|
||||
import { recordRendererCrashBreadcrumb } from '../../lib/crash-breadcrumb-recorder'
|
||||
|
||||
export const SPEECH_MODEL_STATE_CHURN_BREADCRUMB = 'speech_model_state_churn'
|
||||
const CHURN_WINDOW_MS = 5_000
|
||||
// Why this high: main clamps download progress at 0.9 and emits on whole-percent
|
||||
// change, so one healthy download is capped at ~91 refreshes and a 56MB model on a
|
||||
// fast link lands all of them inside a single window. Anything past 50/s is the
|
||||
// renderer being driven per HTTP chunk again — the shape that starves React's
|
||||
// commit loop. `noOpRefreshes` in the payload separates the two causes.
|
||||
const CHURN_REFRESH_THRESHOLD = 250
|
||||
|
||||
export type DictationSlice = {
|
||||
dictationState: DictationState
|
||||
@@ -43,31 +33,11 @@ function resolveModelStates(
|
||||
}
|
||||
|
||||
export const createDictationSlice: StateCreator<AppState, [], [], DictationSlice> = (set) => {
|
||||
let windowStartedAt = 0
|
||||
let refreshes = 0
|
||||
let noOpRefreshes = 0
|
||||
|
||||
// Why: all four page.settings React #185 reports crashed inside the open speech-model
|
||||
// menu with no speech telemetry in the bundle. Record the refresh rate so the next
|
||||
// occurrence says whether model-state churn was driving the commit storm.
|
||||
const noteRefresh = (changed: boolean): void => {
|
||||
const now = Date.now()
|
||||
if (now - windowStartedAt > CHURN_WINDOW_MS) {
|
||||
windowStartedAt = now
|
||||
refreshes = 0
|
||||
noOpRefreshes = 0
|
||||
}
|
||||
refreshes += 1
|
||||
if (!changed) {
|
||||
noOpRefreshes += 1
|
||||
}
|
||||
if (refreshes === CHURN_REFRESH_THRESHOLD) {
|
||||
recordRendererCrashBreadcrumb(SPEECH_MODEL_STATE_CHURN_BREADCRUMB, {
|
||||
refreshes,
|
||||
noOpRefreshes,
|
||||
windowMs: now - windowStartedAt
|
||||
})
|
||||
}
|
||||
const setModelStates = (states: SpeechModelState[]): void => {
|
||||
set((prev) => {
|
||||
const modelStates = resolveModelStates(prev.modelStates, states)
|
||||
return modelStates === prev.modelStates ? prev : { modelStates }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -79,19 +49,11 @@ export const createDictationSlice: StateCreator<AppState, [], [], DictationSlice
|
||||
setDictationState: (state) => set({ dictationState: state }),
|
||||
setPartialTranscript: (text) => set({ partialTranscript: text }),
|
||||
setActiveModelId: (id) => set({ activeModelId: id }),
|
||||
setModelStates: (states) =>
|
||||
set((prev) => ({ modelStates: resolveModelStates(prev.modelStates, states) })),
|
||||
setModelStates,
|
||||
|
||||
refreshModelStates: async () => {
|
||||
try {
|
||||
const states = await window.api.speech.getModelStates()
|
||||
let changed = false
|
||||
set((prev) => {
|
||||
const resolved = resolveModelStates(prev.modelStates, states)
|
||||
changed = resolved !== prev.modelStates
|
||||
return { modelStates: resolved }
|
||||
})
|
||||
noteRefresh(changed)
|
||||
setModelStates(await window.api.speech.getModelStates())
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch model states:', err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user