diff --git a/src-tauri/src/ureq.rs b/src-tauri/src/ureq.rs index 3bca6eb..be1ed36 100644 --- a/src-tauri/src/ureq.rs +++ b/src-tauri/src/ureq.rs @@ -678,6 +678,19 @@ mod tests { assert_eq!(parse_content_range("invalid"), None); } + #[test] + fn download_agent_bounds_every_network_wait() { + // 原来的 agent 没有任何超时:连接建好之后服务端不再发数据时,`read` 会一直 + // 阻塞——既不报错、也不重试,分段下载里的 `join` 永远不返回,界面上的进度 + // 就停在原地。这里锁住 PR #84 加上的超时,防止以后被悄悄改回去。 + for url in ["https://example.com/x", "http://127.0.0.1:1/x"] { + let debug = format!("{:?}", super::download_agent(url)); + assert!(debug.contains("timeout_read: Some(30s)"), "{debug}"); + assert!(debug.contains("timeout_write: Some(30s)"), "{debug}"); + assert!(debug.contains("timeout_connect: Some(30s)"), "{debug}"); + } + } + const MIB: f64 = 1024.0 * 1024.0; #[test] diff --git a/src/celemod-ui/package.json b/src/celemod-ui/package.json index cbed3c0..df6c014 100644 --- a/src/celemod-ui/package.json +++ b/src/celemod-ui/package.json @@ -16,6 +16,7 @@ "test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts", "test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts", "test:deep-link": "node --import tsx --test src/deepLink.test.ts", + "test:download-tasks": "node --import tsx --test src/downloadTasks.test.ts", "test:mod-updates": "node --import tsx --test src/modUpdates.test.ts", "test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx" }, diff --git a/src/celemod-ui/src/components/DownloadList.tsx b/src/celemod-ui/src/components/DownloadList.tsx index aa917fe..86f341c 100644 --- a/src/celemod-ui/src/components/DownloadList.tsx +++ b/src/celemod-ui/src/components/DownloadList.tsx @@ -1,8 +1,9 @@ import _i18n from "src/i18n"; import "./DownloadList.scss"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { Icon } from "./Icon"; +import { isDownloadRowVisible, nextDownloadRowExpiry } from "../downloadTasks"; import { Download, useDownloadStore } from "../stores/download"; const formatBytes = (bytes: number) => { @@ -202,7 +203,7 @@ export const DownloadTask = ({ const canControl = allowControl && displayTask.state === "pending" && !displayTask.canceled; const action = - allowRetry && task.state === "failed" && task.source + allowRetry && (task.state === "failed" || task.canceled) && task.source ? { icon: "replay", onClick: () => downloadMod(task.name, task.source!, { force: true }), @@ -308,17 +309,37 @@ export const DownloadTask = ({ ); }; +/** Re-renders once the next finished row leaves its grace period. */ +const useFinishedRowClock = (tasks: Download.TaskInfo[]) => { + const [now, setNow] = useState(() => Date.now()); + const expiry = nextDownloadRowExpiry(tasks, now); + useEffect(() => { + if (expiry === null) return; + const timer = setTimeout( + () => setNow(Date.now()), + Math.max(0, expiry - Date.now()) + 50, + ); + return () => clearTimeout(timer); + }, [expiry]); + return now; +}; + export const DownloadListPage = () => { const downloadTasks = useDownloadStore((state) => state.tasks); const allTasks = Object.values(downloadTasks); + const now = useFinishedRowClock(allTasks); + /* Finished rows retire on their own once the user has seen them finish. + Leaving them in the list made the page disagree with the sidebar badge and + with itself (a 100% row next to "0 项") until the app was restarted. */ + const visibleTasks = allTasks.filter((task) => + isDownloadRowVisible(task, now), + ); const taskMap = new Map( - allTasks.map((task) => [task.name.toLocaleLowerCase(), task]), + visibleTasks.map((task) => [task.name.toLocaleLowerCase(), task]), ); - const roots = allTasks.filter((task) => task.requested); - const visibleRoots = roots.length > 0 ? roots : allTasks; - const visibleCount = allTasks.filter( - (task) => task.state !== "finished" || task.canceled, - ).length; + const roots = visibleTasks.filter((task) => task.requested); + const visibleRoots = roots.length > 0 ? roots : visibleTasks; + const visibleCount = visibleTasks.length; return (
@@ -345,7 +366,7 @@ export const DownloadListPage = () => { const dependencies = collectDependencies(task, taskMap); return dependencies.length > 0 ? dependencies - : allTasks.filter( + : visibleTasks.filter( (candidate) => !candidate.requested && candidate.cancelKey === task.cancelKey, diff --git a/src/celemod-ui/src/components/ModList.tsx b/src/celemod-ui/src/components/ModList.tsx index 92d4114..189b4ad 100644 --- a/src/celemod-ui/src/components/ModList.tsx +++ b/src/celemod-ui/src/components/ModList.tsx @@ -215,7 +215,13 @@ export const Mod = memo( }; if (downloadTask) { - if (downloadTask.state === "failed" && downloadTask.source) { + // A failed or canceled task must be restartable from here; a + // cancellation keeps `state: "pending"`, so it used to fall + // through and make the button do nothing. + if ( + (downloadTask.state === "failed" || downloadTask.canceled) && + downloadTask.source + ) { downloadMod(downloadTask.name, downloadTask.source, { force: true, autoDisableNewMods, diff --git a/src/celemod-ui/src/downloadTasks.test.ts b/src/celemod-ui/src/downloadTasks.test.ts new file mode 100644 index 0000000..7db1f26 --- /dev/null +++ b/src/celemod-ui/src/downloadTasks.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + FINISHED_ROW_GRACE_MS, + downloadAttemptDecision, + isDownloadRowVisible, + nextDownloadRowExpiry, +} from "./downloadTasks"; + +test("starts a new attempt when no task exists or a forced retry is asked", () => { + assert.equal(downloadAttemptDecision(undefined, false), "start"); + assert.equal(downloadAttemptDecision({ state: "pending" }, true), "start"); + assert.equal(downloadAttemptDecision({ state: "failed" }, false), "start"); + assert.equal(downloadAttemptDecision({ state: "finished" }, false), "start"); +}); + +test("attaches to an attempt that is still running", () => { + assert.equal(downloadAttemptDecision({ state: "pending" }, false), "attach"); + assert.equal( + downloadAttemptDecision({ state: "pending", canceled: false }, false), + "attach", + ); +}); + +test("a canceled attempt can be restarted without force", () => { + // The store keeps `state: "pending"` after a cancel, so this used to be + // treated as "already downloading" and the request was dropped. + assert.equal( + downloadAttemptDecision({ state: "pending", canceled: true }, false), + "start", + ); +}); + +test("finished rows disappear after the grace period", () => { + const now = 1_000_000; + const finished = { state: "finished" as const, settledAt: now - 1_000 }; + assert.equal(isDownloadRowVisible(finished, now), true); + assert.equal( + isDownloadRowVisible(finished, now + FINISHED_ROW_GRACE_MS), + false, + ); + // pending / failed / canceled rows stay, they still need controls. + assert.equal(isDownloadRowVisible({ state: "pending" }, now), true); + assert.equal( + isDownloadRowVisible({ state: "pending", canceled: true }, now), + true, + ); + assert.equal( + isDownloadRowVisible({ state: "failed", settledAt: now - 10_000 }, now), + true, + ); +}); + +test("reports when the next finished row expires", () => { + const now = 1_000_000; + const fresh = { state: "finished" as const, settledAt: now }; + const older = { state: "finished" as const, settledAt: now - 1_000 }; + assert.equal( + nextDownloadRowExpiry([{ state: "pending" }, older, fresh], now), + older.settledAt + FINISHED_ROW_GRACE_MS, + ); + assert.equal( + nextDownloadRowExpiry([fresh], now), + now + FINISHED_ROW_GRACE_MS, + ); + assert.equal(nextDownloadRowExpiry([{ state: "pending" }], now), null); + assert.equal( + nextDownloadRowExpiry( + [{ state: "finished", settledAt: now - FINISHED_ROW_GRACE_MS - 1 }], + now, + ), + null, + ); +}); diff --git a/src/celemod-ui/src/downloadTasks.ts b/src/celemod-ui/src/downloadTasks.ts new file mode 100644 index 0000000..efcd392 --- /dev/null +++ b/src/celemod-ui/src/downloadTasks.ts @@ -0,0 +1,63 @@ +/* Rules shared by the download store and the Downloads page. They live in a + plain module (no store/tauri imports) so they can be unit tested directly. */ + +export interface DownloadTaskState { + state: "finished" | "failed" | "pending"; + canceled?: boolean; + settledAt?: number; +} + +export type DownloadAttemptDecision = "start" | "attach"; + +/** + * What `downloadMod` should do when a task with the same name already exists. + * + * - `start` – nothing is running for this Mod, run a new attempt. + * - `attach` – an attempt is already in flight, so forward its outcome to the + * new caller instead of starting a second download of the same + * file. + * + * A canceled task still carries `state: "pending"`, but its download is over: + * treating it as "in flight" left buttons that await the returned promise (the + * Mod list download button, "下载缺失依赖") disabled with no way to restart. + */ +export const downloadAttemptDecision = ( + existing: DownloadTaskState | undefined, + force: boolean, +): DownloadAttemptDecision => { + if (force || !existing) return "start"; + if (existing.state !== "pending") return "start"; + if (existing.canceled) return "start"; + return "attach"; +}; + +/** + * Finished rows are kept for a moment so the user sees the task complete, then + * they disappear. Nothing else ever removed them, so a finished download kept + * sitting in the Downloads page at 100% (with the sidebar badge already back to + * zero) until the app was restarted. + */ +export const FINISHED_ROW_GRACE_MS = 4_000; + +export const isDownloadRowVisible = ( + task: DownloadTaskState, + now: number, + grace = FINISHED_ROW_GRACE_MS, +) => { + if (task.state !== "finished") return true; + const settledAt = task.settledAt ?? now; + return now - settledAt < grace; +}; + +/** Timestamp at which the next finished row falls out of its grace period. */ +export const nextDownloadRowExpiry = ( + tasks: DownloadTaskState[], + now: number, + grace = FINISHED_ROW_GRACE_MS, +): number | null => { + const expiries = tasks + .filter((task) => task.state === "finished" && task.settledAt !== undefined) + .map((task) => task.settledAt! + grace) + .filter((expiry) => expiry > now); + return expiries.length > 0 ? Math.min(...expiries) : null; +}; diff --git a/src/celemod-ui/src/stores/download.ts b/src/celemod-ui/src/stores/download.ts index 13efdbd..cf1dfcb 100644 --- a/src/celemod-ui/src/stores/download.ts +++ b/src/celemod-ui/src/stores/download.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { downloadAttemptDecision } from "../downloadTasks"; import { reloadBlacklistState, reloadInstalledMods, @@ -24,6 +25,8 @@ export namespace Download { canceled?: boolean; paused?: boolean; attemptId: number; + /** When the task reached `finished`/`failed`, used to retire finished rows. */ + settledAt?: number; } } @@ -89,6 +92,20 @@ const replaceTasks = ( return next; }; +/** + * Timestamp a task reached a terminal state, carried over as long as it stays + * in that state so the Downloads page can retire finished rows on their own. + */ +const settledAtFor = ( + state: Download.TaskInfo["state"], + previous: Download.TaskInfo | undefined, +) => { + if (state === "pending") return undefined; + if (previous?.state === state && previous.settledAt) + return previous.settledAt; + return Date.now(); +}; + const aggregateProgress = ( root: Download.TaskInfo, byName: Map, @@ -108,6 +125,39 @@ const aggregateProgress = ( return values.reduce((sum, value) => sum + value, 0) / values.length; }; +/** + * Forward the outcome of an attempt that is already running to a new caller. + * + * Only the attempt that is still in the store (same `attemptId`) may settle the + * promise; if the task is replaced by a newer attempt, or disappears, the + * caller is left to that attempt's own callbacks. + */ +const attachToRunningAttempt = ( + task: Download.TaskInfo, + callbacks: Pick, +) => { + const key = normalizeName(task.name); + let settle = () => {}; + const unsubscribe = useDownloadStore.subscribe((state) => { + const current = state.tasks[key]; + if (!current || current.attemptId !== task.attemptId) return; + if (current.state === "finished") { + settle(); + callbacks.onFinished?.(current); + } else if (current.state === "failed" || current.canceled) { + const error = current.error || "Download failed"; + settle(); + callbacks.onFailed?.(current, error); + } + }); + let done = false; + settle = () => { + if (done) return; + done = true; + unsubscribe(); + }; +}; + export const useDownloadStore = create((set, get) => ({ tasks: {}, @@ -201,32 +251,41 @@ export const useDownloadStore = create((set, get) => ({ reject(new Error(message)); return; } - const mapped = backendTasks.map((task, index) => ({ - name: task.name, - requested: Boolean(task.requested), - dependencies: task.dependencies ?? [], - cancelKey: task.cancel_key, - source: task.url, - ownerId, - mod: { name: task.name }, - state: (task.status === "Finished" - ? "finished" - : task.status === "Failed" - ? "failed" - : "pending") as Download.TaskInfo["state"], - progress: + const mapped = backendTasks.map((task, index) => { + const mappedState = ( task.status === "Finished" - ? 100 - : Number.parseFloat(task.data) || 0, - error: task.status === "Failed" ? task.data : undefined, - downloadedBytes: task.downloaded_bytes || 0, - totalBytes: task.total_bytes || 0, - speedBytesPerSec: task.speed_bytes_per_sec || 0, - canceled: - task.status === "Failed" && task.data === "Download canceled", - paused: get().tasks[normalizeName(task.name)]?.paused, - attemptId: attemptId + index, - })); + ? "finished" + : task.status === "Failed" + ? "failed" + : "pending" + ) as Download.TaskInfo["state"]; + return { + name: task.name, + requested: Boolean(task.requested), + dependencies: task.dependencies ?? [], + cancelKey: task.cancel_key, + source: task.url, + ownerId, + mod: { name: task.name }, + state: mappedState, + progress: + task.status === "Finished" + ? 100 + : Number.parseFloat(task.data) || 0, + error: task.status === "Failed" ? task.data : undefined, + downloadedBytes: task.downloaded_bytes || 0, + totalBytes: task.total_bytes || 0, + speedBytesPerSec: task.speed_bytes_per_sec || 0, + canceled: + task.status === "Failed" && task.data === "Download canceled", + paused: get().tasks[normalizeName(task.name)]?.paused, + attemptId: attemptId + index, + settledAt: settledAtFor( + mappedState, + get().tasks[normalizeName(task.name)], + ), + }; + }); const byName = new Map( mapped.map((task) => [normalizeName(task.name), task]), ); @@ -332,8 +391,17 @@ export const useDownloadStore = create((set, get) => ({ return failedTask; } - if (existingTask && !force && existingTask.state === "pending") + if ( + downloadAttemptDecision(existingTask, force) === "attach" && + existingTask + ) { + // An attempt for this Mod is already running: hand this caller the + // outcome of that attempt instead of dropping the request. Callers that + // await the promise (the Mod list button, "下载缺失依赖") used to stay + // disabled forever because nothing ever settled their promise. + attachToRunningAttempt(existingTask, { onFinished, onFailed }); return existingTask; + } const attemptId = nextAttemptId++; const initialTask: Download.TaskInfo = { @@ -408,6 +476,7 @@ export const useDownloadStore = create((set, get) => ({ task.status === "Failed" && task.data === "Download canceled", paused: previous?.paused, attemptId: previous?.attemptId ?? attemptId, + settledAt: settledAtFor(taskState, previous), } satisfies Download.TaskInfo; }); const byName = new Map(