✨ feat: implement robust AbortController for Claude Code agent streams
- Add AbortController support to agent service interface and implementations - Enhance client disconnect detection with multiple HTTP event listeners (req.close, req.aborted, res.close) - Implement proper abort error handling in ClaudeCodeService with 'cancelled' event type - Add comprehensive documentation explaining SSE disconnect detection behavior - Clean up stream interfaces by removing unused properties and simplifying event structure - Extend timeout from 5 to 10 minutes for longer-running agent tasks - Ensure proper resource cleanup and prevent orphaned processes on client disconnect This enables reliable cancellation of long-running Claude Code processes when clients disconnect unexpectedly (browser tab close, curl Ctrl+C, network issues, etc.)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AgentStreamEvent } from '@main/services/agents/interfaces/AgentStreamInterface'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { agentService, sessionMessageService, sessionService } from '../../../../services/agents'
|
||||
@@ -42,13 +43,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
|
||||
|
||||
const messageStream = sessionMessageService.createSessionMessage(session, messageData)
|
||||
const abortController = new AbortController()
|
||||
const messageStream = sessionMessageService.createSessionMessage(session, messageData, abortController)
|
||||
|
||||
// Track stream lifecycle so we keep the SSE connection open until persistence finishes
|
||||
let responseEnded = false
|
||||
let streamFinished = false
|
||||
let awaitingPersistence = false
|
||||
let persistenceResolved = false
|
||||
const persistenceResolved = false
|
||||
|
||||
const finalizeResponse = () => {
|
||||
if (responseEnded) {
|
||||
@@ -73,15 +75,39 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
res.end()
|
||||
}
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
/**
|
||||
* Client Disconnect Detection for Server-Sent Events (SSE)
|
||||
*
|
||||
* We monitor multiple HTTP events to reliably detect when a client disconnects
|
||||
* from the streaming response. This is crucial for:
|
||||
* - Aborting long-running Claude Code processes
|
||||
* - Cleaning up resources and preventing memory leaks
|
||||
* - Avoiding orphaned processes
|
||||
*
|
||||
* Event Priority & Behavior:
|
||||
* 1. res.on('close') - Most common for SSE client disconnects (browser tab close, curl Ctrl+C)
|
||||
* 2. req.on('aborted') - Explicit request abortion
|
||||
* 3. req.on('close') - Request object closure (less common with SSE)
|
||||
*
|
||||
* When any disconnect event fires, we:
|
||||
* - Abort the Claude Code SDK process via abortController
|
||||
* - Clean up event listeners to prevent memory leaks
|
||||
* - Mark the response as ended to prevent further writes
|
||||
*/
|
||||
const handleDisconnect = () => {
|
||||
if (responseEnded) return
|
||||
logger.info(`Client disconnected from streaming message for session: ${sessionId}`)
|
||||
responseEnded = true
|
||||
messageStream.removeAllListeners()
|
||||
})
|
||||
abortController.abort('Client disconnected')
|
||||
}
|
||||
|
||||
req.on('close', handleDisconnect)
|
||||
req.on('aborted', handleDisconnect)
|
||||
res.on('close', handleDisconnect)
|
||||
|
||||
// Handle stream events
|
||||
messageStream.on('data', (event: any) => {
|
||||
messageStream.on('data', (event: AgentStreamEvent) => {
|
||||
if (responseEnded) return
|
||||
|
||||
try {
|
||||
@@ -101,12 +127,6 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
logger.error(`Streaming message error for session: ${sessionId}:`, event.error)
|
||||
|
||||
streamFinished = true
|
||||
awaitingPersistence = Boolean(event.persistScheduled)
|
||||
|
||||
if (!awaitingPersistence) {
|
||||
persistenceResolved = true
|
||||
}
|
||||
|
||||
finalizeResponse()
|
||||
break
|
||||
}
|
||||
@@ -121,23 +141,13 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
break
|
||||
}
|
||||
|
||||
case 'persisted':
|
||||
// Send persistence success event
|
||||
// res.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
logger.debug(`Session message persisted for session: ${sessionId}`, { messageId: event.message?.id })
|
||||
|
||||
persistenceResolved = true
|
||||
finalizeResponse()
|
||||
break
|
||||
|
||||
case 'persist-error':
|
||||
// Send persistence error event
|
||||
// res.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
logger.error(`Failed to persist session message for session: ${sessionId}:`, event.error)
|
||||
|
||||
persistenceResolved = true
|
||||
case 'cancelled': {
|
||||
logger.info(`Streaming message cancelled for session: ${sessionId}`)
|
||||
// res.write(`data: ${JSON.stringify({ type: 'cancelled' })}\n\n`)
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle other event types as generic data
|
||||
@@ -199,8 +209,8 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
res.end()
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
) // 5 minutes timeout
|
||||
10 * 60 * 1000
|
||||
) // 10 minutes timeout
|
||||
|
||||
// Clear timeout when response ends
|
||||
res.on('close', () => clearTimeout(timeout))
|
||||
|
||||
Reference in New Issue
Block a user