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:
Kevin
2026-05-13 17:12:08 +08:00
parent 6f6ac0d550
commit c4d2a38b09
3 changed files with 96 additions and 19 deletions

View File

@@ -21,5 +21,10 @@ export const config = {
reconnectBaseDelayMs: 1_000,
reconnectMaxDelayMs: 30_000,
heartbeatIntervalMs: 30_000,
/**
* 仅当 App 处于 `background` 连续超过该毫秒数才释放当前会话 WebSocket。
* 短暂切到其它应用再返回时保持连接,避免反复重连。
*/
backgroundDisconnectAfterMs: 300_000,
},
} as const;

View File

@@ -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 时断开长连(避免后台挂 socketinactive 不处理以减少控制中心等短暂打断 */
/** 回到前台时取消;超时触发才真正 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();
}

View File

@@ -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]);
/**