mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
f7d6daedff
commit
675bf4bb21
2 changed files with 90 additions and 44 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue