feat(expo): 后台超过 5 分钟才断开会话 WebSocket
- 进入 background 后延迟释放长连,回到前台则取消计时;短切应用保持连接 - 池支持 subscribeConversationPoolSlotDisposed;聊天页在槽位释放时同步状态 - 前台 active 时按需 connect 或重绑会话 - backgroundDisconnectAfterMs 默认 300_000(5 分钟) 未纳入:api/uploads/ 本地文件 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,5 +21,10 @@ export const config = {
|
||||
reconnectBaseDelayMs: 1_000,
|
||||
reconnectMaxDelayMs: 30_000,
|
||||
heartbeatIntervalMs: 30_000,
|
||||
/**
|
||||
* 仅当 App 处于 `background` 连续超过该毫秒数才释放当前会话 WebSocket。
|
||||
* 短暂切到其它应用再返回时保持连接,避免反复重连。
|
||||
*/
|
||||
backgroundDisconnectAfterMs: 300_000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
|
||||
import { config } from '@/core/config';
|
||||
|
||||
import {
|
||||
RealtimeSession,
|
||||
type RealtimeSessionUiOwner,
|
||||
@@ -10,23 +12,67 @@ type Slot = { conversationId: string; session: RealtimeSession };
|
||||
|
||||
let slot: Slot | null = null;
|
||||
|
||||
/** 与常见聊天 App 一致:仅当应用进入 background 时断开长连(避免后台挂 socket);inactive 不处理以减少控制中心等短暂打断 */
|
||||
/** 回到前台时取消;超时触发才真正 dispose,避免短切换也断连 */
|
||||
let backgroundDisconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const slotDisposedListeners = new Set<() => void>();
|
||||
|
||||
/** 槽位被释放(超时 / 登出 / 换会话等)时通知;用于聊天页同步 sessionRef 与连接状态 */
|
||||
export function subscribeConversationPoolSlotDisposed(
|
||||
cb: () => void,
|
||||
): () => void {
|
||||
slotDisposedListeners.add(cb);
|
||||
return () => {
|
||||
slotDisposedListeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** 与常见聊天 App 一致:后台停留超过 `backgroundDisconnectAfterMs` 再断开长连;短切应用保持 socket */
|
||||
let backgroundUnsubscribe: (() => void) | null = null;
|
||||
|
||||
function cancelBackgroundDisconnectTimer(): void {
|
||||
if (backgroundDisconnectTimer != null) {
|
||||
clearTimeout(backgroundDisconnectTimer);
|
||||
backgroundDisconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackgroundDisconnect(): void {
|
||||
cancelBackgroundDisconnectTimer();
|
||||
backgroundDisconnectTimer = setTimeout(() => {
|
||||
backgroundDisconnectTimer = null;
|
||||
disposeSlot();
|
||||
}, config.ws.backgroundDisconnectAfterMs);
|
||||
}
|
||||
|
||||
function installBackgroundLifecycleOnce(): void {
|
||||
if (backgroundUnsubscribe) return;
|
||||
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
||||
if (next === 'background') {
|
||||
disposeAllBackgroundConversationWs();
|
||||
if (slot) {
|
||||
scheduleBackgroundDisconnect();
|
||||
}
|
||||
} else if (next === 'active') {
|
||||
cancelBackgroundDisconnectTimer();
|
||||
}
|
||||
});
|
||||
backgroundUnsubscribe = () => sub.remove();
|
||||
backgroundUnsubscribe = () => {
|
||||
cancelBackgroundDisconnectTimer();
|
||||
sub.remove();
|
||||
};
|
||||
}
|
||||
|
||||
function disposeSlot(): void {
|
||||
if (!slot) return;
|
||||
slot.session.dispose();
|
||||
slot = null;
|
||||
for (const cb of slotDisposedListeners) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
/* listener 自行兜底 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const offScreenUi = {
|
||||
@@ -54,12 +100,14 @@ export function releaseConversationWsUi(
|
||||
/** 删除会话等场景:关闭对应长连 */
|
||||
export function disposeBackgroundConversationWs(conversationId: string): void {
|
||||
if (slot?.conversationId === conversationId) {
|
||||
cancelBackgroundDisconnectTimer();
|
||||
disposeSlot();
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出 / 清账号:关闭池中连接 */
|
||||
export function disposeAllBackgroundConversationWs(): void {
|
||||
cancelBackgroundDisconnectTimer();
|
||||
disposeSlot();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
import {
|
||||
AppState,
|
||||
InteractionManager,
|
||||
type AppStateStatus,
|
||||
} from 'react-native';
|
||||
|
||||
import i18n from '@/i18n';
|
||||
import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types';
|
||||
@@ -9,9 +13,9 @@ import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types';
|
||||
import { conversationApi } from './api';
|
||||
import {
|
||||
acquireBackgroundConversationWs,
|
||||
disposeAllBackgroundConversationWs,
|
||||
disposeBackgroundConversationWs,
|
||||
releaseConversationWsUi,
|
||||
subscribeConversationPoolSlotDisposed,
|
||||
} from './conversation-ws-background-pool';
|
||||
import { conversationMessagesRepository } from './conversation-messages-repository';
|
||||
import { conversationKeys } from './query-keys';
|
||||
@@ -246,7 +250,10 @@ export function useRealtimeSession({
|
||||
|
||||
const [foregroundResumeGeneration, setForegroundResumeGeneration] =
|
||||
useState(0);
|
||||
const needsResumeAfterBackgroundRef = useRef(false);
|
||||
const enabledRef = useRef(enabled);
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
enabledRef.current = enabled;
|
||||
conversationIdRef.current = conversationId;
|
||||
|
||||
const handleStreamingText: StreamingTextCallback = useCallback(
|
||||
(text, isComplete) => {
|
||||
@@ -295,21 +302,38 @@ export function useRealtimeSession({
|
||||
useEffect(() => {
|
||||
if (!enabled || !conversationId) return;
|
||||
|
||||
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
||||
if (next === 'background') {
|
||||
needsResumeAfterBackgroundRef.current = true;
|
||||
disposeAllBackgroundConversationWs();
|
||||
sessionRef.current = null;
|
||||
setConnectionState('disconnected');
|
||||
setStreamingMessage(null);
|
||||
setAwaitingAssistantReply(false);
|
||||
} else if (next === 'active' && needsResumeAfterBackgroundRef.current) {
|
||||
needsResumeAfterBackgroundRef.current = false;
|
||||
setForegroundResumeGeneration((g) => g + 1);
|
||||
}
|
||||
const unsubSlotDisposed = subscribeConversationPoolSlotDisposed(() => {
|
||||
sessionRef.current = null;
|
||||
setConnectionState('disconnected');
|
||||
setStreamingMessage(null);
|
||||
setAwaitingAssistantReply(false);
|
||||
});
|
||||
|
||||
return () => sub.remove();
|
||||
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
||||
if (next !== 'active') return;
|
||||
/**
|
||||
* 短切后台不断开时:若 TCP 已断则 connect()。
|
||||
* 长后台已由池超时 dispose:此处 sessionRef 为空则重绑整段会话。
|
||||
*/
|
||||
void InteractionManager.runAfterInteractions(() => {
|
||||
if (!enabledRef.current || !conversationIdRef.current) {
|
||||
return;
|
||||
}
|
||||
const s = sessionRef.current;
|
||||
if (!s) {
|
||||
setForegroundResumeGeneration((g) => g + 1);
|
||||
return;
|
||||
}
|
||||
if (s.getConnectionState() === 'disconnected') {
|
||||
void s.connect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubSlotDisposed();
|
||||
sub.remove();
|
||||
};
|
||||
}, [enabled, conversationId]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user