Running engine jobs: stop offering this tab's own jobs as orphans with Cancel

In the browser the banner only knew the upload queue and the one-shot
upload, so a Files download or upload, a Convert or image build, a RAR
extract or a link download running in this same tab showed up as an
orphan. Every follower polls through jobStatus, so a poll now claims the
job for the tab; Files downloads and the Convert pipeline are also claimed
from their stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-10 12:38:38 -07:00
1 parent 6f8cea0ed6
commit da4797bcdc
4 files changed
+92 -4

No files matched your search

+4
View File
@@ -17,6 +17,7 @@ import { getEngineUrl } from "../state/engine";
import { isTauriEnv } from "../lib/tauriEnv";
import { invoke } from "../lib/invokeLogged";
import { randomHexId } from "../lib/randomId";
import { noteJobWatched } from "../lib/watchedJobIds";
import { getCachedIcon, setCachedIcon } from "../lib/iconMemoryCache";
import {
appendManualListLine,
@@ -4331,6 +4332,9 @@ export async function jobStatus(
* console's pairing dialog. Without it the failure opens nothing. */
host?: string,
): Promise<JobSnapshot> {
// Whoever polls a job is following it: the running-jobs banner must not
// offer it as an orphan.
noteJobWatched(jobId);
const raw = await invoke<Record<string, unknown>>("job_status", { jobId });
// Cheap shape validation. If the Rust side ever returns a non-snapshot
// payload (e.g., an error envelope we forgot to map to a thrown
+23 -4
View File
@@ -8,6 +8,9 @@ import { isTauriEnv } from "../lib/tauriEnv";
import { formatEtaSeconds } from "../lib/uploadEta";
import { useRateEta } from "../lib/useRateEta";
import { useDocumentVisible } from "../lib/visibility";
import { watchedJobIds } from "../lib/watchedJobIds";
import { downloadForHost, useFsDownloadOpStore } from "../state/fsBulkOp";
import { useFpkgConversion } from "../state/fpkgConversion";
import { useTr } from "../state/lang";
import { useTransferStore } from "../state/transfer";
import { useUploadQueueStore } from "../state/uploadQueue";
@@ -57,6 +60,20 @@ export const RunningEngineJobs = memo(function RunningEngineJobs() {
.flatMap((p) => (p.kind === "running" && p.jobId ? [p.jobId] : []))
.join(SEP),
);
// Files downloads and the Convert pipeline name their job in a store; every
// other follower (uploads, image builds, RAR, link downloads) is claimed by
// polling it — see lib/watchedJobIds.
const downloadClaimed = useFsDownloadOpStore((s) =>
Object.keys(s.byHost)
.flatMap((h) => {
const d = downloadForHost(s, h);
return d.active && d.jobId ? [d.jobId] : [];
})
.join(SEP),
);
const convertClaimed = useFpkgConversion((s) =>
s.pipeline.phase === "running" ? (s.pipeline.jobId ?? "") : "",
);
useEffect(() => {
if (!web || !visible) return;
@@ -74,11 +91,13 @@ export const RunningEngineJobs = memo(function RunningEngineJobs() {
}, [web, visible]);
const orphans = useMemo(() => {
const claimed = new Set(
[queueClaimed, oneShotClaimed].flatMap((ids) => (ids ? ids.split(SEP) : [])),
);
// `jobs` changes every tick, so the polled set is re-read as often.
const claimed = watchedJobIds();
for (const ids of [queueClaimed, oneShotClaimed, downloadClaimed, convertClaimed]) {
if (ids) for (const id of ids.split(SEP)) claimed.add(id);
}
return unclaimedJobs(jobs, claimed);
}, [jobs, queueClaimed, oneShotClaimed]);
}, [jobs, queueClaimed, oneShotClaimed, downloadClaimed, convertClaimed]);
if (!web || orphans.length === 0) return null;
return (
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from "vitest";
import { noteJobWatched, resetWatchedJobIds, watchedJobIds, WATCHED_FRESH_MS } from "./watchedJobIds";
import { unclaimedJobs, type RunningEngineJob } from "./engineJobs";
const job = (jobId: string): RunningEngineJob => ({
jobId,
bytesSent: 0,
totalBytes: 0,
filesFinalized: 0,
filesFinalizingTotal: 0,
startedAtMs: 0,
});
describe("watchedJobIds", () => {
beforeEach(() => resetWatchedJobIds());
it("claims a job this tab polled recently, so it is not shown as an orphan", () => {
noteJobWatched("download-1", 1_000);
const claimed = watchedJobIds(2_000);
expect(unclaimedJobs([job("download-1"), job("closed-tab")], claimed).map((j) => j.jobId)).toEqual([
"closed-tab",
]);
});
it("lets go of a job nothing has polled for a while", () => {
noteJobWatched("old", 1_000);
expect(watchedJobIds(1_000 + WATCHED_FRESH_MS + 1).has("old")).toBe(false);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Engine jobs this tab is following, by when it last read each one's status.
*
* Every screen that follows a job polls it through `jobStatus` (Files
* downloads and uploads, Convert and image builds, RAR, link downloads, the
* queues), so recording the read there claims the job for the whole tab
* without each of those places registering itself. The running-jobs banner
* uses it to tell a job nobody is watching (a closed tab's) from one this tab
* is already showing elsewhere.
*/
/** A job read within this window counts as watched. The slowest follower
* (a lost-contact poll) reads every 5 s. */
export const WATCHED_FRESH_MS = 15_000;
const lastRead = new Map<string, number>();
export function noteJobWatched(jobId: string, now: number = Date.now()): void {
if (jobId) lastRead.set(jobId, now);
}
/** The ids read within WATCHED_FRESH_MS of `now`; older entries are forgotten. */
export function watchedJobIds(now: number = Date.now()): Set<string> {
const out = new Set<string>();
for (const [id, at] of lastRead) {
if (now - at <= WATCHED_FRESH_MS) out.add(id);
else lastRead.delete(id);
}
return out;
}
/** Tests only. */
export function resetWatchedJobIds(): void {
lastRead.clear();
}