Skip to content

Fix stability issues and improve error handling (Issue #6045) #6047

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,13 @@ export class Task extends EventEmitter<ClineEvents> {
} catch (error) {
console.error("Error reverting diff changes:", error)
}

// Reset all streaming-related state
this.isStreaming = false
this.isWaitingForFirstChunk = false
this.didCompleteReadingStream = true
this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false
}

public async abortTask(isAbandoned = false) {
Expand All @@ -1091,6 +1098,10 @@ export class Task extends EventEmitter<ClineEvents> {
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
// Don't rethrow - we want abort to always succeed
}

// Ensure streaming state is reset even if dispose fails
this.isStreaming = false
this.isWaitingForFirstChunk = false
// Save the countdown message in the automatic retry or other content.
try {
// Save the countdown message in the automatic retry or other content.
Expand Down Expand Up @@ -1359,6 +1370,24 @@ export class Task extends EventEmitter<ClineEvents> {
let reasoningMessage = ""
this.isStreaming = true

// Add a timeout for the entire streaming operation
const streamTimeout = setTimeout(
async () => {
if (this.isStreaming && !this.abort) {
console.error(`Stream timeout for task ${this.taskId} - aborting stream`)
await this.say(
"error",
"The API request timed out. This might be due to network issues or server problems. The task will be cancelled.",
undefined,
false,
)
// Trigger abort to clean up properly
this.abort = true
}
},
5 * 60 * 1000,
) // 5 minute timeout

try {
for await (const chunk of stream) {
if (!chunk) {
Expand Down Expand Up @@ -1451,19 +1480,25 @@ export class Task extends EventEmitter<ClineEvents> {
? undefined
: (error.message ?? JSON.stringify(serializeError(error), null, 2))

// Now call abortTask after determining the cancel reason
await this.abortTask()
try {
// Now call abortTask after determining the cancel reason
await this.abortTask()

await abortStream(cancelReason, streamingFailedMessage)
await abortStream(cancelReason, streamingFailedMessage)

const history = await provider?.getTaskWithId(this.taskId)
const history = await provider?.getTaskWithId(this.taskId)

if (history) {
await provider?.initClineWithHistoryItem(history.historyItem)
if (history) {
await provider?.initClineWithHistoryItem(history.historyItem)
}
} catch (abortError) {
console.error(`Error during stream failure recovery for task ${this.taskId}:`, abortError)
// Continue execution even if abort fails to prevent hanging
}
}
} finally {
this.isStreaming = false
clearTimeout(streamTimeout)
}

if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) {
Expand Down
90 changes: 64 additions & 26 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,20 @@ export class ClineProvider
console.log(`[subtasks] removing task ${cline.taskId}.${cline.instanceId} from stack`)

try {
// Abort the running task and set isAbandoned to true so
// all running promises will exit as well.
await cline.abortTask(true)
// Set a timeout for the abort operation
const abortPromise = cline.abortTask(true)
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Abort timeout")), 3000),
)

// Race between abort completion and timeout
await Promise.race([abortPromise, timeoutPromise])
} catch (e) {
this.log(
`[subtasks] encountered error while aborting task ${cline.taskId}.${cline.instanceId}: ${e.message}`,
)
// Force abandon the task if abort fails
cline.abandoned = true
}

// Make sure no reference kept, once promises end it will be
Expand Down Expand Up @@ -971,38 +978,69 @@ export class ClineProvider

console.log(`[subtasks] cancelling task ${cline.taskId}.${cline.instanceId}`)

const { historyItem } = await this.getTaskWithId(cline.taskId)
// Preserve parent and root task information for history item.
const rootTask = cline.rootTask
const parentTask = cline.parentTask
let historyItem: HistoryItem | undefined
let rootTask: Task | undefined
let parentTask: Task | undefined

try {
const taskData = await this.getTaskWithId(cline.taskId)
historyItem = taskData.historyItem
// Preserve parent and root task information for history item.
rootTask = cline.rootTask
parentTask = cline.parentTask
} catch (error) {
console.error(`Failed to get task history for ${cline.taskId}:`, error)
// Continue with cancellation even if we can't get history
}

// Start the abort process
cline.abortTask()

await pWaitFor(
() =>
this.getCurrentCline()! === undefined ||
this.getCurrentCline()!.isStreaming === false ||
this.getCurrentCline()!.didFinishAbortingStream ||
// If only the first chunk is processed, then there's no
// need to wait for graceful abort (closes edits, browser,
// etc).
this.getCurrentCline()!.isWaitingForFirstChunk,
{
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
})
// Wait for abort to complete with a timeout
try {
await pWaitFor(
() => {
const currentCline = this.getCurrentCline()
return (
!currentCline ||
currentCline.taskId !== cline.taskId ||
!currentCline.isStreaming ||
currentCline.didFinishAbortingStream ||
currentCline.isWaitingForFirstChunk
)
},
{
timeout: 5_000, // Increased timeout for better reliability
interval: 100,
},
)
} catch (error) {
console.error("Timeout waiting for task abort, forcing cleanup")
// Force cleanup if timeout occurs
const currentCline = this.getCurrentCline()
if (currentCline && currentCline.taskId === cline.taskId) {
currentCline.abandoned = true
// Force remove from stack
await this.removeClineFromStack()
}
}

if (this.getCurrentCline()) {
// Final check and cleanup
const currentCline = this.getCurrentCline()
if (currentCline && currentCline.taskId === cline.taskId) {
// 'abandoned' will prevent this Cline instance from affecting
// future Cline instances. This may happen if its hanging on a
// streaming request.
this.getCurrentCline()!.abandoned = true
currentCline.abandoned = true
}

// Clears task again, so we need to abortTask manually above.
await this.initClineWithHistoryItem({ ...historyItem, rootTask, parentTask })
// Only reinitialize with history if we have it
if (historyItem) {
await this.initClineWithHistoryItem({ ...historyItem, rootTask, parentTask })
} else {
// Just clear the task if no history available
await this.removeClineFromStack()
}
}

async updateCustomInstructions(instructions?: string) {
Expand Down
21 changes: 12 additions & 9 deletions webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionMessage } from "@roo/ExtensionMessage"
import TranslationProvider from "./i18n/TranslationContext"
import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager"
import { ErrorBoundary } from "./components/ErrorBoundary"

import { vscode } from "./utils/vscode"
import { telemetryClient } from "./utils/TelemetryClient"
Expand Down Expand Up @@ -283,15 +284,17 @@ const App = () => {
const queryClient = new QueryClient()

const AppWithProviders = () => (
<ExtensionStateContextProvider>
<TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
<App />
</TooltipProvider>
</QueryClientProvider>
</TranslationProvider>
</ExtensionStateContextProvider>
<ErrorBoundary>
<ExtensionStateContextProvider>
<TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
<App />
</TooltipProvider>
</QueryClientProvider>
</TranslationProvider>
</ExtensionStateContextProvider>
</ErrorBoundary>
)

export default AppWithProviders
105 changes: 105 additions & 0 deletions webview-ui/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { Component, ErrorInfo, ReactNode } from "react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"

interface Props {
children: ReactNode
fallback?: ReactNode
}

interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}

export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
}
}

static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error,
errorInfo: null,
}
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error details for debugging
console.error("ErrorBoundary caught an error:", error, errorInfo)

// Update state with error details
this.setState({
error,
errorInfo,
})
}

handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
})
}

