Improve loading speed of desktop app (#4865)

# Description of Changes
Improve loading speed of desktop app by loading a default config until
the backend has spawned.
This commit is contained in:
James Brunton
2025-11-11 11:54:43 +00:00
committed by GitHub
parent 4d349c047b
commit 044bf3c2aa
21 changed files with 622 additions and 137 deletions
@@ -0,0 +1,44 @@
import { AxiosInstance } from 'axios';
import { alert } from '@app/components/toast';
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { createBackendNotReadyError } from '@app/constants/backendErrors';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
/**
* Desktop-specific API interceptors
* - Reuses the core interceptors
* - Blocks API calls while the bundled backend is still starting and shows
* a friendly toast for user-initiated requests (non-GET)
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
client.interceptors.request.use(
(config) => {
const skipCheck = config?.skipBackendReadyCheck === true;
if (skipCheck || tauriBackendService.isBackendHealthy()) {
return config;
}
const method = (config.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
);
}
@@ -0,0 +1,125 @@
import i18n from '@app/i18n';
import { tauriBackendService } from '@app/services/tauriBackendService';
import type { BackendHealthState } from '@app/types/backendHealth';
type Listener = (state: BackendHealthState) => void;
class BackendHealthMonitor {
private listeners = new Set<Listener>();
private intervalId: ReturnType<typeof setInterval> | null = null;
private readonly intervalMs: number;
private state: BackendHealthState = {
status: tauriBackendService.getBackendStatus(),
isChecking: false,
error: null,
isHealthy: tauriBackendService.getBackendStatus() === 'healthy',
};
constructor(pollingInterval = 5000) {
this.intervalMs = pollingInterval;
// Reflect status updates from the backend service immediately
tauriBackendService.subscribeToStatus((status) => {
this.updateState({
status,
error: status === 'healthy' ? null : this.state.error,
message: status === 'healthy'
? i18n.t('backendHealth.online', 'Backend Online')
: this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'),
isChecking: status === 'healthy' ? false : this.state.isChecking,
});
});
}
private updateState(partial: Partial<BackendHealthState>) {
const nextStatus = partial.status ?? this.state.status;
this.state = {
...this.state,
...partial,
status: nextStatus,
isHealthy: nextStatus === 'healthy',
};
this.listeners.forEach((listener) => listener(this.state));
}
private ensurePolling() {
if (this.intervalId !== null) {
return;
}
void this.pollOnce();
this.intervalId = setInterval(() => {
void this.pollOnce();
}, this.intervalMs);
}
private stopPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async pollOnce(): Promise<boolean> {
this.updateState({
isChecking: true,
lastChecked: Date.now(),
error: this.state.error ?? 'Backend offline',
});
try {
const healthy = await tauriBackendService.checkBackendHealth();
if (healthy) {
this.updateState({
status: 'healthy',
isChecking: false,
message: i18n.t('backendHealth.online', 'Backend Online'),
error: null,
lastChecked: Date.now(),
});
} else {
this.updateState({
status: 'unhealthy',
isChecking: false,
message: i18n.t('backendHealth.offline', 'Backend Offline'),
error: i18n.t('backendHealth.offline', 'Backend Offline'),
lastChecked: Date.now(),
});
}
return healthy;
} catch (error) {
console.error('[BackendHealthMonitor] Health check failed:', error);
this.updateState({
status: 'unhealthy',
isChecking: false,
message: 'Backend is unavailable',
error: 'Backend offline',
lastChecked: Date.now(),
});
return false;
}
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this.state);
if (this.listeners.size === 1) {
this.ensurePolling();
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.stopPolling();
}
};
}
getSnapshot(): BackendHealthState {
return this.state;
}
async checkNow(): Promise<boolean> {
return this.pollOnce();
}
}
export const backendHealthMonitor = new BackendHealthMonitor();
@@ -0,0 +1,35 @@
import i18n from '@app/i18n';
import { alert } from '@app/components/toast';
import { tauriBackendService } from '@app/services/tauriBackendService';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
/**
* Desktop-specific guard that ensures the embedded backend is healthy
* before tools attempt to call any API endpoints.
*/
export async function ensureBackendReady(): Promise<boolean> {
if (tauriBackendService.isBackendHealthy()) {
return true;
}
// Trigger a health check so we get the freshest status
await tauriBackendService.checkBackendHealth();
if (tauriBackendService.isBackendHealthy()) {
return true;
}
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.checking', 'Checking backend status...'),
isPersistentPopup: false,
});
}
return false;
}
@@ -1,8 +1,14 @@
import { invoke } from '@tauri-apps/api/core';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
export class TauriBackendService {
private static instance: TauriBackendService;
private backendStarted = false;
private backendStatus: BackendStatus = 'stopped';
private healthMonitor: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private statusListeners = new Set<(status: BackendStatus) => void>();
static getInstance(): TauriBackendService {
if (!TauriBackendService.instance) {
@@ -15,32 +21,84 @@ export class TauriBackendService {
return this.backendStarted;
}
getBackendStatus(): BackendStatus {
return this.backendStatus;
}
isBackendHealthy(): boolean {
return this.backendStatus === 'healthy';
}
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
this.statusListeners.add(listener);
return () => {
this.statusListeners.delete(listener);
};
}
private setStatus(status: BackendStatus) {
if (this.backendStatus === status) {
return;
}
this.backendStatus = status;
this.statusListeners.forEach(listener => listener(status));
}
async startBackend(backendUrl?: string): Promise<void> {
if (this.backendStarted) {
return;
}
try {
const result = await invoke('start_backend', { backendUrl });
console.log('Backend started:', result);
this.backendStarted = true;
// Wait for backend to be healthy
await this.waitForHealthy();
} catch (error) {
console.error('Failed to start backend:', error);
throw error;
if (this.startPromise) {
return this.startPromise;
}
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then((result) => {
console.log('Backend started:', result);
this.backendStarted = true;
this.setStatus('starting');
this.beginHealthMonitoring();
})
.catch((error) => {
this.setStatus('unhealthy');
console.error('Failed to start backend:', error);
throw error;
})
.finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
private beginHealthMonitoring() {
if (this.healthMonitor) {
return;
}
this.healthMonitor = this.waitForHealthy()
.catch((error) => {
console.error('Backend failed to become healthy:', error);
})
.finally(() => {
this.healthMonitor = null;
});
}
async checkBackendHealth(): Promise<boolean> {
if (!this.backendStarted) {
this.setStatus('stopped');
return false;
}
try {
return await invoke('check_backend_health');
const isHealthy = await invoke<boolean>('check_backend_health');
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
console.error('Health check failed:', error);
this.setStatus('unhealthy');
return false;
}
}
@@ -54,6 +112,7 @@ export class TauriBackendService {
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
this.setStatus('unhealthy');
throw new Error('Backend failed to become healthy after 60 seconds');
}
}