修复新会话可能泄漏问题

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]) }, [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(() => { useEffect(() => {
let cancelled = false let cancelled = false
let unlisten: (() => void | Promise<void>) | null = null let unlisten: (() => void | Promise<void>) | null = null
@@ -975,9 +1001,8 @@ export function ConversationDetailPanel() {
const payload = event.payload const payload = event.payload
if (payload.type !== "turn_complete") return if (payload.type !== "turn_complete") return
const runtimeConversationId = getConversationIdByExternalId( const runtimeConversationId =
payload.session_id getConversationIdByExternalIdRef.current(payload.session_id)
)
const summary = conversationsRef.current.find( const summary = conversationsRef.current.find(
(item) => item.external_id === payload.session_id (item) => item.external_id === payload.session_id
) )
@@ -997,12 +1022,12 @@ export function ConversationDetailPanel() {
if (isOpenInTabs) return if (isOpenInTabs) return
// Promote liveMessage + optimisticTurns to localTurns immediately // Promote liveMessage + optimisticTurns to localTurns immediately
runtimeCompleteTurn(matchedConversationId) runtimeCompleteTurnRef.current(matchedConversationId)
// If tab was closed while agent was responding, clean up now // If tab was closed while agent was responding, clean up now
const session = getSession(matchedConversationId) const session = getSessionRef.current(matchedConversationId)
if (session?.pendingCleanup) { if (session?.pendingCleanup) {
runtimeRemoveConversation(matchedConversationId) runtimeRemoveConversationRef.current(matchedConversationId)
} }
// Update conversation status — use the DB summary (found by // Update conversation status — use the DB summary (found by
@@ -1013,7 +1038,7 @@ export function ConversationDetailPanel() {
(matchedConversationId > 0 ? matchedConversationId : null) (matchedConversationId > 0 ? matchedConversationId : null)
if (dbId && (!summary || summary.status === "in_progress")) { if (dbId && (!summary || summary.status === "in_progress")) {
updateConversationStatus(dbId, "pending_review") updateConversationStatus(dbId, "pending_review")
.then(() => refreshConversations()) .then(() => refreshConversationsRef.current())
.catch((error: unknown) => .catch((error: unknown) =>
console.error( console.error(
"[ConversationDetailPanel] background update status:", "[ConversationDetailPanel] background update status:",
@@ -1044,13 +1069,7 @@ export function ConversationDetailPanel() {
"ConversationDetailPanel.backgroundRefresh" "ConversationDetailPanel.backgroundRefresh"
) )
} }
}, [ }, [])
getConversationIdByExternalId,
getSession,
runtimeCompleteTurn,
runtimeRemoveConversation,
refreshConversations,
])
const hasNoTabs = tabs.length === 0 && !activeTabId const hasNoTabs = tabs.length === 0 && !activeTabId
const activeConversationTab = useMemo( const activeConversationTab = useMemo(

View File

@@ -1149,6 +1149,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
// Guard against concurrent connect() calls // Guard against concurrent connect() calls
const connectingKeysRef = useRef(new Set<string>()) 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 = type AutoLinkBlockState =
| { kind: "none"; reason: "" } | { kind: "none"; reason: "" }
@@ -1772,6 +1774,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
await waitForListenerReady() await waitForListenerReady()
const connectionId = await acpConnect(agentType, workingDir, sessionId) 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) reverseMapRef.current.set(connectionId, contextKey)
lastActivityRef.current.set(contextKey, Date.now()) lastActivityRef.current.set(contextKey, Date.now())
dispatch({ dispatch({
@@ -1800,6 +1810,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
throw err throw err
} finally { } finally {
connectingKeysRef.current.delete(contextKey) connectingKeysRef.current.delete(contextKey)
abandonedKeysRef.current.delete(contextKey)
} }
}, },
[ [
@@ -1816,7 +1827,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
const disconnect = useCallback( const disconnect = useCallback(
async (contextKey: string) => { async (contextKey: string) => {
const conn = storeRef.current.connections.get(contextKey) 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) await acpDisconnect(conn.connectionId)
reverseMapRef.current.delete(conn.connectionId) reverseMapRef.current.delete(conn.connectionId)
lastActivityRef.current.delete(contextKey) lastActivityRef.current.delete(contextKey)

View File

@@ -55,6 +55,7 @@ export function useConnectionLifecycle({
status, status,
selectorsReady, selectorsReady,
connect: connConnect, connect: connConnect,
disconnect: connDisconnect,
sendPrompt, sendPrompt,
setMode: connSetMode, setMode: connSetMode,
setConfigOption: connSetConfigOption, setConfigOption: connSetConfigOption,
@@ -256,9 +257,18 @@ export function useConnectionLifecycle({
t, 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(() => { useEffect(() => {
return () => { return () => {
connDisconnectRef.current().catch(() => {})
if (taskIdRef.current) { if (taskIdRef.current) {
removeTask(taskIdRef.current) removeTask(taskIdRef.current)
} }