From da4797bcdcdad3620dc7d4e346dcea21891578cd Mon Sep 17 00:00:00 2001 From: phantomptr Date: Sat, 10 Oct 2026 12:38:38 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9 --- client/src/api/ps5.ts | 4 +++ client/src/components/RunningEngineJobs.tsx | 27 +++++++++++++--- client/src/lib/watchedJobIds.test.ts | 30 ++++++++++++++++++ client/src/lib/watchedJobIds.ts | 35 +++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 client/src/lib/watchedJobIds.test.ts create mode 100644 client/src/lib/watchedJobIds.ts diff --git a/client/src/api/ps5.ts b/client/src/api/ps5.ts index 5650e2a4..cdb9f359 100644 --- a/client/src/api/ps5.ts +++ b/client/src/api/ps5.ts @@ -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 { + // Whoever polls a job is following it: the running-jobs banner must not + // offer it as an orphan. + noteJobWatched(jobId); const raw = await invoke>("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 diff --git a/client/src/components/RunningEngineJobs.tsx b/client/src/components/RunningEngineJobs.tsx index e73bd8ed..59a70792 100644 --- a/client/src/components/RunningEngineJobs.tsx +++ b/client/src/components/RunningEngineJobs.tsx @@ -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 ( diff --git a/client/src/lib/watchedJobIds.test.ts b/client/src/lib/watchedJobIds.test.ts new file mode 100644 index 00000000..32689c16 --- /dev/null +++ b/client/src/lib/watchedJobIds.test.ts @@ -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); + }); +}); diff --git a/client/src/lib/watchedJobIds.ts b/client/src/lib/watchedJobIds.ts new file mode 100644 index 00000000..485c4372 --- /dev/null +++ b/client/src/lib/watchedJobIds.ts @@ -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(); + +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 { + const out = new Set(); + 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(); +}