Skip to content

Commit cc0ea53

Browse files
fix(Task): catch updateClineMessage rejections on partial-message paths
The four fire-and-forget updateClineMessage calls in say()/ask() partial-message branches were void-prefixed without .catch arms. The callee's webview post is internally guarded, but its synchronous RooCodeEventName.Message emit can throw via a consumer-attached listener — so the void sites can produce unhandled rejections. Replace each `void this.updateClineMessage(...)` with an explicit .catch arm that logs the error, matching the other rejection-handling sites in this file. Two new tests pin the say() and ask() catch arms. Also pin the void on getCheckpointService with a rationale comment: its top-level try/catch returns undefined on failure, so .catch is not needed there.
1 parent 1f92d82 commit cc0ea53

2 files changed

Lines changed: 101 additions & 6 deletions

File tree

src/core/task/Task.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,8 +1188,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11881188
// data or one whole message at a time so ignore partial for
11891189
// saves, and only post parts of partial message instead of
11901190
// whole array in new listener.
1191-
/* v8 ignore next -- fire-and-forget webview update; rejection is benign */
1192-
void this.updateClineMessage(lastMessage)
1191+
// Fire-and-forget: the webview post is internally guarded, but
1192+
// the `RooCodeEventName.Message` emit can synchronously throw
1193+
// if any consumer-attached listener does, which would surface
1194+
// here as an unhandled rejection. Log it instead.
1195+
this.updateClineMessage(lastMessage).catch((error) => {
1196+
console.error("[Task#ask] updateClineMessage failed:", error)
1197+
})
11931198
// console.log("Task#ask: current ask promise was ignored (#1)")
11941199
throw new AskIgnoredError("updating existing partial")
11951200
} else {
@@ -1227,8 +1232,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12271232
lastMessage.progressStatus = progressStatus
12281233
lastMessage.isProtected = isProtected
12291234
await this.saveClineMessages()
1230-
/* v8 ignore next -- fire-and-forget webview update; rejection is benign */
1231-
void this.updateClineMessage(lastMessage)
1235+
// Fire-and-forget: see updateClineMessage call above for the
1236+
// rationale on the .catch arm.
1237+
this.updateClineMessage(lastMessage).catch((error) => {
1238+
console.error("[Task#ask] updateClineMessage failed:", error)
1239+
})
12321240
} else {
12331241
// This is a new and complete message, so add it like normal.
12341242
this.askResponse = undefined
@@ -1674,7 +1682,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
16741682
lastMessage.images = images
16751683
lastMessage.partial = partial
16761684
lastMessage.progressStatus = progressStatus
1677-
void this.updateClineMessage(lastMessage)
1685+
// Fire-and-forget: webview post is internally guarded, but the
1686+
// `RooCodeEventName.Message` emit can synchronously throw via a
1687+
// consumer-attached listener. Surface that as a log, not an
1688+
// unhandled rejection.
1689+
this.updateClineMessage(lastMessage).catch((error) => {
1690+
console.error("[Task#say] updateClineMessage failed:", error)
1691+
})
16781692
} else {
16791693
// This is a new partial message, so add it with partial state.
16801694
const sayTs = Date.now()
@@ -1713,7 +1727,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17131727
await this.saveClineMessages()
17141728

17151729
// More performant than an entire `postStateToWebview`.
1716-
void this.updateClineMessage(lastMessage)
1730+
// Fire-and-forget: see updateClineMessage call above for the
1731+
// rationale on the .catch arm.
1732+
this.updateClineMessage(lastMessage).catch((error) => {
1733+
console.error("[Task#say] updateClineMessage failed:", error)
1734+
})
17171735
} else {
17181736
// This is a new and complete message, so add it like normal.
17191737
const sayTs = Date.now()
@@ -2365,6 +2383,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23652383

23662384
private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise<void> {
23672385
// Kicks off the checkpoints initialization process in the background.
2386+
// `getCheckpointService` wraps its full body in a try/catch and returns
2387+
// `undefined` on failure (see src/core/checkpoints/index.ts), so the
2388+
// returned promise cannot reject. `void` is sufficient — no `.catch`
2389+
// arm needed.
23682390
void getCheckpointService(this)
23692391

23702392
let nextUserContent = userContent

src/core/task/__tests__/Task.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,79 @@ describe("Cline", () => {
19761976
)
19771977
expect(presentErrors).toHaveLength(0)
19781978
})
1979+
1980+
it("logs (instead of crashing) when updateClineMessage rejects from the say() partial-update path", async () => {
1981+
// Pins the symmetric .catch arm on the fire-and-forget
1982+
// updateClineMessage call in say(). The callee's webview post is
1983+
// internally guarded, but its synchronous emit can throw via a
1984+
// consumer-attached listener — that path must surface as a log,
1985+
// not an unhandled rejection.
1986+
const boom = new Error("updateClineMessage boom")
1987+
const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => {
1988+
throw boom
1989+
})
1990+
1991+
const task = new Task({
1992+
provider: mockProvider,
1993+
apiConfiguration: mockApiConfig,
1994+
task: "test task",
1995+
startTask: false,
1996+
})
1997+
1998+
// Seed a prior partial "say" so the partial-update branch fires.
1999+
task.clineMessages.push({
2000+
ts: Date.now() - 1,
2001+
type: "say",
2002+
say: "text",
2003+
text: "partial",
2004+
partial: true,
2005+
})
2006+
2007+
await task.say("text", "updated partial", undefined, true)
2008+
await flushMicrotasks()
2009+
2010+
expect(updateSpy).toHaveBeenCalled()
2011+
expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#say] updateClineMessage failed:", boom)
2012+
updateSpy.mockRestore()
2013+
})
2014+
2015+
it("logs (instead of crashing) when updateClineMessage rejects from the ask() complete-partial path", async () => {
2016+
// Pins the symmetric .catch arm on the fire-and-forget
2017+
// updateClineMessage call in ask() when finalizing a partial.
2018+
const boom = new Error("updateClineMessage boom")
2019+
const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => {
2020+
throw boom
2021+
})
2022+
const saveSpy = vi.spyOn(Task.prototype as any, "saveClineMessages").mockResolvedValue(true)
2023+
2024+
const task = new Task({
2025+
provider: mockProvider,
2026+
apiConfiguration: mockApiConfig,
2027+
task: "test task",
2028+
startTask: false,
2029+
})
2030+
2031+
// Seed a prior partial "ask" of type "tool" so the complete-partial
2032+
// branch fires when ask("tool", ..., false) is called.
2033+
task.clineMessages.push({
2034+
ts: Date.now() - 1,
2035+
type: "ask",
2036+
ask: "tool",
2037+
text: "partial",
2038+
partial: true,
2039+
})
2040+
2041+
// ask() resolves only after a response — fire-and-forget so the
2042+
// promise the suite awaits stays bounded. The .catch on the
2043+
// pending ask handles the never-resolved promise.
2044+
void task.ask("tool", "complete", false).catch(() => {})
2045+
await flushMicrotasks()
2046+
2047+
expect(updateSpy).toHaveBeenCalled()
2048+
expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#ask] updateClineMessage failed:", boom)
2049+
updateSpy.mockRestore()
2050+
saveSpy.mockRestore()
2051+
})
19792052
})
19802053
})
19812054

0 commit comments

Comments
 (0)