mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 19:33:08 -05:00
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:
1 parent
6f8cea0ed6
commit
da4797bcdc
4 files changed
+92
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user