Fix startup connection and chunk recovery

This commit is contained in:
Bohdan Triapitsyn
2026-04-25 16:56:37 +03:00
parent 5c7f5aa4f7
commit d40041fccd
8 changed files with 194 additions and 17 deletions
+47 -2
View File
@@ -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 '-';
+93
View File
@@ -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));
+29
View File
@@ -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;
+9 -3
View File
@@ -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()
}