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

+8 -2
Переглянути файл
@@ -39,9 +39,13 @@ export function checkForUpdates(): Promise<void> {
async function runCheck() {
const title = t("app.update.checking");
upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false });
// The check only reads: cancelling drops its answer (the request finishes on its own).
let abandon = () => {};
const abandoned = new Promise<"cancelled">((resolve) => (abandon = () => resolve("cancelled")));
upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false, cancel: () => abandon() });
try {
const r = await call(commands.appCheckUpdate());
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
if (r === "cancelled") return;
if (!r.enabled) {
notifications().notify({
level: "info",
@@ -122,6 +126,8 @@ async function runInstall(version: string) {
fraction,
done: false,
cancel: p.type === "installing" ? undefined : cancel,
// Replacing the app happens in one step that must not be cut short.
uncancellable: p.type === "installing" ? t("app.update.installUncancellable") : undefined,
});
};
// The notification's action is spent: progress lives in the status bar from here.
+5 -2
Переглянути файл
@@ -23,7 +23,7 @@ import {
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
export interface BuildSettings {
@@ -231,7 +231,10 @@ export async function syncTool(tool: BuildTool): Promise<void> {
export function cancelSync(tool: BuildTool) {
const s = useBuild.getState().syncing[tool];
if (s) void commands.cancelRequest(s.request);
if (!s) return;
const taskId = `build-sync:${tool}`;
if (useTasks.getState().tasks.some((t) => t.id === taskId && t.cancel)) cancelTask(taskId);
else void commands.cancelRequest(s.request);
}
export function resetBuild() {
+5 -2
Переглянути файл
@@ -25,7 +25,7 @@ import {
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { appendOutput, finishSummary, firstError, messageFiles, type BuildStatus, type ConsoleLine } from "./model";
export const BUILD_TASK_ID = "build:compile";
@@ -248,7 +248,10 @@ function showFailure() {
export function stopBuild() {
const { request } = useBuildRun.getState();
if (request != null) backend.cancel(request);
if (request == null) return;
// Through the task, so its progress says "Cancelling…" until the build has stopped.
if (useTasks.getState().tasks.some((t) => t.id === BUILD_TASK_ID && t.cancel)) cancelTask(BUILD_TASK_ID);
else backend.cancel(request);
}
/** Runs the last build again (the tool window's rerun button). */
+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);
}
+26 -17
Переглянути файл
@@ -6,9 +6,10 @@ import { Channel } from "@tauri-apps/api/core";
import { create } from "zustand";
import type { GitOperation, RemoteOp } from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands, emitLocalIdeEvent, isCancelled, nextRequestId } from "@/platform/ipc";
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks";
import { progressFraction } from "./model";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
@@ -129,7 +130,10 @@ export function lastRemoteFailure(): string | null {
}
export function cancelRemote() {
if (activeRemote !== null) void commands.cancelRequest(activeRemote);
if (activeRemote === null) return;
const taskId = `vcs:${activeRemote}`;
if (useTasks.getState().tasks.some((t) => t.id === taskId)) cancelTask(taskId);
else void commands.cancelRequest(activeRemote);
}
/** Every repository of the project (Fetch and Update Project act on all of them, as in IDEA). */
@@ -154,11 +158,12 @@ export async function runRemote(
const [progressText, doneTitle] = REMOTE_TITLE[op.op];
const progressTitle = tx(progressText);
const request = nextRequestId();
const taskId = 900_000 + request;
const taskId = `vcs:${request}`;
activeRemote = request;
useVcs.setState({ remoteBusy: progressTitle });
const cancel = () => void commands.cancelRequest(request);
const task = (message: string | null, fraction: number | null, done: boolean) =>
emitLocalIdeEvent({ type: "task", id: taskId, title: progressTitle, message, fraction, done });
upsertTaskSoon({ id: taskId, title: progressTitle, message, fraction, done, cancel });
task(detail ?? null, null, false);
notifications().notify({
group: "vcs",
@@ -220,13 +225,16 @@ export async function runRemote(
const cancelled = isCancelled(e);
lastRemoteError = cancelled ? null : errorMessage(e);
logConsole(cancelled ? "info" : "err", cancelled ? `${doneTitle} cancelled` : errorMessage(e));
notifications().notify({
group: "vcs",
level: cancelled ? "warning" : "error",
key: "vcs.remote",
title: cancelled ? `${doneTitle} cancelled` : `${doneTitle} failed`,
body: cancelled ? undefined : errorMessage(e),
});
// The user asked for it: the progress balloon just goes away.
if (cancelled) notifications().expire?.("vcs.remote");
else
notifications().notify({
group: "vcs",
level: "error",
key: "vcs.remote",
title: `${doneTitle} failed`,
body: errorMessage(e),
});
return false;
} finally {
task(null, 1, true);
@@ -320,12 +328,13 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
}
await saveAll();
const request = nextRequestId();
const taskId = 900_000 + request;
const taskId = `vcs:${request}`;
activeRemote = request;
const progress = tx(opts.progress);
useVcs.setState({ remoteBusy: progress });
const cancel = () => void commands.cancelRequest(request);
const task = (message: string | null, done: boolean) =>
emitLocalIdeEvent({ type: "task", id: taskId, title: progress, message, fraction: done ? 1 : null, done });
upsertTaskSoon({ id: taskId, title: progress, message, fraction: done ? 1 : null, done, cancel });
task(null, false);
notifications().notify({
group: "vcs",
@@ -357,14 +366,14 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
logConsole(cancelled ? "info" : "err", cancelled ? `${opts.failure}: cancelled` : message);
notifications().expire?.("vcs.operation");
await refreshNow();
const handled = !cancelled && opts.onError ? await opts.onError(message) : false;
const handled = cancelled || (opts.onError ? await opts.onError(message) : false);
if (!handled)
notifications().notify({
group: "vcs",
level: cancelled ? "warning" : "error",
level: "error",
key: "vcs.operation",
title: cancelled ? `${opts.progress} cancelled` : opts.failure,
body: cancelled ? undefined : message,
title: opts.failure,
body: message,
});
return false;
} finally {