Files
openidea/src/workbench/tasks.ts
T
l0ng-ai 1c46a1cd04 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
2026-10-29 09:05:12 +08:00

220 lines
7.1 KiB
TypeScript

/** Background task progress (from `task` / `indexState` backend events). */
import { create } from "zustand";
import { commands, IpcError, onIdeEvent } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
export interface BackgroundTask {
id: number | string;
title: string;
message: string | null;
/** 0..1, or null when indeterminate. */
fraction: number | null;
done: boolean;
startedAt: number;
/** Stops the task (shown as a cancel button in the Background Tasks popup). Call it through `cancelTask`. */
cancel?: () => void;
/** Why the task can't be stopped (a disabled cancel button with this tooltip), when it has no `cancel`. */
uncancellable?: string;
/** Cancel was requested: shown as "Cancelling…" until the task ends, then removed at once. */
cancelling?: boolean;
/** Waiting for the tasks ahead of it in its queue (`queueTask`); cancelling drops it. */
queued?: boolean;
}
interface TaskState {
tasks: BackgroundTask[];
indexing: { files: number; symbols: number } | null;
}
export const useTasks = create<TaskState>(() => ({ tasks: [], indexing: null }));
const DONE_LINGER_MS = 600;
/** Backend tasks that finish sooner (a language server's per-keystroke work) never reach the status bar. */
const SHOW_DELAY_MS = 500;
export const INDEX_TASK_ID = "indexing";
export type TaskUpdate = Omit<BackgroundTask, "startedAt" | "cancelling" | "queued">;
export function upsertTask(t: TaskUpdate) {
let cancelled = false;
useTasks.setState((s) => {
const i = s.tasks.findIndex((x) => x.id === t.id);
const tasks = [...s.tasks];
if (i >= 0) {
cancelled = !!tasks[i]!.cancelling;
// Progress that arrives after Cancel keeps saying "Cancelling…".
tasks[i] = { ...tasks[i]!, ...t, queued: false, message: cancelled ? tasks[i]!.message : t.message };
} else if (!t.done) tasks.push({ ...t, startedAt: Date.now() });
else return s;
return { tasks };
});
// A cancelled task leaves without the finished (full) progress bar.
if (t.done) {
if (cancelled) removeTask(t.id);
else setTimeout(() => removeTask(t.id), DONE_LINGER_MS);
}
}
export function removeTask(id: BackgroundTask["id"]) {
useTasks.setState((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
}
/** Whether `t` offers a working Cancel now. */
export function canCancel(t: BackgroundTask): boolean {
return !!t.cancel && !t.done && !t.cancelling;
}
/** Cancels task `id`: it shows "Cancelling…" until its producer ends it. */
export function cancelTask(id: BackgroundTask["id"]) {
const t = useTasks.getState().tasks.find((x) => x.id === id);
if (!t || !canCancel(t)) return;
useTasks.setState((s) => ({
tasks: s.tasks.map((x) => (x.id === id ? { ...x, cancelling: true, message: tx("Cancelling…") } : x)),
}));
t.cancel!();
}
export function setIndexing(indexing: boolean, files: number, symbols: number) {
useTasks.setState({ indexing: indexing ? { files, symbols } : null });
upsertTask({
id: INDEX_TASK_ID,
title: "Indexing",
message: indexing ? tx`${files.toLocaleString()} files · ${symbols.toLocaleString()} symbols` : null,
fraction: null,
done: !indexing,
uncancellable: tx("Code insight needs the index; it can't be stopped"),
});
}
// ---------------------------------------------------------------------------
// Queues: tasks that run one at a time, the rest waiting (listed, cancellable).
/** The error a task cancelled while queued rejects with (`isCancelled` is true for it). */
export function queuedCancelled(): IpcError {
return new IpcError({ kind: "cancelled", message: "Cancelled" });
}
interface Waiter {
id: BackgroundTask["id"];
start: () => void;
}
interface Queue {
limit: number;
running: number;
waiting: Waiter[];
}
const queues = new Map<string, Queue>();
function startNext(q: Queue) {
while (q.running < q.limit && q.waiting.length) {
const w = q.waiting.shift()!;
// The queue's Cancel is spent; the work sets its own (or says why it has none).
useTasks.setState((s) => ({
tasks: s.tasks.map((t) => (t.id === w.id ? { ...t, queued: false, cancel: undefined } : t)),
}));
w.start();
}
}
/**
* Runs `work` once the tasks queued before it in `queue` are done (`limit` run at a time).
* Until then task `task.id` is listed as queued, and its Cancel drops it: `work` never
* starts and the promise rejects with a cancellation (`isCancelled`). Once started,
* `work` reports its own progress with `upsertTask` under `task.id` and ends the task.
*/
export function queueTask<T>(
queue: string,
task: { id: BackgroundTask["id"]; title: string },
work: () => Promise<T>,
limit = 1,
): Promise<T> {
let q = queues.get(queue);
if (!q) queues.set(queue, (q = { limit, running: 0, waiting: [] }));
const slot = q;
const run = async () => {
slot.running++;
try {
return await work();
} finally {
slot.running--;
startNext(slot);
}
};
if (slot.running < slot.limit && !slot.waiting.length) return run();
return new Promise<T>((resolve, reject) => {
const waiter: Waiter = { id: task.id, start: () => void run().then(resolve, reject) };
slot.waiting.push(waiter);
const cancel = () => {
const i = slot.waiting.indexOf(waiter);
if (i < 0) return;
slot.waiting.splice(i, 1);
removeTask(task.id);
reject(queuedCancelled());
};
const queued: BackgroundTask = {
id: task.id,
title: task.title,
message: tx("Waiting for other tasks"),
fraction: null,
done: false,
startedAt: Date.now(),
queued: true,
cancel,
};
useTasks.setState((s) => ({ tasks: [...s.tasks.filter((t) => t.id !== task.id), queued] }));
});
}
// ---------------------------------------------------------------------------
// Tasks that often finish at once
const pending = new Map<BackgroundTask["id"], ReturnType<typeof setTimeout>>();
const latest = new Map<BackgroundTask["id"], TaskUpdate>();
/** `upsertTask`, except that a task ending within `SHOW_DELAY_MS` of its start is never shown. */
export function upsertTaskSoon(t: TaskUpdate) {
if (useTasks.getState().tasks.some((x) => x.id === t.id)) return upsertTask(t);
if (t.done) {
clearTimeout(pending.get(t.id));
pending.delete(t.id);
latest.delete(t.id);
return;
}
latest.set(t.id, t);
if (pending.has(t.id)) return;
pending.set(
t.id,
setTimeout(() => {
pending.delete(t.id);
const last = latest.get(t.id);
latest.delete(t.id);
if (last) upsertTask(last);
}, SHOW_DELAY_MS),
);
}
export function installTaskEvents(): () => void {
const notCancellable = tx("This task can't be cancelled");
const a = onIdeEvent("task", (e) =>
upsertTaskSoon({
id: e.id,
title: e.title,
message: e.message,
fraction: e.fraction,
done: e.done,
cancel: e.cancellable ? () => void commands.cancelTask(e.id).catch(() => {}) : undefined,
uncancellable: e.cancellable ? undefined : notCancellable,
}),
);
const b = onIdeEvent("indexState", (e) => setIndexing(e.indexing, e.files, e.symbols));
return () => {
pending.forEach(clearTimeout);
pending.clear();
latest.clear();
a();
b();
};
}