mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 11:23:35 -05:00
fix(queue): duplicate installs join the queued one; library rows show queue state
A second identical install now awaits the queued one instead of failing, and Install Package rows show Queued/Installing and block Delete while their install waits in the console queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b6901fa53
commit
5944090346
4 files changed
+84
-24
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/yunpengl/workspace/github.com/phantomptr/ps5upload/client/node_modules
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { libraryInstallStates, useUploadQueueStore } from "../../state/uploadQueue";
|
||||
import { isRemotePath } from "../../lib/remotePath";
|
||||
import { PackagePanel } from "../../components/PackagePanel";
|
||||
import { QueuePanel } from "../Upload/QueuePanel";
|
||||
@@ -547,6 +548,19 @@ export default function InstallPackageScreen() {
|
||||
Map<string, InstalledPkgArtifact[]>
|
||||
>(() => new Map());
|
||||
const [pickError, setPickError] = useState<string | null>(null);
|
||||
// Library rows' queue state, as a string key so this screen re-renders only
|
||||
// when a row's state changes, not on every progress tick.
|
||||
const libStateKey = useUploadQueueStore((s) =>
|
||||
[...libraryInstallStates(s.items, host)].map(([p, v]) => `${p}\u0000${v}`).join("\n"),
|
||||
);
|
||||
const libStates = useMemo(() => {
|
||||
const m = new Map<string, "queued" | "installing">();
|
||||
for (const line of libStateKey ? libStateKey.split("\n") : []) {
|
||||
const [p, v] = line.split("\u0000");
|
||||
m.set(p, v as "queued" | "installing");
|
||||
}
|
||||
return m;
|
||||
}, [libStateKey]);
|
||||
// The row whose original file is open in the package viewer.
|
||||
const [viewEntry, setViewEntry] = useState<PkgEntry | null>(null);
|
||||
/** Terminal outcome of the last stream install, shown IN THIS VIEW.
|
||||
@@ -1210,6 +1224,10 @@ export default function InstallPackageScreen() {
|
||||
const installBlocked = false;
|
||||
|
||||
const renderPkgRow = (entry: PkgEntry) => {
|
||||
// A row whose install is waiting or running in the console queue shows it
|
||||
// (and can't be deleted from under the queued install).
|
||||
const queued = libStates.get(entry.path);
|
||||
if (queued && entry.status === "idle") entry = { ...entry, status: queued };
|
||||
const installed = pkgRowInstalled(
|
||||
entry,
|
||||
installedIds,
|
||||
@@ -1223,7 +1241,7 @@ export default function InstallPackageScreen() {
|
||||
host={host}
|
||||
installed={installed}
|
||||
installDisabled={installBlocked}
|
||||
deleteDisabled={installing}
|
||||
deleteDisabled={installing || !!queued}
|
||||
alternativeKey={alternativeKey}
|
||||
selectedForInstallAll={
|
||||
!!alternativeKey &&
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
import { useUploadSettingsStore } from "./uploadSettings";
|
||||
import { runPkgInstall, pkgLibraryStore } from "./pkgLibrary";
|
||||
import { registerInstallExecutor, type InstallResult } from "./consoleQueueBridge";
|
||||
import { isUploadItem, sameInstall } from "./uploadQueue";
|
||||
import { isUploadItem, libraryInstallStates, sameInstall } from "./uploadQueue";
|
||||
|
||||
// The store before any test stubs its actions (some blocks replace startHost).
|
||||
const pristineQueue = useUploadQueueStore.getState();
|
||||
@@ -1233,14 +1233,16 @@ describe("install items", () => {
|
||||
expect(calls).toEqual(["/hold.pkg", "/base.pkg", "/upd.pkg", "/dlc.pkg"]);
|
||||
});
|
||||
|
||||
it("refuses the same install queued twice on one console", () => {
|
||||
it("a second identical install joins the first instead of failing", async () => {
|
||||
const s = useUploadQueueStore.getState();
|
||||
s.enqueueInstall({ host, request: { via: "stream", source: "/x.pkg" }, displayName: "X" });
|
||||
const again = s.enqueueInstall({ host, request: { via: "stream", source: "/x.pkg" }, displayName: "X" });
|
||||
return expect(again.done).resolves.toMatchObject({
|
||||
ok: false,
|
||||
message: expect.stringMatching(/already in the queue/),
|
||||
});
|
||||
const a = s.enqueueInstall({ host, request: { via: "stream", source: "/x.pkg" }, displayName: "X" });
|
||||
const b = s.enqueueInstall({ host, request: { via: "stream", source: "/x.pkg" }, displayName: "X" });
|
||||
expect(b.id).toBe(a.id);
|
||||
await waitFor(() => calls.length === 1);
|
||||
gate.get("/x.pkg")!({ ok: true });
|
||||
await expect(a.done).resolves.toEqual({ ok: true });
|
||||
await expect(b.done).resolves.toEqual({ ok: true });
|
||||
expect(calls).toEqual(["/x.pkg"]);
|
||||
});
|
||||
|
||||
it("never auto-recovers an install item", async () => {
|
||||
@@ -1615,3 +1617,23 @@ describe("review fixes", () => {
|
||||
useRestAfterUploadStore.setState({ enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("libraryInstallStates", () => {
|
||||
it("maps a console's queued and running library installs by path", () => {
|
||||
const it = (id: string, addr: string, path: string, status: string) =>
|
||||
({ id, addr, sourceKind: "install", install: { via: "library", path }, status }) as unknown as QueueItem;
|
||||
const m = libraryInstallStates(
|
||||
[
|
||||
it("a", "10.0.0.2:9113", "/lib/a.pkg", "pending"),
|
||||
it("b", "10.0.0.2:9113", "/lib/b.pkg", "running"),
|
||||
it("c", "10.0.0.2:9113", "/lib/c.pkg", "done"),
|
||||
it("d", "10.0.0.3:9113", "/lib/d.pkg", "pending"),
|
||||
],
|
||||
"10.0.0.2",
|
||||
);
|
||||
expect(m.get("/lib/a.pkg")).toBe("queued");
|
||||
expect(m.get("/lib/b.pkg")).toBe("installing");
|
||||
expect(m.has("/lib/c.pkg")).toBe(false);
|
||||
expect(m.has("/lib/d.pkg")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -398,6 +398,23 @@ export function installOrderPriority(it: QueueItem): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** A console's library installs that are waiting or running, by staged path:
|
||||
* Install Package shows these on the library rows (and blocks Delete). */
|
||||
export function libraryInstallStates(
|
||||
items: QueueItem[],
|
||||
host: string,
|
||||
): Map<string, "queued" | "installing"> {
|
||||
const h = hostOf(host);
|
||||
const out = new Map<string, "queued" | "installing">();
|
||||
for (const it of items) {
|
||||
if (it.sourceKind !== "install" || it.install?.via !== "library") continue;
|
||||
if (hostOf(it.addr) !== h) continue;
|
||||
if (it.status === "pending") out.set(it.install.path, "queued");
|
||||
else if (it.status === "running") out.set(it.install.path, "installing");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a queue item is an upload the activity bar and task list should
|
||||
* show as one. An install item's executor registers its own task, so
|
||||
* mirroring it too would show every install twice, once as an "Upload". */
|
||||
@@ -516,17 +533,24 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
|
||||
|
||||
/** Callers awaiting an install item's outcome. In memory only: after a
|
||||
* restart nobody is waiting, and the row itself shows the result. */
|
||||
const waiters = new Map<string, (r: InstallResult) => void>();
|
||||
const waiters = new Map<string, ((r: InstallResult) => void)[]>();
|
||||
/** A promise for item `id`'s outcome; any number of callers can wait. */
|
||||
const wait = (id: string) =>
|
||||
new Promise<InstallResult>((resolve) => {
|
||||
const list = waiters.get(id) ?? [];
|
||||
list.push(resolve);
|
||||
waiters.set(id, list);
|
||||
});
|
||||
/** Per console: the install that is running right now. Sony's install
|
||||
* can't be stopped halfway, so Stop and Clear leave it running; a new
|
||||
* drain loop waits for it before starting anything else on that console
|
||||
* (the PS5 installs one package at a time). */
|
||||
const installInFlight = new Map<string, Promise<unknown>>();
|
||||
const settle = (id: string, r: InstallResult) => {
|
||||
const w = waiters.get(id);
|
||||
if (w) {
|
||||
const list = waiters.get(id);
|
||||
if (list) {
|
||||
waiters.delete(id);
|
||||
w(r);
|
||||
for (const w of list) w(r);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1444,15 +1468,10 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
|
||||
|
||||
enqueueInstall(input) {
|
||||
const existing = get().items.find((it) => sameInstall(it, input));
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
done: Promise.resolve({
|
||||
ok: false,
|
||||
message: `${input.displayName} is already in the queue.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// The same install again (a second click, or the same package from
|
||||
// another screen) joins the one already queued: one run, one result
|
||||
// for every caller.
|
||||
if (existing) return { id: existing.id, done: wait(existing.id) };
|
||||
const bare = hostOf(input.host);
|
||||
get().add({
|
||||
sourceKind: "install",
|
||||
@@ -1472,7 +1491,7 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
|
||||
});
|
||||
const items = get().items;
|
||||
const added = items[items.length - 1];
|
||||
const done = new Promise<InstallResult>((resolve) => waiters.set(added.id, resolve));
|
||||
const done = wait(added.id);
|
||||
void get().startHost(bare);
|
||||
return { id: added.id, done };
|
||||
},
|
||||
@@ -1493,7 +1512,7 @@ export const useUploadQueueStore = create<QueueState>((set, get) => {
|
||||
}),
|
||||
}));
|
||||
scheduleSave();
|
||||
const done = new Promise<InstallResult>((resolve) => waiters.set(id, resolve));
|
||||
const done = wait(id);
|
||||
void get().startHost(hostOf(it.addr));
|
||||
return { id, done };
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user