From 1230ccfff9845a63370bff5f0b82b5898e9f7605 Mon Sep 17 00:00:00 2001 From: phantomptr Date: Mon, 6 Jul 2026 02:02:45 -0700 Subject: [PATCH] =?UTF-8?q?fix(install):=20DPI=20daemon=20init=20+=20relia?= =?UTF-8?q?bility=20=E2=80=94=20fixes=20the=20FW-10.40=20helper=20crash=20?= =?UTF-8?q?(#152,=20#164,=20#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the elf-arsenal gap-analysis fixes for the install-crash and reliability reports, HARDWARE-VERIFIED on a real FW 9.60 PS5. The headline fix is the #152 root cause: the standalone DPI daemon called sceAppInstUtilInstallByPackage COLD (no sceAppInstUtilInitialize), which wedged Sony's IPMI state and let the watchdog kill the on-console helper ~4 s after a rejected patch install. Payload (payload/dpi/ezremote_dpi.c): - Boot-timing wait (25 s, 500 ms steps) so kstuff's kernel patches land before touching AppInstUtil. - Timed sceAppInstUtilInitialize on a detached thread (10 s timeout) + per-request init-retry. Never calls InstallByPackage cold. - Path safety (reject `..`, non-absolute) + /data/ -> /user/data/ rewrite so the sandboxed installer can see the staged pkg. - Tri-state ok / error:0x%08X / error:init:... / error:badpath / error:recv reply (backward-compatible with the old decimal form). Engine (pkg_install.rs): - parse_dpi_reply — parses the daemon's tri-state (8 unit tests, incl. the high-bit-u32 overflow case and legacy-decimal back-compat). - delete_staging_with_retry — bounded retry on the bare `fs_delete_failed` token (Sony briefly holds the staged pkg open post-install); pure is_retryable_delete_error predicate (5 unit tests). Applied at all three staging-cleanup sites. - /api/pkg/dpi-direct-install — streaming install (beta, #81): serve the pkg at /pkg-host/ and hand the daemon the HTTP URL, no staging upload. Client: - InstallPackage "Stream (beta)" affordance + pkgLibrary installStream / runDpiDirectInstall (routes through browserInvoke for the webui too). - AppShell transfer-port (:9113) liveness probe: logs the up->down transition of the "mgmt up but transfer dead" wedge (read-only diagnostic — does NOT auto-reconnect, so a real crash isn't papered over). connection.ts carries transferAlive. HARDWARE VERIFICATION (FW 9.60 Pro @192.168.86.100): - New DPI daemon loads + binds :9040 without hanging. - Path traversal -> `error:badpath`; well-formed nonexistent path -> `error:0x80020002` (it INITIALIZED, called InstallByPackage, got Sony's real reject — not the cold-call wedge). - CRUX: the main helper stayed ALIVE + ucred_elevated 6 s after the rejected install (the original #152 bundle showed helper death ~4 s after exactly this). Reviewed + gated by me on top of another model's implementation: fixed a `cy33bc`->`cy33hc` credit typo and 22 rustfmt violations in pkg_install.rs that would have failed CI. Full non-HW gate green: payload+DPI -Werror, engine fmt/clippy/78 tests, desktop 89 tests/clippy, client typecheck/lint/770 tests, i18n 18 langs. NOT in scope (documented follow-ups in docs/elf-arsenal-reference-fixes.md): the FW-11+ credential escalation (jb_escalate_pid) — the DPI daemon relies on elfldr's pristine ucred, which is install-capable only below FW 11 (bgft.c:505); fan-threshold persistence (P2-7) and NP/offline accounts (P2-8) feature requests. Co-Authored-By: Claude Opus 4.8 --- client/src-tauri/src/commands/ps5_engine.rs | 19 + client/src-tauri/src/lib.rs | 1 + client/src/layout/AppShell.tsx | 53 +- client/src/lib/browserInvoke.ts | 14 + client/src/lib/uploadStreams.test.ts | 2 + client/src/screens/InstallPackage/index.tsx | 68 ++ client/src/state/auditLog.ts | 1 + client/src/state/connection.ts | 16 + client/src/state/pkgLibrary.ts | 228 ++++++ docs/elf-arsenal-reference-fixes.md | 533 +++++++++++++ .../ps5upload-engine/src/pkg_install.rs | 727 +++++++++++++++--- payload/dpi/Makefile | 4 +- payload/dpi/ezremote-dpi.elf | Bin 114792 -> 117256 bytes payload/dpi/ezremote-dpi.elf.gz | Bin 27208 -> 29497 bytes payload/dpi/ezremote_dpi.c | 184 ++++- payload/dpi/sceAppInstUtil.h | 7 + 16 files changed, 1737 insertions(+), 120 deletions(-) create mode 100644 docs/elf-arsenal-reference-fixes.md 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 ee70f1b43c2e48035999afcb22c2eac623265c99..8a3d3dd61260c480e223fc494f384d41bb3713aa 100755 GIT binary patch delta 28763 zcmd74dt6k-8$UjCmR%N5cL7&HQP&IJFJNlkP%x=QNzw9BrhtHm7u;Ymv~*D*SyvP~ z+FF)ES1VIY{b-tsSF}$SnWm){mKO-i?Pg|u+VAts%vqLG{a&x%_pk4Hu{$&G=Q__k zGv}N+XQ?mnFIn$j78X($NZ1j}3!*<--g`)(bq#M?*ObqjL&J7{I%KrH%tA;_{GUju ziN9=w!s65>eFcT1IBB291FdnqH629anfV@_A((( z##dNP9jwyuv4?4@1(L-%)KWS)^=m?^h{t3<2~ho$Izk-97El4^wlOBOU$)odaWdFZ zybVO9^vApkqXv>6aVC#x zzK`T3@CjHR`mV{sYeP7rr8KY3Qaa_b$21-U9Pz&tF8c+10wQU+f}>bd93{F4)uH?3 zS#fQ~BNDd{lgDGa^d*L&uQv?890K3TevFBwv=nh35|3#oa6;zP-iFM%v!8%?{bq-J zI4;6o1r$edPv9*)h~LDpwmbCFlE$!;MOu_mWDm7uc8gG(qxjU<;3I~y2&aI>$$*ny zLx&Lcn5O-IGTRl^kQVh2qy0F99#bC>Le0gkpy^!pi^ufi7Z8I4)PoX;WL}Vo(`}hJuayPzDvFF32H!3Xm+N+B%r{(45O#XL-xYTgQ1T zpS2Xh0#4h?X(g<6kSp7R=3p4m5s%3a`r(Lo7cO%{q7LNY$cH4roSEXr{!bAZGapcA zo*O!p{|)QVmoSqW!nw$UTm;y|ukr|v1O)N~0*$JRyCYxe!3v)=>Rit`=K-gq_$8n; zJjVGw3ctOXK$ki$FuX~iTa#Mn!|p?|tZ7;!GzT{^0s-a*8i6T{PQ&QJ5TQSov<-;k z@VY-2Df1>&8m9hX$4JHe%<~cQ8Khw8a{^H;ea`qlEh~=WscYu3DHg|dOX01k8f~j# zxX1vL_S#TNAk3g;nHx?@M=YhLc$jn+$HyMidQijGhZe{x*6t^vZgDPidrZrKoLv)- zRw0JB(ha2(q2|>72UIdKHu<%ypy`PJvtbw$)SdzXp@hJJo#!&g%1Dm8@&&eysj+VNemyeF^Vb_3|;}cIU^wbO! zVdk-q*dh9Bwm6o#Ej)qvSv%fsIK=0d=oxmQ;V^R$SW5^00BO13AKD&M<~b3BPEF)P zkV56=%f?e3*qqv5Kf@IS zf>v)?v8*0ff5S>&Ry%qMfkM+fP-YPVp9tKwIACr#rqqM&yEqN5LmzY?;W0f6`dJsu z&}6PT^MLJPU^BI3spT=v1D*ypN6>X=-d&3mR26f z3E_Ydh5+(%F)%u``GYAW-dgxZhoh^3)z~y-PHh2DEY8@Tj-eK3zo%J4`wS~0mqFpF zGX(MRg>Q(?q`~sw_)dL$BF6|V1rxdL;EKjPiV{PoBCtTnN$}58Z5ehd=U0oPgKn@~ z5Z}ul3Sp0f3lERyFn%4v3z^LN(!Ri3Ty2Y}VcuodKVv5Z+NLmj4R%*(vGc@;Up{EG;-OWa@_cV1D3(O zRP#?k4(H&hPU;q-gvm=2N88WV5^?~-*ldz6j|627ZB2jHAw$tx za23Q_K+t@{JB_n#YR{s%bCgF_GXr?T}(m|XC1N9nH*cU3$*fJ)z=mOq`4C~Y~ttJB0?RC@fG z%&>r3OX-<0#>jtQQBsAV7qxFV$w?sYJ->`10P}T|ReKdBV&EHUTpP~)wJy6>Q zp)JYJu&z#M@MsL{$`lPyj}<~hV<@tSx)JIlQNg1^fhX9o4zhyv#{;hA3k9Q%2K%~s zLW&vJ8t+^$B)af}v`yI0+6%h&td!M(pKr!8jU*WGVo57P(+U(JaAfhWcdW z+$1<%GTA{Pa97PuV2m<-`QuV@JX+b|ZWJr?Rl9G3*aNOgvFlLXP81fl#~ zbcg5+MGqsVS#WwFlup5!0^tjTB?VPlq22=O4Zn$mk*F;g30jEUV@UW42o|TtQd$CM zrN3Z3p}{yi0|B_(exU>aVSpVGo3R_*bDBIxg(Y^%R0M7aK%0N=bNYTY20C4MWA?YT zV4d?f6zBTb8UdI2rsutil3p%GTN}JUdcihaN7pNWy~LQ;Lenr&bj+;bYCCHT#T|Uq zK7eXt6`_*pmlvbj(O$ro{vbL$$_rHQUW!1Z0JQmSzNYV=!XCUThA0;1Sd23NQ=k!z z8TXdbKN#IT!YP=1MR@1hutpFGjHpA@wNM{|l8Xc&X?YinT0`*+z?4xKiCtK3P(n|GVhJRWTRLAz~Cl48?Bfmtw0A+6wh5BC|@+ zR)`l!kGhDX5Nx2ZyTX#fnAZ}L#dNn7_fK0t&0lJO-3OL#A(Weeb-8%8j~SL&?+TI9 zlCTJtSlhgS$g6j}fXJ)o6f3LF*&(smuo#6cqUW~EdP8w1A6vt)OJls6l4h>LF2#9) z^eC2Cv0lKq$ACTRq1XzWc8@l^E{0U&$E_sU{S3wLgJ~slv1sOUOJ$VyU0#l6E_nf4 zdJ>MwmtLUSmW{v>0ci7|zae@wTG)yW=C)FT4aF0DO!Y%k_9wlXth`HBFOXi%Mrn~3 zFy;kem!>MV!f=qziNzyH;%2Opp|~DQD?PH{><{wl=ow+f9t8@(4zoXG6!sv%%Thh) zNd(+2L@wmF{z7yqTi8kou#Lyr59it9Wj?kBVV5d|Txp4QCYz>Pyg+){1G`gk6{3Q_X5UWmmp9l0GNq?(8g32flzM49BL@e z^RY1)ZEW-^AhXt>36~d0FZ%)`@x0xO7zc;q;4V{a)UODT-mgWKiXzaUeV&o^PfFoPcW9bI1>57sy5DK_cjb&|$mtX)8DB+qHZj3KQ+dcJgE6pz2JHthLEbKVf zvV8}*52{}c*$Q`4I*W5=ZKD)@$x?jD*2z+MEDHCf1=rX0>0Yk*I^e1Xd#3|2xD;+; zSL{LuX2RCOSDE$WE-M$=&@Ev2vi`0ec5vXbHVXy7)DXM@MiFF>Xks^W_755N5!|(d zt-+S}F{#rwGzwccGva2=Jf;jXOQm+q4?M+*R0{V3>E;WhVfX(ek4K)mvg?E2;>iZ? zT)K%MxV=Hhg8T;0g4nJ_u_rH}v1>G3`+95mk$+%rF;?dV#jCa>=bcn09{=oWW{JKfnD`L+Dcl3gR$UoMVmp zqSG+skp}rl(P>*pn0GTJ`N$H-;Ki_cQ#y%8-zoe(&5-z$V{irlmb8y8N3Qz84Ag+Q zBO=)17#pl_NWpYLnezEteGyZh!OV<|>=~fNhPU0d%HpEbbX$Q*kvJzW0OtXmKCu%n$O4;;!xW|H`*k zK3v?Fu8?mP_ieu(w@Uu88|~!#p6x4r|2-;;==rZ~+rO79p6w%5Hp<@@_i4ZT$A42d zTn=5;S8B=CS^kyn<6CmUsy@<@@0hxC?tfGFxcoDy>vxNJH}_xJerS|aO8Q7w8<@IZ z;JMmAcXp}#SxH~%;YJp@l7D4uC69LWrBq(vh>U12xM2ORhCK+eYGKw?eh0&8md`la z#GL+tTO4805P>?~crE$?^sma1;c zH=OIGvp3~u)G8xzUxVRZ7k3b*kf*U7%%Xii$X~6QKnvu4rQu;Z zU;(El^WAR6755E!R%yqu+8c}*0mM4+v!468|AxH2^kF(*{-(4SO_f`$?N3AG5o_B? zzg?H7t&Q!t=Q>ImiXTLa@DKr>9I!`@iH;lf?|&=rS=)y$kgu+7M<>aDg6tT%&$>Re zjXZ7Lz=*C5JbXBFFvd-}{?;}5^>y8)71vm(XRhqfuKwRxd&%9(dPtOWE-VX|@01Ob z50?eW!^_*rwz7~8|A9W^xtO1S>f)^E_Py`11~+M;@k4o=h}s(0asXMV3KARZ?JTUna9rqyAx8&64$4TM0 zIegiSoUKw}gA*x?gXuTesXJ`=!IBBBplFG-v70S(2~I{x4@p#_MwS%L}A=*W}AD&!k=D;hVcqBG261-gxaTmOuHa zkXi6->zZ7$**s@Ag2OHvinr)MD}3N7&|i2hvx##r83#G=X#s8;H*E8G5*;@^rc>X- z72^QFaUx{ae+oFPtiHmtK)BHe9Cw~ZjvaEVEsZoyzPDwdRIx_xUy&g#`DWLKiY?Um z7;GuQI$9HJ;3wi7-?@JG?-sO4a87h^3B` z>XybNFG25;-w^#81rK=|3XtTCudbA!z5eY9%(njGc)CBAwGyr zJUgawwz=>GfU${O%!JVRPLwFlHvf0d$8zzN?B+ZzInPwVg1I;*r#Z`aCz(^n1Pk6i zUR1HvaF(GL3~*b3OX?u#2*YOy{0BDK25)6g-GKkW^K#j{9qBB2$Gg3y_Nnrv zcSlAZJjZUsK8`>;y{1%qC9eY=bndw(-D6z?jTr$Jezr{ zaUk?*2P6)U9>SURZyl41w-2I^$oscP(8cnF?dG;qPOvk7$sQ(mu>LPtZA^DRlUwbG z3=LQGBOWBUmDX> z*6!+U-vSL55_qZ^hYx18mq3E0TpAlG(?eNJ9GUfJ;88X(9a5O!@i(rxofjyPg|j{M z@eR&)_+z1~EoW=5%5uA%^O+TOaG`?g=FDxbZaQZRR@hE+wv!pn*`hhyO2z6coGnf@ zAp$wuC1A5dPi?UOW4f#KQkC#8?(|Zz)d2nh=C{669$32!9=To~b>{(CE9cSZg z`T-}v8|AQuw+Gr6kZb~j)o<^nz(yD&o`eqc(e9f;h;5+d$XyLwu@>H?afe`7NJ98Y=v?BH@_Qe|+K--S zT0VKUvP|9j#6K*ct@f~y$pjJKm2q@R^IW)zS7{FIDbB_g#`7wc71 z)r~nd+P^^01n7NZV2$=CsJDT7boH?s?R99hV2#G{alf5}eFS1NTAVRopkdqyvwq=$ zmFO%q;T;N(=~Kj=EwK$I*eeM~Ve$DAJAJW6D?NW)e(6vTX~}W<#GwgNhvTy8aGZ4G zm^}S(@38ldp;5L|hVQn(DidQF7W3LM`NhK>?au&_IeqC8>>p_tVzcnLe#T62@(E~w z7aq3vCf9r`~IknvrKJSCy%#TFX_fdAzx*0i4YuG<5|{SxqcVQwz=#$ysc06K6`x9Nt__ zLlp}{ppTkZn2yQNB&Sa-s*FD{D{Q-)c?+wv-@*$50 zE)Pg8lpCRR!xra0EE7DQi%?p}c|3PFgwr1OiqY_Xqd!1#t&Gn~546;2{?N7X*OLf< zP;0@19uFjA6J0ulWJZT>lPIvz4s*gQZ=#C)Ge`%^e?aM_OLx5TFA*|?7{MjDS@B_5 z>Sg!iLhL>n-Z4gj{0STq14LfOD+JSv{W8VYV_YnMc)s=z3 zZrYm0G0?)kC$jHH+4l_goyNWwuB;Zvb_qf{P2L$WJmsYysTE?`+wkgtqM2($niQA?rz|?TZKzs zbDW%KrQK_|gCH0=!Hd6ogA15dxZf>~N2&0+*Et@m!tbl&cv3SQlFojO6H=NH2zg%M zRuz8dTh3pg!Z(XZl&SC@0z&#zvnploZI(?dPhAc7=eI?k`voS56mk3Rb>cN_C>*L&;hjWIxK%iP%{9qD+uFZXlK#u)4o_6p9TmP_S$9|hiS?Z4Jf?wGEOby}idEqm zqQ^-p{57!4(lA9u=p+x7~@75=S=oLhwtRfgcQG6Z58CJMtwFM%`MQ-(l=pB7^urNZ}p&E;ZM_-6u7 zQsIx-MTk;VgwK?-uL{Rs%i&R=!mo%3m8tL!q5~Bwe3D3ZdzFfirOW~q?h!drtHMj` zm_MXWg|`uU+$wyp=m5FG3LDR^dhwp(qtTNc5P9>l!7udL~e2 zQQr)Q@vr`w_rTbU(1gbd13F;iDmcHz2bVa+zLR`#jSoKE2lrEO%)b;Lf>t3wIoAhA zE>`{2P|l#o+Cc!&>P>w|~-;1_-H)(mI;C#24YVDb^D_rcrv z;BFthtq*?J2M_bXqeO1uaQO};k^52qz4{Q^EY<@aoU_J915|0( z_)Nz;XKw#>dH=@6j}B}K(A8gA+Ds}5MZS_Ks{%GwFyK z>DH{7=3HB9jxEn>ekwJ~W=>y}ozd5vmbzd;R(7WO-}VdwiL0>#jo5oQf@WyCay2;_ zwwxs>lm%N0=APD^jK$`hw3w*;$oyV`uC8YKw$|Kp(fnSnkIeK)XD)-g5nyV}lBCqM z`Kg&1LLh4B^g!2|&h)hZT${~0e82$Lqg`l5AZ(8;${C)Mk+#^iuM1t--`sN!2+syl z^TI{x8NJBAC|xu^ko52G8rGF=?aNu1i*r)57G$LNHRGlV`W=}YNVtjFsp-~K+gw*& zSNd~%rFZi((rg*&=Dh6O)HxYuqzCRj+l_umBQusx&RDp}mSG;7l)!t>RSyr0%q7@^ zT{-f0UnaBEAeF5`@qD!gKOL2d z$5RF%ex3n>GOhN0(4Riq0;g;$yno6L<@C^5)rOy3H=|EfwKG-irKFl`_tg|A-dQwF5e?OA#`*& z?CB075{K+TXd)ztu@MsLC1??E{daLfOllCGAi-OD!UT%7#pOB7>l-)|?)5DQeQVbl z+`k`uZa*=U25EWMB7As&)Bz~iGhpu+3OySMes^Lz1;3fcg^lWV(F zNi=Z)_{GE52%^>KsLoICFFgWHQYBT=CTedYWg+^WR4Fx~IwG;t*Dk42(}Z>)(gx(+ zEmit8p}|CY2hn{}rIw*~X0{8Fjv!nkRqC3UBZbCljx^M zs%+(NMP)iFRT`QYB8dMgg#7_FHo>ik|7L`Z00%e0?TG&#iWffJ0S{@yDe-3^>IZPB z{{@un57pL990Q0yi%T@XCjZ}&V+2&&G;wH%zY(tmV*zg41P2lS9te*CIE=$~9QBsO ze-z^501t0sZ$tFjDTqHVRkjOw9BNvf7E*&@Go;G)QZ71+Hc!C9Mo0Sc{>wRVWUHW)zi(ww0a+wgWswGbRv!85!(1 zA`m5vbV!gL)ffVYLNiPo6&MR;Tbdn+KMQR`W3(oJy|FVsnW9s}$%`0*V5vO66WJuH zHnX>C?d+|lbRjRzgn0?PPxK=%@4%|2yf!(CY(9*2zw+9{_Gq#N8g^`*I&2?EDn8<* zQ$H@hk5qoj8)ZK(A4y($9In-Xt*8-C@--ODGF53mu^WjX{G zEx4Ck$lIR)b4b^b9Qw$;Jc+!6TmW`^9WMTZ{p{sc?&Xh=FE!9dklN;6KAn6ev~G1T ze~f&KjZnYxirdR)kfF2>@ImXP+smII!zi;DiOGoW-Cq7Uxr+>NwC;WkzUkCSt zm0M?$towl&(jx^Z5Sj%^#QL}$eeM~@k?9taiyp&a>V?nq=+yRPVjGBf3YmBU`>iQI zm6=MC9z<_7)BD1CNo)uV0^9%2TbHqPgPgv@8(+vsy41gX|aaeCZ|4HHYUdNC=8IpZ3 zNDR5f^7;0>WbtHl4%3ysPO{9Xw5_=%d%lq{v&9EjgFb?X&^gb&`6w%;} zVeTY_lfc=)5UcOdVW>VlJO(B8?MV12jJ&>mRQPCCi5NrzR|DxGefJJg`cC0vFw}Z8 z34ef9I_ty7p3uQ~=(`xhV;IymEIgi7x|zctWR=LCBB4IoosU4@)hfOmE4&X9@Q6&7)_K+=s(IC?mDpRFT z?TmSfUrf_l6-xp~12H)$E@&*29cKkjfFgs8@01?Zf>^?5XF`)Cknprztc>gDbN&={ z7AH*$5?BHp+1l}eWl%1pubZETa)IU*_-125Nm}RmVd0s$$dO4Td@ir}g=b;HkxBaS zdAtHQS=#_WHV7DdspH$PS%YljATh_nK*Hg@r}ws9D`II|3)sZt7%xbzXtT%WfVsuh z98M!V+~O7;Ck!4|arM?4o^iLecLhWXj-W|2sOenLO4A*F3DfIENz?les_A3b!0f@_ zIYQ2obnx*ce5OEs1C;MU?;@al5kB)lJ{8)ub{02dPgYFLwOd;I{17I zb^9%-lx2Y758r=<5`Rei29*9#4ulW9I!r} z{~vrF0DKny0{S6f#(cO5#V7D-1@#(GIuY_h+d|n3u>SyU zEVMVlCjsaI(3SzOfc9G8F~es&)E@!b37~xfANa=-qz-siQ0j^=p<`*5tE7ZhPO#;Y zT)2BnTa=xin`|T5Br7{PH9b8C$}C%QP8zXgrNiq5$&2R9$<444xS1rmsmbiUg4EbCfAyg#?k@ji85J3#n$zLYY+fsUKWTQpfTU@_Q31B%oOxU;m9;$sN@Exw-*l83WLgO z1DBYLAwuv4H9`~Dj98(lrx1KVc*xbkZH}l67G+<^GdyY`zu4i=i$_G6E=pzHQr0VF zol@2(GG%t2>(m-LE1(5v!q^XV4Jf4@q&Czwp_GQlwP2wOnIb3cZZO>J`=5_``(M}= z{T^7O>)zga@$Kr&7lxN+FKrnAPVQd-O1;njcJ*3W)`E32W_6xF`~!KEYilX(MW?&Y zmeNu7hoJk~PtF(Jzw_<)Pn=&GwRd2}>4$zoIcMyQ`md`$&osV&U;H0CXTDW|;9Gsa zAN9b&U7L=*cjnwLxB4H(x2vTWgTA?uyeoI#X~)&J`FH2Ihn6_lx*be1f-DX{; z{zhJgew6;YzEBz+T0TQ}4WL4OX8BD0Vtt~1fF?xW{{lPor*-3iwtd9K$Mi+Xtp2lZ z`Z(P@{m+l-G>4W+-N4duX*wFv7imiEWtux=`i1g5UC>A=fKZB#!v&ofDa}nn1BGFqknq{YvP;mdEvv1^p~}olr7J za~TYLaA1orsK2Ch-Cj$7jYw7|bpN@;w#1r|3uUshj=a8(_H`M{=qOiG88zFL?jM8k z#-lxaBsftjeN;}kT?DT5OF8ilP;k`=*aA2uA%8p1Kgw{}j^;D5i}wOxmQbYh3omBz znCF^QM%#v4p^Ex?ayghN{8;AN31nKtxaeBfRcyV;Ta~WhauCJ)QxuJj`2Bf+zH+TupmvDT+ZG7YU6qVqDM@cZz3-=(m*m1{2pMwh!pfR#h zMTXys10F*5scg3v_;eK>A@FK}$HJu}9zBHq)++tO0rw+6+C7}D=^N$IFtI=d4kaeo zMBJ>x@dsCk_f+B21RkZrX912;OL_ve!wQSzhX-C^prWBFfv1H)H4`928U7-Hy9KVS zx1|CnGr8V&ykM6?XxQwFrvvd#ArLDFilQokCkecTP_$p*DFPRfCMN;M4(wBP;Ee04 z42f`FkV{nL%dX({K)woO7?8h>N24na$o8Lv(W*4=#T+rK?g`wykmK`&lM*bv*xNFJ z9~C%WULl^6&4DcfZzpiFhy(Eg@4|6ANfLw{LFgj{Y6YGz@F9R>6!fZY#t6Jxg^v~Z zTosPja;T@4;Uk(7F$oCRgV-mzMG6tX1FtkupiHG`wyS&t1Tb5WEh=)JQ0EpMdmq|( z!1~Y3RORuuk`)etR|_1kP4RFEymm2fD}jBTTQ=FhE+93oA1iqG&$$o(wMMO6X zyasUWpHbC;k6kOC$Mj<4K%%^dS|%p$*RGvFw*Muh>r~Qh!5!3kN`>Q%2I5sJyt}~b zRXA=qA-`3H<9}O)w02Ec&`P=T!9x9uqF|?O!5To z7We^9CeI4IY6S<@3Eai;$4Tr;jyxs^uL*$)fkTrWcwdJORS8^~;zt3;4uq*XaM~5U z5yk+o`H)Q&)i(sPLYE|Ko{a01z-H1*^Cxcsq&5aHgmwslq!oIE=g?6S$(k zEoiH7aHXd6zhEb_!1;>>b_IrQ4g(4ZYfUf-V>q07M2{6xH-@7d&1L#Ayp{I_1%-dq zKjDc$ox})+zX{C#7aZ@CLwt>j3&6aw!1IWSz{Un%%%4kU}wZxpySmE$gf|0eKik+8dk9tx8kJ65n10m#E^g3wwJ zYKu9b^thM6VPlvbT?IdGC!(S zgupU^n?*=F3EV00I)Q&Gdh8N-fq3UbiRi0}9&rJ?0TLCD?SS_~k1G0bKsZK@10fiE zor@QXa43o3OV=GiwyMZ2VLqT(feOd1cEs~lIPO{^zEy?eEi~d)DtraU?W96Q zctHqkRN*@XUZ%p&0FM2(j^h1(N(AtVz>Om3@P8-baZBhaVR~T5lz?cUf{Ns9PY6PiASeP0 z1YV%RpB8wPz6@+e_PWB1`tH9d|{6m443A{q! zR|Fmp$%E2||P*RK#(hzX<6NMPLla&k20Iz^le` ze1OoCCh$6e&lC7!fhUO}9w6+KwSrLKC2)y16$2{#sK8Sm;(Wqia#7(IxRNWl7l1vE zvT%OIA-x?|UL;hg2xfsB6SzPoFUSyq7pU+=fx8866Z~lcw~ph0a#gle;PyH}Q10y3 z2}06%E|4V@y(aK#fh+v`1s;VbVK@|iJk_v{30&dF|00aI@nObp_X_+a2nC$rz13_D z=NJ@l3tTbKTi_`Zm?AHKoWN@ZuJBJ6cTd5 zffuOoTLLdp;X!a#XW2fD^@lx!!disDYweG70{(MiJo+dCX}tXsFUT-~C*?H969ir{ zljGt>mm~|^EUssSI+7>wsASG>PvIpg5ri^9Q1F)o?iRSX>>yPFHws1tKQ8bpaYy{I z=-7FICy9mNHn(dh*94(j+`gR>4&4=aT`>nzg`zwI4{2E;$1e!}T33A%=>hTKak^3x%Q^MMSs$?|9|0^z`YE{?XRLcTuXLR zkB-Z@j=V=h_bz#lCTeutsHK~|eRzQO4^m#+tiF%JJ+q0u zL~j0ohW#TUj)b)b-&Z&^qBc z-kvQq9H!>LDuEd_u5pKfr+NeDNpU$2)B88QYO PcXEktM%TFgwB!E)sRW*A delta 24009 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 R_2W=IV8nY8HnWP({U05GNelo0 diff --git a/payload/dpi/ezremote-dpi.elf.gz b/payload/dpi/ezremote-dpi.elf.gz index 020a66531f718b5f7d7f9ffa1466e8b16fd0adf1..bca5c0cb2a0759bbb714cbbd765b0005e26c7753 100755 GIT binary patch literal 29497 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
r*$biw;p4YsH+ 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 literal 27208 zcmb@MQ*$K@kcMO1wlU!(6WhrI6WhkbIVZMldty5|u{AL!&WUYnzrET&uvJ~%@8w(F z7yVRqlSd;USlCe7LO@#pT`U}&+$>nlf7^Vsu(yOb??(2*k$Pm<5=u@$tRtidL|_mL z4yw)CEsPAZG?oUs6^%*U1Y#snnxo3`WCj*c^wY>*v18$SZfbZsuV{EKeOTbK3kM)Z z0x0CY5O!@uha$xgA}B%gPdSyR0CgJHQ!>DQ3 z?Z3LIG}&kc<4lgJiwDn67J*DX7-YQl)kL17@p#Pzu&mZ%$hxyZ|9|p}cK}81^QW#~ z(TRsxd5eL#Y|TXhV$t;T$UB`6@fPS;r28d4%)ezrP_qF$5FT3P_Gf#S4#ouRSN=mu zB~&2*@s9#|-7lQ#W1Y1gIaQc`HB2#EuZBJ*CsotBfAKG+N{6k=^=w~#xIrpr0!39V zq!L(%OKWk4ys(uDhMw5{DyD3g*deCpzBCs?hwv0m6Ye1)V!-)KEN?HPfY?J#(o#|7 zN~d2{Mlx0L{Ia%W^9@-}VT&6D)C^s5@ZQ?WHwk3p0(%8mdX4C?F0EcCI@Z{|sk1%^ zFceE^@-YC}pA{094PYIraAjad1Odjdn%KXnem{j8$jIEy5F^HC$r7k0tSN+`7YXVh z9-bUE9E@y;rH}0tin$sFU^ydT#cY>83Uj2|Fw!9m$;AeVTNQ-)g B6YNpy235rv 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
    qGYFc<3vtp9rf7 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 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. */