Merge branch 'pr2439' into triage/backend-startup

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-10-01 18:45:45 +05:30
57 changed files with 723 additions and 46 deletions
+10
View File
@@ -767,6 +767,16 @@ systemd unit, …) is already providing the same key, the panel says so instead,
since that external value keeps winning on every future restart too, not just
this one.
**While the backend is busy, the app now stays usable.** The desktop shell polls
the backend's `/health` endpoint while you work, and a long GPU job can hold the
Python event loop long enough to miss those probes. The shell knows the process
is still alive (it checks for an exit before reacting), so it now shows a
**recoverable busy state** instead of an error: the status-bar dot pulses amber,
your workspace stays open, and requests wait for the current job to finish
rather than failing with "Can't reach the local backend". Nothing to do — it
clears itself as soon as the job releases the event loop. If the dot turns
**red** and names an exit code, that is a real crash; use the sections above.
**Two things changed here** ([#1190](https://github.com/debpalash/VoiceStudio/issues/1190)):
- **Waiting in line is no longer counted as compute.** The generate budget used
+150
View File
@@ -0,0 +1,150 @@
// @vitest-environment node
// #2430 — a live-but-busy backend must never be reported as a failure.
//
// The supervisor already knew better: it checked `exitCode`/`signalCode`
// before touching the stage precisely because "inference can monopolize
// Python's event loop longer than the health deadline". It then published the
// terminal `failed` stage anyway. The renderer acts on `failed` by replacing the
// workspace with an error gate, pausing every query and dead-ending in-flight
// requests — for a stall that the very next probe clears on its own. That is how
// a long voice-clone job took the app down mid-generation.
//
// These pin both halves of the contract: a LIVE child that stops answering is
// `unresponsive` (non-terminal, self-recovering, never killed), while a child
// that is actually gone still reports `crashed`.
import { EventEmitter } from 'node:events';
import { afterEach, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ spawn: vi.fn(), spawnSync: vi.fn() }));
vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/unused-supervise-test' } }));
vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: mocks.spawnSync }));
// `availableBackendPort` preflights the preferred port; a clean bind means the
// managed launch keeps 3900.
vi.mock('node:net', () => ({
createServer: () => {
const server = Object.assign(new EventEmitter(), {
listen: (_options: unknown, done: () => void) => queueMicrotask(done),
address: () => ({ port: 39152 }),
close: (done: () => void) => done(),
});
return server;
},
}));
vi.mock('./runtime-project', () => ({
runtimeReady: async () => true,
runtimeCompatible: async () => true,
runtimeDependenciesReady: async () => true,
stageRuntimeSources: async () => {},
runtimePython: () => '/runtime/python',
installRuntime: vi.fn(),
promoteLegacyRuntimeCaches: vi.fn(async () => {}),
runtimeInstallInterrupted: vi.fn(async () => false),
}));
import { BackendSupervisor } from './backend';
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.clearAllMocks();
});
/** `down` is a cold port, `busy` a blocked event loop, `up` an answering backend. */
type Health = { mode: 'down' | 'busy' | 'up' };
/** /health answers only in `up`. */
function stubHealth(health: Health): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
if (health.mode !== 'up') throw new Error('no answer');
return new Response(JSON.stringify({ status: 'ok', version: 'test' }), {
headers: { 'x-omnivoice-backend': 'test' },
});
}),
);
}
/** A spawned child that is alive: no exit code, no signal, nothing to kill. */
function liveChild() {
return Object.assign(new EventEmitter(), {
stdin: null,
stdout: null,
stderr: null,
stdio: [],
pid: 4242,
exitCode: null as number | null,
signalCode: null as string | null,
});
}
it('reports a live-but-busy backend as unresponsive, not failed (#2430)', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
vi.stubEnv('OMNIVOICE_PORT', '');
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
vi.stubEnv('OMNIVOICE_STARTUP_BUDGET_S', '60');
const health: Health = { mode: 'down' };
stubHealth(health);
const child = liveChild();
mocks.spawn.mockReturnValue(child);
const supervisor = new BackendSupervisor();
try {
await supervisor.start();
// Cold port: nothing to attach to, so the shell owns the process.
expect(mocks.spawn).toHaveBeenCalledOnce();
health.mode = 'up';
await vi.advanceTimersByTimeAsync(1_000);
expect(supervisor.status.stage).toBe('ready');
expect(supervisor.status.managed).toBe(true);
// A heavy job blocks the event loop past the probe deadline.
health.mode = 'busy';
// SUPERVISE_POLL_MS (2 s) x SUPERVISE_MISSES (3), plus a tick of margin.
await vi.advanceTimersByTimeAsync(7_000);
// The whole bug: this used to be `failed`.
expect(supervisor.status.stage).toBe('unresponsive');
expect(supervisor.status.message).toMatch(/busy/i);
// Still the same live, managed process — observed, never killed.
expect(supervisor.status.managed).toBe(true);
expect(child.exitCode).toBeNull();
expect(mocks.spawnSync).not.toHaveBeenCalled();
// The job finishes: the health loop retires the stage on its own.
health.mode = 'up';
await vi.advanceTimersByTimeAsync(3_000);
expect(supervisor.status.stage).toBe('ready');
expect(supervisor.status.message).toBeUndefined();
} finally {
(supervisor as unknown as { child: null }).child = null;
await supervisor.shutdown();
}
});
it('still reports a backend that is genuinely gone as crashed', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
vi.stubEnv('OMNIVOICE_PORT', '');
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
const health: Health = { mode: 'up' };
stubHealth(health);
// No child to inspect: an attached/external backend that stops answering is
// indistinguishable from one that died, so it must still surface as `crashed`.
const supervisor = new BackendSupervisor();
try {
await supervisor.start();
await vi.advanceTimersByTimeAsync(1_000);
expect(supervisor.status.stage).toBe('ready');
expect(mocks.spawn).not.toHaveBeenCalled();
health.mode = 'busy';
await vi.advanceTimersByTimeAsync(7_000);
expect(supervisor.status.stage).toBe('crashed');
} finally {
await supervisor.shutdown();
}
});
+18 -3
View File
@@ -54,6 +54,15 @@ const PROBE_TIMEOUT_MS = 1500;
const SHUTDOWN_INTENT_TIMEOUT_MS = 1000;
/** Consecutive supervisor probe misses (2 s apart) before a ready backend is declared gone. */
const SUPERVISE_MISSES = 3;
/**
* Stages a later successful /health probe must retire back to `ready` (#2430).
* `unresponsive` is a live-but-busy backend, not a failure: the supervisor
* already proved the process is alive, so the health loop owns clearing it.
*/
const RECOVERABLE_STAGES: ReadonlySet<BackendStage> = new Set<BackendStage>([
'failed',
'unresponsive',
]);
const LOG_RING_LINES = 200;
const LOG_TAIL_LINES = 40;
/** EX_CONFIG (sysexits.h): backend/main.py exits with it when the port is taken (#1223). */
@@ -1248,7 +1257,7 @@ export class BackendSupervisor extends EventEmitter<{
}
if (await this.probe()) {
misses = 0;
if (gen === this.generation && this.stage === 'failed') {
if (gen === this.generation && RECOVERABLE_STAGES.has(this.stage)) {
this.setStage('ready', { message: undefined });
}
} else if (++misses >= SUPERVISE_MISSES && gen === this.generation) {
@@ -1262,10 +1271,16 @@ export class BackendSupervisor extends EventEmitter<{
// Inference can monopolize Python's event loop longer than the health
// deadline. A missed HTTP probe is not proof of process death. Keep
// observing our live child; its exit handler owns crash reporting.
//
// Report this as `unresponsive`, NOT `failed` (#2430): the process is
// demonstrably alive, so the failure channel would be a lie. `failed`
// tears the workspace down behind an error gate, pauses every query
// and dead-ends in-flight requests, all for a stall that the very next
// probe clears. Announced once, then left to recover on its own.
if (this.child && this.child.exitCode === null && this.child.signalCode === null) {
if (misses === SUPERVISE_MISSES) {
this.setStage('failed', {
message: `Backend is running but temporarily not responding on port ${this.port}. Waiting for recovery.`,
this.setStage('unresponsive', {
message: `Backend is running but busy on port ${this.port}; it is not answering health checks right now. This resolves on its own once the current job finishes.`,
});
}
if (gen === this.generation) void tick();
+1
View File
@@ -12,6 +12,7 @@ export type BackendStage =
| 'attaching' // an already-running backend on the port answered → we reuse it
| 'starting' // we spawned it and are polling /system/info
| 'ready'
| 'unresponsive' // alive, but too busy to answer /health — self-recovering, never terminal
| 'crashed' // the spawned process exited unexpectedly
| 'port_in_use' // backend exited 78 (EX_CONFIG): another process holds the port
| 'failed'; // could not start within the budget / spawn error
@@ -6,6 +6,7 @@ import { BarChart3Icon, XIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiJson } from '@/lib/api/client';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { setupWasStarted } from '@/lib/setup-progress';
import {
capture,
@@ -28,7 +29,7 @@ export function AnalyticsRuntime() {
const analytics = useQuery({
queryKey: analyticsKey,
queryFn: ({ signal }) => apiJson<AnalyticsState>('/api/settings/analytics', { signal }),
enabled: backend.stage === 'ready',
enabled: isBackendReachable(backend.stage),
retry: false,
});
useEffect(() => {
@@ -146,13 +147,13 @@ export function AnalyticsConsentBanner() {
const analytics = useQuery({
queryKey: analyticsKey,
queryFn: ({ signal }) => apiJson<AnalyticsState>('/api/settings/analytics', { signal }),
enabled: backend.stage === 'ready',
enabled: isBackendReachable(backend.stage),
retry: false,
});
const setup = useQuery({
queryKey: ['setup-status'],
queryFn: ({ signal }) => apiJson<{ models_ready: boolean }>('/setup/status', { signal }),
enabled: backend.stage === 'ready',
enabled: isBackendReachable(backend.stage),
retry: false,
});
const state = analytics.data;
@@ -7,6 +7,7 @@ import { RepairAgentDock } from './repair-agent-dock';
import { isMac } from '../bridge';
import { cn } from '@/lib/utils';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { SystemNotifications } from './system-notifications';
export function AppShell() {
@@ -66,7 +67,7 @@ export function AppShell() {
data-slot="macos-system-notifications"
className="app-no-drag fixed top-3.5 right-3.5 z-50"
>
<SystemNotifications enabled={backend.stage === 'ready'} titlebar />
<SystemNotifications enabled={isBackendReachable(backend.stage)} titlebar />
</div>
)}
</div>
@@ -27,6 +27,7 @@ import { useIsFetching, useQuery } from '@tanstack/react-query';
import { apiJson } from '@/lib/api/client';
import { useTranslation } from 'react-i18next';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { engineFamilyState, useEngines } from '@/hooks/use-engines';
import { useDeviceUsage } from '@/hooks/use-device-usage';
import { useDictationSelection } from '@/hooks/use-dictation-selection';
@@ -144,6 +145,9 @@ const DOT: Record<BackendStage, string> = {
attaching: 'bg-warning animate-pulse motion-reduce:animate-none',
starting: 'bg-warning animate-pulse motion-reduce:animate-none',
ready: 'bg-success',
// Alive but busy (#2430): transient and self-recovering, so it pulses as a
// warning rather than sitting on the app as a destructive red failure.
unresponsive: 'bg-warning animate-pulse motion-reduce:animate-none',
crashed: 'bg-destructive',
port_in_use: 'bg-destructive',
failed: 'bg-destructive',
@@ -165,7 +169,7 @@ export function StatusBar({
const [appliedProfile, setAppliedProfile] = useState<PerformanceProfileState | null>(null);
const enginesRefreshing = useIsFetching({ queryKey: ['engines'] }) > 0;
const status = useBackendStatus();
const computeTarget = useComputeTarget(status.stage === 'ready');
const computeTarget = useComputeTarget(isBackendReachable(status.stage));
const activeComputeTarget = computeTarget.data?.active;
const activeRemoteTarget = activeComputeTarget?.remote
? computeTarget.data?.targets.find((item) => item.id === activeComputeTarget.worker_id)
@@ -183,13 +187,13 @@ export function StatusBar({
activeRemoteTarget?.id,
selectedTts?.id,
'tts',
status.stage === 'ready' && Boolean(activeRemoteTarget),
isBackendReachable(status.stage) && Boolean(activeRemoteTarget),
activityCount > 0,
);
const model = useQuery({
queryKey: ['sidebar-model-status'],
queryFn: () => apiJson<SidebarModelStatus>('/model/status'),
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
refetchInterval: (query) => modelStatusPollMs(activityCount, query.state.data?.status),
});
const translation = useTranslationEngines();
@@ -199,7 +203,7 @@ export function StatusBar({
const dictation = useDictationSelection();
const modelCatalogue = useQuery({
queryKey: ['model-catalogue'],
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
staleTime: 30_000,
queryFn: () =>
apiJson<{
@@ -213,7 +217,7 @@ export function StatusBar({
});
const batchJobs = useQuery({
queryKey: ['batch-jobs', 'active'],
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
queryFn: ({ signal }) => apiJson<BatchJob[]>('/batch/jobs?status=active&limit=100', { signal }),
staleTime: 1_000,
refetchInterval: (query) => batchStatusPollMs(query.state.data?.length ?? 0),
@@ -230,7 +234,7 @@ export function StatusBar({
const batchTtsActive = runningBatchStages.has('generate');
const loadedModels = useQuery({
queryKey: ['loaded-models'],
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
staleTime: 5_000,
refetchInterval: loadedModelsPollMs(activityCount > 0 || hasBatchWork),
queryFn: () =>
@@ -249,7 +253,7 @@ export function StatusBar({
const loadedDiarisation = loadedModels.data?.models.find((entry) => entry.id === 'diarization');
const diarisation = useQuery({
queryKey: ['diarisation-status'],
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
refetchInterval: IDLE_STATUS_POLL_MS,
queryFn: () =>
apiJson<{
@@ -763,7 +767,7 @@ export function StatusBar({
{viewControl}
</div>
)}
{status.stage === 'ready' && (
{isBackendReachable(status.stage) && (
<PerformanceProfile
onApplied={(applied) => {
appliedRefreshStarted.current = presetRefreshing;
@@ -789,7 +793,7 @@ export function StatusBar({
key={row.family}
row={row}
level={level}
online={status.stage === 'ready'}
online={isBackendReachable(status.stage)}
dotClass={engineStateClass(row.state)}
open={selectedDetail === row.family}
onToggle={() =>
@@ -13,6 +13,7 @@ import { WorkspaceNavigation } from './workspace-menu';
import { StatusBar } from './status-bar';
import { SystemNotifications } from './system-notifications';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
export function WorkspaceSidebar() {
const backend = useBackendStatus();
@@ -101,7 +102,7 @@ export function WorkspaceSidebar() {
/>
</Link>
{mac && <StatusBar compact inline />}
{!mac && <SystemNotifications enabled={backend.stage === 'ready'} compact />}
{!mac && <SystemNotifications enabled={isBackendReachable(backend.stage)} compact />}
</div>
</aside>
)}
@@ -190,7 +191,7 @@ export function WorkspaceSidebar() {
/>
{t('nav.settings')}
</Link>
<SystemNotifications enabled={backend.stage === 'ready'} />
<SystemNotifications enabled={isBackendReachable(backend.stage)} />
</div>
)}
</div>
@@ -115,6 +115,48 @@ it('shows package installation after the last large download instead of a stale
expect(screen.queryByText('Downloaded scipy')).not.toBeInTheDocument();
});
// #2430 — a live-but-busy backend is not a failure.
//
// A heavy job blocks the Python event loop past the health-probe deadline. The
// supervisor had already proven the process was alive, yet it published the
// terminal `failed` stage, so the gate replaced the workspace with an error
// screen mid-generation. `unresponsive` must instead stay on the pass-through
// path — the same path `ready` takes.
const renderGate = (stage: BackendStatus['stage']) => {
backendStatus.stage = stage;
return render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<BackendGate>
<div>workspace</div>
</BackendGate>
</QueryClientProvider>,
);
};
it('keeps the workspace mounted while a live backend is only busy (#2430)', () => {
backendStatus.managed = true;
backendStatus.message = 'Backend is running but busy on port 3900.';
renderGate('unresponsive');
expect(screen.queryByTestId('backend-gate-scroll')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: i18n.t('backend.retry') })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /report this bug/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /view crash details/i })).not.toBeInTheDocument();
});
it('still takes over the workspace for a real failure', () => {
// The contrast that makes the assertion above meaningful.
backendStatus.message = 'The Python environment is missing or incomplete.';
renderGate('failed');
expect(screen.getByTestId('backend-gate-scroll')).toBeInTheDocument();
expect(screen.getByRole('button', { name: i18n.t('backend.retry') })).toBeInTheDocument();
});
it('keeps agent repair available when the backend is down', () => {
backendStatus.stage = 'failed';
@@ -25,6 +25,7 @@ import { Spinner } from '@/components/ui/spinner';
import { ConfirmDialog } from '@/features/clone/confirm-dialog';
import { RemoteBackendSettings } from '@/features/settings/remote-backend-settings';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendBusy } from '@shared/utils/backendStage';
import i18n, { APP_LANGUAGE_ITEMS, APP_LANGUAGES, setAppLanguage, type AppLocale } from '@/i18n';
import { brandIcon } from '@/lib/brand';
import { cn } from '@/lib/utils';
@@ -126,6 +127,11 @@ export function BackendGate({ children, repairDock }: BackendGateProps) {
progress?.transferUpdatedAt !== undefined && Date.now() - progress.transferUpdatedAt < 15_000;
const recovering = reachedReady && status.stage === 'failed' && status.managed;
// A live-but-busy backend (#2430) is not a gate. The process is running the
// user's own job, so the workspace stays mounted and usable and the status
// bar carries the only signal this needs. Tearing the app down behind an
// error screen mid-generation is the bug this stage exists to prevent.
const busy = isBackendBusy(status.stage);
useEffect(() => {
setRestarting(false);
@@ -134,7 +140,7 @@ export function BackendGate({ children, repairDock }: BackendGateProps) {
if (status.stage === 'ready') setReachedReady(true);
}, [status.stage]);
if (status.stage === 'ready')
if (status.stage === 'ready' || busy)
return (
<div className="contents">
<SetupGate>{children}</SetupGate>
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { useDeviceUsage } from '@/hooks/use-device-usage';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
function valid(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
@@ -11,7 +12,7 @@ export function LiveDeviceUsage({ open }: { open: boolean }) {
const query = useDeviceUsage(open);
const backend = useBackendStatus();
// Don't keep presenting an old successful sample as live after an error.
const data = !query.isError && backend.stage === 'ready' ? query.data : undefined;
const data = !query.isError && isBackendReachable(backend.stage) ? query.data : undefined;
const number = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat(i18n.language, {
style: 'percent',
@@ -74,7 +75,7 @@ export function LiveDeviceUsage({ open }: { open: boolean }) {
) : (
<span className="text-muted-foreground">
{t(
backend.stage !== 'ready'
!isBackendReachable(backend.stage)
? 'modelSettings.unavailable'
: !data
? 'preferences.loading'
@@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { apiJson, describeError } from '@/lib/api/client';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { useDictationSelection } from '@/hooks/use-dictation-selection';
import { engineFamilyState, useEngines } from '@/hooks/use-engines';
import { Button, buttonVariants } from '@/components/ui/button';
@@ -48,7 +49,7 @@ export function PerformanceProfile({
const dictation = useDictationSelection();
const batch = useQuery({
queryKey: ['batch-jobs', 'active'],
enabled: backend.stage === 'ready',
enabled: isBackendReachable(backend.stage),
queryFn: ({ signal }) => apiJson<unknown[]>('/batch/jobs?status=active&limit=100', { signal }),
staleTime: 1_000,
refetchInterval: (query) => (query.state.data?.length ? 1_000 : 15_000),
@@ -60,7 +61,7 @@ export function PerformanceProfile({
const tierRefs = useRef<(HTMLButtonElement | null)[]>([]);
const busy =
profile.isSaving ||
backend.stage !== 'ready' ||
!isBackendReachable(backend.stage) ||
batch.isPending ||
batch.isError ||
Boolean(batch.data?.length) ||
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { apiJson } from '@/lib/api/client';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
export interface CatalogueModel {
repo_id: string;
@@ -37,7 +38,7 @@ export function useModelCatalogue() {
queryKey: ['model-catalogue'],
queryFn: () => apiJson<ModelCatalogueResponse>('/models'),
staleTime: 30_000,
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
refetchInterval: (query) =>
query.state.data?.target && query.state.data.target !== 'local' ? 5_000 : false,
});
@@ -12,6 +12,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { getBridge } from '@/components/bridge';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { apiJson, apiPath, describeError } from '@/lib/api/client';
import { SettingsActionError } from './settings-action-error';
@@ -68,7 +69,7 @@ export function OpenApiSettings() {
<CheckCircle2Icon
aria-hidden="true"
className={
backend.stage === 'ready'
isBackendReachable(backend.stage)
? 'size-4 shrink-0 text-emerald-500'
: 'size-4 shrink-0 text-muted-foreground'
}
@@ -1,4 +1,5 @@
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { LanguagesIcon } from 'lucide-react';
import { Link } from '@tanstack/react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -29,7 +30,7 @@ export interface TranslationEngine {
export function useTranslationEngines() {
const status = useBackendStatus();
return useQuery({
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
queryKey: ['translation-engines'],
queryFn: () =>
apiJson<{
@@ -0,0 +1,150 @@
import { act, render } from '@testing-library/react';
import { useSyncExternalStore } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CaptureWidget } from './capture-widget';
// A real external store, not a plain object read: without a subscription the
// component never re-renders on a stage change, and these assertions would
// pass even against the bug they exist to catch.
const backendStore = vi.hoisted(() => {
const listeners = new Set<() => void>();
// Closure state, not `this`: these are handed to React as bare callbacks,
// where `this` is undefined in a strict-mode module.
let stage = 'unresponsive';
return {
reset: () => {
stage = 'unresponsive';
},
set: (next: string) => {
if (stage === next) return;
stage = next;
for (const listener of listeners) listener();
},
get: () => stage,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
});
// The real predicate is deliberately NOT stubbed: whether a busy backend counts
// as reachable is exactly what this test pins down.
vi.mock('@/hooks/use-backend-status', () => ({
useBackendStatus: () => {
// Subscribing is what makes a stage change re-render the widget, which is
// the entire mechanism the regression depended on.
useSyncExternalStore(backendStore.subscribe, backendStore.get, backendStore.get);
return { stage: backendStore.get(), baseUrl: 'http://127.0.0.1:3900' };
},
}));
const capture = vi.hoisted(() => ({
accept: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
deliver: vi.fn(async () => 'copied'),
onEvent: vi.fn(),
phase: vi.fn(async () => {}),
ready: vi.fn(async () => {}),
}));
const dictation = vi.hoisted(() => {
// `useSyncExternalStore` compares snapshots with Object.is, so this must be
// one stable object — a fresh literal per call loops React forever.
const snapshot = { stage: 'recording', text: '', paused: false };
return {
cancel: vi.fn(),
pause: vi.fn(),
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
});
// `CaptureWidget` does `new LiveDictation()`, so the mock must be a real
// constructor whose instances share the spies above.
vi.mock('./live-dictation', () => ({
LiveDictation: class {
cancel = dictation.cancel;
pause = dictation.pause;
start = dictation.start;
stop = dictation.stop;
getSnapshot = dictation.getSnapshot;
subscribe = dictation.subscribe;
},
}));
vi.mock('@shared/utils/transcriptionsStore', () => ({ addTranscription: vi.fn() }));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
/** Let the effect subscribe, then push a `start`-shaped capture event into it. */
async function mountAndStartSession(): Promise<void> {
const onEvent = capture.onEvent.mock.calls.at(-1)?.[0] as ((e: unknown) => void) | undefined;
if (!onEvent) throw new Error('capture widget never subscribed to native events');
await act(async () => {
onEvent({ action: 'start', session: 7 });
});
}
beforeEach(() => {
backendStore.reset();
for (const target of [capture, dictation]) {
for (const value of Object.values(target)) {
if (typeof value === 'function' && 'mockClear' in value) value.mockClear();
}
}
capture.onEvent.mockReturnValue(() => {});
vi.stubGlobal('voicestudio', { capture });
});
afterEach(() => {
vi.unstubAllGlobals();
});
// #2430 — a busy backend is a live backend, so dictation may start during the
// busy window. The regression this pins: subscribing on `unresponsive` while
// still listing `backend.stage` as an effect dependency meant the very next
// successful health probe flipped the stage to `ready`, re-ran the effect, and
// ran its cleanup — which cancels the recording and tells main to drop the
// session. The dictation died on recovery, before transcription could finish.
describe('native dictation survives a busy backend recovering (#2430)', () => {
it('keeps the accepted session when a health probe restores ready', async () => {
render(<CaptureWidget />);
await mountAndStartSession();
expect(capture.accept).toHaveBeenCalledWith(7);
// The supervisor retires `unresponsive` back to `ready` on the next probe.
await act(async () => backendStore.set('ready'));
await act(async () => {});
expect(dictation.cancel).not.toHaveBeenCalled();
expect(capture.cancel).not.toHaveBeenCalled();
});
it('also survives a second busy window without resubscribing', async () => {
render(<CaptureWidget />);
await mountAndStartSession();
const subscriptions = capture.onEvent.mock.calls.length;
for (const stage of ['ready', 'unresponsive', 'ready', 'unresponsive']) {
await act(async () => backendStore.set(stage));
}
expect(dictation.cancel).not.toHaveBeenCalled();
expect(capture.cancel).not.toHaveBeenCalled();
// A stable subscription, not one torn down and rebuilt per stage change.
expect(capture.onEvent).toHaveBeenCalledTimes(subscriptions);
});
it('still tears the session down when the backend is genuinely lost', async () => {
render(<CaptureWidget />);
await mountAndStartSession();
await act(async () => backendStore.set('crashed'));
expect(capture.cancel).toHaveBeenCalledWith(7);
expect(dictation.cancel).toHaveBeenCalled();
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CopyIcon, MicIcon, PauseIcon, PlayIcon, SquareIcon, XIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { LiveDictation } from './live-dictation';
import { addTranscription } from '@shared/utils/transcriptionsStore';
@@ -18,9 +19,19 @@ export function CaptureWidget() {
const output = useRef<Promise<unknown>>(Promise.resolve());
const finishTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const api = window.voicestudio?.capture;
// The capture subscription is a long-lived session, so it is driven by
// whether the backend can serve at all and never by the stage LABEL.
// Depending on `backend.stage` tore the session down whenever a health probe
// moved the backend from `unresponsive` back to `ready`: the effect cleanup
// cancels the active recording, so a dictation started during a busy window
// was killed the moment the backend recovered, before transcription could
// finish (#2430). `isBackendReachable` folds `ready` and `unresponsive` into
// one value, so a busy <-> ready flip no longer re-runs the effect, while a
// genuine loss of the backend still tears the session down.
const backendReachable = isBackendReachable(backend.stage);
useEffect(() => {
if (!api || backend.stage !== 'ready' || !backend.baseUrl) return;
if (!api || !backendReachable || !backend.baseUrl) return;
const unsubscribe = api.onEvent((event) => {
if (event.action === 'cancel') {
if (session.current !== event.session) return;
@@ -80,7 +91,7 @@ export function CaptureWidget() {
if (id !== null) void api.cancel(id).catch(() => {});
if (finishTimer.current) clearTimeout(finishTimer.current);
};
}, [api, backend.stage, backend.baseUrl, live]);
}, [api, backendReachable, backend.baseUrl, live]);
useEffect(() => {
const id = session.current;
@@ -57,3 +57,44 @@ it('pauses backend queries until the native supervisor reports ready', async ()
expect(onlineManager.isOnline()).toBe(false);
stop();
});
// #2430: `unresponsive` is a live-but-busy backend, not a dead one. The
// supervisor proves the child is alive before publishing it, so React Query
// must stay online — going offline here pauses every query and leaves the
// workspace visible but inert for the whole duration of a long generation.
it('stays online while a live backend is only busy, and offline when it dies', async () => {
onlineManager.setOnline(true);
let push!: (status: unknown) => void;
vi.stubGlobal('voicestudio', {
backend: {
onStatus: (callback: typeof push) => {
push = callback;
return () => {};
},
getStatus: () => new Promise(() => {}),
},
});
const status = await import('./use-backend-status');
const stop = status.subscribeBackendStatus(() => {});
const base = status.FALLBACK_BACKEND_STATUS;
push({ ...base, stage: 'unresponsive' });
expect(onlineManager.isOnline()).toBe(true);
// ...and it recovers on its own once the job releases the event loop.
push({ ...base, stage: 'ready' });
expect(onlineManager.isOnline()).toBe(true);
push({ ...base, stage: 'crashed' });
expect(onlineManager.isOnline()).toBe(false);
stop();
});
it('treats only ready and unresponsive as able to answer a request (#2430)', async () => {
const { isBackendReachable } = await import('./use-backend-status');
expect(isBackendReachable('ready')).toBe(true);
expect(isBackendReachable('unresponsive')).toBe(true);
// Everything else either is not listening yet or is terminally gone.
for (const stage of ['crashed', 'failed', 'port_in_use', 'starting', 'attaching'] as const) {
expect(isBackendReachable(stage)).toBe(false);
}
});
@@ -1,5 +1,6 @@
import { useSyncExternalStore } from 'react';
import { onlineManager } from '@tanstack/react-query';
import { isBackendReachable } from '@shared/utils/backendStage';
// Derived from the global `Window.voicestudio` declaration (src/preload/index.d.ts,
// included by tsconfig.web.json) rather than imported by path: with
@@ -9,6 +10,12 @@ export type VoiceStudioBridge = Window['voicestudio'];
export type BackendStatus = Awaited<ReturnType<VoiceStudioBridge['backend']['getStatus']>>;
export type BackendStage = BackendStatus['stage'];
// The `ready`/`unresponsive` policy lives in shared/utils/backendStage so the
// main process, the shared client and the renderer cannot drift apart on what
// a stage means for an in-flight request. Re-exported here because nearly every
// readiness consumer already reads this module.
export { isBackendReachable, isBackendBusy } from '@shared/utils/backendStage';
/** Used when the bridge is absent (vitest, a plain browser tab): behave as if the backend is up. */
export const FALLBACK_BACKEND_STATUS: BackendStatus = {
stage: 'ready',
@@ -41,7 +48,7 @@ let bridgeUnsubscribe: (() => void) | null = null;
function publish(status: BackendStatus): void {
revision++;
current = status;
if (nativeBackend) onlineManager.setOnline(status.stage === 'ready');
if (nativeBackend) onlineManager.setOnline(isBackendReachable(status.stage));
for (const listener of listeners) listener();
}
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { apiJson } from '@/lib/api/client';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
export interface DeviceUsage {
cpu: number;
@@ -20,7 +21,7 @@ export interface DeviceUsage {
/** Share one local telemetry query; poll only while a usage panel is open. */
export function useDeviceUsage(open: boolean) {
const backend = useBackendStatus();
const enabled = open && backend.stage === 'ready';
const enabled = open && isBackendReachable(backend.stage);
return useQuery({
queryKey: ['sysinfo'],
enabled,
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { apiJson } from '@/lib/api/client';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
export interface DictationSelectionModel {
id: string;
@@ -12,7 +13,7 @@ export function useDictationSelection() {
const status = useBackendStatus();
return useQuery({
queryKey: ['sidebar-dictation'],
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
staleTime: 30_000,
queryFn: async () => {
const [prefs, catalogue] = await Promise.all([
@@ -3,6 +3,7 @@ import { getEngines } from '@/lib/api/engines';
import type { EngineBackend, EngineFamilyState, EnginesResponse } from '@/lib/api/types';
import { queryKeys } from '@/lib/query';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
const ENGINES_STALE_MS = 30_000;
// While the selected TTS engine is unusable, poll: the user may be installing
@@ -44,7 +45,7 @@ export function useEngines(): UseEnginesResult {
queryKey: queryKeys.engines,
queryFn: getEngines,
staleTime: ENGINES_STALE_MS,
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
refetchInterval: (query) =>
activeTtsReady(query.state.data) ? false : ENGINES_POLL_WHILE_UNREADY_MS,
});
@@ -18,6 +18,7 @@ import type { HistoryItem } from '@/lib/api/types';
import { tr } from '@/lib/i18n-text';
import { queryKeys } from '@/lib/query';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
const HISTORY_STALE_MS = 10_000;
@@ -27,7 +28,7 @@ export function useHistory(): UseQueryResult<HistoryItem[]> {
queryKey: queryKeys.history,
queryFn: listHistory,
staleTime: HISTORY_STALE_MS,
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
});
}
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiJson } from '@/lib/api/client';
import { useBackendStatus } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
export interface ModelInstallJob {
repo_id: string;
@@ -38,7 +39,7 @@ export function useModelInstallJobs<T extends ModelInstallJob = ModelInstallJob>
return useQuery({
queryKey: ['model-install-jobs'],
queryFn: () => apiJson<ModelInstallJobsResponse<T>>('/models/install/status'),
enabled: backend.stage === 'ready',
enabled: isBackendReachable(backend.stage),
refetchInterval: modelInstallPollInterval,
});
}
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { apiJson } from '@/lib/api/client';
export const dictationPreferencesKey = ['dictation-shortcut-prefs'];
export const nativeShortcutKey = ['native-shortcut'];
@@ -27,7 +28,7 @@ export function useNativeShortcut(enabled = true) {
export function NativeDictationSync() {
const api = window.voicestudio?.capture;
const backend = useBackendStatus();
const prefs = useDictationPreferences(!!api && backend.stage === 'ready');
const prefs = useDictationPreferences(!!api && isBackendReachable(backend.stage));
const client = useQueryClient();
useEffect(() => {
if (!api || !prefs.data) return;
@@ -1,6 +1,7 @@
import { useIsMutating, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiJson } from '@/lib/api/client';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { IDLE_STATUS_POLL_MS } from '@/lib/status-polling';
export const performanceTiers = ['fast', 'balanced', 'quality', 'max'] as const;
@@ -69,7 +70,7 @@ export function usePerformanceProfile() {
const saving = useIsMutating({ mutationKey: ['performance-profile'] }) > 0;
const query = useQuery({
queryKey: ['performance-profile'],
enabled: backend.stage === 'ready',
enabled: isBackendReachable(backend.stage),
staleTime: 30_000,
refetchInterval: IDLE_STATUS_POLL_MS,
queryFn: () => apiJson<PerformanceProfileState>('/api/settings/performance-profile'),
@@ -25,6 +25,7 @@ import {
} from '@/lib/store/clone-settings';
import { readDraft, writeDraft } from '@/features/design/design-draft';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
const PROFILES_STALE_MS = 30_000;
@@ -34,7 +35,7 @@ export function useProfiles(): UseQueryResult<Profile[]> {
queryKey: queryKeys.profiles,
queryFn: listProfiles,
staleTime: PROFILES_STALE_MS,
enabled: status.stage === 'ready',
enabled: isBackendReachable(status.stage),
});
}
@@ -4,6 +4,7 @@ import { queryKeys } from '@/lib/query';
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
const EVENT_QUERY_KEYS: Readonly<Record<string, readonly QueryKey[]>> = {
projects: [['projects']],
@@ -35,9 +36,15 @@ async function devBackendReady(signal: AbortSignal, remote: boolean): Promise<bo
export function RealtimeEventSync() {
const backend = useBackendStatus();
const client = useQueryClient();
// Keyed on reachability, not the stage label, for the same reason as
// CaptureWidget: a `unresponsive` -> `ready` flip is the backend simply
// finishing its job, not a reason to drop a healthy socket. Depending on
// `backend.stage` tore the connection down and reconnected on every such
// flip, which is the reconnect storm this guard exists to avoid (#2430).
const backendReachable = isBackendReachable(backend.stage);
useEffect(() => {
if (backend.stage !== 'ready') return;
if (!backendReachable) return;
let active = true;
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -107,7 +114,7 @@ export function RealtimeEventSync() {
socket.close();
}
};
}, [backend.baseUrl, backend.remote, backend.stage, client]);
}, [backend.baseUrl, backend.remote, backendReachable, client]);
return null;
}
@@ -15,9 +15,13 @@ const state = vi.hoisted(() => ({
computeTargetCalls: [] as Array<[boolean, string]>,
}));
vi.mock('./use-backend-status', () => ({
useBackendStatus: () => ({ stage: state.backendStage }),
}));
vi.mock('./use-backend-status', async (importOriginal) => {
const actual = await importOriginal<typeof import('./use-backend-status')>();
return {
...actual,
useBackendStatus: () => ({ stage: state.backendStage }),
};
});
vi.mock('./use-engines', () => ({
engineFamilyState: (data: Record<string, unknown> | undefined, family: string) => data?.[family],
@@ -152,4 +156,22 @@ describe('target-aware TTS readiness', () => {
expect(renderHook(() => useTtsReadiness('clone')).result.current).toBeNull();
expect(state.computeTargetCalls).toContainEqual([true, 'clone']);
});
// #2430: a busy backend is mid-job, not a loading state. Reporting
// 'loading' here left the workspace visible with every generation control
// disabled for the whole length of a long generation.
it('keeps generation available while a live backend is only busy', () => {
state.backendStage = 'unresponsive';
state.localReady = true;
expect(renderHook(() => useTtsReadiness()).result.current).toBeNull();
expect(state.computeTargetCalls).toContainEqual([true, 'tts']);
});
it('still blocks generation when the backend is terminally gone', () => {
state.backendStage = 'crashed';
state.localReady = true;
expect(renderHook(() => useTtsReadiness()).result.current).toBe('loading');
});
});
@@ -3,6 +3,7 @@ import { resolveRemoteRuntime } from '@/components/app-shell/status-runtime';
import { useComputeRuntime, useComputeTarget } from './use-compute-target';
import { engineFamilyState, useEngines } from './use-engines';
import { useBackendStatus } from './use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
export type TtsReadinessBlocker = 'engine' | 'loading' | 'cloning' | null;
@@ -16,7 +17,7 @@ export function useTtsReadiness(operation = 'tts', requireLocal = false): TtsRea
// Resolve the target for this exact surface. If a chosen worker is offline
// or does not support the operation, routing falls back to Local and the
// local readiness checks below remain authoritative.
const computeTarget = useComputeTarget(backend.stage === 'ready', operation);
const computeTarget = useComputeTarget(isBackendReachable(backend.stage), operation);
const remoteTarget = computeTarget.data?.active.remote
? computeTarget.data.active.worker_id
: undefined;
@@ -26,9 +27,12 @@ export function useTtsReadiness(operation = 'tts', requireLocal = false): TtsRea
remoteTarget,
activeEngine,
operation,
backend.stage === 'ready' && Boolean(remoteTarget),
isBackendReachable(backend.stage) && Boolean(remoteTarget),
);
if (backend.stage !== 'ready') return 'loading';
// A live-but-busy backend (#2430) is not a loading state: it is mid-job, so
// the engine answers as soon as that job releases the event loop. Blocking
// generation here is what left the workspace visible but unusable.
if (!isBackendReachable(backend.stage)) return 'loading';
if (engines.isLoading) return 'loading';
if (engines.isError || !engines.data || !activeEngine) return 'engine';
if (computeTarget.isLoading) return 'loading';
@@ -2101,6 +2101,7 @@
"starting": "جارٍ بدء المحرك المحلي…",
"attaching": "جارٍ الاتصال بالمحرك قيد التشغيل…",
"crashed": "توقف المحرك المحلي بشكل غير متوقع.",
"unresponsive": "المحرك المحلي مشغول حالياً ولا يستجيب — سيستأنف العمل تلقائياً.",
"failed": "تعذر بدء المحرك المحلي.",
"idle": "في انتظار المحرك المحلي…",
"log_label": "سجل المحرك",
@@ -2093,6 +2093,7 @@
"starting": "Lokale Engine wird gestartet…",
"attaching": "Verbindung zur laufenden Engine wird hergestellt…",
"crashed": "Die lokale Engine wurde unerwartet beendet.",
"unresponsive": "Die lokale Engine ist gerade ausgelastet und antwortet nicht — sie erholt sich von selbst.",
"failed": "Die lokale Engine konnte nicht gestartet werden.",
"idle": "Warten auf die lokale Engine…",
"log_label": "Engine-Protokoll",
@@ -308,6 +308,7 @@
"attaching": "Connecting to the running engine…",
"ready": "Engine ready",
"crashed": "The local engine stopped unexpectedly.",
"unresponsive": "The local engine is busy and not answering right now — it recovers on its own.",
"port_in_use": "Port {{port}} is already in use by another program. Close it, or set OMNIVOICE_PORT to a free port, then retry.",
"failed": "The local engine could not start.",
"retry": "Retry",
@@ -2095,6 +2095,7 @@
"starting": "Iniciando el motor local…",
"attaching": "Conectando con el motor en ejecución…",
"crashed": "El motor local se detuvo inesperadamente.",
"unresponsive": "El motor local está ocupado y no responde ahora mismo; se recuperará por sí solo.",
"failed": "No se pudo iniciar el motor local.",
"idle": "Esperando al motor local…",
"log_label": "Registro del motor",
@@ -2095,6 +2095,7 @@
"starting": "Démarrage du moteur local…",
"attaching": "Connexion au moteur en cours d’exécution…",
"crashed": "Le moteur local s’est arrêté de manière inattendue.",
"unresponsive": "Le moteur local est occupé et ne répond pas pour le moment ; il reprendra de lui-même.",
"failed": "Impossible de démarrer le moteur local.",
"idle": "En attente du moteur local…",
"log_label": "Journal du moteur",
@@ -2093,6 +2093,7 @@
"starting": "स्थानीय इंजन शुरू हो रहा है…",
"attaching": "चल रहे इंजन से जुड़ रहा है…",
"crashed": "स्थानीय इंजन अचानक बंद हो गया।",
"unresponsive": "स्थानीय इंजन अभी व्यस्त है और जवाब नहीं दे रहा है — यह अपने आप ठीक हो जाएगा।",
"failed": "स्थानीय इंजन शुरू नहीं हो सका।",
"idle": "स्थानीय इंजन की प्रतीक्षा है…",
"log_label": "इंजन लॉग",
@@ -2093,6 +2093,7 @@
"starting": "Memulai mesin lokal…",
"attaching": "Menghubungkan ke mesin yang sedang berjalan…",
"crashed": "Mesin lokal berhenti secara tak terduga.",
"unresponsive": "Mesin lokal sedang sibuk dan belum merespons — akan pulih sendiri.",
"failed": "Mesin lokal tidak dapat dimulai.",
"idle": "Menunggu mesin lokal…",
"log_label": "Log mesin",
@@ -2095,6 +2095,7 @@
"starting": "Avvio del motore locale…",
"attaching": "Connessione al motore in esecuzione…",
"crashed": "Il motore locale si è arrestato in modo imprevisto.",
"unresponsive": "Il motore locale è occupato e al momento non risponde — si riprenderà da solo.",
"failed": "Impossibile avviare il motore locale.",
"idle": "In attesa del motore locale…",
"log_label": "Registro del motore",
@@ -2093,6 +2093,7 @@
"starting": "ローカルエンジンを起動中…",
"attaching": "実行中のエンジンに接続中…",
"crashed": "ローカルエンジンが予期せず停止しました。",
"unresponsive": "ローカルエンジンが混雑しており、現在応答していません。処理が終わると自動的に回復します。",
"failed": "ローカルエンジンを起動できませんでした。",
"idle": "ローカルエンジンを待機中…",
"log_label": "エンジンログ",
@@ -2093,6 +2093,7 @@
"starting": "로컬 엔진 시작 중…",
"attaching": "실행 중인 엔진에 연결 중…",
"crashed": "로컬 엔진이 예기치 않게 중지되었습니다.",
"unresponsive": "로컬 엔진이 현재 바빠 응답하지 않습니다. 작업이 끝나면 자동으로 복구됩니다.",
"failed": "로컬 엔진을 시작할 수 없습니다.",
"idle": "로컬 엔진 대기 중…",
"log_label": "엔진 로그",
@@ -2093,6 +2093,7 @@
"starting": "Lokale engine starten…",
"attaching": "Verbinding maken met de actieve engine…",
"crashed": "De lokale engine is onverwacht gestopt.",
"unresponsive": "De lokale engine is bezet en reageert nu niet — hij komt vanzelf weer terug.",
"failed": "De lokale engine kon niet worden gestart.",
"idle": "Wachten op de lokale engine…",
"log_label": "Engine-logboek",
@@ -2097,6 +2097,7 @@
"starting": "Uruchamianie lokalnego silnika…",
"attaching": "Łączenie z działającym silnikiem…",
"crashed": "Lokalny silnik nieoczekiwanie się zatrzymał.",
"unresponsive": "Silnik lokalny jest zajęty i nie odpowiada — sam wróci do normy.",
"failed": "Nie udało się uruchomić lokalnego silnika.",
"idle": "Oczekiwanie na lokalny silnik…",
"log_label": "Dziennik silnika",
@@ -2095,6 +2095,7 @@
"starting": "Iniciando o motor local…",
"attaching": "Conectando ao motor em execução…",
"crashed": "O motor local parou inesperadamente.",
"unresponsive": "O motor local está ocupado e não está a responder neste momento — recupera sozinho.",
"failed": "Não foi possível iniciar o motor local.",
"idle": "Aguardando o motor local…",
"log_label": "Registro do motor",
@@ -2097,6 +2097,7 @@
"starting": "Запуск локального движка…",
"attaching": "Подключение к работающему движку…",
"crashed": "Локальный движок неожиданно остановился.",
"unresponsive": "Локальный движок занят и сейчас не отвечает — он восстановится сам.",
"failed": "Не удалось запустить локальный движок.",
"idle": "Ожидание локального движка…",
"log_label": "Журнал движка",
@@ -2093,6 +2093,7 @@
"starting": "Startar den lokala motorn…",
"attaching": "Ansluter till den körande motorn…",
"crashed": "Den lokala motorn stannade oväntat.",
"unresponsive": "Den lokala motorn är upptagen och svarar inte just nu — den återgår av sig själv.",
"failed": "Den lokala motorn kunde inte startas.",
"idle": "Väntar på den lokala motorn…",
"log_label": "Motorlogg",
@@ -2093,6 +2093,7 @@
"starting": "กำลังเริ่มเอนจินในเครื่อง…",
"attaching": "กำลังเชื่อมต่อกับเอนจินที่ทำงานอยู่…",
"crashed": "เอนจินในเครื่องหยุดทำงานโดยไม่คาดคิด",
"unresponsive": "เอนจิ้นในเครื่องกำลังยุ่งและยังไม่ตอบสนอง — จะกลับมาทำงานเอง",
"failed": "ไม่สามารถเริ่มเอนจินในเครื่องได้",
"idle": "กำลังรอเอนจินในเครื่อง…",
"log_label": "บันทึกเอนจิน",
@@ -2093,6 +2093,7 @@
"starting": "Yerel motor başlatılıyor…",
"attaching": "Çalışan motora bağlanılıyor…",
"crashed": "Yerel motor beklenmedik şekilde durdu.",
"unresponsive": "Yerel motor şu anda meşgul ve yanıt vermiyor — kendiliğinden toparlanacak.",
"failed": "Yerel motor başlatılamadı.",
"idle": "Yerel motor bekleniyor…",
"log_label": "Motor günlüğü",
@@ -2097,6 +2097,7 @@
"starting": "Запуск локального рушія…",
"attaching": "Підключення до запущеного рушія…",
"crashed": "Локальний рушій несподівано зупинився.",
"unresponsive": "Локальний рушій зайнятий і зараз не відповідає — він відновиться сам.",
"failed": "Не вдалося запустити локальний рушій.",
"idle": "Очікування локального рушія…",
"log_label": "Журнал рушія",
@@ -2093,6 +2093,7 @@
"starting": "Đang khởi động bộ máy cục bộ…",
"attaching": "Đang kết nối với bộ máy đang chạy…",
"crashed": "Bộ máy cục bộ đã dừng đột ngột.",
"unresponsive": "Động cơ cục bộ đang bận và tạm thời không phản hồi — nó sẽ tự phục hồi.",
"failed": "Không thể khởi động bộ máy cục bộ.",
"idle": "Đang chờ bộ máy cục bộ…",
"log_label": "Nhật ký bộ máy",
@@ -2097,6 +2097,7 @@
"starting": "正在启动本地引擎…",
"attaching": "正在连接运行中的引擎…",
"crashed": "本地引擎意外停止。",
"unresponsive": "本地引擎正忙,暂时没有响应,任务结束后会自动恢复。",
"failed": "无法启动本地引擎。",
"idle": "正在等待本地引擎…",
"log_label": "引擎日志",
@@ -2093,6 +2093,7 @@
"starting": "正在啟動本機引擎…",
"attaching": "正在連線至執行中的引擎…",
"crashed": "本機引擎意外停止。",
"unresponsive": "本機引擎忙碌中,暫時沒有回應,工作完成後會自動恢復。",
"failed": "無法啟動本機引擎。",
"idle": "正在等待本機引擎…",
"log_label": "引擎日誌",
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ stage: 'ready' as string }));
// `apiFetch` reads the stage through this module, so the real one is replaced
// with a controllable snapshot. `isBackendReachable` stays REAL: it is the
// predicate under test, and stubbing it would make these assertions vacuous.
vi.mock('@/hooks/use-backend-status', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks/use-backend-status')>();
return { ...actual, getBackendStatusSnapshot: () => ({ stage: state.stage }) };
});
const { apiFetch, apiJson } = await import('./client');
/** The guard only engages in the native shell, where a real bridge exists. */
function nativeShell(): void {
vi.stubGlobal('voicestudio', { backend: {} });
}
beforeEach(() => {
state.stage = 'ready';
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// #2430 — the renderer has its own pre-flight guard, separate from the shared
// client this PR already taught to wait. It rejected every request whose stage
// was not `ready`, so a live-but-busy backend failed the request before it was
// ever issued — including the fetch for the finished audio of a streamed
// generation that had already succeeded.
describe('apiFetch while a live backend is only busy (#2430)', () => {
it('issues the request instead of rejecting an unresponsive stage', async () => {
state.stage = 'unresponsive';
nativeShell();
const fetchMock = vi.fn(async () => new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(apiJson<{ ok: boolean }>('/audio/take.wav')).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledOnce();
});
it('still rejects a terminal failure without issuing a doomed request', async () => {
state.stage = 'crashed';
nativeShell();
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(apiFetch('/audio/take.wav')).rejects.toMatchObject({ status: 0 });
expect(fetchMock).not.toHaveBeenCalled();
});
it('still rejects while the backend has not started listening', async () => {
state.stage = 'starting';
nativeShell();
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(apiFetch('/audio/take.wav')).rejects.toMatchObject({ status: 0 });
expect(fetchMock).not.toHaveBeenCalled();
});
it('leaves the browser build alone, with or without a stage', async () => {
// No native bridge: the guard never engaged here and must not start to.
state.stage = 'unresponsive';
vi.stubGlobal('fetch', async () => new Response('{"ok":true}', { status: 200 }));
await expect(apiJson<{ ok: boolean }>('/system/info')).resolves.toEqual({ ok: true });
});
});
+7 -1
View File
@@ -5,6 +5,7 @@ import { languageRejectionMessage } from '@shared/utils/languageRejection.ts';
import type { ApiErrorPayload } from './types';
import { tr } from '@/lib/i18n-text';
import { getBackendStatusSnapshot } from '@/hooks/use-backend-status';
import { isBackendReachable } from '@shared/utils/backendStage';
import { recordBackendContact } from '@shared/utils/backendContact';
import {
clearAdminSession,
@@ -141,10 +142,15 @@ export async function errorFromResponse(res: Response): Promise<ApiError> {
* are re-thrown untouched so callers can tell them apart.
*/
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
// #2430: only a stage that cannot answer at all short-circuits here. A
// live-but-busy `unresponsive` backend is still listening, so the request is
// issued and simply resolves late, when the job holding the event loop
// finishes. Rejecting it up front is what made fetching the audio of an
// already-succeeded streamed generation fail.
if (
typeof window !== 'undefined' &&
window.voicestudio?.backend &&
getBackendStatusSnapshot().stage !== 'ready'
!isBackendReachable(getBackendStatusSnapshot().stage)
)
throw new ApiError(0, tr('tts_errors.backend_unreachable'));
let res: Response;
@@ -0,0 +1,46 @@
import { afterEach, expect, it, vi } from 'vitest';
import { backendLifecycleStage } from '../utils/backendLifecycle';
afterEach(() => {
vi.unstubAllGlobals();
});
function stubStage(stage: string, message?: string): void {
vi.stubGlobal('voicestudio', {
backend: { getStatus: async () => ({ stage, message, logTail: [] }) },
});
}
// #2430 — a live-but-busy backend is mid-job, not down.
//
// The shell's supervisor had already proved the process was alive (it checks
// exitCode/signalCode before reporting), yet `unresponsive` fell through to
// `unknown`, and `unknown` is the branch that tells apiFetch to give up at once.
// Every request issued during a long generation therefore dead-ended with
// "Can't reach the local VoiceStudio backend" while the backend was faithfully
// working through the job. `starting` is the honest answer: still in progress,
// keep waiting, and let it land when the event loop frees up.
describe('backendLifecycleStage — a busy backend is still in progress (#2430)', () => {
it('holds requests open instead of dead-ending them', async () => {
stubStage('unresponsive');
expect(await backendLifecycleStage()).toEqual({ stage: 'starting', message: null });
});
it('never carries a failure message on it', async () => {
stubStage('unresponsive', 'Backend is running but busy on port 3900.');
expect(await backendLifecycleStage()).toEqual({ stage: 'starting', message: null });
});
it('still gives up immediately for a real terminal failure', async () => {
stubStage('failed', 'The Python environment is missing or incomplete.');
expect(await backendLifecycleStage()).toEqual({
stage: 'failed',
message: 'The Python environment is missing or incomplete.',
});
});
it('still reports unknown with no shell to ask', async () => {
vi.stubGlobal('voicestudio', undefined);
expect(await backendLifecycleStage()).toEqual({ stage: 'unknown', message: null });
});
});
@@ -28,6 +28,8 @@
* The stage probe now returns `{ stage, message }` and callers surface it.
*/
import { isBackendBusy } from './backendStage';
export type BackendLifecycleStage = 'ready' | 'starting' | 'failed' | 'unknown';
/** The shell's lifecycle answer: the coarse stage plus, for `failed`, the
@@ -83,6 +85,12 @@ export async function backendLifecycleStage(): Promise<BackendLifecycle> {
if (['setup_required', 'installing', 'attaching', 'starting'].includes(status.stage)) {
return { stage: 'starting', message: null };
}
// #2430: a live-but-busy backend is mid-job, not down. Treating it as
// `unknown` dead-ended every request the moment the health probe slipped
// past its deadline; the shell already knows the process is alive, so hold
// the request open the same way a start/restart does and let it land once
// the current job finishes.
if (isBackendBusy(status.stage)) return { stage: 'starting', message: null };
return { stage: 'unknown', message: null };
} catch {
return { stage: 'unknown', message: null };
+37
View File
@@ -0,0 +1,37 @@
/**
* backendStage — one place that decides what a lifecycle stage means for a
* request, shared by the main process, the shared client and the renderer.
*
* `unresponsive` (#2430) is the stage that made this necessary. A live backend
* running a long inference monopolizes Python's event loop for longer than the
* health deadline, and the supervisor reports that as a distinct, non-terminal
* stage. The process is still listening — the supervisor proves it before
* publishing, by checking `exitCode`/`signalCode` — so a request issued then is
* answered late rather than never.
*
* Every consumer previously spelled this "stage === 'ready'", which made the
* busy backend behave like a dead one: the renderer threw before the request
* was even issued and took React Query offline, while the shared client waited.
* They must not disagree about whether a request can still succeed.
*/
/**
* True when a backend in `stage` can still answer a request, so callers should
* issue it and let it resolve rather than rejecting it up front.
*
* `ready` obviously; `unresponsive` because the backend is alive and merely
* mid-job. Every other stage means the backend is either not listening yet
* (setup, install, start, attach) or terminally gone (crash, port taken, failed
* to start), so a request must not be sent at all.
*/
export function isBackendReachable(stage: string): boolean {
return stage === 'ready' || stage === 'unresponsive';
}
/**
* True when a backend in `stage` is known to be a live process that is not
* answering right now, and recovers on its own once its current job finishes.
*/
export function isBackendBusy(stage: string): boolean {
return stage === 'unresponsive';
}
+1
View File
@@ -38,6 +38,7 @@
"src/shared/utils/runCrashRecord.ts",
"src/shared/utils/nativeExit.ts",
"src/shared/utils/backendContact.ts",
"src/shared/utils/backendStage.ts",
"src/shared/utils/deploymentMode.ts",
"src/shared/utils/errorDocsMap.ts",
"src/shared/utils/analytics.ts",