修复新会话可能泄漏问题

This commit is contained in:
xintaofei
2026-03-22 21:25:55 +08:00
parent e9cf613635
commit 06ac2be0b1
3 changed files with 64 additions and 17 deletions

View File

@@ -964,7 +964,33 @@ export function ConversationDetailPanel() {
})
}, [onPreviewTabReplaced, disconnectByKey])
// Background turn_complete handler: for conversations not open in tabs
// Refs for background turn_complete handler so the listener
// can be registered once and always read the latest values.
const getConversationIdByExternalIdRef = useRef(getConversationIdByExternalId)
const getSessionRef = useRef(getSession)
const runtimeCompleteTurnRef = useRef(runtimeCompleteTurn)
const runtimeRemoveConversationRef = useRef(runtimeRemoveConversation)
const refreshConversationsRef = useRef(refreshConversations)
useEffect(() => {
getConversationIdByExternalIdRef.current = getConversationIdByExternalId
}, [getConversationIdByExternalId])
useEffect(() => {
getSessionRef.current = getSession
}, [getSession])
useEffect(() => {
runtimeCompleteTurnRef.current = runtimeCompleteTurn
}, [runtimeCompleteTurn])
useEffect(() => {
runtimeRemoveConversationRef.current = runtimeRemoveConversation
}, [runtimeRemoveConversation])
useEffect(() => {
refreshConversationsRef.current = refreshConversations
}, [refreshConversations])
// Background turn_complete handler: for conversations not open in tabs.
// Registered once — uses refs to avoid re-creating the listener on every
// state change, which would cause "Couldn't find callback id" warnings
// due to the async gap between unlisten and the new listen().
useEffect(() => {
let cancelled = false
let unlisten: (() => void | Promise<void>) | null = null
@@ -975,9 +1001,8 @@ export function ConversationDetailPanel() {
const payload = event.payload
if (payload.type !== "turn_complete") return
const runtimeConversationId = getConversationIdByExternalId(
payload.session_id
)
const runtimeConversationId =
getConversationIdByExternalIdRef.current(payload.session_id)
const summary = conversationsRef.current.find(
(item) => item.external_id === payload.session_id
)
@@ -997,12 +1022,12 @@ export function ConversationDetailPanel() {
if (isOpenInTabs) return
// Promote liveMessage + optimisticTurns to localTurns immediately
runtimeCompleteTurn(matchedConversationId)
runtimeCompleteTurnRef.current(matchedConversationId)
// If tab was closed while agent was responding, clean up now
const session = getSession(matchedConversationId)
const session = getSessionRef.current(matchedConversationId)
if (session?.pendingCleanup) {
runtimeRemoveConversation(matchedConversationId)
runtimeRemoveConversationRef.current(matchedConversationId)
}
// Update conversation status — use the DB summary (found by
@@ -1013,7 +1038,7 @@ export function ConversationDetailPanel() {
(matchedConversationId > 0 ? matchedConversationId : null)
if (dbId && (!summary || summary.status === "in_progress")) {
updateConversationStatus(dbId, "pending_review")
.then(() => refreshConversations())
.then(() => refreshConversationsRef.current())
.catch((error: unknown) =>
console.error(
"[ConversationDetailPanel] background update status:",
@@ -1044,13 +1069,7 @@ export function ConversationDetailPanel() {
"ConversationDetailPanel.backgroundRefresh"
)
}
}, [
getConversationIdByExternalId,
getSession,
runtimeCompleteTurn,
runtimeRemoveConversation,
refreshConversations,
])
}, [])
const hasNoTabs = tabs.length === 0 && !activeTabId
const activeConversationTab = useMemo(

View File

@@ -1149,6 +1149,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
// Guard against concurrent connect() calls
const connectingKeysRef = useRef(new Set<string>())
// Keys whose disconnect was requested while connect was still in flight
const abandonedKeysRef = useRef(new Set<string>())
type AutoLinkBlockState =
| { kind: "none"; reason: "" }
@@ -1772,6 +1774,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
await waitForListenerReady()
const connectionId = await acpConnect(agentType, workingDir, sessionId)
// If disconnect was requested while connect was in flight,
// tear down immediately instead of registering the connection.
if (abandonedKeysRef.current.delete(contextKey)) {
acpDisconnect(connectionId).catch(() => {})
return
}
reverseMapRef.current.set(connectionId, contextKey)
lastActivityRef.current.set(contextKey, Date.now())
dispatch({
@@ -1800,6 +1810,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
throw err
} finally {
connectingKeysRef.current.delete(contextKey)
abandonedKeysRef.current.delete(contextKey)
}
},
[
@@ -1816,7 +1827,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
const disconnect = useCallback(
async (contextKey: string) => {
const conn = storeRef.current.connections.get(contextKey)
if (!conn) return
if (!conn) {
// connect() is still in flight — mark as abandoned so it
// tears down immediately when acpConnect returns.
if (connectingKeysRef.current.has(contextKey)) {
abandonedKeysRef.current.add(contextKey)
}
return
}
await acpDisconnect(conn.connectionId)
reverseMapRef.current.delete(conn.connectionId)
lastActivityRef.current.delete(contextKey)

View File

@@ -55,6 +55,7 @@ export function useConnectionLifecycle({
status,
selectorsReady,
connect: connConnect,
disconnect: connDisconnect,
sendPrompt,
setMode: connSetMode,
setConfigOption: connSetConfigOption,
@@ -256,9 +257,18 @@ export function useConnectionLifecycle({
t,
])
// Clean up lingering task on unmount (e.g. tab closed while connecting)
// Keep a ref to disconnect so the unmount cleanup always calls the
// latest version without adding it as a dependency.
const connDisconnectRef = useRef(connDisconnect)
useEffect(() => {
connDisconnectRef.current = connDisconnect
}, [connDisconnect])
// Clean up on unmount (e.g. tab closed): disconnect the ACP connection
// so it doesn't leak, and remove lingering tasks.
useEffect(() => {
return () => {
connDisconnectRef.current().catch(() => {})
if (taskIdRef.current) {
removeTask(taskIdRef.current)
}