render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback
}

// Default error UI
return (
<div className="error-boundary-container p-4 m-4 bg-vscode-inputValidation-errorBackground border border-vscode-inputValidation-errorBorder rounded">
<h2 className="text-lg font-semibold mb-2 text-vscode-errorForeground">Something went wrong</h2>
<p className="text-vscode-foreground mb-4">
An unexpected error occurred. The application may continue to work, but some features might be
affected.
</p>

{this.state.error && (
<details className="mb-4">
<summary className="cursor-pointer text-vscode-textLink-foreground hover:underline">
Error details
</summary>
<pre className="mt-2 p-2 bg-vscode-editor-background rounded text-xs overflow-auto">
{this.state.error.message}
{this.state.error.stack && "\n\n" + this.state.error.stack}
</pre>
</details>
)}

<div className="flex gap-2">
<VSCodeButton onClick={this.handleReset}>Try Again</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={() => window.location.reload()}>
Reload Window
</VSCodeButton>
</div>
</div>
)
}

return this.props.children
}
}

// Higher-order component for easier usage
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode,
): React.ComponentType<P> {
return (props: P) => (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
)
}
14 changes: 14 additions & 0 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { CommandExecutionError } from "./CommandExecutionError"
import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning"
import { CondenseContextErrorRow, CondensingContextRow, ContextCondenseRow } from "./ContextCondenseRow"
import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay"
import { LongRunningOperationIndicator } from "../common/LongRunningOperationIndicator"

interface ChatRowProps {
message: ClineMessage
Expand Down Expand Up @@ -987,6 +988,9 @@ export const ChatRowContent = ({
/>
)
case "api_req_started":
// Show long-running operation indicator for streaming API requests
const isApiStreaming = isLast && !cost && !apiRequestFailedMessage && !apiReqCancelReason

return (
<>
<div
Expand Down Expand Up @@ -1037,6 +1041,16 @@ export const ChatRowContent = ({
</>
)}

{isApiStreaming && (
<LongRunningOperationIndicator
isRunning={true}
elapsedTime={Date.now() - message.ts}
onCancel={() => {
vscode.postMessage({ type: "cancelTask" })
}}
/>
)}

{isExpanded && (
<div style={{ marginTop: "10px" }}>
<CodeAccordian
Expand Down
Loading
Loading