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:
phantomptrandClaude Opus 5.5 committed 2026-09-28 00:16:13 -07:00
1 parent 0b6901fa53
commit 5944090346
4 files changed
+84 -24

No files matched your search

+1
View File
@@ -0,0 +1 @@
/Users/yunpengl/workspace/github.com/phantomptr/ps5upload/client/node_modules
+19 -1
View File
@@ -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 &&
+30 -8
View File
@@ -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);
});
});
+34 -15
View File
@@ -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 };
},