Fix startup connection and chunk recovery
This commit is contained in:
+47
-2
@@ -55,9 +55,10 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
|
||||
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
||||
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
|
||||
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
||||
const OnboardingScreen = React.lazy(() =>
|
||||
const OnboardingScreen = lazyWithChunkRecovery(() =>
|
||||
import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })),
|
||||
);
|
||||
|
||||
@@ -206,6 +207,7 @@ function App({ apis }: AppProps) {
|
||||
: null;
|
||||
});
|
||||
const appReadyDispatchedRef = React.useRef(false);
|
||||
const initializationInFlightRef = React.useRef(false);
|
||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
|
||||
@@ -345,12 +347,55 @@ function App({ apis }: AppProps) {
|
||||
if (isVSCodeRuntime) {
|
||||
return;
|
||||
}
|
||||
await initializeApp();
|
||||
if (initializationInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
initializationInFlightRef.current = true;
|
||||
try {
|
||||
await initializeApp();
|
||||
} finally {
|
||||
initializationInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [initializeApp, isVSCodeRuntime]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isVSCodeRuntime || isInitialized) return;
|
||||
|
||||
let active = true;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const retryInitialization = async () => {
|
||||
if (!active) return;
|
||||
const state = useConfigStore.getState();
|
||||
if (state.isInitialized) return;
|
||||
if (initializationInFlightRef.current) {
|
||||
retryTimer = setTimeout(retryInitialization, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
initializationInFlightRef.current = true;
|
||||
try {
|
||||
await state.initializeApp();
|
||||
} finally {
|
||||
initializationInFlightRef.current = false;
|
||||
}
|
||||
|
||||
const next = useConfigStore.getState();
|
||||
if (!active || next.isInitialized) return;
|
||||
retryTimer = setTimeout(retryInitialization, 1000);
|
||||
};
|
||||
|
||||
retryTimer = setTimeout(retryInitialization, 1000);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [isInitialized, isVSCodeRuntime]);
|
||||
|
||||
// Startup recovery: poll until providers AND agents are loaded.
|
||||
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
||||
// so a reactive effect can't detect failure — we need an interval.
|
||||
|
||||
@@ -26,13 +26,14 @@ import { filterVisibleParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { TurnGroupingContext } from './lib/turns/types';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
||||
|
||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
const EXPANDED_TOOLS_CACHE_MAX = 4000;
|
||||
const expandedToolsStateCache = new Map<string, Set<string>>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
|
||||
// Thin lazy wrapper around the heavy MarkdownRenderer implementation.
|
||||
// The full implementation (marked, react-markdown, beautiful-mermaid,
|
||||
@@ -8,11 +9,11 @@ import React from 'react';
|
||||
export type { MarkdownVariant } from './MarkdownRendererImpl';
|
||||
|
||||
|
||||
const MarkdownRendererLazy = React.lazy(() =>
|
||||
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
|
||||
);
|
||||
|
||||
const SimpleMarkdownRendererLazy = React.lazy(() =>
|
||||
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||
);
|
||||
|
||||
|
||||
@@ -22,18 +22,19 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
|
||||
import { ChatView } from '@/components/views';
|
||||
|
||||
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
|
||||
const GitView = React.lazy(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
||||
const DiffView = React.lazy(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
||||
const TerminalView = React.lazy(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
||||
const FilesView = React.lazy(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
||||
const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsWindow = React.lazy(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||
const MultiRunWindow = React.lazy(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
|
||||
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
||||
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
||||
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
||||
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||
|
||||
// Mobile drawer width as screen percentage
|
||||
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
||||
|
||||
@@ -24,10 +24,11 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { UsageWindow } from '@/types';
|
||||
import { RiAddLine, RiArrowLeftLine, RiRefreshLine, RiRobot2Line, RiSettings3Line, RiTimerLine } from '@remixicon/react';
|
||||
|
||||
const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
declare const __APP_VERSION__: string | undefined;
|
||||
|
||||
const RELOAD_STORAGE_KEY = 'openchamber:chunk-import-reload';
|
||||
const RETRY_DELAY_MS = 250;
|
||||
const RELOAD_GUARD_MS = 30_000;
|
||||
|
||||
const DYNAMIC_IMPORT_ERROR_PATTERNS = [
|
||||
/Importing a module script failed/i,
|
||||
/Failed to fetch dynamically imported module/i,
|
||||
/error loading dynamically imported module/i,
|
||||
/Loading chunk \S+ failed/i,
|
||||
/ChunkLoadError/i,
|
||||
];
|
||||
|
||||
function readErrorText(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return `${error.name}\n${error.message}\n${error.stack ?? ''}`;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function isDynamicImportError(error: unknown): boolean {
|
||||
const text = readErrorText(error);
|
||||
return DYNAMIC_IMPORT_ERROR_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function reloadMarkerSignature(error: unknown): string {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
|
||||
return `${appVersion || 'unknown'}:${readErrorText(error).slice(0, 500)}`;
|
||||
}
|
||||
|
||||
function scheduleReloadOnce(error: unknown): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const now = Date.now();
|
||||
const signature = reloadMarkerSignature(error);
|
||||
|
||||
try {
|
||||
const rawMarker = window.sessionStorage.getItem(RELOAD_STORAGE_KEY);
|
||||
const marker = rawMarker ? JSON.parse(rawMarker) as { signature?: unknown; timestamp?: unknown } : null;
|
||||
const markerTimestamp = typeof marker?.timestamp === 'number' ? marker.timestamp : 0;
|
||||
if (marker?.signature === signature && now - markerTimestamp < RELOAD_GUARD_MS) {
|
||||
return;
|
||||
}
|
||||
window.sessionStorage.setItem(RELOAD_STORAGE_KEY, JSON.stringify({ signature, timestamp: now }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export async function importWithChunkRecovery<T>(
|
||||
load: () => Promise<T>,
|
||||
options: { retries?: number } = {},
|
||||
): Promise<T> {
|
||||
const retries = options.retries ?? 1;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
return await load();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isDynamicImportError(error) || attempt >= retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
if (isDynamicImportError(lastError)) {
|
||||
scheduleReloadOnce(lastError);
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export const lazyWithChunkRecovery: typeof lazy = (load) => lazy(() => importWithChunkRecovery(load));
|
||||
@@ -426,6 +426,14 @@ const ensureModelsMetadataFetch = (
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const CONNECTION_PROBE_TIMEOUT_MS = 800;
|
||||
|
||||
const probeOpenCodeHealth = async (timeoutMs = CONNECTION_PROBE_TIMEOUT_MS): Promise<boolean> => {
|
||||
return Promise.race([
|
||||
opencodeClient.checkHealth().catch(() => false),
|
||||
sleep(Math.max(1, timeoutMs)).then(() => false),
|
||||
]);
|
||||
};
|
||||
|
||||
const DIRECTORY_KEY_GLOBAL = "__global__";
|
||||
|
||||
@@ -560,6 +568,7 @@ interface ConfigStore {
|
||||
getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null;
|
||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
||||
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
||||
probeConnection: (options?: { timeoutMs?: number }) => Promise<boolean>;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
initializeApp: () => Promise<void>;
|
||||
getCurrentProvider: () => ProviderWithModelList | undefined;
|
||||
@@ -1899,6 +1908,26 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
probeConnection: async (options?: { timeoutMs?: number }) => {
|
||||
const isHealthy = await probeOpenCodeHealth(options?.timeoutMs);
|
||||
if (isHealthy) {
|
||||
set({ isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
||||
return true;
|
||||
}
|
||||
|
||||
const state = get();
|
||||
if (state.isConnected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
set({
|
||||
isConnected: false,
|
||||
connectionPhase: state.hasEverConnected ? "reconnecting" : "connecting",
|
||||
lastDisconnectReason: 'health_probe_unhealthy',
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
||||
checkConnection: async () => {
|
||||
const maxAttempts = 5;
|
||||
let attempt = 0;
|
||||
|
||||
@@ -67,14 +67,20 @@ function connectionLostError(): Error {
|
||||
// Wait briefly for the pipeline to re-establish connection before failing a
|
||||
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
||||
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
||||
// pipeline recovers within a second. Poll isConnected at 100ms intervals.
|
||||
// pipeline recovers within a second. While waiting, run bounded health probes
|
||||
// inside the same grace window so stale disconnected state can recover quickly.
|
||||
const CONNECTION_GRACE_MS = 2000
|
||||
export async function waitForConnectionOrThrow(): Promise<void> {
|
||||
if (useConfigStore.getState().isConnected) return
|
||||
const deadline = Date.now() + CONNECTION_GRACE_MS
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (useConfigStore.getState().isConnected) return
|
||||
const remainingMs = deadline - Date.now()
|
||||
if (remainingMs <= 0) break
|
||||
if (await useConfigStore.getState().probeConnection({ timeoutMs: Math.min(500, remainingMs) })) return
|
||||
const sleepMs = Math.min(100, deadline - Date.now())
|
||||
if (sleepMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepMs))
|
||||
}
|
||||
}
|
||||
throw connectionLostError()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user