fix(tasks): Make every background task cancellable, queued or running (#177)

Background tasks often couldn't be cancelled, or cancelling didn't stop them:
- A cancel that arrived before the backend registered the request was dropped (build while saving files, multi-repository fetch/pull between repositories). The host now remembers early cancels (bounded, 2 min), and request ids start at a random value per page.
- There was no queue and no common cancel path: backend-reported tasks (LSP progress, deployment transfers) couldn't be cancelled, and tool installs, VCS operations and update checks had no cancel button in Background Tasks.
- Only the direct child process was killed, and JDK / tool archives were extracted without checking for cancellation.

Tool installs and JDK downloads now share a downloads queue; cancelling a queued task removes it without starting any work. Every task cancels through one path that shows "Cancelling…" and then removes it; tasks that can't be interrupted show a disabled cancel button with the reason. IdeEvent::Task carries `cancellable` with a cancel_task command, cancellable LSP progress sends window/workDoneProgress/cancel, installers, curl and tar run in their own process group and are killed as a group, and extraction checks for cancellation between entries.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 09:05:12 +08:00
1 джерело c6621c5c36
коміт 1c46a1cd04
40 files changed
+888 -148

No files matched your search

+16
Переглянути файл
@@ -70,6 +70,22 @@ describe("tools store", () => {
expect(useTools.getState().tools.find((t) => t.name === "gopls")?.status).toBe("missing");
});
it("installs one at a time; one cancelled while it waits never starts", async () => {
backend.stepMs = 10;
await refreshTools();
const install = vi.spyOn(backend, "install");
const first = installTool("gopls");
const queued = installTool("vtsls");
expect(useTasks.getState().tasks.find((t) => t.id === "tools:vtsls")).toMatchObject({ queued: true });
cancelInstall("vtsls");
expect(useTasks.getState().tasks.find((t) => t.id === "tools:vtsls")).toBeUndefined();
expect(await queued).toBeNull();
expect(useTools.getState().logs["vtsls"]?.error).toBe("Cancelled");
expect((await first)?.status).toBe("installed");
expect(install.mock.calls.map((c) => c[0])).toEqual(["gopls"]);
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
});
it("uninstalls", async () => {
await refreshTools();
expect(await uninstallTool("rust-analyzer")).toBe(true);
+14 -9
Переглянути файл
@@ -17,10 +17,12 @@ import {
requiredJdk,
TEMURIN,
type DownloadJdkArgs,
type JdkDownload,
} from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { upsertTask } from "@/workbench/tasks";
import { queueTask, upsertTask } from "@/workbench/tasks";
import { DOWNLOAD_QUEUE } from "./store";
import { tx } from "@/platform/i18n";
const JAVA_SETTINGS = "languages.java";
@@ -42,16 +44,19 @@ export function downloadJdkFor(args: DownloadJdkArgs): Promise<Sdk | null> {
const title = `Downloading JDK ${args.version}`;
const taskId = `jdk-download:${args.version}`;
const key = `jdk-download-${args.version}`;
const cancel = () => job.cancel();
const job = downloadJdk(TEMURIN, args.version, null, (e) => {
const message = e.type === "line" ? e.text : e.message;
const fraction = e.type === "progress" ? e.fraction : null;
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
});
upsertTask({ id: taskId, title, message: "Eclipse Temurin", fraction: null, done: false, cancel });
const p = (async () => {
try {
const sdk = await job.promise;
// Waits for the downloads ahead of it; cancelling it there means it never starts.
const sdk = await queueTask(DOWNLOAD_QUEUE, { id: taskId, title }, () => {
const cancel = () => job.cancel();
upsertTask({ id: taskId, title, message: "Eclipse Temurin", fraction: null, done: false, cancel });
const job: JdkDownload = downloadJdk(TEMURIN, args.version, null, (e) => {
const message = e.type === "line" ? e.text : e.message;
const fraction = e.type === "progress" ? e.fraction : null;
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
});
return job.promise;
});
if (args.languageServer) await chooseForLanguageServer(sdk.path);
notifications().notify({
group: "tools",
+23 -17
Переглянути файл
@@ -4,11 +4,13 @@ import type { RequestId, ToolInfo } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { isCancelled, nextRequestId } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { upsertTask } from "@/workbench/tasks";
import { cancelTask, queueTask, upsertTask, useTasks } from "@/workbench/tasks";
import { toolsBackend } from "./backend";
import { tx } from "@/platform/i18n";
export const SETTINGS_PAGE_ID = "languages.servers";
/** Tool installs and JDK downloads take turns (one network download / package manager at a time). */
export const DOWNLOAD_QUEUE = "downloads";
const MAX_LOG_LINES = 400;
export interface InstallJob {
@@ -100,24 +102,25 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
const taskId = `tools:${name}`;
const key = opts.notificationKey ?? `tools-install-${name}`;
useTools.setState((s) => ({ jobs: { ...s.jobs, [name]: { request, lines: [], fraction: null, message: null } } }));
upsertTask({ id: taskId, title: tx`Installing ${display}`, message: null, fraction: null, done: false });
const title = tx`Installing ${display}`;
const cancel = () => toolsBackend().cancel(request);
const task = (message: string | null, fraction: number | null) =>
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
const p = (async () => {
try {
const info = await toolsBackend().install(name, request, (e) => {
if (e.type === "line") {
patchJob(name, (j) => ({ ...j, lines: [...j.lines, e.text].slice(-MAX_LOG_LINES), message: e.text }));
upsertTask({ id: taskId, title: tx`Installing ${display}`, message: e.text, fraction: null, done: false });
} else {
patchJob(name, (j) => ({ ...j, fraction: e.fraction, message: e.message }));
upsertTask({
id: taskId,
title: tx`Installing ${display}`,
message: e.message,
fraction: e.fraction,
done: false,
});
}
// Downloads and package managers run one at a time; the rest wait, cancellable.
const info = await queueTask(DOWNLOAD_QUEUE, { id: taskId, title }, () => {
task(null, null);
return toolsBackend().install(name, request, (e) => {
if (e.type === "line") {
patchJob(name, (j) => ({ ...j, lines: [...j.lines, e.text].slice(-MAX_LOG_LINES), message: e.text }));
task(e.text, null);
} else {
patchJob(name, (j) => ({ ...j, fraction: e.fraction, message: e.message }));
task(e.message, e.fraction);
}
});
});
upsertTool(info);
endJob(name, null);
@@ -151,7 +154,7 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
});
return null;
} finally {
upsertTask({ id: taskId, title: tx`Installing ${display}`, message: null, fraction: 1, done: true });
upsertTask({ id: taskId, title, message: null, fraction: 1, done: true });
running.delete(name);
}
})();
@@ -159,7 +162,10 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
return p;
}
/** Cancels the install of `name`, queued or running (as the task's Cancel does). */
export function cancelInstall(name: string) {
const taskId = `tools:${name}`;
if (useTasks.getState().tasks.some((t) => t.id === taskId)) return cancelTask(taskId);
const job = useTools.getState().jobs[name];
if (job) toolsBackend().cancel(job.request);
}