дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
c6621c5c36
коміт
1c46a1cd04
40 files changed
+888
-148
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Заблокувати користувача