diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4de2b84590..8da6bb62a6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3345,6 +3345,15 @@ export class Task extends EventEmitter implements TaskLike { // could still build tools and call `createMessage()`. this.abort = true + // Stop post-save diagnostics tails that are still waiting on their delay. A + // disposed task cannot receive their say() emit, and without this the timer (and + // the provider + diagnostics snapshot it holds) survives the teardown. + try { + this.diffViewProvider.cancelPostSaveDiagnosticsTails() + } catch (error) { + console.error("Error cancelling post-save diagnostics tails:", error) + } + // Cancel any in-progress HTTP request try { this.cancelCurrentRequest() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index b484d327d2..1fa1b6d2d7 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -4594,6 +4594,34 @@ describe("Cline", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) }) + it("cancels post-save diagnostics tails when the task is disposed", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false }) + const cancelSpy = vi + .spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails") + .mockImplementation(() => {}) + // disposeOnce is private; bracket notation is the repo's convention for it. + await task["disposeOnce"]() + expect(cancelSpy).toHaveBeenCalledTimes(1) + }) + + it("continues disposal when cancelling the post-save diagnostics tails throws", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false }) + vi.spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails").mockImplementation(() => { + throw new Error("cancel boom") + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + // The step after the cancel in the same teardown: if the throw escaped, this would + // never run and the HTTP request would keep streaming into a dead task. + const cancelRequestSpy = vi.spyOn(task, "cancelCurrentRequest").mockImplementation(() => {}) + // disposeOnce is private; bracket notation is the repo's convention for it. + await task["disposeOnce"]() + expect(cancelRequestSpy).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error cancelling post-save diagnostics tails:"), + expect.any(Error), + ) + }) + it("refuses to send a request when the task is disposed during the bounded metadata wait", async () => { // Disposal alone — no cancel button, no abortTask — must make the // task observe cancellation: disposeOnce sets the abort state diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 908159f7ab..6ca6734c78 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1166,7 +1166,7 @@ }, "integrations/editor/__tests__/DiffViewProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 310 + "count": 306 } }, "integrations/editor/__tests__/EditorUtils.spec.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..7e1ba06dd6 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -46,6 +46,14 @@ export class DiffViewProvider { private streamedLines: string[] = [] private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] private preEditScrollLine: number | undefined + // One controller per post-save diagnostics tail that is still waiting, so task + // disposal can cancel the wait instead of leaving a timer (and this provider and + // the pre-save diagnostics snapshot it closes over) running past the teardown. + private readonly postSaveTails = new Set() + // Latched once Task disposal has cancelled the tails. A save that was already awaiting + // its file operations can still reach the tail start after that point, and a tail that + // begins after disposal would emit into a dead task and keep its providers alive. + private tailsDisposed = false // Tracks whether the user activated the target file's editor tab during the // diff session. When the file was not already open before the edit, we only // keep it open afterward if the user explicitly interacted with it. @@ -1151,8 +1159,11 @@ export class DiffViewProvider { }> { const absolutePath = path.resolve(this.cwd, relPath) - // Get diagnostics before editing the file - this.preDiagnostics = vscode.languages.getDiagnostics() + // Get diagnostics before editing the file. Capture the snapshot locally: + // overlapping saveDirectly calls (multi-file edits) must not let a later + // call overwrite this one's baseline before its diagnostics tail runs. + const preDiagnostics = vscode.languages.getDiagnostics() + this.preDiagnostics = preDiagnostics // Write the content directly to the file await createDirectoriesForFile(absolutePath) @@ -1175,23 +1186,88 @@ export class DiffViewProvider { await doc.save() } - // Force a small delay to ensure diagnostics are triggered - await new Promise((resolve) => setTimeout(resolve, 100)) + // The 100 ms diagnostics-settle wait is carried by the + // emitPostSaveDiagnostics tail (inMemoryDocument) instead of here: + // blocking the save path delayed every openFile=false save even when + // diagnostics were disabled or the write delay was 0. } - let newProblemsMessage = "" - + // L1 (A2): resolve without awaiting the LSP diagnostics settle. The + // diagnostics check becomes a fire-and-forget tail that emits any new + // problems via the existing "error" ClineSay type; the returned + // newProblemsMessage is therefore always undefined. if (diagnosticsEnabled) { - // Add configurable delay to allow linters time to process - const safeDelayMs = Math.max(0, writeDelayMs) + // The method's outer try/catch guarantees it never rejects, so the + // fire-and-forget call needs no .catch wrapper. + void this.emitPostSaveDiagnostics(relPath, writeDelayMs, preDiagnostics, !openFile) + } + + // Store the results for formatFileWriteResponse + this.newProblemsMessage = undefined + this.userEdits = undefined + this.relPath = relPath + this.newContent = content + return { + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: content, + } + } + + // L1 (A2): fire-and-forget post-save diagnostics. After the write delay, + // collects new Error-severity problems and emits them via the existing + // "error" ClineSay type (only Error-severity diagnostics reach this branch; + // "error" carries no task-failure semantics in core). Abort-safe: say() + // rejects when the task is aborted, so the whole body sits inside a + // try/catch that degrades to a console.warn — the tail can never reject. + // The wait itself is registered in postSaveTails so Task disposal can cancel + // it: an unregistered delay keeps the timer, this provider and the pre-save + // diagnostics snapshot alive past disposal, and the tail then does stale + // diagnostics work against a task that is already gone. + private async emitPostSaveDiagnostics( + relPath: string, + writeDelayMs: number, + preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][], + inMemoryDocument = false, + ): Promise { + if (this.tailsDisposed) { + // Disposal already ran: emitting now would call say() on a disposed task. + return + } + + const controller = new AbortController() + this.postSaveTails.add(controller) + try { + // Add configurable delay to allow linters time to process. When the + // document was opened in memory (openFile=false), the tail also + // carries the 100 ms diagnostics-settle wait that used to block + // saveDirectly. The signal is the disposal hook: delay() rejects with + // AbortError once the task is gone, which is the tail's expected end. + const safeDelayMs = Math.max(0, writeDelayMs) + (inMemoryDocument ? 100 : 0) try { - await delay(safeDelayMs) + await delay(safeDelayMs, { signal: controller.signal }) } catch (error) { - console.warn(`Failed to apply write delay: ${error}`) + if (controller.signal.aborted) { + return + } + throw error + } + // A cancellation can also land between the wait resolving and the work + // starting; either way nothing is queried or emitted after it. + if (controller.signal.aborted) { + return } - const postDiagnostics = vscode.languages.getDiagnostics() + // Filter to the saved file: saveDirectly resolves before this tail + // completes, so in a multi-file write sequence (e.g. apply_patch) + // a later file's problems must not be attributed to this relPath. + const savedFilePath = path.resolve(this.cwd, relPath) + // arePathsEqual: case-insensitive on Windows, where a relPath whose + // casing differs from the diagnostic URI is still the same file. + const postDiagnostics = vscode.languages + .getDiagnostics() + .filter(([uri]) => arePathsEqual(uri.fsPath, savedFilePath)) // Get diagnostic settings from state const task = this.taskRef.deref() @@ -1200,27 +1276,44 @@ export class DiffViewProvider { const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50 const newProblems = await diagnosticsToProblemsString( - getNewDiagnostics(this.preDiagnostics, postDiagnostics), + getNewDiagnostics(preDiagnostics, postDiagnostics), [vscode.DiagnosticSeverity.Error], this.cwd, includeDiagnosticMessages, maxDiagnosticMessages, ) - newProblemsMessage = - newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : "" - } + // Formatting is awaited too, so a cancellation can land inside it. The emit below + // persists an error row into the task, so it must not start once the caller is gone: + // say() would otherwise be dropped or land in a task the user already left. + if (controller.signal.aborted) { + return + } - // Store the results for formatFileWriteResponse - this.newProblemsMessage = newProblemsMessage - this.userEdits = undefined - this.relPath = relPath - this.newContent = content + if (newProblems.length > 0) { + await task?.say("error", `New problems detected after saving file: ${relPath}\n\n${newProblems}`) + } + } catch (error) { + // Abort-safe: never let a post-save diagnostic emit become an + // unhandled rejection (say() rejects when the task is aborted). + console.warn(`Post-save diagnostics emit failed: ${error}`) + } + finally { + this.postSaveTails.delete(controller) + } + } - return { - newProblemsMessage, - userEdits: undefined, - finalContent: content, + /** + * Cancel post-save diagnostics tails that are still waiting. Called when the + * owning task is disposed. Deliberately NOT called from reset(): reset follows a + * successful write, and the tail that write started still has to report the + * problems it is waiting for. + */ + public cancelPostSaveDiagnosticsTails(): void { + this.tailsDisposed = true + for (const controller of this.postSaveTails) { + controller.abort() } + this.postSaveTails.clear() } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 00b3dcaf7a..800ef31234 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -26,7 +26,14 @@ vi.mock("../../../utils/fs", () => ({ // Mock path vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + normalize: vi.fn((p: string) => p), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), + // diagnosticsToProblemsString formats its output header via + // path.relative(cwd, uri.fsPath).toPosix(); the object-with-toPosix shape + // mirrors the repo's own diagnostics.spec.ts mock. + relative: vi.fn((cwd: string, p: string) => ({ toPosix: () => p.replace(`${cwd}/`, "") })), })) // Mock vscode @@ -165,6 +172,8 @@ describe("DiffViewProvider", () => { }), }), }, + // L1: saveDirectly's fire-and-forget diagnostics tail emits via say(). + say: vi.fn().mockResolvedValue(true), } diffViewProvider = new DiffViewProvider(mockCwd, mockTask) @@ -802,12 +811,17 @@ describe("DiffViewProvider", () => { { preview: false, preserveFocus: true }, ) - // Verify diagnostics were checked after delay - expect(mockDelay).toHaveBeenCalledWith(2000) + // L1: saveDirectly resolves before the fire-and-forget diagnostics + // tail runs; flush one macrotask tick so the mocked delay (and the + // post-write getDiagnostics) have been reached before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Verify the tail applied the configured write delay + expect(mockDelay).toHaveBeenCalledWith(2000, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - // Verify result - expect(result.newProblemsMessage).toBe("") + // Verify result: L1 no longer returns a problems message + expect(result.newProblemsMessage).toBeUndefined() expect(result.userEdits).toBeUndefined() expect(result.finalContent).toBe("new content") }) @@ -838,6 +852,10 @@ describe("DiffViewProvider", () => { expect(mockDelay).not.toHaveBeenCalled() // getDiagnostics is called once for pre-diagnostics, but not for post-diagnostics expect(vscode.languages.getDiagnostics).toHaveBeenCalledTimes(1) + + // L1: no diagnostics tail is launched, so nothing is ever emitted + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockTask.say).not.toHaveBeenCalled() }) it("should handle negative delay values", async () => { @@ -846,18 +864,250 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, true, -500) + // L1: the tail runs after resolve; flush one macrotask tick first. + await new Promise((resolve) => setTimeout(resolve, 0)) + // Verify delay was called with 0 (safe minimum) - expect(mockDelay).toHaveBeenCalledWith(0) + expect(mockDelay).toHaveBeenCalledWith(0, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) it("should store results for formatFileWriteResponse", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 1000) - // Verify internal state was updated - expect((diffViewProvider as any).newProblemsMessage).toBe("") - expect((diffViewProvider as any).userEdits).toBeUndefined() - expect((diffViewProvider as any).relPath).toBe("test.ts") - expect((diffViewProvider as any).newContent).toBe("new content") + // Verify internal state was updated (L1: the problems message is no + // longer stored; it is emitted asynchronously via say("error")) + expect(diffViewProvider["newProblemsMessage"]).toBeUndefined() + expect(diffViewProvider["userEdits"]).toBeUndefined() + expect(diffViewProvider["relPath"]).toBe("test.ts") + expect(diffViewProvider["newContent"]).toBe("new content") + }) + + it("resolves immediately and emits new problems via say('error') after the write delay", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // Pre-write diagnostics are empty; the post-write snapshot (read by + // the fire-and-forget tail) reports one new Error-severity problem. + // vscode.workspace.fs.stat is an unimplemented vi.fn() mock, so + // diagnosticsToProblemsString takes its "(unavailable)" fallback + // branch and still formats the line. + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "boom", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + // L1: saveDirectly resolves before the tail emits anything. + expect(result.newProblemsMessage).toBeUndefined() + expect(mockTask.say).not.toHaveBeenCalled() + + // Flush the fire-and-forget tail (the mocked delay resolves immediately). + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockDelay).toHaveBeenCalledWith(100, expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(mockTask.say).toHaveBeenCalledTimes(1) + // The existing "error" ClineSay type is used, with the new-problems text. + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("New problems detected after saving file: test.ts"), + ) + expect(mockTask.say.mock.calls[0]?.[1]).toContain("boom") + }) + + it("does not start a post-save diagnostics tail once the tails have been cancelled", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // A save that was already awaiting its file operations can reach the tail start + // after Task disposal has cancelled the tails. The latch has to refuse it. + diffViewProvider.cancelPostSaveDiagnosticsTails() + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockDelay).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it("attributes only the saved file's new problems to the saved file", async () => { + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // Multi-file write sequence: a later file's new error must not be + // attributed to this tail's relPath by the workspace-wide snapshot. + const ownDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "own-problem", + } + const otherDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "other-file-problem", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [ + [makeUri(`${mockCwd}/test.ts`), [ownDiag]], + [makeUri(`${mockCwd}/other.ts`), [otherDiag]], + ] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + // Flush the fire-and-forget tail. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).toHaveBeenCalledTimes(1) + expect(mockTask.say.mock.calls[0]?.[1]).toContain("own-problem") + expect(mockTask.say.mock.calls[0]?.[1]).not.toContain("other-file-problem") + }) + + it("stops a post-save tail that is cancelled while the diagnostic settings are read", async () => { + const mockDelay = vi.mocked(delay) + // Two delay() calls exist on this path (the save's own settle wait and the tail's), + // so both must resolve: if the tail's wait never resolves the test would pass + // vacuously instead of proving the re-check. + mockDelay.mockImplementationOnce(() => Promise.resolve()).mockImplementationOnce(() => Promise.resolve()) + + // A real problem is pending, so the only thing that can keep it from being + // persisted is the re-check after the awaited settings read. + const ownDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "cancelled-tail-problem", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [ownDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + // The settings read is the last await before the formatting and the emit. Resolving + // it AFTER the cancellation is how a cancellation lands inside it: the await returns + // normally and only the signal shows that the task is gone. + const provider = mockTask.providerRef.deref() + const originalGetState = provider.getState + const slowGetState = vi.fn( + () => + new Promise((resolve) => + setTimeout(() => resolve({ includeDiagnosticMessages: true, maxDiagnosticMessages: 50 }), 20), + ), + ) + provider.getState = slowGetState + + try { + await diffViewProvider.saveDirectly("test.ts", "content", true, true, 100) + // Let the tail pass the settle delay and reach the settings await. + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(mockTask.say).not.toHaveBeenCalled() + + diffViewProvider.cancelPostSaveDiagnosticsTails() + // The pending settings read resolves after the cancellation. + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(slowGetState).toHaveBeenCalledTimes(1) + // The tail had already taken its pre-save baseline and read the post-save + // diagnostics; it stopped at the re-check, so the pending problem is never + // emitted into a task that no longer exists. + expect(vscode.languages.getDiagnostics).toHaveBeenCalledTimes(2) + expect(mockTask.say).not.toHaveBeenCalled() + } finally { + provider.getState = originalGetState + } + }) + + it("attributes diagnostics to the saved file when the URI casing differs (Windows)", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32") + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // The diagnostic URI uses different casing than the saved relPath: + // on Windows this is still the same file (arePathsEqual). + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "case-mismatch-problem", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/Test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + // Flush the fire-and-forget tail. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).toHaveBeenCalledTimes(1) + expect(mockTask.say.mock.calls[0]?.[1]).toContain("case-mismatch-problem") + platformSpy.mockRestore() + }) + + it("does not block the save on a diagnostics settle delay when diagnostics are disabled", async () => { + // openFile=false used to await a 100 ms settle delay even when + // diagnostics were disabled; that delay now lives in the tail, which + // does not run at all when diagnosticsEnabled is false. + vi.mocked(vscode.languages.getDiagnostics).mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + const result = await diffViewProvider.saveDirectly("test.ts", "new content", false, false) + + expect(result.finalContent).toBe("new content") + expect(mockDelay).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it("carries the in-memory settle delay in the tail for openFile=false saves", async () => { + vi.mocked(vscode.languages.getDiagnostics).mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 100) + + // writeDelayMs (100) + the 100 ms in-memory diagnostics settle, both + // applied by the tail instead of the save path. + expect(mockDelay).toHaveBeenCalledWith(200, expect.objectContaining({ signal: expect.any(AbortSignal) })) + }) + + it("never calls say when there are no new problems", async () => { + vi.mocked(vscode.languages.getDiagnostics).mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 50) + + // Flush the fire-and-forget tail (pre/post snapshots are both empty, + // so diagnosticsToProblemsString returns "" and nothing is emitted). + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it("logs a warning instead of an unhandled rejection when the post-save say() is aborted", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // One new Error-severity problem so the tail reaches say(). + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "boom", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + // The task is aborted while the diagnostics tail is emitting: say() rejects. + mockTask.say.mockRejectedValueOnce(new Error("aborted")) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 0) + // Flush the fire-and-forget tail. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The method's outer catch swallows the rejection with a warning. + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Post-save diagnostics emit failed:")) + + consoleWarnSpy.mockRestore() }) })