diff --git a/client/src-tauri/src/commands/ps5_engine.rs b/client/src-tauri/src/commands/ps5_engine.rs index 2a281f65..26a86aa2 100644 --- a/client/src-tauri/src/commands/ps5_engine.rs +++ b/client/src-tauri/src/commands/ps5_engine.rs @@ -1580,6 +1580,25 @@ pub async fn pkg_dpi_install( post_json_long(&url, &body).await } +/// Direct/streaming install (beta, #81): hand the DPI daemon the engine's +/// /pkg-host/ URL for an existing session instead of a staged PS5 path. +/// The daemon pulls the pkg over HTTP — no staging copy uploaded to the +/// PS5 first. The session must already be registered with the engine via +/// a prior `pkg_install_start` (which creates the pkg-host listener). +/// Long-deadline client — the installer ingests the pkg before replying. +#[tauri::command] +pub async fn pkg_dpi_direct_install( + ps5_addr: String, + session_id: String, +) -> Result { + let url = format!("{}/api/pkg/dpi-direct-install", engine::url()); + let body = serde_json::json!({ + "ps5_addr": ps5_addr, + "session_id": session_id, + }); + post_json_long(&url, &body).await +} + /// Poll an in-flight install for status. Cheap; called every 1-2s. #[tauri::command] pub async fn pkg_install_status(session: String) -> Result { diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 128d6835..84f15cc7 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -234,6 +234,7 @@ pub fn run() { commands::ffpkg_extract, commands::pkg_install_start, commands::pkg_dpi_install, + commands::pkg_dpi_direct_install, commands::dpi_ensure, commands::pkg_install_status, commands::pkg_install_cancel, diff --git a/client/src/layout/AppShell.tsx b/client/src/layout/AppShell.tsx index 706e9a11..a8d820b8 100644 --- a/client/src/layout/AppShell.tsx +++ b/client/src/layout/AppShell.tsx @@ -11,12 +11,13 @@ import { useConnectionStore, EMPTY_HOST_RUNTIME, PS5_LOADER_PORT, + PS5_PAYLOAD_PORT, } from "../state/connection"; import { usePayloadPlaylistsStore } from "../state/payloadPlaylists"; import { log } from "../state/logs"; import { useUpdateStore } from "../state/update"; import { engineApi } from "../api/engine"; -import { payloadCheck } from "../api/ps5"; +import { payloadCheck, portCheck } from "../api/ps5"; import { installActivityWiring } from "../state/activityWiring"; import { ensureRosterMigrated, @@ -100,6 +101,12 @@ function useStatusPolling() { // scan, momentary network jitter) shouldn't flash "Helper isn't running" // when the helper is actually alive. Require N misses in a row first. const missCountRef = useRef>({}); + // Last transfer-port (:9113) liveness result per host. Used to log the + // up→down TRANSITION only (not every poll) when the transfer listener + // dies while mgmt stays up — the "uploads fail but the dot is green" + // wedge state. See HostRuntime.transferAlive for why this is tracked + // separately from the mgmt-port STATUS probe. + const transferAliveRef = useRef>({}); // Auto-loader: last wall-clock ms we auto-ran the playlist for a host, used // to suppress re-triggering. The playlist itself sends ELFs to the loader, // which momentarily drops the helper (down→up flap) — without a cooldown @@ -167,6 +174,7 @@ function useStatusPolling() { for (const ref of [ autoLoaderFiredAtRef, missCountRef, + transferAliveRef, warnedMismatchRef, warnedNoUcredRef, ]) { @@ -257,6 +265,43 @@ function useStatusPolling() { }); // Clear the active console's "rechecking…" flag once its probe lands. if (isActive(key)) setStatus({ payloadProbing: false }); + // Transfer-port (:9113) liveness — only worth probing when the + // mgmt probe says the helper is up. A refused/timeout here while + // mgmt answers is the "uploads fail but the dot is green" wedge: + // the transfer listener died (or never came up) while the mgmt + // thread is still serving STATUS. Log the up→down TRANSITION so + // the bug bundle has a smoking gun before the user files an + // "upload refused" issue. Skipped entirely when mgmt is down — + // no point probing :9113 when :9114 is already dark. + if (s.reachable && newStatus === "up") { + try { + const alive = await portCheck(probedHost, PS5_PAYLOAD_PORT); + if (cancelled) return; + const was = transferAliveRef.current[key]; + transferAliveRef.current[key] = alive; + setHostStatus(probedHost, { transferAlive: alive }); + if (!alive && was !== false) { + log.warn( + "connection", + `transfer port :${PS5_PAYLOAD_PORT} DOWN on ${probedHost} (mgmt still up) — uploads will fail until the payload is redeployed`, + ); + } else if (alive && was === false) { + log.info( + "connection", + `transfer port :${PS5_PAYLOAD_PORT} recovered on ${probedHost}`, + ); + } + } catch { + // portCheck best-effort — leave transferAlive unchanged. + } + } else if (!s.reachable) { + // mgmt down ⇒ transfer port state unknown; clear so the next + // reachable poll reports a fresh transition. + if (transferAliveRef.current[key] !== undefined) { + delete transferAliveRef.current[key]; + } + setHostStatus(probedHost, { transferAlive: null }); + } if (s.reachable) { // Update the matching roster row's cached firmware/payload. const roster = useRosterStore.getState(); @@ -314,6 +359,12 @@ function useStatusPolling() { } setHostStatus(probedHost, { payloadStatus: newStatus }); if (isActive(key)) setStatus({ payloadProbing: false }); + // mgmt probe threw — transfer port state is unknown. Clear so a + // subsequent successful poll re-establishes the baseline. + if (transferAliveRef.current[key] !== undefined) { + delete transferAliveRef.current[key]; + } + setHostStatus(probedHost, { transferAlive: null }); } }; const tick = () => { diff --git a/client/src/lib/browserInvoke.ts b/client/src/lib/browserInvoke.ts index 4096371c..8967819d 100644 --- a/client/src/lib/browserInvoke.ts +++ b/client/src/lib/browserInvoke.ts @@ -435,6 +435,20 @@ export async function browserInvoke(cmd: string, args: AnyArgs = {}): Promise /*long=*/ true, ); + case "pkg_dpi_direct_install": + // TS caller: { ps5Addr, sessionId } (Tauri 2 camelCase). + // Direct/streaming install (beta, #81): the engine serves the pkg + // at /pkg-host/{session}/ and the DPI daemon pulls it over HTTP — + // no staging copy uploaded to the PS5 first. + return postJson( + "/api/pkg/dpi-direct-install", + { + ps5_addr: args["ps5Addr"], + session_id: args["sessionId"], + }, + /*long=*/ true, + ); + // ── Payload probe ──────────────────────────────────────────────────────── case "payload_check": { diff --git a/client/src/lib/uploadStreams.test.ts b/client/src/lib/uploadStreams.test.ts index 4e439c5e..37f5fae4 100644 --- a/client/src/lib/uploadStreams.test.ts +++ b/client/src/lib/uploadStreams.test.ts @@ -68,6 +68,7 @@ describe("effectiveUploadStreams", () => { ps5Kernel: null, ucredElevated: true, maxTransferStreams: 1, + transferAlive: null, }, "192.168.86.100": { payloadStatus: "up", @@ -75,6 +76,7 @@ describe("effectiveUploadStreams", () => { ps5Kernel: null, ucredElevated: true, maxTransferStreams: 4, + transferAlive: null, }, }, }); diff --git a/client/src/screens/InstallPackage/index.tsx b/client/src/screens/InstallPackage/index.tsx index 0294cadf..c43c7286 100644 --- a/client/src/screens/InstallPackage/index.tsx +++ b/client/src/screens/InstallPackage/index.tsx @@ -305,6 +305,7 @@ export default function InstallPackageScreen() { const addAndUpload = usePkgLibrary(host, (s) => s.addAndUpload); const install = usePkgLibrary(host, (s) => s.install); const installAll = usePkgLibrary(host, (s) => s.installAll); + const installStream = usePkgLibrary(host, (s) => s.installStream); const cancelPendingInstall = usePkgLibrary( host, (s) => s.cancelPendingInstall, @@ -327,6 +328,7 @@ export default function InstallPackageScreen() { ); const [pickError, setPickError] = useState(null); const [picking, setPicking] = useState(false); + const [streaming, setStreaming] = useState(false); const [dropActive, setDropActive] = useState(false); const hostReady = !!host?.trim(); @@ -448,6 +450,45 @@ export default function InstallPackageScreen() { } } + // Stream-install (beta, #81): pick a single PC-side .pkg and install it + // WITHOUT staging it on the PS5 first — the engine serves the file over + // HTTP and the DPI daemon pulls it directly. Useful for a quick one-shot + // install when you don't want to wait out the staging upload (or don't + // have the disk space for it). Shares the `installing` lock with the + // regular install flow. + async function handleStreamPick() { + setPickError(null); + if (!host?.trim()) { + setPickError( + tr( + "install.error.noHost", + "Set a PS5 host on the Connection tab first.", + ), + ); + return; + } + setStreaming(true); + try { + const sel = isAndroid() + ? await pickPath({ + mode: "file", + filters: [{ name: "PS5 Package", extensions: ["pkg"] }], + }) + : await openDialog({ + multiple: false, + filters: [{ name: "PS5 Package", extensions: ["pkg"] }], + }); + const p = Array.isArray(sel) ? sel[0] : sel; + if (!p) return; + const r = await installStream(p as string, host); + if (!r.ok && r.message) setPickError(r.message); + } catch (e) { + setPickError(`${e}`); + } finally { + setStreaming(false); + } + } + // Install-order guard. A base game (CATEGORY "gd") and its update ("gp") or // DLC ("ac") share a ContentID AND a title_id — they never overwrite each // other (the library stages them to separate sub-dirs; on the PS5 the base @@ -621,6 +662,33 @@ export default function InstallPackageScreen() { > {tr("install.add", "Add .pkg")} + } /> diff --git a/client/src/state/auditLog.ts b/client/src/state/auditLog.ts index 263a6d35..511724b6 100644 --- a/client/src/state/auditLog.ts +++ b/client/src/state/auditLog.ts @@ -32,6 +32,7 @@ export type AuditKind = | "peripheral_eject" | "peripheral_bd_off" | "pkg_install_start" + | "pkg_dpi_direct_install" | "lwfs_mount" | "pkg_direct_mount"; diff --git a/client/src/state/connection.ts b/client/src/state/connection.ts index 9c61735f..9801488d 100644 --- a/client/src/state/connection.ts +++ b/client/src/state/connection.ts @@ -87,6 +87,13 @@ export interface HostRuntime { ps5Kernel: string | null; ucredElevated: boolean | null; maxTransferStreams: number | null; + /** Whether the FTX2 transfer listener (:9113) accepts TCP. Probed + * alongside the mgmt STATUS frame so the UI can flag the wedge + * state where mgmt (:9114) is up but the transfer port is dead — + * uploads will fail with "connection refused" until the payload is + * redeployed, but the status pill would otherwise show green. + * null = host not yet probed or transfer-port check not run. */ + transferAlive: boolean | null; } export const EMPTY_HOST_RUNTIME: HostRuntime = { @@ -95,6 +102,7 @@ export const EMPTY_HOST_RUNTIME: HostRuntime = { ps5Kernel: null, ucredElevated: null, maxTransferStreams: null, + transferAlive: null, }; export interface ConnectionState { @@ -136,6 +144,11 @@ export interface ConnectionState { * yet); the Upload path treats null as 1. The effective stream count is * min(this, the user's upload-streams setting). */ maxTransferStreams: number | null; + /** Mirror of the active console's transfer-port (:9113) liveness. + * True = TCP connect to :9113 succeeded; false = refused/timeout; + * null = host not yet probed. See HostRuntime.transferAlive for + * why this is tracked separately from the mgmt-port STATUS. */ + transferAlive: boolean | null; /** True when a fresh payload-info probe is in flight and the * currently-displayed payloadVersion / ps5Kernel may be stale. * Set by Connection's handleSend on entry (the user just kicked @@ -167,6 +180,7 @@ export interface ConnectionState { | "ucredElevated" | "maxTransferStreams" | "payloadProbing" + | "transferAlive" > > ) => void; @@ -187,6 +201,7 @@ function mirrorRuntime(host: string, rt: HostRuntime) { ps5Kernel: rt.ps5Kernel, ucredElevated: rt.ucredElevated, maxTransferStreams: rt.maxTransferStreams, + transferAlive: rt.transferAlive, }; } @@ -201,6 +216,7 @@ export const useConnectionStore = create((set) => ({ ps5Kernel: null, ucredElevated: null, maxTransferStreams: null, + transferAlive: null, payloadProbing: false, step1: "idle", step1Msg: "Enter your PS5's address and check", diff --git a/client/src/state/pkgLibrary.ts b/client/src/state/pkgLibrary.ts index fbe391af..a43d41bd 100644 --- a/client/src/state/pkgLibrary.ts +++ b/client/src/state/pkgLibrary.ts @@ -442,6 +442,17 @@ interface PkgLibraryState { refresh: (host: string) => Promise; addAndUpload: (localPath: string, host: string) => Promise; install: (path: string, host: string) => Promise; + /** Stream-install (beta, #81) a local PC-side `.pkg` WITHOUT uploading it + * to PS5 staging first. The engine serves the file over HTTP at + * `/pkg-host/{session}/` and the DPI daemon pulls it directly. Saves the + * staging upload (and the disk space) for the quick-install case. The + * pkg is NOT retained on the PS5 afterwards — nothing was staged. + * Shares the `installing` lock with `install()`. + * Returns `{ ok, message?, mayNotLaunch? }`. */ + installStream: ( + localPcPath: string, + host: string, + ) => Promise<{ ok: boolean; message?: string; mayNotLaunch?: boolean }>; /** Install every staged, not-yet-installed, idle row sequentially, in * base → update → DLC order (`pkgEntryInstallOrder`). Each item runs the * full readiness-gated `install()` cascade; one failure doesn't abort the @@ -842,6 +853,96 @@ async function runDpiInstall( }; } +/** + * Streaming/direct install (beta, #81): hand the DPI daemon the engine's + * `/pkg-host/` URL for an existing session instead of a staged PS5 path. + * The daemon pulls the pkg over HTTP — no staging copy is uploaded to + * the PS5 first. Mirrors `runDpiInstall`'s daemon-bring-up + restore + * dance (the DPI ELF still replaces the main payload on the loader), but + * sends a session_id rather than a local path. + * + * The caller MUST have already registered the session with the engine + * (via `pkg_install_start` with `localPs5Path: null` and a PC-side file + * path) so `/pkg-host/{session}/` is serving bytes when the daemon comes + * asking. Returns `daemonFailed:true` distinctly so callers can treat a + * "daemon never came up" dead-end separately from a Sony-side reject. + * + * Like `runDpiInstall`, `ok:true` here is the daemon's word that + * `InstallByPackage` accepted — callers should confirm with + * `titleRegisteredOnDisk` before claiming a real install. + */ +async function runDpiDirectInstall( + host: string, + sessionId: string, + onStatus?: (msg: string) => void, +): Promise<{ ok: boolean; errMessage: string; daemonFailed: boolean; rc: number }> { + const ip = hostOf(host); + log.info("install", `DPI ensure (direct): bringing up daemon on ${ip}:9040 (loads via :9021)`); + const ens = (await invoke("dpi_ensure", { ip })) as { + ok?: boolean; + error?: string; + listening?: boolean; + sent?: boolean; + }; + log.info( + "install", + `DPI ensure (direct) result: ok=${ens.ok} listening=${ens.listening ?? "?"} sent=${ens.sent ?? "?"}` + + (ens.error ? ` error="${ens.error}"` : ""), + ); + if (!ens.ok) { + return { + ok: false, + daemonFailed: true, + rc: 0, + errMessage: ens.error || "the DPI daemon didn't come up on :9040", + }; + } + let resp: { ok?: boolean; rc?: number; err_message?: string } = {}; + for (let attempt = 1; attempt <= DPI_MAX_ATTEMPTS; attempt++) { + try { + resp = (await invoke("pkg_dpi_direct_install", { + ps5Addr: mgmtAddr(host), + sessionId, + })) as typeof resp; + } catch (e) { + resp = { ok: false, rc: 0, err_message: pkgError(e) }; + } + const rcNow = (resp.rc ?? 0) >>> 0; + if (resp.ok || rcNow !== DPI_TRANSIENT_BUSY_RC || attempt === DPI_MAX_ATTEMPTS) { + break; + } + onStatus?.( + `PS5 is busy finishing the last install — waiting for it to be ready (attempt ${attempt}/${DPI_MAX_ATTEMPTS})…`, + ); + await waitForConsoleReady(host, { + onWait: () => onStatus?.("Waiting for the PS5 to be ready…"), + }); + } + // Restore the main payload — the daemon replaced it. Best-effort. + try { + const bp = (await invoke("payload_bundled_path")) as { + ok?: boolean; + path?: string; + }; + if (bp?.ok && bp.path) { + await invoke("payload_send", { ip, path: bp.path, port: null }); + } + } catch { + /* best-effort restore */ + } + const ok = !!resp.ok; + const rc = (resp.rc ?? 0) >>> 0; + return { + ok, + daemonFailed: false, + rc, + errMessage: ok + ? "" + : resp.err_message || + `Install was rejected (0x${rc.toString(16).padStart(8, "0")}).`, + }; +} + /** * The bare `.pkg` install mechanism, with NO store/UI side effects — shared by * the Install Package screen's manual install (`install()`) and the upload @@ -1661,6 +1762,133 @@ const makePkgLibraryStore = () => ); }, + async installStream(localPcPath, host) { + if (!host?.trim()) { + return { ok: false, message: "No PS5 host selected." }; + } + if (get().installing) { + return { ok: false, message: "Another install is in progress." }; + } + set({ installing: true, busyNotice: null, installPending: false }); + const clearBusy = () => + set({ installing: false, busyNotice: null, installPending: false }); + try { + // Wait behind any active transfer — the DPI payload swap would kill + // the transfer port mid-upload, same as install()/installExternal(). + const transfersActive = () => + transferScreenBusy(host) || + get().entries.some( + (e) => e.status === "uploading" || e.status === "queued", + ); + if (transfersActive()) { + set({ + installPending: true, + busyNotice: + "Waiting for the current upload to finish before installing…", + }); + while (transfersActive()) { + if (!get().installing) return { ok: false, message: "Cancelled." }; + await sleep(400); + } + set({ installPending: false, busyNotice: null }); + } + set({ installPending: false }); + + // 1. Parse the PC-side pkg header for content_id + category. The + // engine needs the content_id to canonicalise the pkg-host URL + // filename (Sony's installer cross-checks it against the header). + let meta: SplitParseResponse; + try { + meta = (await invoke("pkg_metadata_split", { + path: localPcPath, + })) as SplitParseResponse; + } catch (e) { + return { ok: false, message: `Couldn't read .pkg header: ${pkgError(e)}` }; + } + if ((meta.parts?.length ?? 1) > 1) { + return { + ok: false, + message: + "Split .pkg sets aren't supported by the streaming installer — pick the single lead .pkg.", + }; + } + const contentId = meta.head?.content_id ?? ""; + const label = meta.head?.title || contentId || basenameOf(localPcPath); + + set({ + busyNotice: `Stream-installing ${label} (beta) — the PS5 pulls the pkg directly over HTTP, no staging upload…`, + }); + + // 2. Register the session with the engine. Passing `localPs5Path: + // null` + a PC `path` makes the engine create a pkg-host serving + // session WITHOUT expecting a staged file on the PS5. The URL + // the engine builds is what the DPI daemon will fetch. + const onStatus = (msg: string) => set({ busyNotice: msg }); + const startResp = (await invoke("pkg_install_start", { + ps5Addr: mgmtAddr(host), + path: localPcPath, + splitRoot: null, + packageTypeOverride: pkgTypeForCategory(meta.head?.category), + localPs5Path: null, + contentId: contentId || null, + // No staging file is created, so deleteStaging is moot — pass + // false so the engine doesn't record a staging_path to clean up. + deleteStaging: false, + })) as { + err_code?: number; + session_id?: string; + err_message?: string; + may_not_launch?: boolean; + }; + + const rc = (startResp.err_code ?? 0) >>> 0; + const sessionId = startResp.session_id; + // The engine creates a pkg-host session even when BGFT register + // rejects (rc != 0) — but without a session_id there's nothing for + // the daemon to fetch, so this is a hard fail. + if (!sessionId) { + return { + ok: false, + message: + startResp.err_message || + `The engine wouldn't start a serving session (0x${rc.toString(16).padStart(8, "0")}).`, + }; + } + + // 3. Hand the session's pkg-host URL to the DPI daemon. The daemon + // pulls the pkg over HTTP; no staging copy lands on the PS5. + const dpi = await runDpiDirectInstall(host, sessionId, onStatus); + if (dpi.daemonFailed) { + return { ok: false, message: dpi.errMessage }; + } + if (!dpi.ok) { + return { ok: false, message: dpi.errMessage }; + } + + // 4. Verify the title actually landed (DPI's `ok` alone isn't + // proof — the daemon reports InstallByPackage's rc, not the + // async install result). The pkg-host session is still alive + // for this, then the engine GCs it. + const verdict = await verifyInstallCompleted(sessionId); + if (verdict.completed) { + pushNotification("success", `Installed ${label}`, { + body: "Stream-install complete. The pkg was fetched over HTTP — nothing was staged on the PS5.", + }); + return { ok: true, mayNotLaunch: false }; + } + // Stall / async failure. Nothing was staged on the PS5 so there's + // no pkg to keep — the pkg-host session is engine-side only. + return { + ok: false, + message: verdict.message || "The install didn't complete.", + }; + } catch (e) { + return { ok: false, message: pkgError(e) }; + } finally { + clearBusy(); + } + }, + async installExternal(pkg, host) { if (!host?.trim() || get().installing) { return { ok: false, message: "Another install is in progress." }; diff --git a/docs/elf-arsenal-reference-fixes.md b/docs/elf-arsenal-reference-fixes.md new file mode 100644 index 00000000..35d0b277 --- /dev/null +++ b/docs/elf-arsenal-reference-fixes.md @@ -0,0 +1,533 @@ +# ps5upload vs elf-arsenal — Gap Analysis & Fix Plan + +**Scope:** Cross-reference the elf-arsenal reference repo +(`git.earthonion.com/soniciso/elf-arsenal`, v1.6.21) against the current +ps5upload HEAD (v3.3.24) to find concrete, code-level fixes for the open +user-reported issues: + +- #164 — FW 12.40: remote operations not working (PKG installs, folder + uploads, connection losses), plus feature requests (web UI, persistent + fan speed, NP fake sign-in / user-ID management). +- #152 — Patch (update) PKGs can't be installed; the on-PS5 `ps5upload` + helper process dies ~4–7 s after the first install attempt is rejected. +- #81 — Direct/streaming install request (avoid double-storage). + +Each item below states: **Symptom → Root cause → elf-arsenal's proven +technique (with file:line) → ps5upload gap → Concrete fix → Priority & +risk.** Priorities: P0 = data-loss / crash, P1 = the headline bug users +hit, P2 = feature parity, P3 = polish. + +--- + +## 0. Status of the three "headline" bugs (already merged) + +Before pulling new fixes, confirm what's **already** in v3.3.24 so we +don't redo work: + +| Bug | Status | Commit / code | +| --- | --- | --- | +| **A. Early staging-delete** (engine deletes the staged `.pkg` the instant the first install attempt is rejected, so the fallback has nothing to install) | **FIXED** | `engine/.../pkg_install.rs` `install_verdict` (L977–1020), delete gate (L1370), guarded-patch preservation (L668–700); commit `ad74098` + the verdict rewrite. Pinned by 9 unit tests (L2092–2279). | +| **B. `pt_mmap` treats kernel errno as a valid pointer** (ShellUI scratch mmap returns e.g. `-ENOMEM` → dereferenced → ShellUI crash → watchdog kills helper) | **FIXED** | `payload/src/ptrace_remote.c:355–368` now collapses `[-4095,-1]` → `-1`; callers in `shellui_rpc.c` (L399, 438, 486, 765) check `scratch == -1 \|\| scratch == 0`. Commit `a9fe56d` (#173). | +| **C. Installed-Apps / Library poll every few seconds on the same mgmt channel as a big upload → speed collapses → "upload failed"** | **FIXED** | `client/src/lib/ps5Transfers.ts` `transferScreenBusy` (L33–50), gates in `InstalledApps/index.tsx:494`, `RunningAppsPanel.tsx:155`, `pkgLibrary.ts` (L1231, 1386, 1699, 1822). Commit `3fe35e2` (#164). | + +**These need hardware confirmation on FW 10.40 / 12.40 / 12.70 (the +issue threads are explicitly waiting on that), but the code-level fixes +are in.** The remaining work is the **gap analysis below** — things the +reference does that ps5upload still doesn't, which explain the residual +"helper dies" and "patch won't apply" reports **even after** A/B/C. + +--- + +## P0-1 — DPI daemon has no `sceAppInstUtilInitialize` + no authid escalation + +### Symptom (#152, the part still open after fix B) +On FW 10.40 the in-process `sceAppInstUtilInstallByPackage` rejects a +PS4 patch (`package_type=PS4DP`) with `0x80B21106` and returns cleanly. +The engine then hands the staged path to the standalone DPI daemon +(`payload/dpi/ezremote_dpi.c`). The helper goes unreachable ~4 s later. +The DPI fallback runs with **zero diagnostics** (only fixed for logging +by #178; the *behavior* is still broken). + +### Root cause (read `ezremote_dpi.c` end-to-end) +`ezremote_dpi.c:65` `int main(void)` does **three things missing from +the reference**: + +1. **Never calls `sceAppInstUtilInitialize()`** before + `sceAppInstUtilInstallByPackage` (L158). The reference initializes + exactly once, mutex-guarded (`elf-arsenal src/homebrew.c:515–519`), + and *retries init on every request* if startup init failed + (`payloads-src/dpi/main.c:150–165`). On FW 10.40/12.x Sony's + installer can return `0x80B21106` purely because IPMI was never + brought up in this process. Calling `InstallByPackage` cold leaves + the IPMI state machine half-wedged, and the next install-service + event trips the watchdog. +2. **Never escalates its own credentials.** The reference calls + `jb_escalate_pid(getpid())` at the top of the install worker + (`homebrew.c:720`) and the DPI v1 payload is spawned pre-escalated + (`src/ps5/sys.c:1280–1521`). `ezremote_dpi.c` relies entirely on the + elfldr loader's pristine ucred — which on later FW is **not** an + install-service authid. The main ps5upload payload sidesteps this + with an authid-swap window (`bgft.c:518` + `authid_acquire("InstallByPackage", install_authid)`); the DPI daemon + has no such window. +3. **No boot-timing wait.** Reference DPI v1 sleeps 25 s before init to + let kstuff kernel patches land (`payloads-src/dpi/main.c:103–117`), + with a 10 s timeout on init itself (`timed_init`, L83–100). + `ezremote_dpi.c` binds and serves immediately. + +### elf-arsenal's proven pattern (`payloads-src/dpi/main.c:83–186`) +```c +static int timed_init(void) { + pthread_create(&tid, NULL, init_thread, NULL); /* sceAppInstUtilInitialize */ + pthread_detach(tid); + for (int i = 0; i < INIT_TIMEOUT * 10; i++) { + if (g_init_done) return g_init_rc; + nanosleep(&(struct timespec){0,100000000}, NULL); /* 100 ms */ + } + return -0xDEAD; /* timeout sentinel */ +} +/* per request, retry init if startup failed: */ +if (init_rc != 0) { init_rc = timed_init(); } +if (init_rc != 0) { send(cl, "error:init:0x%08X", ...); continue; } +``` + +### Concrete fix (ps5upload) +In `payload/dpi/ezremote_dpi.c`, before the `accept` loop: +- Add a `jb_escalate_pid(getpid())` (port the 45-line `jb.c` from the + reference, or reuse the main payload's existing escalate path — it's + already linked into `ps5upload.elf`). Set authid `0x4801000000000013`, + privcaps `0xff`, uid/gid = 0. +- Sleep 25 s in 500 ms steps (kstuff patch window), then call + `sceAppInstUtilInitialize()` on a detached thread with a 10 s timeout. +- On **every** accepted request, if `init_rc != 0`, retry `timed_init()` + once; on timeout reply `error:init:timeout` instead of calling + `InstallByPackage` cold. +- Reply with the **hex** `0x%08X` form on error (the engine's + `dpi_install_handler` at `pkg_install.rs:1628` already logs hex, but + the daemon currently replies **decimal** — `snprintf(resp, "%d", ret)` + at L167. The reference replies `error:0x%08X`. Pick one; hex is what + the rest of the codebase logs). + +### Priority / risk +**P0** — this is the most likely remaining cause of the "helper dies +~4 s after the rejected install" symptom on FW 10.40 that #152 is +explicitly still open on. Risk: low — the change is confined to the +177-line standalone daemon and mirrors a battle-tested reference. No +change to the main payload or engine. + +### Test +- Existing `tests/install-fallback-hw.mjs` (hardware-gated) covers the + cascade end-to-end; extend it to assert the daemon's reply is hex and + that init is called (via a klog grep for `sceAppInstUtilInitialize`). +- Add a host-side unit test that a cold `InstallByPackage` returning + `0x80B21106` from the daemon triggers the engine's "stage preserved + for DPI" branch (already covered by the staging-preservation test in + `pkg_install.rs:2092+`, just need the daemon to actually succeed). + +--- + +## P0-2 — No "wait for app.db row" verification after `InstallByPackage` returns 0 + +### Symptom +#81 comment from `Constantine-HD`: *"Installed, but via a fallback that +may not launch on this firmware."* Also #152's RDR2 `ce-109596-0`. Sony's +`sceAppInstUtilInstallByPackage` returns `0` the instant BGFT **queues** +the task — not when the install completes, and not even guaranteed to +mean "will succeed." On FW 11/12 a queue-accept can still fail +asynchronously (authid gate fires later, PlayGo reject, disk full), and +the host reports success prematurely. + +### Root cause +ps5upload's `install_verdict` (`pkg_install.rs:977`) treats +"InstallByPackage returned 0" as the trigger to **delete staging** and +report Complete. There is no cross-check that the title actually landed +in Sony's app database. The reference polls `app.db` for up to 90 s. + +### elf-arsenal's proven pattern (`src/homebrew.c:692–714, 728–745`) +```c +static int wait_for_install_row(const char *title_id, int timeout_sec) { + sqlite3 *db; sqlite3_stmt *st; + sqlite3_open_v2("/system_data/priv/mms/app.db", &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, NULL); + sqlite3_prepare_v2(db, + "SELECT 1 FROM tbl_contentinfo WHERE titleId=?1 LIMIT 1", ...); + /* poll every 500 ms up to timeout_sec */ +} +/* after install_pkg_at_path returns 0: */ +if (rc == 0 && content_id[0]) { + extract_title_id(content_id, tid, sizeof tid); /* "IV9999-NPXS40047_00-..." → "NPXS40047" */ + if (!strcmp(tid, "FAKE00000")) rc = -1; /* placeholder never rewritten */ + else if (wait_for_install_row(tid, 90) != 0) rc = -1; +} +``` +The placeholder `FAKE00000` rejection is the crucial bit — Sony's +installer can accept a fake content_id and then silently drop it. + +### Concrete fix (ps5upload) +Two-layer: + +1. **Payload side** (`payload/src/bgft.c` near `appinst_task_register`): + after a successful `InstallByPackage` / `AppInstallPkg`, extract the + title_id from the content_id and poll + `/system_data/priv/mms/app.db` `tbl_contentinfo` for up to 60 s. + Expose the result through the existing install-status RPC so the + engine's `install_verdict` can consume a new `registered` tri-state: + `Confirmed` (row appeared), `Pending` (still polling), + `Rejected` (timeout / placeholder). This makes "Complete" mean + *actually installed*, not *queued*. +2. **Engine side** (`pkg_install.rs`): extend `InstallVerdict` so + `Complete` requires `registered == Confirmed` on FW that supports + the app.db read; fall back to the existing byte-settle path on FW + where the db path isn't readable. Keep the current `Stalled` → + keep-staging behaviour for `Pending`. + +### Priority / risk +**P0** for the "may not launch" correctness issue; directly resolves +the false-success reports. Risk: medium — touches both payload and +engine; the db read needs the escalated authid from P0-1. Guard the +sqlite open behind a capability probe (some FW may not expose app.db to +the payload). + +--- + +## P1-3 — Path-rewrite `/data/` → `/user/data/` missing for the DPI path + +### Symptom +DPI daemon accepts the path string but `InstallByPackage` still fails +with a generic error; or the staged pkg is invisible to Sony's install +service (sandbox). + +### Root cause +The engine's `dpi_install_handler` passes `req.local_ps5_path` verbatim +to the daemon (`pkg_install.rs:1620`). If the host staged the pkg at +`/data/ps5upload/...`, Sony's install service — which runs in a sandbox +that sees `/data/` as `/user/data/` — can't find the file. The reference +**always** rewrites the path before handing it to any installer +(`elf-arsenal src/homebrew.c:425–436`). + +### elf-arsenal pattern +```c +static void rewrite_path_for_install(const char *in, char *out, size_t n) { + if (strncmp(in, "/data/", 6) == 0) { snprintf(out, n, "/user%s", in); return; } + strncpy(out, in, n - 1); +} +``` + +### Concrete fix +In `pkg_install.rs::dpi_install_handler`, rewrite the path before +sending: if it starts with `/data/`, send `/user/data/...`. Better: +do it inside the daemon so both the local and URL paths are correct +regardless of caller. One-line helper, pure function, trivially tested. + +### Priority / risk +**P1**, low risk. Likely fixes a subset of "DPI fallback rejected" +reports that aren't the authid issue. + +--- + +## P1-4 — Settle time + delete-retry between batch installs (the "Install all" pile-up) + +### Symptom (#81) +`Constantine-HD`'s screenshot: queue installs base, update, DLC out of +order, multi-DLC stalls during the FW12 black screen, and +`fs_delete_failed` when the install service is still busy with the +previous title. + +### Root cause +The engine's "Install all" (#174, commit `775487b`) batches staged +pkgs but doesn't (a) enforce base → update → DLC ordering, (b) wait +a settle window between installs, or (c) retry `fs_delete` on +`fs_delete_failed`. + +### elf-arsenal's proven pattern +- **Ordering:** reference scans and queues base titles first, then + patches, then DLC, by content_id suffix (`…GP` base / `…DP` patch / + `…AC` DLC). See `homebrew.c:825–935`. +- **Settle:** `sleep(3)` between sequential installs + (`homebrew.c:869`). +- **Delete retry:** the reference doesn't delete mid-batch at all; it + defers cleanup. ps5upload's `fs_delete_with_timeout` + (`pkg_install.rs:1389`) has a 10 s cap but no retry on + `fs_delete_failed`. + +### Concrete fix +1. **Sort the batch** in `pkg_install.rs` before issuing installs: + parse the package_type suffix from the pkg header (already parsed + by `ps5upload-pkg`) and order `GP` → `DP` → `AC` → everything else. +2. **Inject a configurable settle** (default 3 s) between the + `Complete` of one session and the `install_start` of the next. +3. **Retry `fs_delete`** up to 3 times with a 2 s backoff on + `fs_delete_failed` — the on-PS5 service is briefly busy post-install. + +### Priority / risk +**P1**, low risk. Directly addresses the #81 thread's ordering +complaint and the `fs_delete_failed` report. + +--- + +## P1-5 — DPI v2 / etaHEN-compatible HTTP bridge (direct/streaming install) + +### Symptom (#81) +User wants to install a `.pkg` by **streaming** it from the PC, never +staging the whole file on PS5 storage first (the "double storage" +problem: pkg + installed game both take space). + +### Root cause +Feature gap — the engine can already serve a pkg over HTTP (`/pkg-host/`, +referenced in `ezremote_dpi.c` comments L16), but there's no UI/API +path that points the PS5's installer at that URL for a streaming +one-pass install. + +### elf-arsenal's proven pattern +Two tiers: +- **DPI v1** (`payloads-src/dpi/main.c`): plain TCP :9040, send URL, + get `ok`/`error:0x...`. ps5upload already has this. +- **DPI v2** (`payloads-src/dpiv2/main.c`): HTTP bridge on :12800, + etaHEN-compatible, forwards to the main installer on :6969 with + `?sync=1` so the HTTP response reflects *actual* install success + (polls `last-install.json`, `homebrew.c:1406–1459`). This is the + front-door external tools call. + +### Concrete fix (the "Direct install (beta)" the owner already promised in #81) +1. **Engine** (`pkg_install.rs`): add a new route + `/api/pkg/direct-install` that: + - Validates the local pkg path. + - Builds an `http://:/pkg-host//` + URL (the engine already serves `/pkg-host/`). + - Sends that URL to the DPI daemon on :9040 (the daemon already + accepts http:// URIs — `ezremote_dpi.c:152` `metainfo.uri = buffer`). + - Tracks install status via the same `install_verdict` machinery, + but with `staging_path = None` (nothing to delete — the file + lives on the PC). +2. **Client:** add a "Direct install (beta)" toggle on the Install + dialog, clearly labelled with the caveats from the owner's #81 reply + (PC must stay connected, FW-dependent reliability). +3. **Honest fallback:** if the direct path returns `0x80B22404` + (PlayGo HTTP pre-flight reject) or any non-zero, automatically stage + + retry via the normal upload-then-install path. Never silently + fail. + +### Priority / risk +**P1** (the owner already committed to shipping this beta). Risk: +medium — long-running HTTP install needs the engine's transfer-port +keepalive logic reused; a flaky LAN can drop the stream. Mitigate with +the auto-fallback. + +--- + +## P1-6 — Connection-loss / helper-unreachable during long operations + +### Symptom (#164) +"Often, I find my app disconnected from the console while I use it to +monitor the temperature and fan speed." Also #152's "helper goes +unreachable." + +### Root cause (the pieces that aren't already fixed) +Fix B (mmap) addresses the ShellUI-crash cascade. Two residual causes: +1. **`recv_exact`/`read_exact` frames** (`connection.rs:397`, + `runtime.c:1943`) surface as `"read frame header: failed to fill + whole buffer"` with no retry — a single dropped TCP frame during a + long-running install status poll kills the whole session. +2. **No watchdog keepalive on the mgmt channel.** The reference's + helper doesn't have one either, but the app's polling every few + seconds *is* the keepalive — and fix C paused that polling during + uploads, so during a long folder upload the mgmt channel can go + idle long enough for Sony's network stack to drop it. + +### Concrete fix +1. **Distinguish fatal vs transient on the read path.** + `connection.rs::recv_header` (L394) should classify `UnexpectedEof` + + `ConnectionReset` as *session-recoverable* (the engine already has + `is_retryable_transfer_error` in `transfer.rs:380–414` for the + transfer port — extend the same idea to the mgmt port). On a + recoverable error during an install-status poll, **mark the session + "reconnecting"** rather than failing the install. +2. **Add a mgmt-channel liveness ping** that runs independent of the + paused-during-upload pollers. A 30 s NOOP frame costs nothing and + keeps the TCP path warm. The payload already handles NOOP + (`runtime.c` `handle_binary_frame`). +3. **Re-ARM the auto-reconnect** (the engine already has + `payload_lifecycle.rs` for the loader port) to cover the case where + the helper genuinely crashed: detect unreachable :9114, re-send the + payload via the loader, restore the in-flight install session from + the engine's session map. + +### Priority / risk +**P1**, medium risk (touches the connection layer). The +session-restore is the delicate part — guard it so a *real* crash +doesn't get papered over (correlate with the bug-bundle klog). + +--- + +## P2-7 — Persistent fan threshold on payload launch (autorun) + +### Symptom (#164 feature request) +"a way to permanently set the fan speed every time the payload launches +… so it can be put in the payload manager autorun." + +### Root cause +Feature gap. ps5upload sets the fan threshold live via `/dev/icc_fan` +ioctls (`0xC01C8F07` threshold, `0xC0068F06` duty) but doesn't persist +it or re-apply it. + +### elf-arsenal's proven pattern (`src/fan.c`) +- `atomic_int g_pinned_threshold_c` — persists across config changes. +- `FAN_REAPPLY_SEC = 15` — watcher thread re-applies the threshold + every 15 s so Sony's thermal manager can't undo it. +- Config is loaded at boot from `/data/elf-arsenal/...` and the watcher + starts unconditionally — so any payload launch (including from an + autorun) inherits the pinned threshold. + +### Concrete fix +1. **Payload** (`payload/src/`): add a fan watcher thread started from + `main.c` boot that reads a pinned threshold from a config file + (`/data/ps5upload/fan.json` or an RPC-set value) and re-applies it + every 15 s. Re-uses the existing ioctl code. +2. **Engine + client:** add a "Fan" settings panel with "Pin threshold" + that writes the config and pushes it live. The threshold then + survives payload relaunch / autorun because the watcher reads it + from disk at boot. + +### Priority / risk +**P2**, low risk. Self-contained payload + engine feature. + +--- + +## P2-8 — NP fake sign-in / local-user ID management (Offact-equivalent) + +### Symptom (#164 feature request) +"a way to add, modify, and activate the user ID for the local accounts +(like Offact + NP Fake sign in)." + +### Root cause +Feature gap. ps5upload has no NP/offline-account surface. + +### elf-arsenal's proven pattern +- `src/offact.c` (117 lines): offline activation via `sceRegMgr*` + registry writes. Per-slot key derivation + (`slot_key(slot, base, magic1, magic2)`), 4 keys per account + (name/id/type/flags). ID generation: FNV-style hash with magic + `0x5EAF00D / 0xCA7F00D`: + ```c + uint64_t offact_gen_id(const char *name) { + uint64_t h = 0x5EAF00D / 0xCA7F00D; + while (*name) h = 0x100000001B3ULL * (h ^ (uint8_t)*name++); + return h; + } + ``` +- `src/np.c` (141 lines): web endpoints to set the fake NP signed-in + state. +- Exposed through `/api/np*` and `/api/offact*` routes. + +### Concrete fix +Port the offact registry-write logic + the NP fake-sign-in RPC into +the ps5upload payload, expose mgmt-port RPCs, add an engine route +`/api/np/account` and a client panel "Accounts". Clearly warn that +this writes to the system registry and to back up first (the reference +takes a registry snapshot). + +**Caveat (owner's call):** this is the feature most likely to "cause +kernel panics" the user is trying to avoid — ps5upload should treat it +as opt-in beta with a confirm dialog and a registry backup step. + +### Priority / risk +**P2**, **higher risk** — registry writes can panic. Port the +reference's snapshot-first pattern (`fpkg_db.c` registry snapshots) and +gate behind a settings flag. + +--- + +## P2-9 — Web-based UI (already in flight — track to ship) + +### Symptom (#164 feature request) +"a web-based interface." + +### Status +**Already done, shipping.** Commit `530af17` (#171) — engine serves the +full React client over HTTP. The Docker/`ghcr.io` image +(`3e9aafe` + `615b50a` + the Makefile docker targets) is the +self-hosted path. The only remaining work is **discoverability**: the +README and the in-app Connection screen should advertise the engine URL +so users know they can point a browser at it instead of installing the +Tauri app. + +### Concrete fix +Docs/UX only — add a "Web UI" section to README.md and a hint in the +Connection screen. No code change. + +### Priority / risk +**P3**, trivial. + +--- + +## P3-10 — DPI daemon: plain protocol hardening + +### Symptom +The daemon's protocol (`ezremote_dpi.c:120–170`) reads up to 4095 bytes +then `strtok`s on `\r`/`\n`. A malformed or partial send (e.g. the +engine's `dpi_send` splitting the write — `pkg_install.rs:1596–1597` +does two `write_all`s) could in principle interleave, though TCP stream +ordering makes this safe in practice. + +### Concrete fix (defensive) +- Add a `Content-Length`-or-`\n`-terminated read loop (the reference + reads until newline with a total cap). +- Reject paths containing `..` (`pkg_filename_is_safe`, + `homebrew.c:1497–1511`) before passing to Sony's installer. +- Reply in the `error:0x%08X` / `ok` form the reference uses, so the + engine's `dpi_install_handler` can parse a success/reject/timeout + tri-state rather than just numeric rc. + +### Priority / risk +**P3**, low risk, defence-in-depth. + +--- + +## Implementation order (suggested) + +1. **P0-1** (DPI escalate + init) — unblocks the #152 "helper dies" + confirmation. Standalone daemon only. +2. **P1-3** (path rewrite) — one-liner, do alongside P0-1. +3. **P0-2** (app.db verify) — the false-success root cause; needs + payload + engine. +4. **P1-4** (batch ordering + settle + delete retry) — the #81 pile-up. +5. **P1-5** (direct/streaming install beta) — the headline #81 ask; + owner already promised it. +6. **P1-6** (connection-loss resilience) — the #164 reliability thread. +7. **P2-7 / P2-8** (fan persistence, NP accounts) — feature requests. +8. **P3-10 / P2-9** (polish). + +Each is independently shippable. P0-1 + P1-3 together are the smallest +unit that could meaningfully move the #152 confirmation forward. + +--- + +## Reference file index (elf-arsenal) + +| Area | File | Key lines | +| --- | --- | --- | +| Install cascade | `src/homebrew.c` | 484–654 (cascade), 692–745 (app.db verify), 425–436 (path rewrite) | +| Async install + status | `src/homebrew.c` | 657–781 (worker, status file) | +| Bulk scan + order | `src/homebrew.c` | 825–935 | +| DPI v1 daemon | `payloads-src/dpi/main.c` | 83–186 (timed_init + retry) | +| DPI v2 HTTP bridge | `payloads-src/dpiv2/main.c` | :12800, `?sync=1` | +| Auth escalation | `src/jb.c` | 1–45 (jb_escalate_pid) | +| Fan control | `src/fan.c` | 15 s reapply, atomic pin | +| Offact / NP | `src/offact.c`, `src/np.c` | registry writes, FNV id | +| FPKG DB snapshots | `src/fpkg_db.c` | 1759+ (snapshot rotation) | +| Filename safety | `src/homebrew.c` | 1497–1511 | + +## Reference file index (ps5upload, current) + +| Area | File | Key lines | +| --- | --- | --- | +| Install verdict (delete gate) | `engine/.../pkg_install.rs` | 977–1020, 1370 | +| Guarded-patch staging keep | `engine/.../pkg_install.rs` | 668–700 | +| DPI send (host side) | `engine/.../pkg_install.rs` | 1585–1641 | +| DPI daemon (PS5 side) | `payload/dpi/ezremote_dpi.c` | 65–177 (whole file) | +| Install dispatch (payload) | `payload/src/bgft.c` | 1344–1466 (cascade), 1392–1416 (patch guard) | +| ptrace mmap (fixed) | `payload/src/ptrace_remote.c` | 355–368 | +| ShellUI scratch alloc | `payload/src/shellui_rpc.c` | 399, 438, 486, 765 | +| Connection read | `engine/.../connection.rs` | 394–404 | +| Polling pause (fixed) | `client/src/lib/ps5Transfers.ts` | 33–50 | +| Web UI | `engine/.../webui.rs` | spa_fallback | diff --git a/engine/crates/ps5upload-engine/src/pkg_install.rs b/engine/crates/ps5upload-engine/src/pkg_install.rs index b0dfb30a..474de5f2 100644 --- a/engine/crates/ps5upload-engine/src/pkg_install.rs +++ b/engine/crates/ps5upload-engine/src/pkg_install.rs @@ -139,6 +139,15 @@ pub fn router(state: PkgInstallStateHandle) -> Router { // process — installs without the PlayGo gate. Caller stages the // pkg first and passes the bare PS5 path. See payload/dpi/. .route("/api/pkg/dpi-install", post(dpi_install_handler)) + // Direct/streaming install (beta, #81): skip the staging upload + // entirely — the engine serves the pkg at /pkg-host/ and the DPI + // daemon pulls it straight over HTTP. Useful when PS5 disk space + // is tight or for a quick one-shot install from a machine that + // already has the pkg mounted. + .route( + "/api/pkg/dpi-direct-install", + post(dpi_direct_install_handler), + ) // Filename component is informational only — the session UUID // is the actual lookup key. We allow ANY {filename} so the URL // can carry the pkg's canonical `.pkg` name that @@ -295,6 +304,98 @@ fn staging_path_for(local_ps5_path: &Option, delete_staging: bool) -> Op } } +/// Maximum number of attempts when retrying a staging cleanup that the +/// payload rejected with `fs_delete_failed`. Sony's in-process installer +/// briefly holds the staged `.pkg` file open (EBUSY / EBUSY-equivalent) +/// right after `terminal_complete` and on a register-reject; a single-shot +/// `fs_delete` races that window and surfaces as a scary "staging cleanup +/// failed" log even though the file would vanish a second later. We retry +/// a bounded number of times with a short backoff so the common case +/// (installer releasing the handle) succeeds without burning a slot. +const STAGING_DELETE_MAX_ATTEMPTS: u32 = 3; + +/// Per-attempt sleep between staging-delete retries. Long enough for +/// Sony's installer to release its file handle on the staged pkg, short +/// enough that the spawn_blocking worker doesn't park the pool. +const STAGING_DELETE_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); + +/// Whether a staging-delete error is worth retrying. Only the bare +/// `fs_delete_failed` token (Sony's installer still holding the file +/// open) qualifies — path-not-allowed, too-many-inflight, socket +/// timeout, or cancellation won't resolve on retry and should surface +/// immediately so the user sees the real cause. Exported as a pure fn +/// so the decision can be unit-tested without a live PS5 socket. +fn is_retryable_delete_error(err_str: &str) -> bool { + err_str.contains("fs_delete_failed") +} + +/// Delete a staged `.pkg` with a bounded retry on `fs_delete_failed`. +/// The payload sends that bare token when `rm_rf` returns non-zero — on +/// FW 10.40+ this is almost always Sony's installer still holding the +/// file open moments after the install completed (or was rejected), not +/// a genuine filesystem error. Retrying mirrors elf-arsenal's +/// `wait_for_install_row` settle window. +/// +/// Returns Ok(()) if the file is gone (either deleted or already absent) +/// or the last error if all attempts failed. Logs each retry at warn so +/// a wedged console is still visible. The `label` is included in logs to +/// distinguish the three call sites (register-reject / terminal / cancel). +fn delete_staging_with_retry(addr: &str, path: &str, label: &str) -> Result<(), String> { + let mut last_err: Option = None; + for attempt in 1..=STAGING_DELETE_MAX_ATTEMPTS { + match ps5upload_core::fs_ops::fs_delete_with_timeout( + addr, + path, + Some(std::time::Duration::from_secs(10)), + ) { + Ok(()) => { + if attempt > 1 { + crate::log_info!( + "staging cleaned after retry: label={} addr={} path={} attempts={}", + label, + addr, + path, + attempt + ); + } + return Ok(()); + } + Err(e) => { + let err_str = format!("{e:#}"); + // Only retry on the bare `fs_delete_failed` token — a + // genuine path-not-allowed, too-many-inflight, or socket + // timeout won't resolve on retry and should surface + // immediately so the user sees the real cause. + let retryable = is_retryable_delete_error(&err_str); + last_err = Some(err_str); + if !retryable || attempt == STAGING_DELETE_MAX_ATTEMPTS { + crate::log_warn!( + "staging cleanup failed: label={} addr={} path={} attempt={}/{} err={}", + label, + addr, + path, + attempt, + STAGING_DELETE_MAX_ATTEMPTS, + e + ); + break; + } + crate::log_warn!( + "staging cleanup retrying: label={} addr={} path={} attempt={}/{} (installer may still hold the file) err={}", + label, + addr, + path, + attempt, + STAGING_DELETE_MAX_ATTEMPTS, + e + ); + std::thread::sleep(STAGING_DELETE_BACKOFF); + } + } + } + Err(last_err.unwrap_or_else(|| "unknown error".to_string())) +} + #[derive(Debug, Serialize)] pub struct InstallStartResponse { pub session_id: String, @@ -430,39 +531,22 @@ async fn install_start_handler( p.to_string() } _ => { - // Pick the LAN IP this host presents to the PS5. Multi-NIC - // safe: bind a UDP socket "connected" to the PS5's mgmt - // addr and read the local addr — that's the IP the OS - // picked for outbound. - // - // strip_host_port handles both `1.2.3.4:9114` and - // `[2001:db8::1]:9114` correctly; the naïve `split(':').next()` - // we used before truncated IPv6 to `[` because IPv6 addresses - // themselves contain colons. - let ps5_host_only = strip_host_port(&req.ps5_addr); - let local_ip = match lan_ip_for_ps5(&ps5_host_only) { - Ok(ip) => ip, + // Pick the LAN IP this host presents to the PS5 and build the + // /pkg-host/ URL Sony's installer will fetch. Centralised in + // pkg_host_url_for so the direct-install (DPI) path constructs + // an identical URL — a divergence would silently break the + // installer's header cross-check (0x80B21106). + let url = match pkg_host_url_for(&req.ps5_addr, &session_id, &head_meta.content_id) { + Ok(u) => u, Err(e) => { + let ps5_host_only = strip_host_port(&req.ps5_addr); return json_err( StatusCode::INTERNAL_SERVER_ERROR, &format!("could not determine local LAN IP for PS5 {ps5_host_only}: {e}"), - ) + ); } }; - let host_port = std::env::var("PS5UPLOAD_ENGINE_PORT") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(19113); - // Filename canonicalisation: when the pkg header carries a - // valid content_id we emit `.pkg` instead of a - // generic `file.pkg`. Sony's installer cross-checks the - // URL's filename component against the pkg header at - // register time; mismatched names get 0x80B21106 on some - // FW points, especially for user-renamed pkgs (e.g. - // "Cool Game.pkg" → header says IV0000-PPSAxxxxx_00-...). - // pkg_url_filename also sanitises against URL-meta chars. - let url_filename = pkg_url_filename(&head_meta.content_id); - format!("http://{local_ip}:{host_port}/pkg-host/{session_id}/{url_filename}") + url } }; @@ -703,34 +787,23 @@ async fn install_start_handler( let addr = req.ps5_addr.clone(); let sid = session_id.clone(); tokio::task::spawn_blocking(move || { - // 10s cap (2.9.0). Bare fs_delete inherits Connection's - // 30s socket timeout and additionally waits for FS_DELETE_ACK - // — a wedged PS5 (kernel hang, payload crashed during cleanup, - // unreachable LAN) parks a blocking-pool worker AND a TCP - // socket for the full duration. Repeated register-rejects - // or cancels could stack against a wedged console and - // exhaust the default 512-thread blocking pool. A single- - // file staging .pkg cleanup completes in <1s normally; - // 10s is generous enough that healthy paths never trip it. - if let Err(e) = ps5upload_core::fs_ops::fs_delete_with_timeout( - &addr, - &path, - Some(std::time::Duration::from_secs(10)), - ) { - crate::log_warn!( + // Retry on `fs_delete_failed` — Sony's installer briefly + // holds the staged pkg open after a register-reject on + // FW 10.40+; see delete_staging_with_retry. + match delete_staging_with_retry(&addr, &path, "register-reject") { + Ok(()) => crate::log_info!( + "register-reject staging cleaned: session={} addr={} path={}", + sid, + addr, + path + ), + Err(e) => crate::log_warn!( "register-reject staging cleanup failed: session={} addr={} path={} err={}", sid, addr, path, e - ); - } else { - crate::log_info!( - "register-reject staging cleaned: session={} addr={} path={}", - sid, - addr, - path - ); + ), } }); } @@ -1377,28 +1450,17 @@ async fn install_status_handler( if let Some(path) = path_to_clean { let addr = ps5_addr.clone(); tokio::task::spawn_blocking(move || { - // 10s cap (2.9.0). Bare fs_delete inherits Connection's - // 30s socket timeout and additionally waits for FS_DELETE_ACK - // — a wedged PS5 (kernel hang, payload crashed during cleanup, - // unreachable LAN) parks a blocking-pool worker AND a TCP - // socket for the full duration. Repeated register-rejects - // or cancels could stack against a wedged console and - // exhaust the default 512-thread blocking pool. A single- - // file staging .pkg cleanup completes in <1s normally; - // 10s is generous enough that healthy paths never trip it. - if let Err(e) = ps5upload_core::fs_ops::fs_delete_with_timeout( - &addr, - &path, - Some(std::time::Duration::from_secs(10)), - ) { - crate::log_warn!( + // Retry on `fs_delete_failed` — Sony's installer briefly + // holds the staged pkg open right after terminal_complete + // on FW 10.40+; see delete_staging_with_retry. + match delete_staging_with_retry(&addr, &path, "terminal") { + Ok(()) => crate::log_info!("staging cleaned: addr={} path={}", addr, path), + Err(e) => crate::log_warn!( "staging cleanup failed: addr={} path={} err={}", addr, path, e - ); - } else { - crate::log_info!("staging cleaned: addr={} path={}", addr, path); + ), } }); } @@ -1529,26 +1591,23 @@ async fn install_cancel_handler( if let Some(path) = path_to_clean { let sid = req.session.clone(); tokio::task::spawn_blocking(move || { - // 10s cap — see staging cleanup comment above for rationale. - if let Err(e) = ps5upload_core::fs_ops::fs_delete_with_timeout( - &ps5_addr, - &path, - Some(std::time::Duration::from_secs(10)), - ) { - crate::log_warn!( + // Retry on `fs_delete_failed` — Sony's installer may briefly + // hold the staged pkg open when a cancel lands mid-install; + // see delete_staging_with_retry. + match delete_staging_with_retry(&ps5_addr, &path, "cancel") { + Ok(()) => crate::log_info!( + "cancel staging cleaned: session={} addr={} path={}", + sid, + ps5_addr, + path + ), + Err(e) => crate::log_warn!( "cancel staging cleanup failed: session={} addr={} path={} err={}", sid, ps5_addr, path, e - ); - } else { - crate::log_info!( - "cancel staging cleaned: session={} addr={} path={}", - sid, - ps5_addr, - path - ); + ), } }); } @@ -1572,17 +1631,84 @@ pub struct DpiInstallRequest { #[derive(Debug, Serialize)] pub struct DpiInstallResponse { - /// True when the daemon accepted the install (`rc == 0`). + /// True when the daemon accepted the install (`ok` reply). pub ok: bool, - /// The daemon's `sceAppInstUtilAppInstallPkg` return code. + /// The daemon's `sceAppInstUtilInstallByPackage` return code, or -1 + /// when the daemon never reached the install call (init/recv/badpath). pub rc: i32, + /// True when the daemon could not initialize AppInstUtil. Distinct + /// from a Sony-side reject: an init failure means the daemon is in + /// fallback mode and retrying will likely fail the same way until + /// the underlying IPMI/kstuff issue resolves. + pub init_failed: bool, pub err_message: Option, } +/// One parsed reply from the DPI daemon. The daemon replies in the +/// reference's ok/error form (elf-arsenal payloads-src/dpi/main.c): +/// "ok" — InstallByPackage accepted +/// "error:0x%08X" — InstallByPackage rejected with rc +/// "error:init:0x%08X" — sceAppInstUtilInitialize failed with rc +/// "error:init:timeout" — sceAppInstUtilInitialize timed out +/// "error:badpath" — path rejected by the daemon's safety check +/// "error:recv" — daemon saw no valid input on the socket +/// The old decimal-only form ("0", "-2147003130") is still accepted for +/// backward compatibility with older daemons still deployed on a console. +enum DpiReply { + Ok, + InstallReject(i32), + InitFailed(Option), // None = timeout + BadPath, + RecvError, + Unknown(String), +} + +fn parse_dpi_reply(s: &str) -> DpiReply { + let t = s.trim(); + if t == "ok" || t == "0" { + return DpiReply::Ok; + } + if let Some(rest) = t.strip_prefix("error:init:") { + if rest == "timeout" { + return DpiReply::InitFailed(None); + } + // Sony error codes have the high bit set (e.g. 0x80B21106) and + // overflow i32 — parse as u32 then cast so the negative i32 + // representation matches what InstallByPackage actually returns. + if let Ok(rc) = u32::from_str_radix(rest.trim_start_matches("0x"), 16) { + return DpiReply::InitFailed(Some(rc as i32)); + } + return DpiReply::Unknown(t.to_string()); + } + if let Some(rest) = t.strip_prefix("error:0x") { + if let Ok(rc) = u32::from_str_radix(rest, 16) { + return DpiReply::InstallReject(rc as i32); + } + return DpiReply::Unknown(t.to_string()); + } + if t == "error:badpath" { + return DpiReply::BadPath; + } + if t == "error:recv" { + return DpiReply::RecvError; + } + // Backward-compat: old daemon replied with a bare decimal rc. + if let Ok(rc) = t.parse::() { + return if rc == 0 { + DpiReply::Ok + } else { + DpiReply::InstallReject(rc) + }; + } + DpiReply::Unknown(t.to_string()) +} + /// Connect to the PS5 DPI daemon on `:9040`, send one line (the staged -/// local path), and read back the decimal return code. The daemon runs -/// `sceAppInstUtilAppInstallPkg(path)` from its own clean loader process. -fn dpi_send(ps5_ip: &str, line: &str) -> std::io::Result { +/// local path or an http(s):// URL), and read back the daemon's reply. +/// The daemon runs `sceAppInstUtilInstallByPackage(uri)` from its own +/// clean loader process, with a timed sceAppInstUtilInitialize + retry +/// so a cold install can never wedge IPMI (issue #152 root cause). +fn dpi_send(ps5_ip: &str, line: &str) -> std::io::Result { use std::io::{Read, Write}; use std::net::ToSocketAddrs; let sa = format!("{ps5_ip}:9040") @@ -1591,19 +1717,13 @@ fn dpi_send(ps5_ip: &str, line: &str) -> std::io::Result { .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "resolve :9040 failed"))?; let mut s = std::net::TcpStream::connect_timeout(&sa, std::time::Duration::from_secs(5))?; s.set_write_timeout(Some(std::time::Duration::from_secs(10)))?; - // AppInstallPkg can take a moment to ingest the pkg before replying. + // InstallByPackage can take a moment to ingest the pkg before replying. s.set_read_timeout(Some(std::time::Duration::from_secs(120)))?; s.write_all(line.as_bytes())?; s.write_all(b"\n")?; let mut buf = String::new(); s.read_to_string(&mut buf)?; - let t = buf.trim(); - t.parse::().map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("non-numeric DPI reply: {t:?}"), - ) - }) + Ok(parse_dpi_reply(&buf)) } async fn dpi_install_handler(Json(req): Json) -> Response { @@ -1621,16 +1741,78 @@ async fn dpi_install_handler(Json(req): Json) -> Response { - if rc == 0 { - crate::log_info!("dpi-install ok (rc=0)"); - } else { - crate::log_warn!("dpi-install rejected rc=0x{:08x}", rc as u32); + Ok(Ok(reply)) => { + let (ok, rc, init_failed, err_message) = match reply { + DpiReply::Ok => (true, 0, false, None), + DpiReply::InstallReject(rc) => { + crate::log_warn!("dpi-install rejected rc=0x{:08x}", rc as u32); + ( + false, + rc, + false, + err_code_message(rc as u32).map(|s| s.to_string()), + ) + } + DpiReply::InitFailed(Some(rc)) => { + crate::log_warn!("dpi-install init failed rc=0x{:08x}", rc as u32); + ( + false, + -1, + true, + Some(format!( + "sceAppInstUtilInitialize failed: 0x{:08X}", + rc as u32 + )), + ) + } + DpiReply::InitFailed(None) => { + crate::log_warn!("dpi-install init timed out"); + ( + false, + -1, + true, + Some( + "sceAppInstUtilInitialize timed out (IPMI backend not ready)" + .to_string(), + ), + ) + } + DpiReply::BadPath => { + crate::log_warn!("dpi-install daemon rejected path"); + ( + false, + -1, + false, + Some("daemon rejected the path (unsafe)".to_string()), + ) + } + DpiReply::RecvError => { + crate::log_warn!("dpi-install daemon saw no valid input"); + ( + false, + -1, + false, + Some("daemon received no valid input".to_string()), + ) + } + DpiReply::Unknown(s) => { + crate::log_warn!("dpi-install unknown reply: {:?}", s); + ( + false, + -1, + false, + Some(format!("unexpected daemon reply: {s}")), + ) + } + }; + if ok { + crate::log_info!("dpi-install ok"); } json_ok(&DpiInstallResponse { - ok: rc == 0, + ok, rc, - err_message: err_code_message(rc as u32).map(|s| s.to_string()), + init_failed, + err_message, }) } Ok(Err(e)) => json_err( @@ -1641,7 +1823,152 @@ async fn dpi_install_handler(Json(req): Json) -> Response, + Json(req): Json, +) -> Response { + let ps5_ip = strip_host_port(&req.ps5_addr); + if ps5_ip.is_empty() { + return json_err(StatusCode::BAD_REQUEST, "ps5_addr is required"); + } + + // Look up the session to get the content_id (for the canonical + // pkg-host filename). Hold the lock only long enough to clone what + // we need — the DPI send below is blocking and must not hold the + // sessions mutex. + let content_id = { + let sessions = state.sessions.lock().unwrap_or_else(|e| e.into_inner()); + match sessions.get(&req.session_id) { + Some(s) => s.content_id.clone(), + None => { + return json_err( + StatusCode::NOT_FOUND, + &format!("no pkg-host session {}", req.session_id), + ) + } + } + }; + + let url = match pkg_host_url_for(&req.ps5_addr, &req.session_id, &content_id) { + Ok(u) => u, + Err(e) => { + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not build pkg-host URL for PS5 {ps5_ip}: {e}"), + ) + } + }; + + crate::log_info!( + "dpi-direct-install: ps5={} session={} url={}", + ps5_ip, + req.session_id, + url + ); + let res = tokio::task::spawn_blocking(move || dpi_send(&ps5_ip, &url)).await; + match res { + Ok(Ok(reply)) => { + let (ok, rc, init_failed, err_message) = match reply { + DpiReply::Ok => (true, 0, false, None), + DpiReply::InstallReject(rc) => { + crate::log_warn!("dpi-direct-install rejected rc=0x{:08x}", rc as u32); + ( + false, + rc, + false, + err_code_message(rc as u32).map(|s| s.to_string()), + ) + } + DpiReply::InitFailed(Some(rc)) => { + crate::log_warn!("dpi-direct-install init failed rc=0x{:08x}", rc as u32); + ( + false, + -1, + true, + Some(format!( + "sceAppInstUtilInitialize failed: 0x{:08X}", + rc as u32 + )), + ) + } + DpiReply::InitFailed(None) => { + crate::log_warn!("dpi-direct-install init timed out"); + ( + false, + -1, + true, + Some( + "sceAppInstUtilInitialize timed out (IPMI backend not ready)" + .to_string(), + ), + ) + } + DpiReply::BadPath => { + crate::log_warn!("dpi-direct-install daemon rejected URL"); + ( + false, + -1, + false, + Some("daemon rejected the URL (unsafe)".to_string()), + ) + } + DpiReply::RecvError => { + crate::log_warn!("dpi-direct-install daemon saw no valid input"); + ( + false, + -1, + false, + Some("daemon received no valid input".to_string()), + ) + } + DpiReply::Unknown(s) => { + crate::log_warn!("dpi-direct-install unknown reply: {:?}", s); + ( + false, + -1, + false, + Some(format!("unexpected daemon reply: {s}")), + ) + } + }; + if ok { + crate::log_info!("dpi-direct-install ok"); + } + json_ok(&DpiInstallResponse { + ok, + rc, + init_failed, + err_message, + }) + } + Ok(Err(e)) => json_err( + StatusCode::BAD_GATEWAY, + &format!("DPI daemon (:9040) not reachable / errored: {e}"), + ), + Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &format!("task: {e}")), + } +} async fn serve_handler( State(state): State, @@ -1957,6 +2284,29 @@ pub fn lan_ip_for_ps5(ps5_host: &str) -> std::io::Result { Ok(sock.local_addr()?.ip()) } +/// Build the engine's `/pkg-host/{session}/{filename}` URL as the PS5 +/// will fetch it. Picks the LAN IP this host presents to the PS5 (multi- +/// NIC safe), stamps the engine port, and canonicalises the filename +/// from the pkg's content_id so Sony's installer header cross-check +/// passes. Returns an Err with a human-readable cause when the LAN IP +/// can't be determined (e.g. PS5 host unresolvable). +/// +/// Shared by the regular install-start flow (which embeds the URL in +/// the BGFT register request) and the direct/streaming install flow +/// (which hands the URL to the DPI daemon instead of a local path). +fn pkg_host_url_for(ps5_addr: &str, session_id: &str, content_id: &str) -> std::io::Result { + let ps5_host_only = strip_host_port(ps5_addr); + let local_ip = lan_ip_for_ps5(&ps5_host_only)?; + let host_port = std::env::var("PS5UPLOAD_ENGINE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(19113); + let url_filename = pkg_url_filename(content_id); + Ok(format!( + "http://{local_ip}:{host_port}/pkg-host/{session_id}/{url_filename}" + )) +} + /// Last-ditch fallback when a `Response::builder()` chain fails. The /// builders in this file only set statically valid headers, so this is /// unreachable in practice — but one engine process serves every @@ -2260,6 +2610,53 @@ mod tests { assert_eq!(staging_path_for(&None, false), None); } + // ── is_retryable_delete_error (the fs_delete_failed retry decision) ── + + #[test] + fn retryable_delete_error_on_fs_delete_failed_token() { + // The bare token the payload sends when rm_rf returns non-zero + // (Sony's installer still holding the staged pkg open). This is + // the ONLY case we retry — it resolves on its own in ~1-2s. + assert!(is_retryable_delete_error( + "payload rejected FS_DELETE: fs_delete_failed" + )); + } + + #[test] + fn retryable_delete_error_not_on_path_not_allowed() { + // A genuine allowlist rejection — won't resolve on retry. + assert!(!is_retryable_delete_error( + "payload rejected FS_DELETE: fs_delete_path_not_allowed" + )); + } + + #[test] + fn retryable_delete_error_not_on_too_many_inflight() { + // All MAX_FS_OPS slots busy — retrying immediately won't help. + assert!(!is_retryable_delete_error( + "payload rejected FS_DELETE: fs_delete_too_many_inflight" + )); + } + + #[test] + fn retryable_delete_error_not_on_socket_timeout() { + // A wedged console / network error — surfacing immediately is + // more useful than silently retrying for 6s. + assert!(!is_retryable_delete_error( + "read frame header: Resource temporarily unavailable (os error 11)" + )); + assert!(!is_retryable_delete_error("connection reset by peer")); + } + + #[test] + fn retryable_delete_error_not_on_cancellation() { + // User hit Stop — cancellation is intentional, not retryable. + assert!(!is_retryable_delete_error("cancelled")); + assert!(!is_retryable_delete_error( + "payload rejected FS_DELETE: fs_delete_cancelled" + )); + } + #[test] fn install_start_request_delete_staging_defaults_true() { // Back-compat: an older client that omits delete_staging must keep the @@ -2322,6 +2719,43 @@ mod tests { ); } + #[test] + fn pkg_host_url_for_builds_canonical_url() { + // Loopback always resolves — exercises the full URL assembly + // (LAN IP lookup, port stamping, filename canonicalisation, path + // pattern) that both install-start and dpi-direct-install share. + // Pin the shape so a divergence between the two routes would + // break Sony's installer header cross-check visibly here. + let url = pkg_host_url_for( + "127.0.0.1:9114", + "abc-123", + "UP9000-CUSA12345_00-GAMECONTENT12345", + ) + .expect("loopback should resolve"); + assert!( + url.starts_with("http://127.0.0.1:"), + "URL should target loopback: {url}" + ); + assert!( + url.ends_with("/pkg-host/abc-123/UP9000-CUSA12345_00-GAMECONTENT12345.pkg"), + "URL should carry session + canonical filename: {url}" + ); + } + + #[test] + fn pkg_host_url_for_uses_env_port_when_set() { + // The engine port is overridable via PS5UPLOAD_ENGINE_PORT; the + // direct-install URL must honour it so the daemon fetches from + // the same port the engine is actually listening on. + std::env::set_var("PS5UPLOAD_ENGINE_PORT", "29113"); + let url = pkg_host_url_for("127.0.0.1:9114", "s", "IV0001-X").expect("loopback"); + std::env::remove_var("PS5UPLOAD_ENGINE_PORT"); + assert!( + url.starts_with("http://127.0.0.1:29113/"), + "URL should use env-overridden port: {url}" + ); + } + #[test] fn pkg_url_filename_fallback_when_empty() { assert_eq!(pkg_url_filename(""), "file.pkg"); @@ -2556,4 +2990,89 @@ mod tests { ); assert!(sessions.contains_key("before-panic")); } + + // ── DPI daemon reply parser (the FW-10.40 helper-death fix, #152) ── + // + // The daemon now replies in the reference's ok/error form so the + // engine can tell accept from reject from init-failure. These pin + // every branch of the parser so a future daemon change can't + // silently regress to "treat init-failure as install-success". + + #[test] + fn dpi_parse_ok() { + assert!(matches!(parse_dpi_reply("ok"), DpiReply::Ok)); + // trailing whitespace / newline tolerated + assert!(matches!(parse_dpi_reply("ok\n"), DpiReply::Ok)); + assert!(matches!(parse_dpi_reply(" ok\r\n"), DpiReply::Ok)); + } + + #[test] + fn dpi_parse_install_reject() { + // 0x80B21106 — the FW-11/12 authid gate (the expected first-attempt + // rejection that triggers the DPI fallback in the first place). + assert!(matches!( + parse_dpi_reply("error:0x80B21106"), + DpiReply::InstallReject(rc) if rc as u32 == 0x80B21106 + )); + assert!(matches!( + parse_dpi_reply("error:0x80b21106\n"), + DpiReply::InstallReject(rc) if rc as u32 == 0x80B21106 + )); + } + + #[test] + fn dpi_parse_init_failed_with_rc() { + // sceAppInstUtilInitialize returned a Sony error — daemon is in + // fallback mode and retrying will likely fail the same way. + assert!(matches!( + parse_dpi_reply("error:init:0x80B21106"), + DpiReply::InitFailed(Some(rc)) if rc as u32 == 0x80B21106 + )); + } + + #[test] + fn dpi_parse_init_timeout() { + // timed_init returned the -0xDEAD sentinel. Distinct from a Sony + // error code — IPMI backend never came up. + assert!(matches!( + parse_dpi_reply("error:init:timeout"), + DpiReply::InitFailed(None) + )); + } + + #[test] + fn dpi_parse_badpath_and_recv() { + assert!(matches!( + parse_dpi_reply("error:badpath"), + DpiReply::BadPath + )); + assert!(matches!(parse_dpi_reply("error:recv"), DpiReply::RecvError)); + } + + #[test] + fn dpi_parse_legacy_decimal_ok() { + // Backward compat: an older daemon still deployed on a console + // replies with a bare decimal rc. "0" must map to Ok. + assert!(matches!(parse_dpi_reply("0"), DpiReply::Ok)); + assert!(matches!(parse_dpi_reply("0\n"), DpiReply::Ok)); + } + + #[test] + fn dpi_parse_legacy_decimal_reject() { + // Legacy decimal reject: 0x80B21106 reinterpreted as i32 is + // -2135813882 (i32::MIN + 0x7F8AAFAE + ... — two's complement). + let expected: i32 = 0x80B21106_u32 as i32; + assert!(matches!( + parse_dpi_reply(&expected.to_string()), + DpiReply::InstallReject(rc) if rc as u32 == 0x80B21106 + )); + } + + #[test] + fn dpi_parse_unknown_is_not_ok() { + // An unrecognised reply must NEVER parse as Ok (would mask a + // real failure as success) — it falls through to Unknown. + assert!(matches!(parse_dpi_reply("error:???"), DpiReply::Unknown(_))); + assert!(matches!(parse_dpi_reply(""), DpiReply::Unknown(_))); + } } diff --git a/payload/dpi/Makefile b/payload/dpi/Makefile index f4fac5eb..62cfb811 100644 --- a/payload/dpi/Makefile +++ b/payload/dpi/Makefile @@ -19,8 +19,10 @@ CFLAGS := -Wall -Wextra -Werror -g # to load the ELF (no run, no :9040 bind — the "silent reject" symptom). # Linking the same set the main payload uses (which loads cleanly on every # supported FW) guarantees the dependency graph resolves. +# -lpthread is needed for the timed_init thread (boot-timing fix for the +# FW-10.40 helper-death symptom, issue #152). LIBS := -lSceSystemService -lSceUserService -lSceAppInstUtil \ - -lSceNotification -lkernel_sys + -lSceNotification -lkernel_sys -lpthread SRCS := ezremote_dpi.c HEADERS := sceAppInstUtil.h diff --git a/payload/dpi/ezremote-dpi.elf b/payload/dpi/ezremote-dpi.elf index ee70f1b4..8a3d3dd6 100755 Binary files a/payload/dpi/ezremote-dpi.elf and b/payload/dpi/ezremote-dpi.elf differ diff --git a/payload/dpi/ezremote-dpi.elf.gz b/payload/dpi/ezremote-dpi.elf.gz index 020a6653..bca5c0cb 100755 Binary files a/payload/dpi/ezremote-dpi.elf.gz and b/payload/dpi/ezremote-dpi.elf.gz differ diff --git a/payload/dpi/ezremote_dpi.c b/payload/dpi/ezremote_dpi.c index 7f09ffe6..279eaa41 100644 --- a/payload/dpi/ezremote_dpi.c +++ b/payload/dpi/ezremote_dpi.c @@ -3,6 +3,8 @@ * * Ported to plain C from cy33hc/ps5-ezremote-dpi (source/main.cpp), * which is in turn modelled on etaHEN's DPI. Credit: cy33hc. + * The boot-timing + init-retry pattern is ported from elf-arsenal's + * payloads-src/dpi/main.c (soniciso/elf-arsenal). * * Why ps5upload ships this as a SEPARATE payload rather than calling * sceAppInstUtilInstallByPackage from the main ps5upload.elf: the main @@ -18,9 +20,26 @@ * * Protocol (matches the reference so it stays drop-in compatible with * the wider scene tooling): connect to TCP :9040, send one line — an - * http(s):// URL, or "stop" to shut the daemon down — terminated by - * \n or \r. The daemon replies with the decimal InstallByPackage - * return code ("0" on accept) and closes the connection. + * http(s):// URL, a bare local path, or "stop" to shut the daemon down + * — terminated by \n or \r. The daemon replies with one of: + * "ok" — InstallByPackage accepted (rc == 0) + * "error:0x%08X" — InstallByPackage rejected with rc + * "error:init:0x%08X" — sceAppInstUtilInitialize failed with rc + * "error:init:timeout" — sceAppInstUtilInitialize timed out + * "error:badpath" — path rejected by safety check + * and closes the connection. + * + * Boot timing + init retry (the fix for the FW-10.40 "helper dies ~4s + * after the first install is rejected" symptom, issue #152): + * kstuff spawns last in the payload chain and applies kernel patches + * that sceAppInstUtilInitialize depends on. This daemon sleeps 25 s at + * startup (500 ms steps) to let those patches land, then runs + * sceAppInstUtilInitialize on a detached thread with a 10 s timeout. + * If startup init failed (IPMI backend not ready yet, or FW-11+ + * SYSTEM_AUTHID gate), every incoming install request retries init + * once before attempting the install. Calling InstallByPackage COLD + * (no init) leaves IPMI state half-wedged and Sony's watchdog kills + * the helper a few seconds later — exactly the symptom users report. */ /* NOTE: no `#undef main` here (the reference had one for a different * loader). The PS5 Payload SDK's crt wraps `main` to wire up the payload @@ -35,14 +54,19 @@ #include #include #include +#include +#include #include #include #include #include "sceAppInstUtil.h" -#define BUF_SIZE 4096 -#define PORT 9040 +#define BUF_SIZE 4096 +#define PORT 9040 +#define INIT_TIMEOUT 10 /* seconds to wait for Initialize */ +#define BOOT_WAIT_MS 500 /* per-step boot sleep */ +#define BOOT_WAIT_STEPS 50 /* 50 * 500ms = 25s total */ /* Sony toast helper — same shape the reference uses. */ typedef struct notify_request { @@ -62,16 +86,99 @@ static void notify(const char *fmt, ...) { sceKernelSendNotificationRequest(0, &req, sizeof req, 0); } +/* ── timed sceAppInstUtilInitialize (ported from elf-arsenal's DPI v1) ── */ +static volatile int g_init_done = 0; +static volatile int g_init_rc = -1; + +static void *init_thread(void *arg) { + (void)arg; + g_init_rc = sceAppInstUtilInitialize(); + g_init_done = 1; + return NULL; +} + +/* Runs sceAppInstUtilInitialize on a detached thread with a timeout. + * Returns the init rc on success, or -0xDEAD on timeout so callers can + * distinguish "init failed with Sony error X" from "init hung". */ +#define INIT_TIMEOUT_SENTINEL (-0xDEAD) +static int timed_init(void) { + pthread_t tid; + g_init_done = 0; + g_init_rc = -1; + if (pthread_create(&tid, NULL, init_thread, NULL) != 0) + return -1; + pthread_detach(tid); + + for (int i = 0; i < INIT_TIMEOUT * 10; i++) { + if (g_init_done) + return g_init_rc; + struct timespec ts = {0, 100000000}; /* 100 ms */ + nanosleep(&ts, NULL); + } + return INIT_TIMEOUT_SENTINEL; +} + +/* ── path safety + rewrite ───────────────────────────────────────────── */ + +/* Reject path traversal and obviously broken inputs before handing them + * to Sony's installer. Mirrors elf-arsenal's pkg_filename_is_safe + * (homebrew.c:1497) for the local-path case; URLs are accepted as-is. */ +static int path_is_safe(const char *s) { + if (s == NULL || s[0] == '\0') return 0; + /* http(s):// URLs are validated by Sony's URI parser; let them through. */ + if (strncmp(s, "http://", 7) == 0 || strncmp(s, "https://", 8) == 0) + return 1; + /* Bare local path. */ + if (s[0] != '/') return 0; + if (strstr(s, "..") != NULL) return 0; + size_t n = strlen(s); + if (n >= BUF_SIZE) return 0; + return 1; +} + +/* Sony's install service runs in a sandbox that sees the filesystem + * rooted at /user, so a file at /data/foo on the payload's view is at + * /user/data/foo from the installer's view. Rewrite the path so the + * installer can actually find the staged pkg. URLs pass through + * unchanged. Ported from elf-arsenal's rewrite_path_for_install + * (homebrew.c:425). */ +static void rewrite_path_for_install(const char *in, char *out, size_t out_size) { + if (strncmp(in, "/data/", 6) == 0) { + snprintf(out, out_size, "/user%s", in); /* /data/foo -> /user/data/foo */ + return; + } + strncpy(out, in, out_size - 1); + out[out_size - 1] = '\0'; +} + int main(void) { int server_fd, new_socket, ret; struct sockaddr_in address; socklen_t addrlen = sizeof(address); char buffer[BUF_SIZE]; + char fixed[BUF_SIZE]; char *pos; PlayGoInfo playgo_info; SceAppInstallPkgInfo pkg_info; MetaInfo metainfo; + /* Boot-timing wait: let kstuff apply kernel patches before we touch + * AppInstUtil. Without this, on FW where kstuff loads after us, + * sceAppInstUtilInitialize hits unpatched kernel paths and either + * hangs or returns an error that leaves IPMI wedged. */ + { + struct timespec ts = {0, BOOT_WAIT_MS * 1000000L}; /* 500 ms */ + for (int i = 0; i < BOOT_WAIT_STEPS; i++) /* 25 s total */ + nanosleep(&ts, NULL); + } + fprintf(stderr, "[dpi] startup wait done, calling sceAppInstUtilInitialize\n"); + int init_rc = timed_init(); + fprintf(stderr, "[dpi] sceAppInstUtilInitialize rc=0x%x%s\n", + (unsigned)init_rc, + init_rc == 0 ? " (ok)" : + (init_rc == INIT_TIMEOUT_SENTINEL ? " (timeout — fallback mode)" : + " (fallback mode)")); + server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { notify("ezRemote DPI create socket failed"); @@ -128,14 +235,48 @@ int main(void) { break; } - notify("ezRemote DPI received:\n%s", buffer); + /* If startup init failed (or timed out), retry once now — + * IPMI may have become available since boot. Calling + * InstallByPackage cold leaves IPMI half-wedged and trips + * Sony's watchdog a few seconds later. */ + if (init_rc != 0) { + fprintf(stderr, "[dpi] retrying init (prev rc=0x%x)\n", + (unsigned)init_rc); + init_rc = timed_init(); + } + if (init_rc != 0) { + char err[64]; + if (init_rc == INIT_TIMEOUT_SENTINEL) + snprintf(err, sizeof(err), "error:init:timeout"); + else + snprintf(err, sizeof(err), "error:init:0x%08X", + (unsigned)init_rc); + fprintf(stderr, "[dpi] init failed, sending %s\n", err); + notify("ezRemote DPI init failed:\n%s", err + 6); + (void)send(new_socket, err, strlen(err), 0); + close(new_socket); + continue; + } + + if (!path_is_safe(buffer)) { + fprintf(stderr, "[dpi] rejected unsafe path\n"); + (void)send(new_socket, "error:badpath", 13, 0); + close(new_socket); + continue; + } + + /* Rewrite /data/ -> /user/data/ so the sandboxed installer + * can see the staged pkg. URLs pass through unchanged. */ + rewrite_path_for_install(buffer, fixed, sizeof(fixed)); + + notify("ezRemote DPI received:\n%s", fixed); memset(&playgo_info, 0, sizeof(playgo_info)); memset(&pkg_info, 0, sizeof(pkg_info)); /* Install via InstallByPackage with the input as the URI — the * etaHEN DPI v2 / upstream ezremote-dpi recipe. A bare local - * absolute path (the staged-pkg case, buffer[0]=='/') and an + * absolute path (the staged-pkg case, fixed[0]=='/') and an * http(s):// URL are BOTH accepted as the uri. * * 2.25.2 — CRITICAL: this used to route local paths to @@ -149,22 +290,37 @@ int main(void) { * chosen to avoid only fires for real http:// URLs that need the * HTTP pre-flight; a bare local path skips it. The caller stages * the .pkg on the console's disk first. */ - metainfo.uri = buffer; + metainfo.uri = fixed; metainfo.ex_uri = ""; metainfo.playgo_scenario_id = ""; metainfo.content_id = ""; - metainfo.content_name = buffer; + metainfo.content_name = fixed; metainfo.icon_url = ""; ret = sceAppInstUtilInstallByPackage(&metainfo, &pkg_info, &playgo_info); if (ret != 0) { - notify("ezRemote DPI install failed\nError Code: 0x%08X", (unsigned)ret); + fprintf(stderr, "[dpi] InstallByPackage rc=0x%08X\n", (unsigned)ret); + notify("ezRemote DPI install failed\nError Code: 0x%08X", + (unsigned)ret); + } else { + fprintf(stderr, "[dpi] InstallByPackage ok\n"); } } - /* Reply with the decimal return code so the caller can tell - * accept (0) from reject. */ - char resp[16]; - int n = snprintf(resp, sizeof(resp), "%d", ret); + /* Reply in the reference's ok/error form so the caller can tell + * accept from reject from init-failure. The engine's + * dpi_install_handler parses this tri-state. */ + char resp[64]; + int n; + if (ret == 0) { + n = snprintf(resp, sizeof(resp), "ok"); + } else if (ret == -1) { + /* recv failed / no valid input — already sent a specific error + * above for the init/badpath cases; for a bare recv failure + * send a generic error so the caller doesn't hang. */ + n = snprintf(resp, sizeof(resp), "error:recv"); + } else { + n = snprintf(resp, sizeof(resp), "error:0x%08X", (unsigned)ret); + } if (n > 0) { (void)send(new_socket, resp, (size_t)n, 0); } diff --git a/payload/dpi/sceAppInstUtil.h b/payload/dpi/sceAppInstUtil.h index 735108bf..78511ec5 100644 --- a/payload/dpi/sceAppInstUtil.h +++ b/payload/dpi/sceAppInstUtil.h @@ -44,6 +44,13 @@ typedef struct { unsigned char unknown[6480]; } PlayGoInfo; +/* One-time initialization of the AppInstUtil/IPMI backend. Must be + * called at least once before InstallByPackage/AppInstallPkg; calling + * InstallByPackage cold (without init) leaves IPMI half-wedged and + * trips Sony's watchdog a few seconds later. The daemon wraps this in + * a timed_init with a 10s timeout + per-request retry. */ +extern int sceAppInstUtilInitialize(void); + /* HTTP-URL installer (ezremote-dpi path): parses meta->uri as a URI and * fetches over HTTP. Gated by Sony's PlayGo HTTP pre-flight on some FW * (0x80B22404), so it's the secondary path. */