feat(connection): improve resilience with watchdog and reconnection logic
This commit is contained in:
@@ -160,6 +160,23 @@ export const useEventStream = () => {
|
||||
[setEventStreamStatus]
|
||||
);
|
||||
|
||||
const bootstrapState = React.useCallback(
|
||||
async (reason: string) => {
|
||||
if (streamDebugEnabled()) {
|
||||
console.info('[useEventStream] Bootstrapping state:', reason);
|
||||
}
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSessions(),
|
||||
currentSessionId ? loadMessages(currentSessionId) : Promise.resolve(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.warn('[useEventStream] Bootstrap failed:', reason, error);
|
||||
}
|
||||
},
|
||||
[currentSessionId, loadMessages, loadSessions]
|
||||
);
|
||||
|
||||
const trackMessage = React.useCallback((messageId: string, event?: string, extraData?: Record<string, unknown>) => {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug(`[MessageTracker] ${messageId}: ${event}`, extraData);
|
||||
@@ -195,6 +212,17 @@ export const useEventStream = () => {
|
||||
const lastEventTimestampRef = React.useRef<number>(Date.now());
|
||||
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
||||
|
||||
const maybeBootstrapIfStale = React.useCallback(
|
||||
(reason: string) => {
|
||||
const now = Date.now();
|
||||
if (now - lastEventTimestampRef.current > 25000) {
|
||||
void bootstrapState(reason);
|
||||
lastEventTimestampRef.current = now;
|
||||
}
|
||||
},
|
||||
[bootstrapState]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isDesktop?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
@@ -325,6 +353,11 @@ export const useEventStream = () => {
|
||||
case 'server.connected':
|
||||
checkConnection();
|
||||
break;
|
||||
case 'global.disposed':
|
||||
case 'server.instance.disposed': {
|
||||
void bootstrapState('server_disposed_event');
|
||||
break;
|
||||
}
|
||||
case 'openchamber:session-activity': {
|
||||
if (!isDesktopRuntimeRef.current) break;
|
||||
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
|
||||
@@ -821,7 +854,8 @@ export const useEventStream = () => {
|
||||
applySessionMetadata,
|
||||
trackMessage,
|
||||
reportMessage,
|
||||
updateSessionActivityPhase
|
||||
updateSessionActivityPhase,
|
||||
bootstrapState
|
||||
]);
|
||||
|
||||
const shouldHoldConnection = React.useCallback(() => {
|
||||
@@ -921,7 +955,9 @@ export const useEventStream = () => {
|
||||
publishStatus('connected', null);
|
||||
checkConnection();
|
||||
|
||||
if (shouldRefresh && currentSessionId) {
|
||||
if (shouldRefresh) {
|
||||
void bootstrapState('sse_reconnected');
|
||||
} else if (currentSessionId) {
|
||||
setTimeout(() => {
|
||||
loadMessages(currentSessionId)
|
||||
.then(() => requestSessionMetadataRefresh(currentSessionId))
|
||||
@@ -980,7 +1016,8 @@ export const useEventStream = () => {
|
||||
effectiveDirectory,
|
||||
updateSessionActivityPhase,
|
||||
waitForDesktopBridge,
|
||||
debugConnectionState
|
||||
debugConnectionState,
|
||||
bootstrapState
|
||||
]);
|
||||
|
||||
const scheduleReconnect = React.useCallback((hint?: string) => {
|
||||
@@ -1064,6 +1101,7 @@ export const useEventStream = () => {
|
||||
|
||||
if (visibilityStateRef.current === 'visible') {
|
||||
clearPauseTimeout();
|
||||
maybeBootstrapIfStale('visibility_restore');
|
||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||
console.info('[useEventStream] Visibility restored, triggering soft refresh...');
|
||||
if (!isDesktopRuntimeRef.current) {
|
||||
@@ -1088,6 +1126,7 @@ export const useEventStream = () => {
|
||||
|
||||
if (visibilityStateRef.current === 'visible') {
|
||||
clearPauseTimeout();
|
||||
maybeBootstrapIfStale('window_focus');
|
||||
|
||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||
console.info('[useEventStream] Window focused after pause, triggering soft refresh...');
|
||||
@@ -1111,6 +1150,7 @@ export const useEventStream = () => {
|
||||
|
||||
const handleOnline = () => {
|
||||
onlineStatusRef.current = true;
|
||||
maybeBootstrapIfStale('network_restored');
|
||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||
publishStatus('connecting', 'Network restored');
|
||||
startStream({ resetAttempts: true });
|
||||
|
||||
@@ -633,6 +633,8 @@ class OpencodeService {
|
||||
const abortController = new AbortController();
|
||||
this.sseAbortController = abortController;
|
||||
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
console.log('[OpencodeClient] Starting SSE subscription...');
|
||||
|
||||
// Start async generator in background with reconnect on failure
|
||||
@@ -645,13 +647,21 @@ class OpencodeService {
|
||||
|
||||
const connect = async (attempt: number): Promise<void> => {
|
||||
try {
|
||||
const result = await this.client.event.subscribe({
|
||||
const subscribeOptions: {
|
||||
query?: { directory?: string };
|
||||
signal: AbortSignal;
|
||||
sseDefaultRetryDelay: number;
|
||||
sseMaxRetryDelay: number;
|
||||
onSseError?: (error: unknown) => void;
|
||||
onSseEvent: (event: StreamEvent<unknown>) => void;
|
||||
headers?: Record<string, string>;
|
||||
lastEventId?: string;
|
||||
} = {
|
||||
query: resolvedDirectory ? { directory: resolvedDirectory } : undefined,
|
||||
signal: abortController.signal,
|
||||
sseMaxRetryAttempts: 2,
|
||||
sseDefaultRetryDelay: 500,
|
||||
sseMaxRetryDelay: 8000,
|
||||
onSseError: (error) => {
|
||||
sseDefaultRetryDelay: 3000,
|
||||
sseMaxRetryDelay: 30000,
|
||||
onSseError: (error: unknown) => {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
@@ -661,14 +671,23 @@ class OpencodeService {
|
||||
}
|
||||
},
|
||||
onSseEvent: (event: StreamEvent<unknown>) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
const payload = event.data;
|
||||
if (payload && typeof payload === 'object') {
|
||||
onMessage(payload as Event);
|
||||
}
|
||||
if (abortController.signal.aborted) return;
|
||||
if (event.id && typeof event.id === 'string') {
|
||||
lastEventId = event.id;
|
||||
}
|
||||
const payload = event.data;
|
||||
if (payload && typeof payload === 'object') {
|
||||
onMessage(payload as Event);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (lastEventId) {
|
||||
subscribeOptions.lastEventId = lastEventId;
|
||||
subscribeOptions.headers = { ...(subscribeOptions.headers || {}), 'Last-Event-ID': lastEventId };
|
||||
}
|
||||
|
||||
const result = await this.client.event.subscribe(subscribeOptions);
|
||||
|
||||
if (onOpen && !abortController.signal.aborted) {
|
||||
console.log('[OpencodeClient] SSE connection opened');
|
||||
@@ -682,6 +701,13 @@ class OpencodeService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
// Stream ended unexpectedly; attempt reconnect
|
||||
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await connect(attempt + 1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||
console.log('[OpencodeClient] SSE stream aborted normally');
|
||||
@@ -691,7 +717,7 @@ class OpencodeService {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
const delay = Math.min(500 * Math.pow(2, attempt), 8000);
|
||||
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
if (!abortController.signal.aborted) {
|
||||
await connect(attempt + 1);
|
||||
|
||||
Reference in New Issue
Block a user