mirror of
https://github.com/debpalash/VoiceStudio.git
synced 2026-10-02 01:26:35 +08:00
Merge branch 'pr2439' into triage/backend-startup
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Vendored
+1
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user