fix: prevent Cancel button from freezing UI with unreachable endpoints

- Make cancelTask() non-blocking by handling abort and rehydration asynchronously
- Add timeout handling to abortTask() to prevent hanging on network timeouts
- Reduce abort wait timeout from 3s to 1s for better responsiveness
- Wrap disposal operations in Promise.resolve() to handle sync errors
- Immediately update UI state after cancel button click

Fixes #9435
This commit is contained in:
Roo Code 2025-11-20 17:09:43 +00:00
parent f7d6daedff
commit 675bf4bb21
2 changed files with 90 additions and 44 deletions

View file

@ -1701,19 +1701,43 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.abort = true
this.emit(RooCodeEventName.TaskAborted)
try {
this.dispose() // Call the centralized dispose method
} catch (error) {
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
// Don't rethrow - we want abort to always succeed
}
// Save the countdown message in the automatic retry or other content.
try {
// Save the countdown message in the automatic retry or other content.
await this.saveClineMessages()
} catch (error) {
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
}
// Run disposal and message saving with a timeout to prevent hanging
// This is crucial for unreachable endpoints that might cause network timeouts
const abortTasks = [
// Dispose task resources with timeout
// Wrap in Promise.resolve().then() to handle synchronous errors properly
Promise.race([
Promise.resolve().then(() => this.dispose()),
new Promise((resolve) =>
setTimeout(() => {
console.warn(`[abortTask] Disposal timed out for task ${this.taskId}.${this.instanceId}`)
resolve(undefined)
}, 2000),
), // 2 second timeout for disposal
]).catch((error) => {
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
// Don't rethrow - we want abort to always succeed
}),
// Save messages with timeout
// Wrap in Promise.resolve().then() to handle synchronous errors properly
Promise.race([
Promise.resolve().then(() => this.saveClineMessages()),
new Promise((resolve) =>
setTimeout(() => {
console.warn(`[abortTask] Message saving timed out for task ${this.taskId}.${this.instanceId}`)
resolve(undefined)
}, 1000),
), // 1 second timeout for saving messages
]).catch((error) => {
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
// Don't rethrow - we want abort to always succeed
}),
]
// Wait for all abort tasks to complete (or timeout)
// Using allSettled ensures we wait for all operations regardless of success/failure
await Promise.allSettled(abortTasks)
}
public dispose(): void {

View file

@ -2734,44 +2734,66 @@ export class ClineProvider
// Immediately mark the original instance as abandoned to prevent any residual activity
task.abandoned = true
await pWaitFor(
() =>
this.getCurrentTask()! === undefined ||
this.getCurrentTask()!.isStreaming === false ||
this.getCurrentTask()!.didFinishAbortingStream ||
// If only the first chunk is processed, then there's no
// need to wait for graceful abort (closes edits, browser,
// etc).
this.getCurrentTask()!.isWaitingForFirstChunk,
{
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
})
// Don't block the UI - handle the abort completion and rehydration asynchronously
// This allows the UI to respond immediately to the cancel button click
const handleAbortAndRehydrate = async () => {
try {
// Wait for abort to complete (with a shorter timeout to be more responsive)
await pWaitFor(
() =>
this.getCurrentTask()! === undefined ||
this.getCurrentTask()!.isStreaming === false ||
this.getCurrentTask()!.didFinishAbortingStream ||
// If only the first chunk is processed, then there's no
// need to wait for graceful abort (closes edits, browser,
// etc).
this.getCurrentTask()!.isWaitingForFirstChunk,
{
timeout: 1_000, // Reduced from 3 seconds to 1 second for better responsiveness
},
).catch(() => {
console.error("Abort wait timed out, proceeding with rehydration")
})
} catch (error) {
console.error("Error waiting for abort:", error)
}
// Defensive safeguard: if current instance already changed, skip rehydrate
const current = this.getCurrentTask()
if (current && current.instanceId !== originalInstanceId) {
this.log(
`[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`,
)
return
}
// Final race check before rehydrate to avoid duplicate rehydration
{
const currentAfterCheck = this.getCurrentTask()
if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) {
// Defensive safeguard: if current instance already changed, skip rehydrate
const current = this.getCurrentTask()
if (current && current.instanceId !== originalInstanceId) {
this.log(
`[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`,
`[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`,
)
return
}
// Final race check before rehydrate to avoid duplicate rehydration
{
const currentAfterCheck = this.getCurrentTask()
if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) {
this.log(
`[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`,
)
return
}
}
try {
// Rehydrate the task with the history item
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
} catch (error) {
this.log(`[cancelTask] Failed to rehydrate task: ${error}`)
}
}
// Clears task again, so we need to abortTask manually above.
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
// Start the abort and rehydration process in the background
// This returns immediately so the UI is not blocked
handleAbortAndRehydrate().catch((error) => {
this.log(`[cancelTask] Background abort/rehydration failed: ${error}`)
})
// Immediately update the UI to show the task is being cancelled
await this.postStateToWebview()
}
// Clear the current task without treating it as a subtask.