diff --git a/.importlinter b/.importlinter index 3e769fc3..f7291667 100644 --- a/.importlinter +++ b/.importlinter @@ -129,6 +129,7 @@ modules = services.metadata services.migration services.playtime + services.prune services.relaunch_options_resolver services.rom_removal services.saves diff --git a/CLAUDE.md b/CLAUDE.md index fb74c79e..884a6979 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,6 +181,40 @@ Format: **invariant** — tier — enforced by. is never mutated while the stash is pending (box IDLE) — every run-entry path passes `try_begin_run`, which clears the stash before any staging write (#1367)** — prompt-only — verified closed in #1367 review; mechanize via a staging-writer call-site audit +- **A prune claim excludes library sync, downloads/resumes, migrations, version switches, core/disc/controller writes, + launch evaluation, save mutations, session writes, uninstalls, connection identity changes, and affected cache + cleanup; each conflicting callable registers before its first await, detached work retains that claim for the task + lifetime, and frontend-owned multi-call work holds and heartbeats a globally registered, bounded tokenized lease + through every sibling Steam continuation's final write (including each paced `sync_stale` removal and terminal repoint + publication); every continuation checks its abort signal before each later Steam mutation; failed event delivery + releases an unreachable token; owner/plugin generation is captured before each backend wait, teardown tombstones it so + late lease-bearing responses are released without work, and only a genuine remount opens a new generation; teardown + stops renewal and future writes but defers explicit release until already-started Steam promises settle; run admission + atomically refuses those registrations before reserving the prune claim and refreshing the preview** — test + - prompt-only — prune service/gate race tests + contract callable-entry matrix; new conflicting entry points are + prompt-only +- **A prune frontend action mutates Steam only after atomically claiming its exact run/token/discriminant/binding; + identical repeat claims are idempotent, action delivery is serialized/deduplicated, completion retries never repeat + the Steam operation, and a claimed outcome lost in transit is reported as ambiguous until live Steam absence is + reconciled** — test + prompt-only — prune service claim tests + `src/utils/pruneActions.test.ts`; new action kinds are + prompt-only +- **Every prune source carries a no-follow claim (root `(device, inode, mount-id, mode, size, mtime, ctime)` plus every + descendant identity and regular-file hash) through descriptor-relative mutation; mount transitions are refused, + regular-file and controller-claim deletion holds kernel writer exclusion from final validation through unlink, + selected sources consume claims decoded from the same held and digest-bound sealed bundle, unselected/recovery-off + sources take a final presence-or-absence claim, every exclusive save is expected absent after quarantine and the whole + set is collectively rechecked immediately before aggregate cascade, quarantine publication is atomic no-replace, + writer-exclusion teardown faults are ambiguity, controller claim branches revalidate and preserve newer held-inode + edits at a surfaced path, and path re-lookup alone never authorizes delete/quarantine; unsafe recovery-failure cleanup + preserves and reports the anchored staging path** — test + prompt-only — descriptor-path, recovery-adapter, real + RomRemovalService, and prune contract tests; new mutation adapters are prompt-only +- **Every prune action/progress/completion frame carries its originating preview ID; a pending frontend preview may + adopt only a matching run, including when the successful start response is lost, and foreign frames never trigger + state or side effects; an accepted contiguous terminal result seals the run against every later frame** — test + + prompt-only — prune service frame tests + `src/utils/pruneStore.test.ts`; new prune frame types are prompt-only +- **Every destructive RomM proof is bound to one canonical server-origin/token-origin/user namespace from preview + through every exact-ID request; a namespace change is uncertainty, never a 404 deletion authority** — test + + prompt-only — prune service namespace-race tests; new destructive RomM proof paths are prompt-only When a change applies a guard / sanitize / backup / grouping pattern, sweep for sibling sites of the same pattern — the register is what that sweep checks against. diff --git a/CONTEXT.md b/CONTEXT.md index 4104f727..ec375a51 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -275,7 +275,20 @@ Three deliberately-distinct ROM-removal notions (see [ADR-0007](docs/adr/0007-ro delete: a stale signal may be a transient server blip or a reversible RomM change, and local playtime/saves must survive. - **Prune** — an explicit, opt-in purge that `DELETE`s the `roms` row, cascading every per-ROM child away atomically. - The **only** thing that deletes rows; not built yet (the cascade FKs exist for it). + The **only** thing that deletes rows. **Clean Up Removed RomM Games** performs it only after fresh exact-ID 404s, + explicit options, and any enabled recovery bundle have passed their final guards. + +### Recovery bundle + +A checksum-verified, atomically sealed pre-mutation snapshot created by explicit Prune. It records the affected local +aggregate state and selected/mandatory recoverable files for manual recovery; it is not a database export or an +automatic restore point. Finalization revalidates both the sealed bytes and their source state before deleting anything. + +### Action token + +A one-run, one-action lease for a frontend-owned Steam operation during Prune. The frontend must claim the exact token +before touching Steam and complete that same token afterward. Duplicate, stale, unknown, and late unclaimed tokens are +non-authoritative and cannot mutate Steam. ### Collection kind (Standard / Smart / Virtual) diff --git a/docs/architecture/backend-architecture.md b/docs/architecture/backend-architecture.md index b748cfbe..ddb91132 100644 --- a/docs/architecture/backend-architecture.md +++ b/docs/architecture/backend-architecture.md @@ -136,9 +136,10 @@ adapter's single-attempt short-timeout `request_once` path, concurrently on the `RommNotFoundError` produces `vanished: true`. All other probe failures and malformed/falsy responses fail open. No result is persisted. -The response carries `vanished` on every version and `bound_vanished` even when `multi_version` is false. Retained -vanished rows remain in the payload and keep the independently-computed `switchable` membership verdict, but are -excluded from default ranking. A bound-id 404 is a successful entity verdict (`bound_vanished: true`, +The response carries `vanished` on every version and `bound_vanished` even when `multi_version` is false. A singleton +also carries its `bound_version`, which lets a definitively vanished synced binding expose scoped cleanup without +inventing a multi-version picker. Retained vanished rows keep the independently-computed `switchable` membership verdict +but are excluded from default ranking. A bound-id 404 is a successful entity verdict (`bound_vanished: true`, `server_query_failed: false`), not a global reachability signal. Artwork and save reads do not contribute availability evidence; `fetch_cover_base64` remains a nullable data callable. @@ -162,6 +163,112 @@ the normal retry/classification policy and is not preceded by a redundant exact- into `version_vanished`. A refusal performs no binding, row/install/applied-launch-options write, launch-command resolution, event, cache invalidation, sync, or completion-stamp update. +#### Explicit removed-game cleanup (`services/prune/`) + +| Module | Role | +| ------------- | ------------------------------------------------------------------------------------------------- | +| `service.py` | Callable facade, atomic preview/run admission, and claimed frontend action leases | +| `executor.py` | Serial per-group liveness, recovery, Steam-action, reconciliation, and finalization state machine | +| `preview.py` | Local generation-gated candidate snapshot, complete affected-group disclosure, sizing, and paging | +| `recovery.py` | Lossless aggregate snapshots, recovery artifact assembly, and sealed-state comparison | +| `registry.py` | Short SQLite reads, action/final race validation, reconciliation, and final cascade delete | +| `requests.py` | Preview/option decoding, bounded selections, and lossless bounded Steam snapshot validation | + +`PruneService` is the only path that deliberately deletes retained `roms` aggregate roots. A bulk preview is local-only: +for each platform it requires a non-empty completed fetch generation and selects rows whose `last_fetch_id` differs, +including NULL row generations. An inline preview may nominate one concrete retained ROM without generation evidence. +Both forms return serialized-byte-budgeted pages plus an ephemeral fingerprint. A page may therefore contain fewer than +the requested row limit, and the next offset advances by the rows actually returned. Pages include every member of an +affected sibling group, with generation candidates marked separately, so whole-game deletion cannot reach an undisclosed +row. `start_prune` consumes a finalized preview-bound installed-content selection. The frontend stages that selection in +bounded pages, so wire bounds do not cap the total selected set. Start atomically refuses any registered conflicting +callable and reserves the run before rebuilding the preview; concurrent starts cannot consume one token twice, and +shutdown owns/cancels an admitted refresh before it can spawn a run. Each sync, download, migration, version-switch, +save-write, session, uninstall, connection-identity change (including a successful connection test), or cache-mutation +callable registers for its full lifetime before its first await. Detached status, download, and playtime tasks transfer +the claim to their task lifetime. Core/disc writes, launch evaluation, and Steam Input application are included because +they mutate recovered state. Frontend-owned shortcut removal, core/disc writes, version switches, uninstalls, SGDB/icon +application, download completion, home migration, startup healing, pre-launch healing, and every post-sync Steam branch +(launch options, collections, playtime, and overview metadata) hold tokenized conflict leases through their final Steam +write and bounded release. Active continuations heartbeat those leases once per minute. A global frontend registry +signals cooperative cancellation by component owner or on plugin dismount. Every backend wait captures the current +plugin/owner mount generation before it starts; teardown tombstones that generation synchronously, so a lease-bearing +response that arrives afterward is released without running its continuation. Only a genuine plugin/component remount +opens a new generation. Cancellation stops every not-yet-started Steam mutation and lease renewal, but explicit backend +release waits for any already-started non-cancellable Steam promise to settle. An unresolved operation stops renewing +after a bounded five minutes; the backend's five-minute no-heartbeat expiry is the abandonment backstop if it never +settles. Launch funnels carry the admission captured at the original Play action through every gate, modal, and +launch-options confirmation wait. The version picker likewise rechecks its captured owner admission after save-sync and +modal waits before any successor `switch_version` mutation, so an unmounted chain cannot resume under a new picker. Each +non-empty `sync_stale` event carries its own lease through the paced removal tail; a later `sync_complete` lease +overlaps and joins that same promise, so success composes both leases while a post-stale backend failure still leaves +the tail covered. A terminal prune result that needs repoint publication likewise acquires its lease before event +delivery while the old run is active; the frontend holds it across release acknowledgement and cover publication. Event +delivery failure releases a token that never reached the frontend. This closes the reciprocal admission race, and each +path refuses while a prune claim is active. Migration and active-library-sync decorators additionally guard preview and +start. + +The executor processes sibling groups serially and catches ordinary exceptions per group. It rejects multiple shortcut +bindings and active downloads, pins the preview's canonical RomM origin/token-origin/user namespace, probes every local +group member with the single-attempt three-second `get_rom_once`, and treats only a typed `RommNotFoundError` from that +same namespace as destructive authority. A connection/server/user change before, during, or after an exact-ID request is +uncertain. Live, malformed, wrong-id, transport, authentication, timeout, server, and unknown outcomes retain data. Long +recovery and frontend round trips are followed by another exact-ID proof before local source removal; every repoint +re-proves both the vanished source and live target after the frontend action, including a vanished source that is not +itself a generation candidate. The natural live repoint target uses the same filename-stem projection and +`resolve_group_representative` ranking as the version picker with empty installed/bound preference sets. Repoint +selection is independent of row deletion. + +Recovery is coordinated through ordered ROM save locks, acquired with no UoW open. The lock set is retried until shared +save ownership is stable. Short read UoWs close before filesystem work, and no RomM request, event emission, or frontend +wait runs under those locks. The bundle is sealed before mutation. Aggregate, save-inventory, bundle/source, controller, +and fresh frontend Steam state are validated before the irreversible Steam action. After that action the service +reacquires the same recovery-set locks and repeats the guards immediately before local finalization; quarantine +ownership is separately projected only for rows being deleted. The locks remain held through save quarantine, +filesystem-only installed-content removal, and the final parent cascade. Source sets record expected presence or +absence, the root's sealed no-follow identity and regular-file hash, and every descendant identity, mount ID, and +regular-file hash. Every resolvable exclusive save path is represented even when it was absent. Traversal refuses nested +mount transitions. After the anchored root rename, deletion obtains kernel read leases for every regular root/descendant +before removing any entry and holds them through the last descriptor/hash check and unlink. An existing writer, an +unsupported lease, or any inability to establish exclusion restores/retains the source rather than deleting bytes not in +recovery. The controller VDF's held claimed inode uses the same writer exclusion from its final identity/hash validation +through claim unlink on both normal publication and collision/rollback discard; setup failure preserves the claim and +teardown failure after unlink is an ambiguous mutation. Content excluded from the bundle and recovery-off saves still +receive a final complete no-follow claim; their remove/quarantine uses anchored parents and atomic no-replace rename +rather than a raw path fallback, so a concurrently created backup is never overwritten. Writer-exclusion teardown faults +after mutation are reported as ambiguous, not exact success. Controller rewrites revalidate the held claimed inode +before every restore/discard branch and retain a newer claimed inode at a surfaced path when a concurrent Steam file +wins publication. Every exclusive current-save path is expected absent after quarantine, regardless of whether it +existed during inventory, and the whole set is collectively rechecked after all filesystem work and immediately before +the aggregate cascade. Validation and claim decoding share one held bundle descriptor and return a digest that every +later guard compares to the cached claims. Recovery root, staging, bundles, destination files, and the sealed bundle +remain descriptor-anchored through copy, metadata application, hashing, cross-directory rename, validation, and +both-parent fsync. Failure cleanup uses the same mount-aware descriptor-relative remover; if cleanup cannot prove a safe +tree, it leaves and reports the full anchored staging path instead of recursively entering uncertain data. Adapter +outcomes carry actual and durability-ambiguous changes into the mutation ledger even when a later item or parent fsync +fails. The final SQLite delete is one short UoW that revalidates complete row/binding state, invalidates intersecting +collection stamps, and clears platform stamps only for fully vanished games. + +Frontend Steam events use a claim/complete protocol. A claim checks the run, token, discriminant, appId, target, exact +single-binding group, and current binding before the frontend rechecks the live `rom-launcher` executable and mutates +Steam. The lease is monotonic-clock bounded and rechecked after asynchronous validation; an identical repeat claim is +idempotent, while mismatched or expired claims cannot authorize a mutation. Repoint commits through the normal +version-switch authority first; shortcut removal is immediately reconciled to an unbound local row after Steam confirms +absence. A claimed action whose completion is lost, or a `RemoveShortcut`/launch-options write that was attempted but +could not be confirmed, is an explicit ambiguous partial. A pre-mutation refusal remains an ordinary failure. A later +run can confirm a shortcut is already absent and reconcile without repeating Steam removal. A later guard failure is +likewise an explicit `partial` result with the committed action and actual mutation categories recorded, not a claim +that the group was unchanged. Cancellation can stop final guards and every later group; shielding starts only with the +first irreversible local mutation and awaits that phase to a known result. Cancellation remains authoritative if that +shielded child faults: the current group's truthful fault/ledger result is recorded and no later group starts. Terminal +result strings/arrays are bounded and chunks are built to a serialized byte budget. Every action/progress/completion +frame carries the originating preview ID, allowing the frontend to adopt a matching run even if the successful start +response is delayed or lost while still rejecting foreign frames; completion finalizes only a contiguous chunk sequence. +An accepted contiguous terminal sequence seals that run against every later action, progress, or completion frame, and +the modal exposes its terminal controls immediately even if the start response is still pending. Committed repoint +publication performs a bounded/retried backend release acknowledgement before gated cover/status work. Mixed runs +continue unrelated groups. + #### LibraryService decomposition (`services/library/`) The library sync subsystem is a façade over three sub-services that coordinate through a shared `LibrarySyncStateBox`: @@ -1162,12 +1269,15 @@ Adapters own all I/O and implement the Protocols defined in `services/protocols/ | `download_file.py` | `DownloadFileAdapter` — download filesystem | | `firmware_file.py` / `migration_file.py` / `rom_files.py` / `save_file.py` | per-subtree filesystem adapters (BIOS, RetroDECK migration, ROM removal, local saves) | | `retrodeck_paths.py` | `RetroDeckPathsAdapter` — reads `retrodeck.json` for ROMs/saves/BIOS/home paths | +| `recovery_bundle.py` | `RecoveryBundleAdapter` — safe recovery-root derivation, exact source measurement, verified copies, checksums/human manifests, fsync, staging cleanup, and atomic bundle sealing | +| `prune_artifacts.py` | `PruneArtifactAdapter` — per-ROM cover/validator and SteamGridDB cache discovery/removal | +| `steam_recovery.py` | `SteamRecoveryAdapter` — per-shortcut Steam grid, Steam Input, and controller-setting recovery inventory plus post-confirmation cleanup | | `retroarch_config.py` | `RetroArchConfigAdapter` — reads `retroarch.cfg` save-sort flags | | `retroarch_core_info.py` | `RetroArchCoreInfoAdapter` — reads RetroArch `.info` files (`corename`, metadata) | | `es_de_config.py` | `CoreResolver` — ES-DE `es_systems.xml` (system-layer default core + available cores); the gamelist is no longer read or written | | `gavel_native.py` | `GavelNativeAdapter` — loads the compiled [romm-gavel](https://github.com/danielcopper/romm-gavel) core (`py_modules/native/libgavel-x86_64-linux.so`) via `ctypes` and is itself the `ResolveUploadConflictFn` seam for the save-sync upload-409 decision (no Python fallback) | | `system_clock.py` / `system_uuid_gen.py` / `asyncio_sleeper.py` | concrete `Clock` / `UuidGen` / `Sleeper` seams | -| `hostname.py` / `path_probe.py` / `plugin_metadata.py` / `debug_logger.py` | hostname, path-exists probe, `package.json` version reader, settings-aware debug logger | +| `hostname.py` / `path_probe.py` / `plugin_metadata.py` / `debug_logger.py` | hostname, path-exists probe, `package.json` name/version reader, settings-aware debug logger | | `renderer_rss.py` / `renderer_gc.py` | `RendererRssFn` — max `steamwebhelper` `VmRSS` from `/proc`; `RendererGcFn` (`HeapProfiler.collectGarbage`) over the CEF debugger. The session-budget measure + settle seams (ADR-0024). The "free memory" action is a frontend `SteamClient.User.StartRestart`, not a backend adapter | #### PersistenceAdapter notes diff --git a/docs/architecture/database-design.md b/docs/architecture/database-design.md index 6843573e..c23f093e 100644 --- a/docs/architecture/database-design.md +++ b/docs/architecture/database-design.md @@ -299,9 +299,16 @@ removal), so foreign keys are deliberately sparse: this cascade is **dormant**: a `roms` row is a permanent identity anchor keyed by RomM's stable `rom_id`. **Auto-stale = unbind** — when the automatic sync finds a ROM gone from RomM (or its shortcut is removed), the row is _unbound_ (`Rom.unbind_shortcut()` NULLs `shortcut_app_id`, the row and its per-ROM children stay) so local - playtime/saves/metadata survive. **Only a deliberate purge = delete** — an explicit, opt-in user action (which does - not exist today) `DELETE`s the row and lets the cascade reap the children. The automatic sync never `DELETE`s a `roms` - row. + playtime/saves/metadata survive. **Only a deliberate purge = delete** — the explicit **Clean Up Removed RomM Games** + action may delete a row after a fresh exact-ID 404, option checks, save/content guards, any enabled recovery bundle, + and required Steam acknowledgement. Its final short UoW revalidates the expected group and binding before repository + deletion lets the cascade reap all six child tables. Installed-content cleanup before that UoW is filesystem-only; + `rom_installs` and every other child remain until the parent cascade, so a failed filesystem phase leaves retryable + aggregate state. A confirmed shortcut removal is reconciled to an unbound row before finalization, making a later + safety refusal an explicit committed partial rather than a stale binding. The automatic sync never `DELETE`s a `roms` + row. Collection completion stamps whose `member_rom_ids` intersect a purged id are deleted in that same UoW. Ordinary + row cleanup preserves the platform completion stamp; a fully vanished game clears its affected platform stamp so one + later complete fetch can rebuild platform truth. - **Caveat — writes to a cascade parent (`roms`) MUST UPSERT, never `INSERT OR REPLACE`/`REPLACE`.** In SQLite `REPLACE` resolves a PK conflict by _delete-then-insert_ of the parent row, and that DELETE fires the `ON DELETE CASCADE` above — silently wiping every per-ROM child on a re-save (a normal library re-sync, where the diff --git a/docs/architecture/save-file-sync-architecture.md b/docs/architecture/save-file-sync-architecture.md index d4b2ff31..76cc137d 100644 --- a/docs/architecture/save-file-sync-architecture.md +++ b/docs/architecture/save-file-sync-architecture.md @@ -256,6 +256,32 @@ exception: the backup a quarantine just wrote is never pruned in that same call, the folder may briefly hold one extra copy (11) — honouring the cap by deleting the just-saved file would defeat the backup. +#### Removed-game cleanup and save recovery + +Explicit cleanup of a retained RomM 404 starts with the same exact per-ROM save paths used by sync and unions path-safe +filenames already retained in `RomSaveSyncState.files`; it never broad-scans a save directory. This preserves exact +historical filenames after a ROM rename or layout change without guessing. Before a row can be purged, the service +canonicalizes those paths for the purge set and every remaining installed ROM. A current save with any owner outside the +purge set is copied into an enabled recovery bundle but left in the emulator directory. An exclusively owned current +save is copied and checksum-verified when recovery is enabled, then moved through +`quarantine_local_file(..., preserve_history=True)`. This operation deliberately keeps all existing `.romm-backup` +history instead of enforcing the normal newest-ten cap. Cleanup always takes a final no-follow source claim and creates +the backup directory through anchored parents before a directory-durable rename, including recovery-off runs. A +post-rename fsync failure is returned as an actual but durability-ambiguous move rather than disappearing from the group +ledger. After quarantine, every exclusively owned current-save path is expected absent, including one that existed +during inventory and was moved successfully. Cleanup collectively rechecks the whole set after later filesystem cleanup +and immediately before deleting the owning aggregate. Any newly created or emulator-recreated save retains the +aggregate, with or without a recovery bundle. + +Recovery also copies matching `.romm-backup` files while leaving the originals untouched. If an uninstalled ROM or an +unsupported layout has no resolvable exact path, the manifest preserves known save-sync filenames/state and records a +warning, but physical files are not guessed or removed. Save states are outside this workflow. Save locks are acquired +in ascending ROM-id order, including remaining owners of shared paths, and retried if ownership expands. A symlinked or +out-of-root `.romm-backup` is rejected both during inventory and immediately before quarantine. No UoW is open while +lock acquisition waits. Short read UoWs close before filesystem work, network/frontend waits run outside the locks, and +the final stable lock set remains held through quarantine and the aggregate cascade so a newer save baseline cannot be +deleted after the lock is released. + ### The `none` slot (legacy) — a migration source, no longer a target - Saves uploaded before v2 (or without the slot parameter) have `slot=null`. diff --git a/docs/architecture/save-sync-coverage.md b/docs/architecture/save-sync-coverage.md index fbea1942..5e9f04b3 100644 --- a/docs/architecture/save-sync-coverage.md +++ b/docs/architecture/save-sync-coverage.md @@ -22,6 +22,13 @@ Two hard properties follow, and they define the entire coverage envelope: 2. **The file must live in the save folder.** A save in RetroArch's _system_ directory (Flycast VMUs), in a per-emulator subdirectory (`mame/nvram/`), or next to the ROM (`savefiles_in_content_dir`) is never seen. +The explicit removed-game cleanup starts with this exact-path projection and adds path-safe filenames already persisted +for that ROM in `RomSaveSyncState.files`. This is an identity-backed exception to the filename rule, not directory +discovery: cleanup never scans for new names. Class (c), unknown fixed/shared names, unresolved uninstalled layouts, and +save states are left physically untouched and recorded as warnings where applicable. If two installed local rows project +the same canonical current-save path, that path is shared ownership; purging one owner may copy it into recovery but +cannot remove it while another owner remains. + The extension list is a small static map keyed by the **RetroDECK system** (the normalized value from `resolve_system` / `platform_map`, not the raw RomM platform slug — keying by system keeps the lookup aligned with the save directory, cores, and gamelists, which are all system-keyed): a default of `.srm` / `.rtc` / `.sav`, plus per-system overrides diff --git a/docs/architecture/steam-non-steam-shortcuts.md b/docs/architecture/steam-non-steam-shortcuts.md index 9551d022..a98336c7 100644 --- a/docs/architecture/steam-non-steam-shortcuts.md +++ b/docs/architecture/steam-non-steam-shortcuts.md @@ -167,6 +167,52 @@ short write UoW, whose fresh membership and bound-elsewhere checks still decide cross-system interval after a successful response: the liveness check reduces stale-list risk but is not a transaction with RomM. +## Explicit cleanup of vanished versions + +Automatic sync remains unbind/retain-only. Deleting retained local state is a separate confirmed workflow under **Danger +Zone → Clean Up Removed RomM Games**, also reachable as **Remove local data...** beside a synced vanished version or as +a focused button for a synced singleton vanished binding. Candidate discovery is not deletion authority: the backend +freshly probes each exact RomM id, and only typed 404s can proceed. + +For a vanished bound version with a live sibling, the default-on repoint action reuses `switch_version` independently of +the row-removal option, then the frontend confirm-writes the returned exact launch options. Cover/cache publication and +the `version_switched` event are deferred until terminal prune completion. Before emitting a terminal result that needs +repoint publication, the backend acquires a continuation lease while the old run is still active; the frontend registers +that token immediately and holds it across both release acknowledgement and the final artwork write. Another prune +therefore cannot enter between the old claim and publication. Publication uses the same path as VersionPicker. Repoint +changes neither shortcut name nor exe and never calls `AddShortcut`, so the assigned appId, collections, and Steam +playtime remain attached. Unsynced-save stranding can be overridden only after enabled recovery has sealed. + +For a fully vanished bound game, whole-game cleanup is separately default-off. With recovery enabled, the root frontend +handler captures complete shortcut details, available Steam playtime fields, and every collection id/name or fails +closed if that JSON cannot fit the wire bound. The backend resolves the active account from Steam's login identity once, +stores that identity in the recovery handle, and adds only that user's grid artwork, both per-app Steam Input roots, and +relevant controller setting. Cleanup must use those exact captured roots even if the active account changes later. + +Every action event is deduplicated and serialized. Before any Steam mutation, the frontend claims its token from the +backend, re-reads the live shortcut, and requires the appId to exist with an exe ending in `/bin/rom-launcher`. Only +then does shortcut removal capture a fresh complete snapshot, compare it with the sealed snapshot, call +`RemoveShortcut(appId)` once, and poll the live store until absence. Identical claim retries are idempotent. Completion +reporting uses bounded retries of the same payload without repeating the Steam operation. If every completion report is +lost, the claimed lease expires as an ambiguous partial and retains source data. A `RemoveShortcut` call followed by an +unreadable store or settle timeout is also explicitly attempted-but-unconfirmed, never reported as an unchanged failure; +a later run can confirm the appId is already absent and reconcile the binding without calling removal again. An +unreadable/foreign store before mutation, stale claim, or unclaimed timeout is failure, and no row/source finalization +follows an uncommitted action. + +The same reciprocal exclusion covers ordinary frontend Steam continuations outside an action event. SGDB fetch results +carry leases through hero/logo/grid/icon writes; `sync_complete` keeps one shared lease until launch-option, collection, +playtime, and overview-metadata branches all settle; and bulk shortcut removal clears its collections before +acknowledging and releasing the removal lease. Leases live in one frontend registry, renew only for a bounded active +continuation, and receive a cooperative cancellation signal before their component owner or plugin dismount releases +them. Owner/plugin mount generations are captured before backend waits, so a token arriving after teardown is released +without admitting old continuation work even if a new owner has since mounted. Each non-empty `sync_stale` frame owns a +lease through its paced tail; successful `sync_complete` processing overlaps that lease while joining the same tail. A +backend emit failure rolls back a token the frontend never received. + +Recovery records the Steam-assigned appId and playtime, but there is no automatic restore and Steam cannot currently +reattach those values to a newly created shortcut. + ## Sync-start reconcile of Steam-UI-deleted shortcuts A user can delete a RomM shortcut through **Steam's own UI** (remove from library), which the plugin never observes. The @@ -416,6 +462,15 @@ The backend also writes each `{app_id}p.png` grid file at commit (`SyncReporter. file even if a per-item API call failed, so a residual gray tile resolves the next time the game's page is opened or on the next client restart. +## Pre-launch launch-options confirmation + +Both launch funnels (the game-detail Play button and Steam's direct-launch watcher) re-fetch the selected ROM's resolved +command and confirm-write it immediately before `RunGame`. Ordinary fetch or Steam-write failures remain best-effort and +the launch proceeds. A three-second callable timeout is different: the already-cancelled launch remains blocked, while +the unresolved callable stays observed so a lease token returned later is released without a Steam write. Each launch +captures its plugin/component generation before gate and modal waits; teardown makes that admission stale, and even an +immediate remount cannot let the old chain write launch options or invoke `RunGame` under the new generation. + ## Key Files | File | Purpose | @@ -475,9 +530,8 @@ two callers differ only in chunk size: - **Remove** (`removeShortcutsPaced` in `src/utils/shortcutRemoval.ts`, shared by the DangerZone actions and the `sync_stale` cleanup) paces in **25-item chunks with a 50ms breather** between them. A removal is a single cheap call, so chunked yielding keeps a 5000-game teardown at ~seconds of overhead instead of the ~4 minutes strict 50ms/item - would cost, while still letting the renderer breathe. No heartbeat hook: the DangerZone actions run outside any sync, - and `sync_stale` fires at run finalize — after every per-unit heartbeat window has already closed and detached from - the watchdog (the backend never awaits the frontend's stale removal). + would cost, while still letting the renderer breathe. DangerZone and `sync_stale` removals hold renewable prune + conflict leases for the complete paced loop even though the backend does not await the frontend's stale removal. ### The apply is chunked; a heartbeat timeout must not discard a chunk's delivered bindings diff --git a/docs/user-guide/managing-games.md b/docs/user-guide/managing-games.md index acbf1864..b06376e7 100644 --- a/docs/user-guide/managing-games.md +++ b/docs/user-guide/managing-games.md @@ -98,6 +98,58 @@ While a switch is being applied, the version control briefly shows a spinner and settles and its version list refreshes — so a fast second tap can't act against the not-yet-updated list (for example, switching straight back before the list has caught up). +### Cleaning up versions removed from RomM + +Normal library sync never deletes retained local rows, installed files, saves, or playtime just because a game +disappears from RomM. To remove that state explicitly, open **Danger Zone → Clean Up Removed RomM Games**. The first +scan is local and finds groups containing rows absent from a completed platform fetch. The modal byte-budgets every page +for the Decky bridge and pages every member of those affected groups, marking which rows are candidates; load every page +before confirmation. The confirmation run checks every exact RomM id again. Only a confirmed 404 can be removed. +Offline, timeout, authentication, server, malformed-response, active-download, and ambiguous multi-shortcut cases are +skipped and reported without deleting data. + +The confirmation options apply to this run only: + +- **Repoint vanished shortcuts to the live Default** is on. It preserves the Steam shortcut and its appId, collections, + artwork, and playtime while switching to the group's natural live Default. This option works independently of row + removal. +- **Remove confirmed rows and installed content from groups with a live version** is on. +- **Remove fully vanished games and their Steam shortcut** is off. Enable it only when you intend to remove a whole + game. +- **Create recovery bundle** is on. Bundles are sealed under `~/decky-romm-sync-recovery/bundles/` before mutation. +- **Include installed ROM content** is off for every disclosed installed row. Its exact recursive size is shown; + selecting more than the currently free recovery space blocks confirmation. Turning recovery off clears and disables + these selections. Large selections are staged in bounded pages before the run without a total selection cap. + Unselected installed content is still deleted if the row is removed. + +The displayed selected-content total is a lower-bound preflight, not the complete bundle size. Use **Refresh free +space** after freeing disk space. The backend remeasures selected ROMs plus mandatory saves, backup history, caches, and +Steam files before mutation and safely fails the group if the complete bundle does not fit. + +Recovery always records the affected database state, local playtime and pending sessions, exact attributable current +saves (including path-safe filenames retained in prior save-sync state) and backup history, and relevant plugin caches. +Fully vanished shortcut recovery also records bounded Steam details, collections/playtime fields, grid art, Steam Input +files, and the controller setting. It contains no settings, credentials, tokens, whole database, BIOS, or save states. +Recovery is manual only: there is no restore UI, and a new Steam shortcut cannot inherit the recorded Steam-assigned +appId or playtime. + +A synced vanished row also has **Remove local data...** directly below it in the version picker. A synced singleton +vanished binding shows the same focused action without opening an otherwise empty picker. Both open the same +confirmation scoped to that one row; the vanished version itself remains non-switchable. + +If Steam changes before local cleanup can finish, the report can show a **partial** group with the concrete committed +action and failure message. A confirmed shortcut removal is reconciled to an unbound retained row; a committed repoint +remains bound to the new Default. If Steam removal succeeded but every completion report was lost, the result is marked +ambiguous and source data stays retained; the same applies when Steam removal was attempted but its absence could not be +confirmed. Retrying confirms an already-absent shortcut instead of removing it twice. Save ownership warnings are shown +in a focusable terminal-detail region even when the group was removed successfully, and a run-level cancellation or +failure message remains visible after earlier groups committed. If bounded warning or message text was omitted or +shortened, the detail distinguishes omitted warnings from shortened displayed text and never reports zero additional +warnings. Progress is tied to the preview that you confirmed, so a matching run is still shown if only the successful +start response is delayed or lost; a matching terminal event makes the modal closable immediately. Once that terminal +result is assembled, delayed frames for the same run cannot replace it. Frames from an older preview are ignored. These +outcomes are intentional and retryable rather than being reported as unchanged. + ### Region and Languages In the panel's **Game Info** tab, the plugin shows the **Region** (e.g. `USA/Europe`) and **Languages** (e.g. `En, Fr`) diff --git a/docs/user-guide/syncing-your-library.md b/docs/user-guide/syncing-your-library.md index 13a3a07f..0d2dd533 100644 --- a/docs/user-guide/syncing-your-library.md +++ b/docs/user-guide/syncing-your-library.md @@ -308,6 +308,10 @@ shortcuts are updated rather than duplicated. If the specific version a shortcut game still has other versions on the server, the shortcut is **kept** and quietly re-pointed at a surviving version — it is not torn down and re-created, so its artwork, collections, and playtime are preserved. +Sync itself never purges a retained local ROM row, installed content, saves, or playtime solely because RomM stopped +returning that id. Removing that state requires the separate confirmed +[Clean Up Removed RomM Games](managing-games.md#cleaning-up-versions-removed-from-romm) workflow. + ## Removing Shortcuts To remove synced games, use the **Danger Zone** page. See diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md index ad8b4343..c1d444f9 100644 --- a/docs/user-guide/troubleshooting.md +++ b/docs/user-guide/troubleshooting.md @@ -214,6 +214,49 @@ While a library sync is running (or cancelling), the shortcut and ROM removal ac unavailable — the buttons are disabled with a short hint, and the backend refuses the request too. Wait for the sync to finish, or cancel it, before removing shortcuts or ROMs. Save-file and BIOS deletions are not affected. +### Clean Up Removed RomM Games + +This is the only workflow that deletes retained local database rows for games RomM no longer has. The initial scan may +show a candidate, but deletion still requires a fresh exact-id 404 during the confirmed run and another check after long +recovery or Steam work. A skip is therefore expected if RomM comes back, the response is uncertain, a download starts, +local state changes, recovery cannot seal, or Steam cannot confirm its action. + +While cleanup is starting or running, library sync, downloads/resumes, migrations, version switches, save mutations, +session finalization, launch evaluation, core/disc changes, Steam Input application, uninstalls, and relevant cache +cleanup are refused. Frontend Steam continuations hold bounded expiring leases until acknowledged, so a lost page or +bridge response cannot block cleanup forever. Active work renews its lease while applying Steam changes; a lease expires +only after five minutes without a successful renewal. Component/plugin teardown stops future Steam writes and renewal, +but a lease is not explicitly released while an already-started Steam operation is still settling. An unresolved +continuation stops renewing after its bounded frontend lifetime and then relies on backend expiry. Server, sign-in, +token, and user changes (including a connection test that can backfill user identity) are refused during the run, and +every exact-id check also verifies the same server/user namespace captured by the preview. Cancel or finish the cleanup +before retrying those actions. This reciprocal block prevents newly downloaded content or recovered state from appearing +after recovery was captured and then being removed by finalization. + +Recovery bundles are under `~/decky-romm-sync-recovery/bundles/`. A directory appears there only after every required +copy and checksum succeeds, directory durability succeeds where supported, and the staging directory is atomically +sealed. A post-rename durability failure preserves the directory with a `.durability-uncertain` suffix instead of making +it look successfully sealed. Before the Steam action and again before local finalization, the backend verifies the seal, +manifest/checksums, bundle-bound source claims, source root/descendant identities, mount IDs and bytes, expected absence +of currently missing saves, database state, save ownership, and captured Steam/controller state. Filesystem mutation +then holds kernel writer-exclusion leases through descriptor-relative removal; replacing a path, keeping a writable file +descriptor open, crossing a nested mount, or creating a previously absent save after sealing does not authorize +deletion. Save quarantine publishes with atomic no-replace semantics, so a backup created concurrently is preserved +rather than overwritten. Recovery destination directories and files stay attached to held descriptors through metadata +updates, hashing, sealing, and validation, so a swapped symlink is not re-authorized. A failed attempt may report +`recovery_failed`; it leaves the local game unchanged. Incomplete staging is removed only when descriptor-relative, +mount-aware cleanup proves the tree safe. Otherwise the error names the full path of that preserved staging so mounted +or uncertain data is never traversed recursively. Free-space blocking in the modal covers the installed ROM content you +selected. The backend checks actual source size and free space again at copy time, so a later disk-space change can +still stop the group safely. + +The cleanup report is per group: unrelated groups continue after a skip or failure. Large runs deliver terminal details +in serialized-byte-bounded chunks, which the UI assembles only after every earlier chunk arrives. A **partial** result +means the report lists a Steam or filesystem action that committed or became ambiguous before a later guard failed; read +that group's concrete message and save warnings before retrying. If a recovery bundle was sealed but a later liveness or +Steam check aborted, keep the bundle; it is a valid pre-action snapshot even though no local row was deleted. Recovery +has no automatic import flow. + A bulk shortcut removal is paced so it never freezes the interface, so on a large library it can take a few seconds. While one is running, all the removal buttons are disabled and a spinner with a live **Removing x of y** counter shows the progress; the buttons re-enable and the final count appears once it finishes. You can't start a second removal (or a diff --git a/main.py b/main.py index 110bd100..cd33c392 100755 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ import os import sys from dataclasses import asdict -from typing import Any +from typing import Any, cast plugin_dir = os.path.dirname(__file__) sys.path.insert(0, os.path.join(plugin_dir, "py_modules")) @@ -17,6 +17,18 @@ ) from lib.migration_gate import migration_blocked +from lib.prune_gate import ( + acquire_prune_conflict_lease, + prune_active_blocked, + prune_exclusive_start, + retain_prune_conflict, +) +from lib.prune_gate import ( + release_prune_conflict_lease as release_prune_gate_lease, +) +from lib.prune_gate import ( + renew_prune_conflict_lease as renew_prune_gate_lease, +) from lib.sync_gate import sync_active_blocked @@ -69,6 +81,29 @@ def _log_debug(self, msg): """ self._debug_logger(msg) + async def _emit_with_prune_continuation(self, event, /, *args): + """Attach a prune lease to events whose Steam writes outlive backend work.""" + payload = cast("dict[str, Any]", args[0]) if args and isinstance(args[0], dict) else None + needs_lease = payload is not None and ( + event == "sync_complete" + or (event == "sync_stale" and bool(payload.get("remove"))) + or (event == "prune_complete" and payload.get("final") is not False and payload.get("publication_required")) + or (event == "download_complete" and payload.get("app_id") is not None) + or (event == "migration_relaunch_options" and bool(payload.get("items"))) + ) + lease_token = None + if needs_lease and payload is not None: + payload = dict(payload) + lease_token = await acquire_prune_conflict_lease(self, event) + payload["prune_lease_token"] = lease_token + args = (payload, *args[1:]) + try: + await decky.emit(event, *args) + except BaseException: + if lease_token is not None: + await release_prune_gate_lease(self, lease_token) + raise + async def _main(self): # Decky lifecycle — must be async self.loop = asyncio.get_event_loop() @@ -102,7 +137,7 @@ async def _main(self): # Decky lifecycle — must be async logger=decky.logger, plugin_dir=decky.DECKY_PLUGIN_DIR, runtime_dir=decky.DECKY_PLUGIN_RUNTIME_DIR, - emit=decky.emit, + emit=self._emit_with_prune_continuation, clock=result.runtime_adapters.clock, uuid_gen=result.runtime_adapters.uuid_gen, sleeper=result.runtime_adapters.sleeper, @@ -130,6 +165,7 @@ async def _main(self): # Decky lifecycle — must be async self._core_service = services["core_service"] self._disc_service = services["disc_service"] self._version_switch_service = services["version_switch_service"] + self._prune_service = services["prune_service"] self._connection_service = services["connection_service"] self._startup_healing_service = services["startup_healing_service"] self._launch_gate_service = services["launch_gate_service"] @@ -161,6 +197,7 @@ async def _main(self): # Decky lifecycle — must be async async def _unload(self): # Decky lifecycle — must be async self._sync_service.shutdown() + await self._prune_service.shutdown() await self._download_service.shutdown() await self._migration_service.shutdown() await self._session_lifecycle_service.shutdown() @@ -186,21 +223,27 @@ async def _cancel_playtime_flush_tasks(self): # framework, which requires `async def` even when no `await` is used. # S7503 warnings are suppressed in sonar-project.properties (fp1). + @prune_active_blocked async def test_connection(self): return await self._connection_service.test_connection() + @prune_active_blocked async def connect_with_credentials(self, romm_url, username, password, allow_insecure_ssl=None): return await self._connection_service.establish_token(romm_url, username, password, allow_insecure_ssl) + @prune_active_blocked async def connect_with_token(self, romm_url, token, allow_insecure_ssl=None): return await self._connection_service.establish_user_token(romm_url, token, allow_insecure_ssl) + @prune_active_blocked async def connect_with_pairing_code(self, romm_url, code, allow_insecure_ssl=None): return await self._connection_service.establish_paired_token(romm_url, code, allow_insecure_ssl) + @prune_active_blocked async def sign_out(self): return self._connection_service.sign_out() + @prune_active_blocked async def save_server_url(self, romm_url, allow_insecure_ssl=None): return self._settings_service.save_server_url(romm_url, allow_insecure_ssl) @@ -222,6 +265,7 @@ async def save_preferred_region(self, region): async def get_known_regions(self): return self._settings_service.get_known_regions() + @prune_active_blocked async def apply_steam_input_setting(self): return self._settings_service.apply_steam_input_setting() @@ -255,16 +299,28 @@ async def get_cached_game_detail(self, app_id): return self._game_detail_service.get_cached_game_detail(app_id) @migration_blocked + @prune_active_blocked async def set_system_core(self, platform_slug, core_label): - return await self._core_service.set_system_core(platform_slug, core_label) + result = await self._core_service.set_system_core(platform_slug, core_label) + if result.get("success") and result.get("rebake_items"): + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "system_core") + return result @migration_blocked + @prune_active_blocked async def set_game_core(self, rom_id, label): - return await self._core_service.set_game_core(rom_id, label) + result = await self._core_service.set_game_core(rom_id, label) + if result.get("success") and result.get("launch_options") is not None and result.get("app_id") is not None: + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "game_core") + return result @migration_blocked + @prune_active_blocked async def clear_game_core(self, rom_id): - return await self._core_service.clear_game_core(rom_id) + result = await self._core_service.clear_game_core(rom_id) + if result.get("success") and result.get("launch_options") is not None and result.get("app_id") is not None: + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "game_core") + return result async def get_platform_core_info(self, rom_id): return await self._core_service.get_platform_core_info(rom_id) @@ -275,8 +331,12 @@ async def get_disc_selection(self, rom_id): return await self._disc_service.get_disc_selection(rom_id) @migration_blocked + @prune_active_blocked async def select_disc(self, rom_id, filename): - return await self._disc_service.select_disc(rom_id, filename) + result = await self._disc_service.select_disc(rom_id, filename) + if result.get("success") and result.get("launch_options") is not None: + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "disc_selection") + return result # ── Version picker delegation to VersionSwitchService ────────────── @@ -284,8 +344,46 @@ async def get_version_list(self, app_id): return await self._version_switch_service.get_version_list(app_id) @migration_blocked + @prune_active_blocked async def switch_version(self, app_id, target_rom_id, allow_stranded): - return await self._version_switch_service.switch_version(app_id, target_rom_id, allow_stranded) + result = await self._version_switch_service.switch_version(app_id, target_rom_id, allow_stranded) + if result.get("success"): + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "version_switch") + return result + + @migration_blocked + @sync_active_blocked + async def get_prune_preview(self, request): + return await self._prune_service.get_prune_preview(request) + + async def stage_prune_installed_selection(self, request): + return await self._prune_service.stage_prune_installed_selection(request) + + @prune_exclusive_start + @migration_blocked + @sync_active_blocked + async def start_prune(self, request): + return await self._prune_service.start_prune(request) + + async def report_prune_action(self, request): + return await self._prune_service.report_prune_action(request) + + async def wait_for_prune_release(self, run_id): + return await self._prune_service.wait_for_prune_release(run_id) + + async def release_prune_conflict_lease(self, lease_token): + await release_prune_gate_lease(self, str(lease_token)) + return {"success": True, "message": "Operation lease released."} + + async def renew_prune_conflict_lease(self, lease_token): + renewed = await renew_prune_gate_lease(self, str(lease_token)) + if not renewed: + return { + "success": False, + "reason": "stale_lease", + "message": "Operation lease is no longer active.", + } + return {"success": True, "message": "Operation lease renewed."} # ── Firmware delegation to FirmwareService ────────────── @@ -350,6 +448,7 @@ async def set_collection_naming_mode(self, mode): return self._settings_service.set_collection_naming_mode(mode) @migration_blocked + @prune_active_blocked async def start_sync(self): return self._sync_service.start_sync() @@ -360,10 +459,12 @@ async def sync_heartbeat(self): return self._sync_service.sync_heartbeat() @migration_blocked + @prune_active_blocked async def sync_preview(self): return await self._sync_service.sync_preview() @migration_blocked + @prune_active_blocked async def sync_apply_delta(self, preview_id): return await self._sync_service.sync_apply_delta(preview_id) @@ -376,6 +477,7 @@ async def get_sync_status(self): async def get_session_budget_status(self): return await self._sync_service.get_session_budget_status() + @prune_active_blocked async def report_unit_results(self, rom_id_to_app_id, run_id, unit_id, chunk_index): return await self._sync_service.report_unit_results(rom_id_to_app_id, run_id, unit_id, chunk_index) @@ -384,42 +486,60 @@ async def get_registry_platforms(self): @migration_blocked @sync_active_blocked + @prune_active_blocked async def remove_platform_shortcuts(self, platform_slug): - return await self._shortcut_removal_service.remove_platform_shortcuts(platform_slug) + result = await self._shortcut_removal_service.remove_platform_shortcuts(platform_slug) + if result.get("success") and result.get("app_ids"): + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "shortcut_removal") + return result @migration_blocked @sync_active_blocked + @prune_active_blocked async def remove_all_shortcuts(self): - return self._shortcut_removal_service.remove_all_shortcuts() + result = self._shortcut_removal_service.remove_all_shortcuts() + if result.get("success") and result.get("app_ids"): + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "shortcut_removal") + return result - async def report_removal_results(self, removed_rom_ids): - return await self._shortcut_removal_service.report_removal_results(removed_rom_ids) + @prune_active_blocked + async def report_removal_results(self, removed_rom_ids, lease_token): + try: + return await self._shortcut_removal_service.report_removal_results(removed_rom_ids) + finally: + await release_prune_gate_lease(self, str(lease_token)) + @prune_active_blocked async def reconcile_shortcuts(self, live_app_ids): return await self._shortcut_removal_service.reconcile_live_shortcuts(live_app_ids) async def get_artwork_base64(self, rom_id): return await self._artwork_service.get_artwork_base64(rom_id) + @prune_active_blocked async def fetch_cover_base64(self, rom_id): return await self._artwork_service.fetch_cover_base64(rom_id) @migration_blocked + @prune_active_blocked async def refresh_cover_artwork(self, rom_id): return await self._artwork_service.refresh_cover(int(rom_id)) @migration_blocked @sync_active_blocked + @prune_active_blocked async def cleanup_orphaned_grid_images(self, live_app_ids, dry_run): return await self._artwork_service.cleanup_orphaned_grid_images(live_app_ids, dry_run) @migration_blocked + @prune_active_blocked async def clear_sync_cache(self): return self._sync_service.clear_sync_cache() async def get_sync_stats(self): return self._sync_service.get_sync_stats() + @prune_active_blocked async def evaluate_launch(self, steam_app_id): verdict = await self._launch_gate_service.evaluate(steam_app_id) return asdict(verdict) @@ -427,27 +547,35 @@ async def evaluate_launch(self, steam_app_id): async def check_local_drift(self, rom_id): return await self._launch_gate_service.check_local_drift(rom_id) + @prune_active_blocked async def get_rom_relaunch_options(self, rom_id): - """Return ``{app_id, launch_options}`` for one installed+bound ROM, or None. + """Return one lease-bearing relaunch item, a gate failure, or ``None``. The Play-button funnel re-confirms the shortcut's launch command from this just before launch to heal mid-session ``launch_options`` drift - (#1150). Read-only — no migration gate, no canonical failure shape. + (#1150). The lease covers the subsequent frontend Steam write. """ - return await self.loop.run_in_executor(None, self._relaunch_options_resolver.relaunch_item_for_rom, int(rom_id)) + item = await self.loop.run_in_executor(None, self._relaunch_options_resolver.relaunch_item_for_rom, int(rom_id)) + if item is not None: + item["success"] = True + item["prune_lease_token"] = await acquire_prune_conflict_lease(self, "launch_reconfirm") + return item async def probe_reachability(self): return await self._connection_service.probe_reachability() + @prune_active_blocked async def refresh_save_status(self, rom_id): # Fire-and-forget: schedule the background status check (which re-reads # the conflict state and emits ``save_status_updated``) and return # immediately so the frontend never blocks on the round-trip. Mirrors the # create_task pattern in services/saves/slots/switching.py (same call, # same target); check_save_status_background owns its own error handling. - self.loop.create_task(self._save_sync_service.check_save_status_background(int(rom_id))) + task = self.loop.create_task(self._save_sync_service.check_save_status_background(int(rom_id))) + await retain_prune_conflict(self, task) return {"success": True} + @prune_active_blocked async def finalize_game_session(self, rom_id): result = await self._session_lifecycle_service.finalize(rom_id) return asdict(result) @@ -455,8 +583,13 @@ async def finalize_game_session(self, rom_id): # ── Download delegation to DownloadService ────────────── @migration_blocked + @prune_active_blocked async def start_download(self, rom_id): - return await self._download_service.start_download(rom_id) + result = await self._download_service.start_download(rom_id) + task = self._download_service.task_for_rom(int(rom_id)) if result.get("success") else None + if task is not None: + await retain_prune_conflict(self, task) + return result async def cancel_download(self, rom_id): return self._download_service.cancel_download(rom_id) @@ -465,8 +598,13 @@ async def pause_download(self, rom_id): return self._download_service.pause_download(rom_id) @migration_blocked + @prune_active_blocked async def resume_download(self, rom_id): - return await self._download_service.resume_download(rom_id) + result = await self._download_service.resume_download(rom_id) + task = self._download_service.task_for_rom(int(rom_id)) if result.get("success") else None + if task is not None: + await retain_prune_conflict(self, task) + return result async def get_download_queue(self): return self._download_service.get_download_queue() @@ -478,13 +616,21 @@ async def get_installed_rom(self, rom_id): return self._download_service.get_installed_rom(rom_id) @migration_blocked + @prune_active_blocked async def remove_rom(self, rom_id): - return await self._rom_removal_service.remove_rom(rom_id) + result = await self._rom_removal_service.remove_rom(rom_id) + if result.get("success"): + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "rom_uninstall") + return result @migration_blocked @sync_active_blocked + @prune_active_blocked async def uninstall_all_roms(self): - return await self._rom_removal_service.uninstall_all_roms() + result = await self._rom_removal_service.uninstall_all_roms() + if result.get("app_ids"): + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "bulk_uninstall") + return result # ── Save Sync / Playtime delegation to services ────────── @@ -494,6 +640,7 @@ async def ensure_device_registered(self): async def list_devices(self): return await self._save_sync_service.list_devices() + @prune_active_blocked async def get_save_status(self, rom_id): return await self._save_sync_service.get_save_status(rom_id) @@ -501,13 +648,16 @@ async def check_core_change(self, rom_id): return self._save_sync_service.check_core_change(rom_id) @migration_blocked + @prune_active_blocked async def pre_launch_sync(self, rom_id): return await self._save_sync_service.pre_launch_sync(rom_id) @migration_blocked + @prune_active_blocked async def sync_rom_saves(self, rom_id): return await self._save_sync_service.sync_rom_saves(rom_id) + @prune_active_blocked async def get_save_slots(self, rom_id): return await self._save_sync_service.get_save_slots(rom_id) @@ -515,6 +665,7 @@ async def get_slot_saves(self, rom_id, slot): return await self._save_sync_service.get_slot_saves(rom_id, slot) @migration_blocked + @prune_active_blocked async def switch_slot(self, rom_id, new_slot): return await self._save_sync_service.switch_slot(rom_id, new_slot) @@ -522,6 +673,7 @@ async def get_slot_delete_info(self, rom_id, slot): return await self._save_sync_service.get_slot_delete_info(rom_id, slot) @migration_blocked + @prune_active_blocked async def delete_slot(self, rom_id, slot): return await self._save_sync_service.delete_slot(rom_id, slot) @@ -532,6 +684,7 @@ async def get_save_setup_info(self, rom_id): return await self._save_sync_service.get_save_setup_info(rom_id) @migration_blocked + @prune_active_blocked async def confirm_slot_choice( self, rom_id, chosen_slot, migrate=False, migrate_from_slot=None, use_server_on_conflict=False ): @@ -540,10 +693,12 @@ async def confirm_slot_choice( ) @migration_blocked + @prune_active_blocked async def sync_all_saves(self): return await self._save_sync_service.sync_all_saves() @migration_blocked + @prune_active_blocked async def resolve_sync_conflict(self, rom_id, filename, server_save_id, action): return await self._save_sync_service.resolve_sync_conflict(rom_id, filename, server_save_id, action) @@ -555,10 +710,12 @@ async def update_save_sync_settings(self, settings): return self._save_sync_service.update_save_sync_settings(settings) @migration_blocked + @prune_active_blocked async def delete_local_saves(self, rom_id): return self._save_sync_service.delete_local_saves(rom_id) @migration_blocked + @prune_active_blocked async def delete_platform_saves(self, platform_slug): return self._save_sync_service.delete_platform_saves(platform_slug) @@ -566,20 +723,24 @@ async def saves_list_file_versions(self, rom_id, slot, filename): return await self._save_sync_service.list_file_versions(rom_id, slot, filename) @migration_blocked + @prune_active_blocked async def saves_rollback_to_version(self, rom_id, slot, save_id): return await self._save_sync_service.rollback_to_version(rom_id, slot, save_id) @migration_blocked + @prune_active_blocked async def copy_save_to_slot(self, rom_id, save_id, target_slot): return await self._save_sync_service.copy_save_to_slot(rom_id, save_id, target_slot) + @prune_active_blocked async def record_session_start(self, rom_id): result = self._playtime_service.record_session_start(rom_id) # Fire-and-forget: drain any offline play-session backlog into RomM's # native ingest on the next launch; returns immediately so the launch is # never blocked on the round-trip. flush_pending_sessions owns its own # error handling (best-effort, offline-safe). - self._schedule_playtime_flush() + task = self._schedule_playtime_flush() + await retain_prune_conflict(self, task) return result def _schedule_playtime_flush(self): @@ -596,10 +757,12 @@ def _schedule_playtime_flush(self): task = self.loop.create_task(self._playtime_service.flush_pending_sessions()) tasks.add(task) task.add_done_callback(tasks.discard) + return task async def get_all_playtime(self): return self._playtime_service.get_all_playtime() + @prune_active_blocked async def reconcile_playtime(self, rom_id): return await self._playtime_service.reconcile_playtime(int(rom_id)) @@ -618,8 +781,12 @@ async def get_playtime_scope_notice(self): # ── SGDB delegation to SteamGridService ─────────────────────── + @prune_active_blocked async def get_sgdb_artwork_base64(self, rom_id, asset_type_num): - return await self._sgdb_service.get_sgdb_artwork_base64(rom_id, asset_type_num) + result = await self._sgdb_service.get_sgdb_artwork_base64(rom_id, asset_type_num) + if result.get("base64") is not None: + result["prune_lease_token"] = await acquire_prune_conflict_lease(self, "sgdb_artwork") + return result async def verify_sgdb_api_key(self, api_key=None): return await self._sgdb_service.verify_sgdb_api_key(api_key) @@ -627,15 +794,18 @@ async def verify_sgdb_api_key(self, api_key=None): async def save_sgdb_api_key(self, api_key): return self._sgdb_service.save_sgdb_api_key(api_key) + @prune_active_blocked async def save_shortcut_icon(self, app_id, icon_base64): return await self._sgdb_service.save_shortcut_icon(app_id, icon_base64) + @prune_active_blocked async def get_sgdb_resolution(self, rom_id): return await self._sgdb_service.get_sgdb_resolution(rom_id) async def search_sgdb_games(self, term): return await self._sgdb_service.search_sgdb_games(term) + @prune_active_blocked async def apply_sgdb_game_id(self, rom_id, sgdb_id): return await self._sgdb_service.apply_sgdb_game_id(rom_id, sgdb_id) @@ -650,11 +820,15 @@ async def get_metadata_cache_page(self, offset, limit): async def get_app_id_rom_id_map(self): return self._metadata_service.get_app_id_rom_id_map() + @prune_active_blocked async def get_installed_relaunch_options(self): - """Return [{app_id, launch_options}] for every installed+bound ROM so the - frontend can re-confirm drifted Steam-shortcut launch commands at startup - (#1043). Read-only — not migration-gated.""" - return await self.loop.run_in_executor(None, self._startup_healing_service.get_installed_relaunch_options) + """Return lease-bearing relaunch items for installed and bound ROMs. + + The frontend uses them to heal Steam-shortcut drift at startup (#1043). + """ + items = await self.loop.run_in_executor(None, self._startup_healing_service.get_installed_relaunch_options) + token = await acquire_prune_conflict_lease(self, "installed_reconcile") if items else None + return {"success": True, "items": items, "prune_lease_token": token} # ── Achievements delegation to AchievementsService ─────── @@ -666,6 +840,7 @@ async def get_achievement_progress(self, rom_id): # ── Migration delegation to MigrationService ────────────── + @prune_active_blocked async def migrate_retrodeck_files(self, conflict_strategy=None): return await self._migration_service.migrate_retrodeck_files(conflict_strategy) @@ -675,6 +850,7 @@ async def get_migration_status(self): async def get_save_sort_migration_status(self): return await self._migration_service.get_save_sort_migration_status() + @prune_active_blocked async def migrate_save_sort_files(self, conflict_strategy=None): return await self._migration_service.migrate_save_sort_files(conflict_strategy) diff --git a/py_modules/adapters/descriptor_paths.py b/py_modules/adapters/descriptor_paths.py new file mode 100644 index 00000000..d59e4851 --- /dev/null +++ b/py_modules/adapters/descriptor_paths.py @@ -0,0 +1,780 @@ +"""Descriptor-relative exact-identity mutation helpers for recovery-backed cleanup.""" + +from __future__ import annotations + +import contextlib +import ctypes +import errno +import fcntl +import hashlib +import os +import signal +import stat +import struct +import threading +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from models.prune import MutationOutcome, SourceClaim, SourceEntry, SourceIdentity + +_F_SETOWN_EX = 15 +_F_OWNER_TID = 0 +_RENAME_NOREPLACE = 1 + + +def identity_for_stat(value: os.stat_result, mount_id: int = 0) -> SourceIdentity: + return { + "exists": True, + "mount_id": mount_id, + "device": value.st_dev, + "inode": value.st_ino, + "mode": value.st_mode, + "size": value.st_size, + "mtime_ns": value.st_mtime_ns, + "ctime_ns": value.st_ctime_ns, + } + + +def missing_identity() -> SourceIdentity: + return { + "exists": False, + "mount_id": 0, + "device": 0, + "inode": 0, + "mode": 0, + "size": 0, + "mtime_ns": 0, + "ctime_ns": 0, + } + + +def stat_beneath(path: str, safe_root: str) -> os.stat_result | None: + try: + parent_fd, name = _open_parent(path, safe_root) + except FileNotFoundError: + return None + try: + try: + return os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return None + finally: + os.close(parent_fd) + + +def claim_source(path: str, safe_root: str) -> SourceClaim: + """Capture a complete no-follow identity claim for one source tree.""" + try: + parent_fd, name = _open_parent(path, safe_root) + except FileNotFoundError: + return _claim(path, safe_root, missing_identity(), None, {}) + try: + current = _stat_name(parent_fd, name) + if current is None: + return _claim(path, safe_root, missing_identity(), None, {}) + if stat.S_ISREG(current.st_mode): + file_fd = _open_child_regular(parent_fd, name) + try: + _require_same_mount(parent_fd, file_fd, path) + identity = _identity_for_fd(file_fd) + _require_entry_matches_fd(path, current, identity) + digest = _sha256_fd(file_fd) + if _identity_for_fd(file_fd) != identity: + raise RuntimeError(f"Recovery source changed while it was claimed: {path}") + finally: + os.close(file_fd) + return _claim(path, safe_root, identity, digest, {}) + if stat.S_ISLNK(current.st_mode): + raise ValueError(f"Recovery source may not be a symlink: {path}") + if not stat.S_ISDIR(current.st_mode): + raise ValueError(f"Recovery source has unsupported type: {path}") + directory_fd = _open_child_directory(parent_fd, name) + try: + _require_same_mount(parent_fd, directory_fd, path) + identity = _identity_for_fd(directory_fd) + _require_entry_matches_fd(path, current, identity) + entries = _inventory_directory(directory_fd, identity["mount_id"]) + finally: + os.close(directory_fd) + return _claim(path, safe_root, identity, None, entries) + finally: + os.close(parent_fd) + + +def remove_claimed(path: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + """Claim, revalidate, and durably remove exactly one source tree.""" + _require_claim_shape(path, safe_root, claim) + expected = claim["source_identity"] + try: + parent_fd, name = _open_parent(path, safe_root) + except FileNotFoundError: + if expected["exists"]: + raise RuntimeError(f"Recovery source disappeared after sealing: {path}") from None + return _outcome(success=True, changed=False, ambiguous=False, message="Source was already absent") + try: + current = _stat_name(parent_fd, name) + _require_identity(path, current, expected) + if current is None: + return _outcome(success=True, changed=False, ambiguous=False, message="Source was already absent") + temporary = f".{name}.romm-prune-{current.st_ino}" + if _stat_name(parent_fd, temporary) is not None: + raise FileExistsError(f"Prune staging entry already exists: {temporary}") + rename_noreplace_at(parent_fd, name, parent_fd, temporary) + claimed = _stat_name(parent_fd, temporary) + lease_stack = contextlib.ExitStack() + leased_files: dict[str, int] = {} + try: + _require_claimed_identity(path, claimed, expected) + if stat.S_ISDIR(current.st_mode): + directory_fd = _open_child_directory(parent_fd, temporary) + try: + _require_same_mount(parent_fd, directory_fd, path) + actual_entries = _inventory_directory(directory_fd, expected["mount_id"]) + finally: + os.close(directory_fd) + if actual_entries != claim["entries"]: + raise RuntimeError(f"Recovery source subtree changed after sealing: {path}") + directory_fd = _open_child_directory(parent_fd, temporary) + try: + _acquire_directory_leases( + directory_fd, + claim["entries"], + expected["mount_id"], + lease_stack, + leased_files, + ) + _validate_claimed_directory(directory_fd, claim["entries"], expected["mount_id"]) + finally: + os.close(directory_fd) + elif claim["entries"]: + raise ValueError(f"Regular-file recovery claim has descendants: {path}") + else: + file_fd = lease_stack.enter_context(_leased_regular(parent_fd, temporary, path)) + leased_files[""] = file_fd + _require_same_mount(parent_fd, file_fd, path) + _require_file_claim(path, file_fd, expected, claim["sha256"], claimed=True) + except Exception: + close_error: BaseException | None = None + try: + lease_stack.close() + except BaseException as exc: + close_error = exc + try: + _restore_claim(parent_fd, name, temporary) + except Exception as restore_exc: + return _outcome( + success=False, + changed=True, + ambiguous=True, + message=f"Source validation failed and rollback was uncertain: {restore_exc}", + ) + if close_error is not None: + raise RuntimeError( + f"Writer-exclusion teardown failed after source validation: {close_error}" + ) from close_error + raise + removal_error: BaseException | None = None + try: + if stat.S_ISDIR(current.st_mode): + directory_fd = _open_child_directory(parent_fd, temporary) + try: + _delete_claimed_directory( + directory_fd, + claim["entries"], + expected["mount_id"], + leased_files=leased_files, + ) + finally: + os.close(directory_fd) + os.rmdir(temporary, dir_fd=parent_fd) + else: + file_fd = leased_files[""] + _require_file_claim(path, file_fd, expected, claim["sha256"], claimed=True) + current_claim = os.stat(temporary, dir_fd=parent_fd, follow_symlinks=False) + _require_entry_matches_fd(path, current_claim, _identity_for_fd(file_fd)) + os.unlink(temporary, dir_fd=parent_fd) + except Exception as exc: + removal_error = exc + try: + lease_stack.close() + except BaseException as exc: + return _outcome( + success=False, + changed=True, + ambiguous=True, + message=f"Source was removed but writer-exclusion teardown is uncertain: {exc}", + ) + if removal_error is not None: + return _outcome( + success=False, + changed=True, + ambiguous=True, + message=f"Source removal stopped after the source was claimed: {removal_error}", + ) + try: + os.fsync(parent_fd) + except OSError as exc: + return _outcome( + success=False, + changed=True, + ambiguous=True, + message=f"Source was removed but directory durability is uncertain: {exc}", + ) + return _outcome(success=True, changed=True, ambiguous=False, message="Source removed") + finally: + os.close(parent_fd) + + +def remove_exact(path: str, safe_root: str, expected: SourceIdentity) -> MutationOutcome: + """Compatibility wrapper for an exact non-directory identity.""" + try: + claim = claim_source(path, safe_root) + except (OSError, ValueError) as exc: + raise RuntimeError(f"Recovery source identity changed after sealing: {path}") from exc + _require_compatible_expected(path, claim["source_identity"], expected) + return remove_claimed(path, safe_root, claim) + + +def remove_current(path: str, safe_root: str) -> MutationOutcome: + """Remove the current entry through anchored parents without following symlinks.""" + return remove_claimed(path, safe_root, claim_source(path, safe_root)) + + +def rename_claimed(src: str, dst: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + """Durably rename only the exact claimed source through anchored parents.""" + _require_claim_shape(src, safe_root, claim) + expected = claim["source_identity"] + source_fd, source_name = _open_parent(src, safe_root) + destination_fd, destination_name = _open_parent(dst, safe_root) + try: + current = _stat_name(source_fd, source_name) + _require_identity(src, current, expected) + if current is None: + return _outcome(success=True, changed=False, ambiguous=False, message="Source was already absent") + if stat.S_ISDIR(current.st_mode): + directory_fd = _open_child_directory(source_fd, source_name) + try: + _require_same_mount(source_fd, directory_fd, src) + if _inventory_directory(directory_fd, expected["mount_id"]) != claim["entries"]: + raise RuntimeError(f"Recovery source subtree changed after sealing: {src}") + finally: + os.close(directory_fd) + elif claim["entries"]: + raise ValueError(f"Regular-file recovery claim has descendants: {src}") + else: + file_fd = _open_child_regular(source_fd, source_name) + try: + _require_same_mount(source_fd, file_fd, src) + _require_file_claim(src, file_fd, expected, claim["sha256"], claimed=False) + finally: + os.close(file_fd) + if _stat_name(destination_fd, destination_name) is not None: + raise FileExistsError(f"Recovery destination already exists: {dst}") + try: + rename_noreplace_at(source_fd, source_name, destination_fd, destination_name) + except FileExistsError: + raise FileExistsError(f"Recovery destination already exists: {dst}") from None + claimed = _stat_name(destination_fd, destination_name) + try: + _require_claimed_identity(src, claimed, expected) + if stat.S_ISDIR(current.st_mode): + directory_fd = _open_child_directory(destination_fd, destination_name) + try: + _require_same_mount(destination_fd, directory_fd, src) + if _inventory_directory(directory_fd, expected["mount_id"]) != claim["entries"]: + raise RuntimeError(f"Recovery source subtree changed while it was renamed: {src}") + finally: + os.close(directory_fd) + else: + file_fd = _open_child_regular(destination_fd, destination_name) + try: + _require_same_mount(destination_fd, file_fd, src) + _require_file_claim(src, file_fd, expected, claim["sha256"], claimed=True) + finally: + os.close(file_fd) + except Exception: + try: + if _stat_name(source_fd, source_name) is None: + rename_noreplace_at(destination_fd, destination_name, source_fd, source_name) + os.fsync(source_fd) + if destination_fd != source_fd: + os.fsync(destination_fd) + except Exception as restore_exc: + return _outcome( + success=False, + changed=True, + ambiguous=True, + message=f"Rename validation failed and rollback was uncertain: {restore_exc}", + ) + raise + try: + os.fsync(source_fd) + if destination_fd != source_fd: + os.fsync(destination_fd) + except OSError as exc: + return _outcome( + success=False, + changed=True, + ambiguous=True, + message=f"Source was renamed but directory durability is uncertain: {exc}", + ) + return _outcome(success=True, changed=True, ambiguous=False, message="Source renamed") + finally: + os.close(source_fd) + os.close(destination_fd) + + +def rename_exact(src: str, dst: str, safe_root: str, expected: SourceIdentity) -> MutationOutcome: + """Compatibility wrapper for an exact non-directory identity.""" + try: + claim = claim_source(src, safe_root) + except (OSError, ValueError) as exc: + raise RuntimeError(f"Recovery source identity changed after sealing: {src}") from exc + _require_compatible_expected(src, claim["source_identity"], expected) + return rename_claimed(src, dst, safe_root, claim) + + +def ensure_directory(path: str, safe_root: str, mode: int = 0o700) -> None: + """Create and open each directory component beneath an anchored root.""" + parts = _relative_parts(path, safe_root) + fd = _open_directory(safe_root, safe_root) + try: + for component in parts: + try: + next_fd = _open_child_directory(fd, component) + except FileNotFoundError: + os.mkdir(component, mode, dir_fd=fd) + os.fsync(fd) + next_fd = _open_child_directory(fd, component) + os.close(fd) + fd = next_fd + finally: + os.close(fd) + + +def require_directory(path: str, safe_root: str) -> None: + """Require every component through *path* to be a real directory, never a symlink.""" + fd = _open_directory(path, safe_root) + os.close(fd) + + +def open_directory_fd(path: str, safe_root: str) -> int: + """Open an anchored directory path with no-follow traversal.""" + return _open_directory(path, safe_root) + + +def open_regular_fd(path: str, safe_root: str) -> int: + """Open an anchored regular file with no-follow traversal.""" + parent_fd, name = _open_parent(path, safe_root) + try: + fd = os.open(name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent_fd) + if not stat.S_ISREG(os.fstat(fd).st_mode): + os.close(fd) + raise ValueError(f"Expected a regular file: {path}") + return fd + finally: + os.close(parent_fd) + + +def mount_id_for_fd(fd: int) -> int: + """Return Linux's descriptor-bound mount identity, failing closed if unavailable.""" + return _mount_id(fd) + + +def _open_parent(path: str, safe_root: str) -> tuple[int, str]: + parts = _relative_parts(path, safe_root) + if not parts: + raise ValueError(f"Path must be below its safe root: {path}") + parent = os.path.join(safe_root, *parts[:-1]) if len(parts) > 1 else safe_root + return _open_directory(parent, safe_root), parts[-1] + + +def _open_directory(path: str, safe_root: str) -> int: + root = os.path.realpath(os.path.abspath(safe_root)) + parts = _relative_parts(path, safe_root) + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(root, flags) + root_mount_id = _mount_id(fd) + try: + for component in parts: + next_fd = os.open(component, flags, dir_fd=fd) + if _mount_id(next_fd) != root_mount_id: + os.close(next_fd) + raise ValueError(f"Path crosses a mount boundary: {path}") + os.close(fd) + fd = next_fd + return fd + except BaseException: + with contextlib.suppress(OSError): + os.close(fd) + raise + + +def _relative_parts(path: str, safe_root: str) -> list[str]: + absolute_root = os.path.abspath(safe_root) + absolute_path = os.path.abspath(path) + try: + if os.path.commonpath((absolute_root, absolute_path)) != absolute_root: + raise ValueError(f"Path is outside its safe root: {path}") + except ValueError as exc: + raise ValueError(f"Path is outside its safe root: {path}") from exc + relative = os.path.relpath(absolute_path, absolute_root) + if relative == ".": + return [] + parts = relative.split(os.sep) + if any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"Unsafe path component: {path}") + return parts + + +def _stat_name(parent_fd: int, name: str) -> os.stat_result | None: + try: + return os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return None + + +def _open_child_directory(parent_fd: int, name: str) -> int: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + return os.open(name, flags, dir_fd=parent_fd) + + +def _open_child_regular(parent_fd: int, name: str) -> int: + return os.open(name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent_fd) + + +def rename_noreplace_at(source_fd: int, source_name: str, destination_fd: int, destination_name: str) -> None: + """Atomically rename one descriptor-relative entry without replacing a destination.""" + try: + renameat2 = ctypes.CDLL(None, use_errno=True).renameat2 + except AttributeError as exc: + raise OSError(errno.ENOSYS, "renameat2(RENAME_NOREPLACE) is unavailable") from exc + result = renameat2( + source_fd, + os.fsencode(source_name), + destination_fd, + os.fsencode(destination_name), + _RENAME_NOREPLACE, + ) + if result == 0: + return + error = ctypes.get_errno() + if error == errno.EEXIST: + raise FileExistsError(error, os.strerror(error), destination_name) + raise OSError(error, os.strerror(error), source_name) + + +@contextlib.contextmanager +def hold_writer_exclusion(fd: int, path: str): + """Exclude external writers from an already-open regular file descriptor.""" + previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGIO}) + leased = False + try: + fcntl.fcntl( + fd, + _F_SETOWN_EX, + struct.pack("=ii", _F_OWNER_TID, threading.get_native_id()), + ) + fcntl.fcntl(fd, fcntl.F_SETSIG, signal.SIGIO) + try: + fcntl.fcntl(fd, fcntl.F_SETLEASE, fcntl.F_RDLCK) + except OSError as exc: + raise RuntimeError(f"Recovery source has an active writer and was retained: {path}") from exc + leased = True + yield fd + finally: + release_error: OSError | None = None + if leased: + try: + fcntl.fcntl(fd, fcntl.F_SETLEASE, fcntl.F_UNLCK) + except OSError as exc: + release_error = exc + if signal.SIGIO in signal.sigpending(): + signal.sigwait({signal.SIGIO}) + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + if release_error is not None: + raise release_error + + +@contextlib.contextmanager +def _leased_regular(parent_fd: int, name: str, path: str): + """Open a regular file and exclude every external writer until context exit.""" + fd = _open_child_regular(parent_fd, name) + try: + with hold_writer_exclusion(fd, path): + yield fd + finally: + os.close(fd) + + +def _inventory_directory(directory_fd: int, mount_id: int, prefix: str = "") -> dict[str, SourceEntry]: + entries: dict[str, SourceEntry] = {} + for name in sorted(os.listdir(directory_fd)): + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + relative = f"{prefix}/{name}" if prefix else name + if stat.S_ISDIR(current.st_mode): + child_fd = _open_child_directory(directory_fd, name) + try: + _require_mount_id(child_fd, mount_id, relative) + identity = _identity_for_fd(child_fd) + _require_entry_matches_fd(relative, current, identity) + entries[relative] = {"identity": identity} + entries.update(_inventory_directory(child_fd, mount_id, relative)) + finally: + os.close(child_fd) + elif stat.S_ISREG(current.st_mode): + file_fd = _open_child_regular(directory_fd, name) + try: + _require_mount_id(file_fd, mount_id, relative) + identity = _identity_for_fd(file_fd) + _require_entry_matches_fd(relative, current, identity) + digest = _sha256_fd(file_fd) + if _identity_for_fd(file_fd) != identity: + raise RuntimeError(f"Recovery source changed while inventorying: {relative}") + entries[relative] = {"identity": identity, "sha256": digest} + finally: + os.close(file_fd) + else: + raise ValueError(f"Recovery source contains unsupported entry: {relative}") + return entries + + +def _sha256_fd(fd: int) -> str: + digest = hashlib.sha256() + os.lseek(fd, 0, os.SEEK_SET) + while block := os.read(fd, 1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def _claim( + path: str, + safe_root: str, + identity: SourceIdentity, + sha256: str | None, + entries: dict[str, SourceEntry], +) -> SourceClaim: + return { + "source_path": path, + "safe_root": safe_root, + "source_identity": identity, + "sha256": sha256, + "entries": entries, + } + + +def _require_claim_shape(path: str, safe_root: str, claim: SourceClaim) -> None: + if claim["source_path"] != path or claim["safe_root"] != safe_root: + raise ValueError(f"Source claim does not match its mutation target: {path}") + + +def _restore_claim(parent_fd: int, name: str, temporary: str) -> None: + if _stat_name(parent_fd, name) is not None: + raise RuntimeError(f"Cannot restore claimed source because its path was replaced: {name}") + rename_noreplace_at(parent_fd, temporary, parent_fd, name) + os.fsync(parent_fd) + + +def _outcome(*, success: bool, changed: bool, ambiguous: bool, message: str) -> MutationOutcome: + return {"success": success, "changed": changed, "ambiguous": ambiguous, "message": message} + + +def _require_identity(path: str, current: os.stat_result | None, expected: SourceIdentity) -> None: + if not expected["exists"]: + if current is not None: + raise RuntimeError(f"Recovery source appeared after sealing: {path}") + return + if current is None: + raise RuntimeError(f"Recovery source disappeared after sealing: {path}") + actual = identity_for_stat(current, expected["mount_id"]) + fields = ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns") + if any(actual[field] != expected[field] for field in fields): + raise RuntimeError(f"Recovery source identity changed after sealing: {path}") + + +def _require_claimed_identity(path: str, current: os.stat_result | None, expected: SourceIdentity) -> None: + if current is None or not expected["exists"]: + raise RuntimeError(f"Recovery source identity changed while it was claimed: {path}") + actual = identity_for_stat(current, expected["mount_id"]) + stable_fields = ("mount_id", "device", "inode", "mode", "size", "mtime_ns") + if any(actual[field] != expected[field] for field in stable_fields): + raise RuntimeError(f"Recovery source identity changed while it was claimed: {path}") + + +def _identity_for_fd(fd: int) -> SourceIdentity: + return identity_for_stat(os.fstat(fd), _mount_id(fd)) + + +def _mount_id(fd: int) -> int: + with open(f"/proc/self/fdinfo/{fd}", encoding="ascii") as info: + for line in info: + key, separator, value = line.partition(":") + if key == "mnt_id" and separator: + return int(value.strip()) + raise OSError(f"Mount identity is unavailable for descriptor {fd}") + + +def _require_same_mount(parent_fd: int, child_fd: int, path: str) -> None: + _require_mount_id(child_fd, _mount_id(parent_fd), path) + + +def _require_mount_id(fd: int, expected: int, path: str) -> None: + if _mount_id(fd) != expected: + raise ValueError(f"Recovery source crosses a mount boundary: {path}") + + +def _require_entry_matches_fd(path: str, entry: os.stat_result, identity: SourceIdentity) -> None: + fields = ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns") + entry_identity = identity_for_stat(entry, identity["mount_id"]) + if any(entry_identity[field] != identity[field] for field in fields): + raise RuntimeError(f"Recovery source changed while opening: {path}") + + +def _require_file_claim( + path: str, + fd: int, + expected: SourceIdentity, + expected_hash: str | None, + *, + claimed: bool, +) -> None: + if expected_hash is None: + raise ValueError(f"Regular-file recovery claim has no content hash: {path}") + before = _identity_for_fd(fd) + stable_fields = ("mount_id", "device", "inode", "mode", "size", "mtime_ns") if claimed else tuple(expected) + if any(before[field] != expected[field] for field in stable_fields if field != "exists"): + raise RuntimeError(f"Recovery source identity changed after sealing: {path}") + if _sha256_fd(fd) != expected_hash or _identity_for_fd(fd) != before: + raise RuntimeError(f"Recovery source content changed after sealing: {path}") + + +def _acquire_directory_leases( + directory_fd: int, + entries: dict[str, SourceEntry], + mount_id: int, + lease_stack: contextlib.ExitStack, + leased_files: dict[str, int], + prefix: str = "", +) -> None: + expected_names = { + relative[len(prefix) + 1 :].split("/", 1)[0] if prefix else relative.split("/", 1)[0] + for relative in entries + if not prefix or relative.startswith(prefix + "/") + } + actual_names = set(os.listdir(directory_fd)) + if actual_names != expected_names: + raise RuntimeError("Recovery source subtree changed before writer exclusion") + for name in sorted(actual_names): + relative = f"{prefix}/{name}" if prefix else name + expected = entries.get(relative) + if expected is None: + raise RuntimeError(f"Recovery source subtree changed before writer exclusion: {relative}") + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if stat.S_ISDIR(current.st_mode): + child_fd = _open_child_directory(directory_fd, name) + try: + _require_mount_id(child_fd, mount_id, relative) + if _identity_for_fd(child_fd) != expected["identity"]: + raise RuntimeError(f"Recovery source changed before writer exclusion: {relative}") + _acquire_directory_leases(child_fd, entries, mount_id, lease_stack, leased_files, relative) + finally: + os.close(child_fd) + elif stat.S_ISREG(current.st_mode): + file_fd = lease_stack.enter_context(_leased_regular(directory_fd, name, relative)) + _require_mount_id(file_fd, mount_id, relative) + _require_entry_matches_fd(relative, current, _identity_for_fd(file_fd)) + _require_file_claim(relative, file_fd, expected["identity"], expected.get("sha256"), claimed=False) + leased_files[relative] = file_fd + else: + raise ValueError(f"Recovery source contains unsupported entry: {relative}") + + +def _delete_claimed_directory( + directory_fd: int, + entries: dict[str, SourceEntry], + mount_id: int, + prefix: str = "", + *, + leased_files: dict[str, int], +) -> None: + expected_names = { + relative[len(prefix) + 1 :].split("/", 1)[0] if prefix else relative.split("/", 1)[0] + for relative in entries + if not prefix or relative.startswith(prefix + "/") + } + actual_names = set(os.listdir(directory_fd)) + if actual_names != expected_names: + raise RuntimeError("Recovery source subtree changed while it was consumed") + for name in sorted(actual_names): + relative = f"{prefix}/{name}" if prefix else name + expected = entries.get(relative) + if expected is None: + raise RuntimeError(f"Recovery source subtree changed while it was consumed: {relative}") + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if stat.S_ISDIR(current.st_mode): + child_fd = _open_child_directory(directory_fd, name) + try: + _require_mount_id(child_fd, mount_id, relative) + if _identity_for_fd(child_fd) != expected["identity"]: + raise RuntimeError(f"Recovery source changed while it was consumed: {relative}") + _delete_claimed_directory( + child_fd, + entries, + mount_id, + relative, + leased_files=leased_files, + ) + finally: + os.close(child_fd) + os.rmdir(name, dir_fd=directory_fd) + elif stat.S_ISREG(current.st_mode): + file_fd = leased_files[relative] + _require_mount_id(file_fd, mount_id, relative) + _require_entry_matches_fd(relative, current, _identity_for_fd(file_fd)) + _require_file_claim(relative, file_fd, expected["identity"], expected.get("sha256"), claimed=False) + os.unlink(name, dir_fd=directory_fd) + else: + raise ValueError(f"Recovery source contains unsupported entry: {relative}") + + +def _validate_claimed_directory( + directory_fd: int, entries: dict[str, SourceEntry], mount_id: int, prefix: str = "" +) -> None: + expected_names = { + relative[len(prefix) + 1 :].split("/", 1)[0] if prefix else relative.split("/", 1)[0] + for relative in entries + if not prefix or relative.startswith(prefix + "/") + } + actual_names = set(os.listdir(directory_fd)) + if actual_names != expected_names: + raise RuntimeError("Recovery source subtree changed while it was consumed") + for name in sorted(actual_names): + relative = f"{prefix}/{name}" if prefix else name + expected = entries.get(relative) + if expected is None: + raise RuntimeError(f"Recovery source subtree changed while it was consumed: {relative}") + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if stat.S_ISDIR(current.st_mode): + child_fd = _open_child_directory(directory_fd, name) + try: + _require_mount_id(child_fd, mount_id, relative) + if _identity_for_fd(child_fd) != expected["identity"]: + raise RuntimeError(f"Recovery source changed while it was consumed: {relative}") + _validate_claimed_directory(child_fd, entries, mount_id, relative) + finally: + os.close(child_fd) + elif stat.S_ISREG(current.st_mode): + file_fd = _open_child_regular(directory_fd, name) + try: + _require_mount_id(file_fd, mount_id, relative) + _require_file_claim(relative, file_fd, expected["identity"], expected.get("sha256"), claimed=False) + finally: + os.close(file_fd) + else: + raise ValueError(f"Recovery source contains unsupported entry: {relative}") + + +def _require_compatible_expected(path: str, actual: SourceIdentity, expected: SourceIdentity) -> None: + fields = ("exists", "device", "inode", "mode", "size", "mtime_ns", "ctime_ns") + if any(actual[field] != expected[field] for field in fields): + raise RuntimeError(f"Recovery source identity changed after sealing: {path}") diff --git a/py_modules/adapters/plugin_metadata.py b/py_modules/adapters/plugin_metadata.py index 783f9c9a..8113082a 100644 --- a/py_modules/adapters/plugin_metadata.py +++ b/py_modules/adapters/plugin_metadata.py @@ -11,14 +11,41 @@ import json import os +from typing import Any, cast class PluginMetadataAdapter: """Real ``PluginMetadataReader`` backed by the on-disk ``package.json``.""" + def __init__(self) -> None: + self._cache: dict[str, tuple[str, str]] = {} + + def read_metadata(self, plugin_dir: str) -> tuple[str, str]: + cached = self._cache.get(plugin_dir) + if cached is not None: + return cached + payload = self._read(plugin_dir) + raw_name = payload.get("name") + value = ( + raw_name if isinstance(raw_name, str) and raw_name else "decky-plugin", + cast("str", payload.get("version", "0.0.0")), + ) + self._cache[plugin_dir] = value + return value + def read_version(self, plugin_dir: str) -> str: + return self.read_metadata(plugin_dir)[1] + + def read_name(self, plugin_dir: str) -> str: + return self.read_metadata(plugin_dir)[0] + + @staticmethod + def _read(plugin_dir: str) -> dict[str, Any]: try: with open(os.path.join(plugin_dir, "package.json")) as f: - return json.load(f).get("version", "0.0.0") + payload = json.load(f) except (OSError, json.JSONDecodeError): - return "0.0.0" + return {} + if not isinstance(payload, dict): + return {} + return payload diff --git a/py_modules/adapters/prune_artifacts.py b/py_modules/adapters/prune_artifacts.py new file mode 100644 index 00000000..c7a3c865 --- /dev/null +++ b/py_modules/adapters/prune_artifacts.py @@ -0,0 +1,66 @@ +"""Per-ROM plugin cache artifacts that follow a purged ROM aggregate.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from adapters.descriptor_paths import claim_source, remove_claimed +from domain.artwork_paths import cache_filename, cover_meta_filename + +if TYPE_CHECKING: + from models.prune import MutationOutcome, RecoveryArtifact, SourceClaim + +_SGDB_TYPES = ("hero", "logo", "grid", "icon") + + +class PruneArtifactAdapter: + """Own recovery discovery and deletion for plugin cover/SGDB caches.""" + + def __init__(self, *, runtime_dir: str) -> None: + self._runtime_dir = runtime_dir + + def recovery_artifacts(self, rom_ids: list[int]) -> list[RecoveryArtifact]: + artifacts: list[RecoveryArtifact] = [] + for rom_id in rom_ids: + for path, kind in self._paths(rom_id): + artifacts.append({"source_path": path, "safe_root": self._runtime_dir, "kind": kind, "rom_id": rom_id}) + return artifacts + + def remove(self, rom_ids: list[int], claims: dict[str, SourceClaim] | None = None) -> MutationOutcome: + changed = False + ambiguous = False + for rom_id in rom_ids: + for path, _kind in self._paths(rom_id): + try: + claim = claims.get(path) if claims is not None else None + if claim is None: + claim = claim_source(path, self._runtime_dir) + outcome = remove_claimed(path, self._runtime_dir, claim) + except Exception as exc: + return { + "success": False, + "changed": changed, + "ambiguous": ambiguous, + "message": str(exc), + } + changed |= outcome["changed"] + ambiguous |= outcome["ambiguous"] + if not outcome["success"]: + return { + "success": False, + "changed": changed, + "ambiguous": ambiguous, + "message": outcome["message"], + } + return {"success": True, "changed": changed, "ambiguous": ambiguous, "message": "Artifacts removed"} + + def _paths(self, rom_id: int) -> list[tuple[str, str]]: + covers = os.path.join(self._runtime_dir, "covers") + artwork = os.path.join(self._runtime_dir, "artwork") + paths = [ + (os.path.join(covers, cache_filename(rom_id)), "cover_cache"), + (os.path.join(covers, cover_meta_filename(rom_id)), "cover_validator"), + ] + paths.extend((os.path.join(artwork, f"{rom_id}_{kind}.png"), "sgdb_cache") for kind in _SGDB_TYPES) + return paths diff --git a/py_modules/adapters/recovery_bundle.py b/py_modules/adapters/recovery_bundle.py new file mode 100644 index 00000000..5010f47b --- /dev/null +++ b/py_modules/adapters/recovery_bundle.py @@ -0,0 +1,730 @@ +"""Verified, atomically sealed recovery bundles for destructive local cleanup.""" + +from __future__ import annotations + +import contextlib +import errno +import hashlib +import json +import os +import re +import stat +from typing import TYPE_CHECKING, Any + +from adapters.descriptor_paths import claim_source, identity_for_stat, mount_id_for_fd, remove_current +from domain.prune import sanitize_package_name + +if TYPE_CHECKING: + from models.prune import RecoveryArtifact, SealedSourceClaims, SourceClaim, SourceEntry, SourceIdentity + +_SAFE_BUNDLE_ID = re.compile(r"^[0-9TZ]+_[1-9][0-9]*_[A-Za-z0-9-]+$", re.ASCII) + + +class RecoveryBundleAdapter: + """Single owner of recovery staging, verification, and atomic sealing.""" + + def __init__(self, *, user_home: str, package_name: str, plugin_version: str) -> None: + self._home = os.path.abspath(user_home) + self._root = os.path.join(self._home, f"{sanitize_package_name(package_name)}-recovery") + self._plugin_version = plugin_version + + def root(self) -> str: + return self._root + + def free_bytes(self) -> int: + descriptors = self._open_layout(create=True) + try: + stats = os.fstatvfs(descriptors[1]) + return stats.f_bavail * stats.f_frsize + finally: + self._close_layout(descriptors) + + def measure_path(self, path: str, safe_root: str) -> int: + total = 0 + for file_path in self._files_for_claim(claim_source(path, safe_root)): + fd = self._open_regular_beneath(file_path, safe_root) + try: + total += os.fstat(fd).st_size + finally: + os.close(fd) + return total + + def validate_sources(self, bundle_path: str, bundle_digest: str | None = None) -> bool: + """Verify that every sealed source set and source byte stream is unchanged.""" + try: + self._validated_source_claims(bundle_path, bundle_digest) + except (OSError, RuntimeError, ValueError, TypeError, KeyError, json.JSONDecodeError): + return False + return True + + def source_claims(self, bundle_path: str) -> SealedSourceClaims: + """Decode claims from the same held descriptor used for full validation.""" + return self._validated_source_claims(bundle_path, None) + + def _validated_source_claims(self, bundle_path: str, expected_digest: str | None) -> SealedSourceClaims: + descriptors, bundle_fd = self._open_bundle(bundle_path) + try: + anchor = self._fd_path(bundle_fd) + seal_bytes = self._read_beneath(os.path.join(anchor, "SEAL.json"), anchor) + checksum_bytes = self._read_beneath(os.path.join(anchor, "checksums.sha256"), anchor) + manifest_bytes = self._read_beneath(os.path.join(anchor, "manifest.json"), anchor) + seal = json.loads(seal_bytes) + manifest = json.loads(manifest_bytes) + if not isinstance(seal, dict) or not isinstance(manifest, dict): + raise ValueError("Recovery bundle metadata is invalid") + if ( + seal.get("sealed") is not True + or seal.get("bundle_id") != os.path.basename(bundle_path) + or type(seal.get("file_count")) is not int + or hashlib.sha256(checksum_bytes).hexdigest() != seal.get("checksums_sha256") + ): + raise ValueError("Recovery bundle seal is invalid") + bundle_stat = os.fstat(bundle_fd) + digest_payload = { + "device": bundle_stat.st_dev, + "inode": bundle_stat.st_ino, + "mode": bundle_stat.st_mode, + "seal": hashlib.sha256(seal_bytes).hexdigest(), + "checksums": hashlib.sha256(checksum_bytes).hexdigest(), + "manifest": hashlib.sha256(manifest_bytes).hexdigest(), + } + bundle_digest = hashlib.sha256( + json.dumps(digest_payload, ensure_ascii=True, sort_keys=True).encode("ascii") + ).hexdigest() + if expected_digest is not None and bundle_digest != expected_digest: + raise ValueError("Recovery bundle identity changed after claims were decoded") + + checksums: dict[str, str] = {} + for raw_line in checksum_bytes.decode("utf-8").splitlines(): + digest, separator, relative = raw_line.partition(" ") + if not separator or not relative or os.path.isabs(relative) or ".." in relative.split("/"): + raise ValueError("Recovery checksum list is invalid") + checksums[relative] = digest + for relative, digest in checksums.items(): + fd = self._open_regular_beneath(os.path.join(anchor, *relative.split("/")), anchor) + try: + if self._sha256_fd(fd) != digest: + raise ValueError("Recovery bundle checksum mismatch") + finally: + os.close(fd) + + source_sets = manifest.get("source_sets") + records = manifest.get("artifacts") + if not isinstance(source_sets, list) or not isinstance(records, list) or seal["file_count"] != len(records): + raise ValueError("Recovery manifest is invalid") + claims: dict[str, SourceClaim] = {} + for item in source_sets: + if not isinstance(item, dict): + raise ValueError("Recovery manifest source claim is invalid") + source_path = item.get("source_path") + safe_root = item.get("safe_root") + files = item.get("files") + raw_identity = item.get("source_identity") + raw_sha256 = item.get("sha256") + raw_entries = item.get("entries") + if ( + not isinstance(source_path, str) + or not isinstance(safe_root, str) + or not isinstance(files, list) + or not isinstance(raw_identity, dict) + or (raw_sha256 is not None and not isinstance(raw_sha256, str)) + or not isinstance(raw_entries, dict) + ): + raise ValueError("Recovery manifest source claim is invalid") + claim = self._decode_claim(source_path, safe_root, raw_identity, raw_sha256, raw_entries) + if self._files_for_claim(claim) != files or claim_source(source_path, safe_root) != claim: + raise ValueError("Recovery source no longer matches the sealed claim") + claims[source_path] = claim + + for record in records: + if not isinstance(record, dict): + raise ValueError("Recovery artifact record is invalid") + source_path = record.get("source_path") + safe_root = record.get("safe_root") + digest = record.get("sha256") + raw_identity = record.get("source_identity") + if ( + not isinstance(source_path, str) + or not isinstance(safe_root, str) + or not isinstance(digest, str) + or not isinstance(raw_identity, dict) + ): + raise ValueError("Recovery artifact record is invalid") + current = claim_source(source_path, safe_root) + if current["source_identity"] != self._decode_identity(raw_identity) or current["sha256"] != digest: + raise ValueError("Recovery artifact source changed after sealing") + return {"claims": claims, "bundle_digest": bundle_digest} + finally: + os.close(bundle_fd) + self._close_layout(descriptors) + + @classmethod + def _read_beneath(cls, path: str, safe_root: str) -> bytes: + fd = cls._open_regular_beneath(path, safe_root) + try: + os.lseek(fd, 0, os.SEEK_SET) + blocks: list[bytes] = [] + while block := os.read(fd, 1024 * 1024): + blocks.append(block) + return b"".join(blocks) + finally: + os.close(fd) + + def seal_bundle( + self, + bundle_id: str, + snapshot: dict[str, object], + artifacts: list[RecoveryArtifact], + readme: str, + playtime_text: str, + ) -> str: + if _SAFE_BUNDLE_ID.fullmatch(bundle_id) is None: + raise ValueError("unsafe recovery bundle id") + bundles_parent = os.path.join(self._root, "bundles") + staging_name = f".{bundle_id}.staging" + sealed = os.path.join(bundles_parent, bundle_id) + descriptors = self._open_layout(create=True) + home_fd, root_fd, staging_parent_fd, bundles_parent_fd = descriptors + staging_fd: int | None = None + renamed = False + try: + if ( + self._stat_at(staging_parent_fd, staging_name) is not None + or self._stat_at(bundles_parent_fd, bundle_id) is not None + ): + raise FileExistsError(f"Recovery bundle already exists: {bundle_id}") + os.mkdir(staging_name, 0o700, dir_fd=staging_parent_fd) + staging_fd = self._open_dir_at(staging_parent_fd, staging_name) + free_bytes = self._free_bytes_fd(root_fd) + records, source_sets, checksums = self._copy_artifacts(staging_fd, artifacts, free_bytes) + enriched = dict(snapshot) + enriched["plugin_version"] = self._plugin_version + enriched["bundle_id"] = bundle_id + enriched["artifacts"] = records + enriched["source_sets"] = source_sets + self._write_rom_states(staging_fd, enriched, checksums) + self._write_verified_text(staging_fd, "README.txt", readme, checksums) + self._write_verified_text(staging_fd, "playtime.txt", playtime_text, checksums) + self._write_verified_text( + staging_fd, + "manifest.json", + json.dumps(enriched, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + checksums, + ) + checksum_text = "".join(f"{digest} {name}\n" for name, digest in sorted(checksums.items())) + checksum_digest = self._write_verified_text(staging_fd, "checksums.sha256", checksum_text, checksums=None) + seal = { + "bundle_id": bundle_id, + "checksums_sha256": checksum_digest, + "file_count": len(records), + "sealed": True, + } + self._write_verified_text( + staging_fd, + "SEAL.json", + json.dumps(seal, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + checksums=None, + ) + self._verify_staging_checksums(staging_fd, checksums) + os.fsync(staging_fd) + os.rename( + staging_name, + bundle_id, + src_dir_fd=staging_parent_fd, + dst_dir_fd=bundles_parent_fd, + ) + renamed = True + try: + os.fsync(staging_parent_fd) + os.fsync(bundles_parent_fd) + self._require_layout_attached(home_fd, root_fd, staging_parent_fd, bundles_parent_fd) + except (OSError, ValueError) as exc: + uncertain_name = bundle_id + ".durability-uncertain" + with contextlib.suppress(OSError): + os.rename( + bundle_id, + uncertain_name, + src_dir_fd=bundles_parent_fd, + dst_dir_fd=bundles_parent_fd, + ) + os.fsync(bundles_parent_fd) + uncertain = os.path.join(bundles_parent, uncertain_name) + raise OSError(f"Recovery bundle durability is uncertain: {uncertain}") from exc + return sealed + except BaseException as primary: + cleanup_path = os.path.join( + bundles_parent if renamed else os.path.join(self._root, "staging"), + bundle_id if renamed else staging_name, + ) + try: + cleanup = remove_current(cleanup_path, self._root) + if not cleanup["success"]: + raise RuntimeError(cleanup["message"]) + except BaseException as cleanup_exc: + raise RuntimeError( + "Recovery bundle failed and unsafe staging was preserved at " + f"{cleanup_path} because cleanup failed: {cleanup_exc}" + ) from primary + raise + finally: + if staging_fd is not None: + os.close(staging_fd) + self._close_layout(descriptors) + + def _copy_artifacts( + self, staging_fd: int, artifacts: list[RecoveryArtifact], free_bytes: int + ) -> tuple[list[dict[str, Any]], list[dict[str, object]], dict[str, str]]: + expanded: list[tuple[RecoveryArtifact, str]] = [] + source_sets: list[dict[str, object]] = [] + seen: set[str] = set() + sealed_claims: dict[str, SourceClaim] = {} + for artifact in artifacts: + claim = claim_source(artifact["source_path"], artifact["safe_root"]) + files = self._files_for_claim(claim) + sealed_claims[artifact["source_path"]] = claim + source_sets.append( + { + "source_path": artifact["source_path"], + "safe_root": artifact["safe_root"], + "files": files, + "kind": artifact["kind"], + "source_identity": claim["source_identity"], + "sha256": claim["sha256"], + "entries": claim["entries"], + **({"rom_id": artifact["rom_id"]} if "rom_id" in artifact else {}), + } + ) + for file_path in files: + lexical = os.path.abspath(file_path) + if lexical in seen: + continue + seen.add(lexical) + expanded.append((artifact, file_path)) + required = 0 + for artifact, path in expanded: + fd = self._open_regular_beneath(path, artifact["safe_root"]) + try: + required += os.fstat(fd).st_size + finally: + os.close(fd) + if free_bytes < required: + raise OSError(f"Insufficient recovery space: need {required} bytes") + + os.mkdir("files", 0o700, dir_fd=staging_fd) + files_fd = self._open_dir_at(staging_fd, "files") + records: list[dict[str, Any]] = [] + checksums: dict[str, str] = {} + try: + for index, (artifact, source) in enumerate(expanded, start=1): + name = f"{index:06d}" + relative = f"files/{name}" + source_stat, source_hash, source_identity = self._copy_opened_source( + source, artifact["safe_root"], files_fd, name + ) + checksums[relative] = source_hash + record: dict[str, Any] = { + "kind": artifact["kind"], + "source_path": source, + "safe_root": artifact["safe_root"], + "destination": relative, + "size": source_stat.st_size, + "mode": stat.S_IMODE(source_stat.st_mode), + "mtime_ns": source_stat.st_mtime_ns, + "sha256": source_hash, + "source_identity": source_identity, + } + if "rom_id" in artifact: + record["rom_id"] = artifact["rom_id"] + records.append(record) + os.fsync(files_fd) + finally: + os.close(files_fd) + for source_path, sealed_claim in sealed_claims.items(): + if claim_source(source_path, sealed_claim["safe_root"]) != sealed_claim: + raise OSError(f"Recovery source changed while the bundle was sealed: {source_path}") + return records, source_sets, checksums + + @classmethod + def _copy_opened_source( + cls, source: str, safe_root: str, destination_parent_fd: int, destination_name: str + ) -> tuple[os.stat_result, str, SourceIdentity]: + source_fd = cls._open_regular_beneath(source, safe_root) + try: + before = os.fstat(source_fd) + source_identity = identity_for_stat(before, mount_id_for_fd(source_fd)) + digest = hashlib.sha256() + destination_fd = os.open( + destination_name, + os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=destination_parent_fd, + ) + try: + while block := os.read(source_fd, 1024 * 1024): + digest.update(block) + view = memoryview(block) + while view: + written = os.write(destination_fd, view) + view = view[written:] + os.fchmod(destination_fd, stat.S_IMODE(before.st_mode)) + os.utime(destination_fd, ns=(before.st_atime_ns, before.st_mtime_ns)) + destination_hash = cls._sha256_fd(destination_fd) + if digest.hexdigest() != destination_hash: + raise OSError(f"Recovery checksum mismatch for {source}") + os.fsync(destination_fd) + finally: + os.close(destination_fd) + after = os.fstat(source_fd) + identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) + identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns) + if identity_after != identity_before: + raise OSError(f"Recovery source changed while it was copied: {source}") + return before, digest.hexdigest(), source_identity + finally: + os.close(source_fd) + + @staticmethod + def _files_for_claim(claim: SourceClaim) -> list[str]: + if not claim["source_identity"]["exists"]: + return [] + if claim["sha256"] is not None: + return [claim["source_path"]] + return sorted( + os.path.join(claim["source_path"], *relative.split("/")) + for relative, entry in claim["entries"].items() + if "sha256" in entry + ) + + @staticmethod + def _open_regular_beneath(path: str, safe_root: str) -> int: + absolute_root = os.path.abspath(safe_root) + absolute_path = os.path.abspath(path) + try: + if os.path.commonpath((absolute_root, absolute_path)) != absolute_root: + raise ValueError(f"Recovery source is outside its safe root: {path}") + except ValueError as exc: + raise ValueError(f"Recovery source is outside its safe root: {path}") from exc + relative = os.path.relpath(absolute_path, absolute_root) + if relative in {".", ".."} or relative.startswith(".." + os.sep): + raise ValueError(f"Recovery source is not a file below its safe root: {path}") + parts = relative.split(os.sep) + nofollow = getattr(os, "O_NOFOLLOW", 0) + canonical_root = os.path.realpath(absolute_root) + directory_fd = os.open(canonical_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | nofollow) + root_mount_id = mount_id_for_fd(directory_fd) + try: + for component in parts[:-1]: + next_fd = os.open( + component, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | nofollow, + dir_fd=directory_fd, + ) + if mount_id_for_fd(next_fd) != root_mount_id: + os.close(next_fd) + raise ValueError(f"Recovery source crosses a mount boundary: {path}") + os.close(directory_fd) + directory_fd = next_fd + file_fd = os.open(parts[-1], os.O_RDONLY | nofollow, dir_fd=directory_fd) + if mount_id_for_fd(file_fd) != root_mount_id: + os.close(file_fd) + raise ValueError(f"Recovery source crosses a mount boundary: {path}") + if not stat.S_ISREG(os.fstat(file_fd).st_mode): + os.close(file_fd) + raise ValueError(f"Recovery source is not a regular file: {path}") + return file_fd + finally: + os.close(directory_fd) + + def _write_rom_states(self, staging_fd: int, snapshot: dict[str, object], checksums: dict[str, str]) -> None: + rows = snapshot.get("roms") + if not isinstance(rows, list): + return + os.mkdir("roms", 0o700, dir_fd=staging_fd) + roms_fd = self._open_dir_at(staging_fd, "roms") + try: + for row in rows: + if not isinstance(row, dict) or type(row.get("rom_id")) is not int or row["rom_id"] <= 0: + raise ValueError("Recovery snapshot contains an invalid ROM id") + rom_id = int(row["rom_id"]) + os.mkdir(str(rom_id), 0o700, dir_fd=roms_fd) + directory_fd = self._open_dir_at(roms_fd, str(rom_id)) + try: + per_rom = { + key: [item for item in value if isinstance(item, dict) and item.get("rom_id") == rom_id] + if isinstance(value, list) + else value + for key, value in snapshot.items() + if key in {"roms", "installs", "metadata", "save_sync", "playtime"} + } + relative = f"roms/{rom_id}/state.json" + digest = self._write_file_at( + directory_fd, + "state.json", + (json.dumps(per_rom, ensure_ascii=True, indent=2, sort_keys=True) + "\n").encode("utf-8"), + ) + checksums[relative] = digest + os.fsync(directory_fd) + finally: + os.close(directory_fd) + os.fsync(roms_fd) + finally: + os.close(roms_fd) + + def _write_verified_text( + self, staging_fd: int, relative: str, content: str, checksums: dict[str, str] | None + ) -> str: + parent_fd, name = self._open_relative_parent(staging_fd, relative) + try: + digest = self._write_file_at(parent_fd, name, content.encode("utf-8")) + finally: + os.close(parent_fd) + if checksums is not None: + checksums[relative] = digest + return digest + + @classmethod + def _write_file_at(cls, parent_fd: int, name: str, content: bytes) -> str: + fd = os.open( + name, + os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=parent_fd, + ) + try: + view = memoryview(content) + while view: + written = os.write(fd, view) + view = view[written:] + digest = cls._sha256_fd(fd) + os.fsync(fd) + return digest + finally: + os.close(fd) + + @classmethod + def _open_relative_parent(cls, root_fd: int, relative: str) -> tuple[int, str]: + parts = relative.split("/") + if not parts or any(part in {"", ".", ".."} for part in parts): + raise ValueError("Recovery destination path is invalid") + fd = os.dup(root_fd) + root_mount_id = mount_id_for_fd(root_fd) + try: + for component in parts[:-1]: + next_fd = cls._open_dir_at(fd, component) + if mount_id_for_fd(next_fd) != root_mount_id: + os.close(next_fd) + raise ValueError("Recovery destination crosses a mount boundary") + os.close(fd) + fd = next_fd + return fd, parts[-1] + except BaseException: + os.close(fd) + raise + + @classmethod + def _verify_staging_checksums(cls, staging_fd: int, checksums: dict[str, str]) -> None: + for relative, expected in checksums.items(): + parent_fd, name = cls._open_relative_parent(staging_fd, relative) + try: + fd = os.open(name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent_fd) + try: + if not stat.S_ISREG(os.fstat(fd).st_mode) or cls._sha256_fd(fd) != expected: + raise OSError(f"Recovery staging checksum mismatch: {relative}") + finally: + os.close(fd) + finally: + os.close(parent_fd) + + @staticmethod + def _sha256(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + @staticmethod + def _sha256_fd(fd: int) -> str: + digest = hashlib.sha256() + os.lseek(fd, 0, os.SEEK_SET) + while block := os.read(fd, 1024 * 1024): + digest.update(block) + return digest.hexdigest() + + def _open_layout(self, *, create: bool) -> tuple[int, int, int, int]: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + if create: + os.makedirs(self._home, exist_ok=True) + home_fd = os.open(self._home, flags) + root_fd: int | None = None + staging_fd: int | None = None + bundles_fd: int | None = None + try: + root_name = os.path.basename(self._root) + root_fd = self._open_or_create_dir(home_fd, root_name, create=create) + staging_fd = self._open_or_create_dir(root_fd, "staging", create=create) + bundles_fd = self._open_or_create_dir(root_fd, "bundles", create=create) + return home_fd, root_fd, staging_fd, bundles_fd + except BaseException: + for fd in (bundles_fd, staging_fd, root_fd, home_fd): + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + raise + + @staticmethod + def _close_layout(descriptors: tuple[int, int, int, int]) -> None: + for fd in reversed(descriptors): + os.close(fd) + + @staticmethod + def _open_or_create_dir(parent_fd: int, name: str, *, create: bool) -> int: + try: + result = RecoveryBundleAdapter._open_dir_at(parent_fd, name) + except FileNotFoundError: + if not create: + raise + os.mkdir(name, 0o700, dir_fd=parent_fd) + os.fsync(parent_fd) + result = RecoveryBundleAdapter._open_dir_at(parent_fd, name) + except OSError as exc: + raise ValueError(f"Recovery directory is not a trusted directory: {name}") from exc + if mount_id_for_fd(result) != mount_id_for_fd(parent_fd): + os.close(result) + raise ValueError(f"Recovery directory crosses a mount boundary: {name}") + return result + + @staticmethod + def _open_dir_at(parent_fd: int, name: str) -> int: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + return os.open(name, flags, dir_fd=parent_fd) + + def _open_bundle(self, bundle_path: str) -> tuple[tuple[int, int, int, int], int]: + expected_parent = os.path.join(self._root, "bundles") + absolute = os.path.abspath(bundle_path) + bundle_id = os.path.basename(absolute) + if os.path.dirname(absolute) != expected_parent or _SAFE_BUNDLE_ID.fullmatch(bundle_id) is None: + raise ValueError("Recovery bundle path is outside the anchored bundle directory") + descriptors = self._open_layout(create=False) + try: + self._require_layout_attached(*descriptors) + bundle_fd = self._open_dir_at(descriptors[3], bundle_id) + if mount_id_for_fd(bundle_fd) != mount_id_for_fd(descriptors[3]): + os.close(bundle_fd) + raise ValueError("Recovery bundle crosses a mount boundary") + return descriptors, bundle_fd + except BaseException: + self._close_layout(descriptors) + raise + + def _require_layout_attached( + self, + home_fd: int, + root_fd: int, + staging_fd: int, + bundles_fd: int, + ) -> None: + root_name = os.path.basename(self._root) + checks = ( + (home_fd, root_name, root_fd), + (root_fd, "staging", staging_fd), + (root_fd, "bundles", bundles_fd), + ) + for parent_fd, name, held_fd in checks: + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + held = os.fstat(held_fd) + if (current.st_dev, current.st_ino, current.st_mode) != (held.st_dev, held.st_ino, held.st_mode): + raise ValueError("Recovery directory identity changed while the bundle was open") + + @staticmethod + def _stat_at(parent_fd: int, name: str) -> os.stat_result | None: + try: + return os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return None + + @staticmethod + def _fd_path(fd: int) -> str: + return f"/proc/self/fd/{fd}" + + @staticmethod + def _free_bytes_fd(fd: int) -> int: + stats = os.fstatvfs(fd) + return stats.f_bavail * stats.f_frsize + + @staticmethod + def _decode_identity(raw: dict[str, object]) -> SourceIdentity: + def integer(key: str) -> int: + value = raw.get(key, 0) + if type(value) is not int: + raise ValueError(f"Recovery source identity field is invalid: {key}") + return value + + return { + "exists": raw.get("exists") is True, + "mount_id": integer("mount_id"), + "device": integer("device"), + "inode": integer("inode"), + "mode": integer("mode"), + "size": integer("size"), + "mtime_ns": integer("mtime_ns"), + "ctime_ns": integer("ctime_ns"), + } + + @classmethod + def _decode_claim( + cls, + source_path: str, + safe_root: str, + raw_identity: dict[str, object], + raw_sha256: str | None, + raw_entries: dict[str, object], + ) -> SourceClaim: + entries: dict[str, SourceEntry] = {} + for relative, raw_entry in raw_entries.items(): + if not isinstance(raw_entry, dict): + raise ValueError("Recovery source claim entry is invalid") + identity = raw_entry.get("identity") + if not isinstance(identity, dict): + raise ValueError("Recovery source claim identity is invalid") + entry: SourceEntry = {"identity": cls._decode_identity(identity)} + digest = raw_entry.get("sha256") + if digest is not None: + if not isinstance(digest, str): + raise ValueError("Recovery source claim checksum is invalid") + entry["sha256"] = digest + entries[relative] = entry + return { + "source_path": source_path, + "safe_root": safe_root, + "source_identity": cls._decode_identity(raw_identity), + "sha256": raw_sha256, + "entries": entries, + } + + def _ensure_dir(self, path: str) -> None: + if os.path.lexists(path): + if os.path.islink(path) or not os.path.isdir(path): + raise ValueError(f"Recovery directory is not a trusted directory: {path}") + return + os.mkdir(path, 0o700) + parent = os.path.dirname(path) + if parent: + self._fsync_dir(parent) + + @staticmethod + def _fsync_dir(path: str) -> None: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + try: + fd = os.open(path, flags) + except OSError as exc: + if exc.errno in {errno.EINVAL, errno.ENOTSUP, errno.EOPNOTSUPP}: + return + raise + try: + os.fsync(fd) + except OSError as exc: + if exc.errno not in {errno.EINVAL, errno.ENOTSUP, errno.EOPNOTSUPP}: + raise + finally: + os.close(fd) diff --git a/py_modules/adapters/rom_files.py b/py_modules/adapters/rom_files.py index 6fbe0050..0c7f8933 100644 --- a/py_modules/adapters/rom_files.py +++ b/py_modules/adapters/rom_files.py @@ -12,6 +12,12 @@ import contextlib import os import shutil +from typing import TYPE_CHECKING + +from adapters.descriptor_paths import claim_source, remove_claimed, remove_exact + +if TYPE_CHECKING: + from models.prune import MutationOutcome, SourceClaim, SourceIdentity class RomFileAdapter: @@ -38,3 +44,12 @@ def remove_file(self, path: str) -> None: def remove_tree(self, path: str) -> None: """Recursively delete *path* and all contents.""" shutil.rmtree(path) + + def remove_exact(self, path: str, safe_root: str, identity: SourceIdentity) -> bool: + return bool(remove_exact(path, safe_root, identity)["changed"]) + + def claim_source(self, path: str, safe_root: str) -> SourceClaim: + return claim_source(path, safe_root) + + def remove_claimed(self, path: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + return remove_claimed(path, safe_root, claim) diff --git a/py_modules/adapters/save_file.py b/py_modules/adapters/save_file.py index 98201bfd..20271df2 100644 --- a/py_modules/adapters/save_file.py +++ b/py_modules/adapters/save_file.py @@ -19,12 +19,15 @@ import zlib from typing import TYPE_CHECKING +from adapters.descriptor_paths import claim_source, ensure_directory, rename_claimed, rename_exact from domain.save_hash import combine_zip_entry_hashes if TYPE_CHECKING: import logging from collections.abc import Iterator + from models.prune import MutationOutcome, SourceClaim, SourceIdentity + _MD5_CHUNK_SIZE = 8192 # Zip-decode failures a positive ``zipfile.is_zipfile`` sniff can still hit once @@ -72,6 +75,18 @@ def is_dir(self, path: str) -> bool: """Return True when *path* exists and is a directory.""" return os.path.isdir(path) + def is_symlink(self, path: str) -> bool: + return os.path.islink(path) + + def canonical_path(self, path: str) -> str: + return os.path.realpath(path) + + def is_within(self, path: str, root: str) -> bool: + try: + return os.path.commonpath((os.path.realpath(path), os.path.realpath(root))) == os.path.realpath(root) + except ValueError: + return False + def make_dirs(self, path: str) -> None: """Create *path* and any missing parents. Idempotent.""" os.makedirs(path, exist_ok=True) @@ -92,6 +107,18 @@ def rename(self, src: str, dst: str) -> None: """Atomically rename *src* to *dst*, replacing any existing file at *dst*.""" os.replace(src, dst) + def rename_exact(self, src: str, dst: str, safe_root: str, identity: SourceIdentity) -> bool: + return bool(rename_exact(src, dst, safe_root, identity)["changed"]) + + def claim_source(self, path: str, safe_root: str) -> SourceClaim: + return claim_source(path, safe_root) + + def ensure_directory(self, path: str, safe_root: str) -> None: + ensure_directory(path, safe_root) + + def rename_claimed(self, src: str, dst: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + return rename_claimed(src, dst, safe_root, claim) + def get_mtime(self, path: str) -> float: """Return the mtime of *path* as a Unix timestamp.""" return os.path.getmtime(path) diff --git a/py_modules/adapters/steam_recovery.py b/py_modules/adapters/steam_recovery.py new file mode 100644 index 00000000..c629cc59 --- /dev/null +++ b/py_modules/adapters/steam_recovery.py @@ -0,0 +1,467 @@ +"""Steam-only recovery state and files for a shortcut about to be removed.""" + +from __future__ import annotations + +import contextlib +import hashlib +import os +from typing import TYPE_CHECKING, Any + +from _vendor import vdf + +from adapters.descriptor_paths import ( + hold_writer_exclusion, + identity_for_stat, + open_directory_fd, + open_regular_fd, + remove_claimed, + rename_noreplace_at, + require_directory, +) +from domain.artwork_paths import grid_image_filenames + +if TYPE_CHECKING: + import logging + + from models.prune import MutationOutcome, RecoveryArtifact, SourceClaim, SourceIdentity, SteamRecoverySnapshot + + +class SteamRecoveryAdapter: + """Own bounded Steam Input/grid snapshots and post-removal file cleanup.""" + + def __init__(self, *, user_home: str, logger: logging.Logger) -> None: + self._user_home = user_home + self._logger = logger + + def snapshot(self, app_id: int) -> SteamRecoverySnapshot: + user_dir, steam_root, user_id = self._resolve_user() + controller_setting = self._controller_setting(app_id, user_dir) + + artifacts: list[RecoveryArtifact] = [] + grid = os.path.join(user_dir, "config", "grid") + if os.path.lexists(grid): + require_directory(grid, user_dir) + for filename in grid_image_filenames(app_id): + path = os.path.join(grid, filename) + artifacts.append({"source_path": path, "safe_root": user_dir, "kind": "steam_grid"}) + first_input, second_input = self._input_roots(user_dir, steam_root, user_id, app_id) + for path, root in ((first_input, user_dir), (second_input, steam_root)): + if os.path.lexists(path): + require_directory(path, root) + artifacts.append({"source_path": first_input, "safe_root": user_dir, "kind": "steam_input"}) + artifacts.append({"source_path": second_input, "safe_root": steam_root, "kind": "steam_input"}) + return { + "user_id": user_id, + "user_dir": user_dir, + "steam_root": steam_root, + "controller_setting": controller_setting, + "artifacts": artifacts, + } + + def validate_state(self, app_id: int, snapshot: SteamRecoverySnapshot) -> bool: + """Require the exact captured user and controller value before mutation.""" + try: + user_dir, _steam_root, _user_id = self._validate_identity(snapshot) + return self._controller_setting(app_id, user_dir) == snapshot["controller_setting"] + except (OSError, ValueError, TypeError, KeyError): + return False + + def remove_state( + self, + app_id: int, + snapshot: SteamRecoverySnapshot, + claims: dict[str, SourceClaim], + ) -> MutationOutcome: + """Remove only state belonging to the exact user captured in *snapshot*.""" + changed = False + ambiguous = False + try: + user_dir, steam_root, user_id = self._validate_identity(snapshot) + controller = self._clear_controller_setting(app_id, user_dir, snapshot["controller_setting"]) + changed |= controller["changed"] + ambiguous |= controller["ambiguous"] + if not controller["success"]: + return {**controller, "changed": changed, "ambiguous": ambiguous} + grid = os.path.join(user_dir, "config", "grid") + paths = [(os.path.join(grid, filename), user_dir) for filename in grid_image_filenames(app_id)] + first_input, second_input = self._input_roots(user_dir, steam_root, user_id, app_id) + paths.extend(((first_input, user_dir), (second_input, steam_root))) + for path, root in paths: + claim = claims.get(path) + if claim is None: + raise ValueError(f"Sealed Steam source claim is missing: {path}") + outcome = remove_claimed(path, root, claim) + changed |= outcome["changed"] + ambiguous |= outcome["ambiguous"] + if not outcome["success"]: + return {**outcome, "changed": changed, "ambiguous": ambiguous} + except Exception as exc: + return {"success": False, "changed": changed, "ambiguous": ambiguous, "message": str(exc)} + return {"success": True, "changed": changed, "ambiguous": ambiguous, "message": "Steam state removed"} + + def _resolve_user(self) -> tuple[str, str, str]: + candidates = ( + os.path.join(self._user_home, ".local", "share", "Steam"), + os.path.join(self._user_home, ".steam", "steam"), + ) + for steam_root in candidates: + steam_root = os.path.realpath(steam_root) + userdata = os.path.join(steam_root, "userdata") + if not os.path.isdir(userdata): + continue + require_directory(userdata, steam_root) + users = sorted(name for name in os.listdir(userdata) if name.isdigit()) + if not users: + continue + user_id = self._most_recent_login(steam_root) + if user_id is None and len(users) == 1: + user_id = users[0] + if user_id is not None and user_id in users: + user_dir = os.path.join(userdata, user_id) + require_directory(user_dir, steam_root) + return user_dir, steam_root, user_id + raise RuntimeError("Cannot locate the active Steam user directory") + + @staticmethod + def _most_recent_login(steam_root: str) -> str | None: + path = os.path.join(steam_root, "config", "loginusers.vdf") + try: + fd = open_regular_fd(path, steam_root) + except FileNotFoundError: + return None + with os.fdopen(fd, encoding="utf-8") as source: + payload: dict[str, Any] = vdf.load(source) + users = payload.get("users") + if not isinstance(users, dict): + return None + recent = [ + steam_id for steam_id, value in users.items() if isinstance(value, dict) and value.get("MostRecent") == "1" + ] + if len(recent) != 1 or not str(recent[0]).isdigit(): + return None + return str(int(recent[0]) & 0xFFFFFFFF) + + def _validate_identity(self, snapshot: SteamRecoverySnapshot) -> tuple[str, str, str]: + user_id = snapshot.get("user_id") + user_dir = snapshot.get("user_dir") + steam_root = snapshot.get("steam_root") + if not user_id.isdigit(): + raise ValueError("Invalid Steam recovery identity") + allowed_roots = { + os.path.realpath(os.path.join(self._user_home, ".local", "share", "Steam")), + os.path.realpath(os.path.join(self._user_home, ".steam", "steam")), + } + real_root = os.path.realpath(steam_root) + expected_user = os.path.realpath(os.path.join(real_root, "userdata", user_id)) + if ( + real_root not in allowed_roots + or os.path.realpath(user_dir) != expected_user + or not os.path.isdir(expected_user) + ): + raise ValueError("Steam recovery identity no longer matches the captured user") + require_directory(expected_user, real_root) + return expected_user, real_root, user_id + + @classmethod + def _controller_setting(cls, app_id: int, user_dir: str) -> str | None: + path = os.path.join(user_dir, "config", "localconfig.vdf") + if not os.path.exists(path): + return None + fd = open_regular_fd(path, user_dir) + with os.fdopen(fd, encoding="utf-8") as source: + payload: dict[str, Any] = vdf.load(source) + apps = payload.get("UserLocalConfigStore", {}).get("Apps", {}) + app = apps.get(str(app_id)) if isinstance(apps, dict) else None + value = app.get("UseSteamControllerConfig") if isinstance(app, dict) else None + return str(value) if value is not None else None + + @staticmethod + def _clear_controller_setting(app_id: int, user_dir: str, expected: str | None) -> MutationOutcome: + if expected is None: + return {"success": True, "changed": False, "ambiguous": False, "message": "No controller setting"} + config_dir = os.path.join(user_dir, "config") + config_fd = open_directory_fd(config_dir, user_dir) + try: + for attempt in range(3): + source_fd = os.open( + "localconfig.vdf", + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=config_fd, + ) + source_identity = identity_for_stat(os.fstat(source_fd)) + source_hash = SteamRecoveryAdapter._sha256_fd(source_fd) + os.lseek(source_fd, 0, os.SEEK_SET) + try: + with os.fdopen(os.dup(source_fd), encoding="utf-8") as source: + payload: dict[str, Any] = vdf.load(source) + except BaseException: + os.close(source_fd) + raise + apps = payload.get("UserLocalConfigStore", {}).get("Apps", {}) + app = apps.get(str(app_id)) if isinstance(apps, dict) else None + if not isinstance(app, dict) or str(app.get("UseSteamControllerConfig")) != expected: + raise RuntimeError("Steam controller setting changed after recovery capture") + del app["UseSteamControllerConfig"] + if not app: + del apps[str(app_id)] + + temporary = f".localconfig.vdf.prune-new-{source_identity['inode']}-{attempt}" + claimed = f".localconfig.vdf.prune-old-{source_identity['inode']}-{attempt}" + source_claimed = False + replacement_installed = False + try: + temporary_fd = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=config_fd, + ) + with os.fdopen(temporary_fd, "w", encoding="utf-8") as output: + vdf.dump(payload, output, pretty=True) + output.flush() + os.fsync(output.fileno()) + rename_noreplace_at(config_fd, "localconfig.vdf", config_fd, claimed) + source_claimed = True + claimed_stat = os.stat(claimed, dir_fd=config_fd, follow_symlinks=False) + stable = ("device", "inode", "mode", "size", "mtime_ns") + claimed_identity = identity_for_stat(claimed_stat) + if any(claimed_identity[field] != source_identity[field] for field in stable): + preserved = SteamRecoveryAdapter._restore_or_preserve_claim( + config_fd, claimed, source_fd, source_identity, source_hash + ) + os.unlink(temporary, dir_fd=config_fd) + os.close(source_fd) + if preserved is not None: + return SteamRecoveryAdapter._preserved_controller_outcome(config_dir, preserved) + continue + if not SteamRecoveryAdapter._source_unchanged(source_fd, source_identity, source_hash): + preserved = SteamRecoveryAdapter._restore_or_preserve_claim( + config_fd, claimed, source_fd, source_identity, source_hash + ) + os.unlink(temporary, dir_fd=config_fd) + os.close(source_fd) + if preserved is not None: + return SteamRecoveryAdapter._preserved_controller_outcome(config_dir, preserved) + continue + try: + os.link( + temporary, + "localconfig.vdf", + src_dir_fd=config_fd, + dst_dir_fd=config_fd, + follow_symlinks=False, + ) + replacement_installed = True + except FileExistsError: + preserved = SteamRecoveryAdapter._restore_or_preserve_claim( + config_fd, claimed, source_fd, source_identity, source_hash + ) + os.unlink(temporary, dir_fd=config_fd) + os.close(source_fd) + if preserved is not None: + return SteamRecoveryAdapter._preserved_controller_outcome(config_dir, preserved) + continue + os.unlink(temporary, dir_fd=config_fd) + if not SteamRecoveryAdapter._source_unchanged(source_fd, source_identity, source_hash): + os.unlink("localconfig.vdf", dir_fd=config_fd) + preserved = SteamRecoveryAdapter._restore_or_preserve_claim( + config_fd, claimed, source_fd, source_identity, source_hash + ) + os.fsync(config_fd) + os.close(source_fd) + if preserved is not None: + return SteamRecoveryAdapter._preserved_controller_outcome(config_dir, preserved) + continue + try: + source_changed = False + with hold_writer_exclusion(source_fd, os.path.join(config_dir, claimed)): + SteamRecoveryAdapter._require_claim_matches_source(config_fd, claimed, source_fd) + source_changed = not SteamRecoveryAdapter._source_unchanged( + source_fd, source_identity, source_hash + ) + if not source_changed: + os.unlink(claimed, dir_fd=config_fd) + if source_changed: + os.unlink("localconfig.vdf", dir_fd=config_fd) + preserved = SteamRecoveryAdapter._restore_or_preserve_claim( + config_fd, claimed, source_fd, source_identity, source_hash + ) + os.fsync(config_fd) + os.close(source_fd) + if preserved is not None: + return SteamRecoveryAdapter._preserved_controller_outcome(config_dir, preserved) + continue + os.fsync(config_fd) + except OSError as exc: + retained_claim = SteamRecoveryAdapter._stat_at(config_fd, claimed) + os.close(source_fd) + if retained_claim is not None: + return { + "success": False, + "changed": True, + "ambiguous": True, + "message": ( + "Controller setting changed but writer exclusion failed; the source was preserved " + f"as {os.path.join(config_dir, claimed)}: {exc}" + ), + } + return { + "success": False, + "changed": True, + "ambiguous": True, + "message": f"Controller setting changed but durability is uncertain: {exc}", + } + os.close(source_fd) + return { + "success": True, + "changed": True, + "ambiguous": False, + "message": "Controller setting removed", + } + except BaseException as exc: + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=config_fd) + cleanup_outcome: MutationOutcome | None = None + try: + claim_stat = SteamRecoveryAdapter._stat_at(config_fd, claimed) if source_claimed else None + if source_claimed and claim_stat is None: + cleanup_outcome = { + "success": False, + "changed": True, + "ambiguous": True, + "message": f"Controller source claim removal is uncertain: {exc}", + } + elif source_claimed: + if replacement_installed: + # The claimed inode may contain a newer unrelated Steam edit. + # If rollback could not restore it, retain that only good copy. + SteamRecoveryAdapter._require_claim_matches_source(config_fd, claimed, source_fd) + os.fsync(config_fd) + else: + preserved = SteamRecoveryAdapter._restore_or_preserve_claim( + config_fd, claimed, source_fd, source_identity, source_hash + ) + if preserved is not None: + cleanup_outcome = SteamRecoveryAdapter._preserved_controller_outcome( + config_dir, preserved, cause=exc + ) + except BaseException as cleanup_exc: + cleanup_outcome = { + "success": False, + "changed": source_claimed, + "ambiguous": True, + "message": ( + "Controller rewrite rollback is uncertain; the claimed source was preserved as " + f"{os.path.join(config_dir, claimed)}: {cleanup_exc}" + ), + } + with contextlib.suppress(OSError): + os.close(source_fd) + if cleanup_outcome is not None: + return cleanup_outcome + if replacement_installed: + return { + "success": False, + "changed": True, + "ambiguous": True, + "message": ( + "Controller setting changed but cleanup was incomplete; " + f"the newer source was preserved as {os.path.join(config_dir, claimed)}: {exc}" + ), + } + raise + raise RuntimeError("Steam controller config kept changing during cleanup") + finally: + os.close(config_fd) + + @staticmethod + def _restore_or_preserve_claim( + config_fd: int, + claimed: str, + source_fd: int, + expected_identity: SourceIdentity, + expected_hash: str, + ) -> str | None: + SteamRecoveryAdapter._require_claim_matches_source(config_fd, claimed, source_fd) + if SteamRecoveryAdapter._stat_at(config_fd, "localconfig.vdf") is None: + try: + rename_noreplace_at(config_fd, claimed, config_fd, "localconfig.vdf") + except FileExistsError: + pass + else: + os.fsync(config_fd) + return None + if not SteamRecoveryAdapter._source_unchanged(source_fd, expected_identity, expected_hash): + os.fsync(config_fd) + return claimed + with hold_writer_exclusion(source_fd, claimed): + SteamRecoveryAdapter._require_claim_matches_source(config_fd, claimed, source_fd) + if not SteamRecoveryAdapter._source_unchanged(source_fd, expected_identity, expected_hash): + os.fsync(config_fd) + return claimed + os.unlink(claimed, dir_fd=config_fd) + os.fsync(config_fd) + return None + + @staticmethod + def _require_claim_matches_source(config_fd: int, claimed: str, source_fd: int) -> None: + claimed_stat = SteamRecoveryAdapter._stat_at(config_fd, claimed) + if claimed_stat is None: + raise RuntimeError(f"Claimed Steam controller source disappeared: {claimed}") + held_stat = os.fstat(source_fd) + fields = ("st_dev", "st_ino", "st_mode") + if any(getattr(claimed_stat, field) != getattr(held_stat, field) for field in fields): + raise RuntimeError(f"Claimed Steam controller source identity changed: {claimed}") + + @staticmethod + def _preserved_controller_outcome( + config_dir: str, claimed: str, *, cause: BaseException | None = None + ) -> MutationOutcome: + suffix = f": {cause}" if cause is not None else "" + return { + "success": False, + "changed": True, + "ambiguous": True, + "message": ( + "Controller rewrite collided with concurrent Steam state; the newer source was preserved as " + f"{os.path.join(config_dir, claimed)}{suffix}" + ), + } + + @staticmethod + def _stat_at(directory_fd: int, name: str) -> os.stat_result | None: + try: + return os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + return None + + @staticmethod + def _sha256_fd(fd: int) -> str: + digest = hashlib.sha256() + os.lseek(fd, 0, os.SEEK_SET) + while block := os.read(fd, 1024 * 1024): + digest.update(block) + return digest.hexdigest() + + @staticmethod + def _source_unchanged(fd: int, expected_identity: SourceIdentity, expected_hash: str) -> bool: + current = identity_for_stat(os.fstat(fd)) + stable = ("device", "inode", "mode", "size", "mtime_ns") + return all(current[field] == expected_identity[field] for field in stable) and ( + SteamRecoveryAdapter._sha256_fd(fd) == expected_hash + ) + + @staticmethod + def _input_roots(user_dir: str, steam_root: str, user_id: str, app_id: int) -> tuple[str, str]: + return ( + os.path.join(user_dir, "config", "controller_configs", "apps", str(app_id)), + os.path.join( + steam_root, + "steamapps", + "common", + "Steam Controller Configs", + user_id, + "config", + str(app_id), + ), + ) diff --git a/py_modules/bootstrap.py b/py_modules/bootstrap.py index efdc7c06..83a298d4 100644 --- a/py_modules/bootstrap.py +++ b/py_modules/bootstrap.py @@ -32,6 +32,8 @@ SettingsPersisterAdapter, ) from adapters.plugin_metadata import PluginMetadataAdapter +from adapters.prune_artifacts import PruneArtifactAdapter +from adapters.recovery_bundle import RecoveryBundleAdapter from adapters.renderer_gc import RendererGcAdapter from adapters.renderer_rss import RendererRssAdapter from adapters.repositories.unit_of_work import SqliteUnitOfWork @@ -45,6 +47,7 @@ from adapters.sgdb_artwork_cache import SgdbArtworkCacheAdapter from adapters.sqlite_migrations import MIGRATIONS_DIR, apply_migrations from adapters.steam_config import SteamConfigAdapter +from adapters.steam_recovery import SteamRecoveryAdapter from adapters.steamgriddb import SteamGridDbAdapter from adapters.system_clock import SystemClock from adapters.system_uuid_gen import SystemUuidGen @@ -65,6 +68,7 @@ from services.metadata import MetadataService, MetadataServiceConfig from services.migration import MigrationService, MigrationServiceConfig from services.playtime import PlaytimeService, PlaytimeServiceConfig +from services.prune import PruneService, PruneServiceConfig from services.relaunch_options_resolver import RelaunchOptionsResolver, RelaunchOptionsResolverConfig from services.rom_removal import RomRemovalService, RomRemovalServiceConfig from services.saves import SaveService, SaveServiceConfig @@ -97,6 +101,8 @@ PathExistsReader, PlatformCoreReader, PluginMetadataReader, + PruneArtifactStore, + RecoveryBundleStore, RendererGcFn, RendererRssFn, ResolveUploadConflictFn, @@ -109,6 +115,7 @@ SgdbArtworkCache, Sleeper, SteamConfigStore, + SteamRecoveryStore, SystemM3uSupportFn, SystemSupportedExtensionsFn, UnitOfWorkFactory, @@ -140,6 +147,9 @@ class AdapterBundle: renderer_rss: RendererRssFn renderer_gc: RendererGcFn resolve_upload_conflict: ResolveUploadConflictFn + recovery_store: RecoveryBundleStore + prune_artifacts: PruneArtifactStore + steam_recovery: SteamRecoveryStore @dataclass(frozen=True) @@ -344,7 +354,8 @@ def bootstrap( # version once at boot and thread the string to every HTTP-talking # adapter. Bot Fight Mode on Cloudflare blocks the default # ``Python-urllib`` UA before requests reach self-hosted RomM (#249). - user_agent = f"decky-romm-sync/{plugin_metadata.read_version(plugin_dir)}" + package_name, plugin_version = plugin_metadata.read_metadata(plugin_dir) + user_agent = f"decky-romm-sync/{plugin_version}" http_adapter = RommHttpAdapter(settings, plugin_dir, logger, user_agent) romm_api = RommApiAdapter(http_adapter) steam_config = SteamConfigAdapter(user_home=user_home, logger=logger) @@ -369,6 +380,13 @@ def bootstrap( hostname_provider = HostnameAdapter() machine_id_provider = MachineIdAdapter() debug_logger = SettingsAwareDebugLogger(settings=settings, logger=logger) + recovery_store = RecoveryBundleAdapter( + user_home=user_home, + package_name=package_name, + plugin_version=plugin_version, + ) + prune_artifacts = PruneArtifactAdapter(runtime_dir=runtime_dir) + steam_recovery = SteamRecoveryAdapter(user_home=user_home, logger=logger) adapters = AdapterBundle( http_adapter=http_adapter, @@ -387,6 +405,9 @@ def bootstrap( renderer_rss=renderer_rss, renderer_gc=renderer_gc, resolve_upload_conflict=resolve_upload_conflict, + recovery_store=recovery_store, + prune_artifacts=prune_artifacts, + steam_recovery=steam_recovery, ) stores = StateBundle( settings=settings, @@ -808,6 +829,28 @@ def _emit_server_retry(attempt: int, max_attempts: int, delay_s: float) -> None: ), ) + prune_service = PruneService( + config=PruneServiceConfig( + loop=cfg.runtime.loop, + logger=cfg.runtime.logger, + clock=cfg.runtime.clock, + uuid_gen=cfg.runtime.uuid_gen, + emit=cfg.runtime.emit, + uow_factory=cfg.callbacks.uow_factory, + romm_api=cfg.adapters.romm_api, + recovery_store=cfg.adapters.recovery_store, + prune_artifacts=cfg.adapters.prune_artifacts, + steam_recovery=cfg.adapters.steam_recovery, + retrodeck_paths=cfg.callbacks.retrodeck_paths, + save_coordinator=save_sync_service, + active_downloads=download_service.active_download_rom_ids, + drift_probe=launch_gate_service.check_local_drift, + remove_installed_files=rom_removal_service.delete_rom_files, + switch_version=version_switch_service.switch_version, + settings=cfg.stores.settings, + ) + ) + session_lifecycle_service = SessionLifecycleService( config=SessionLifecycleServiceConfig( playtime_recorder=playtime_service, @@ -836,6 +879,7 @@ def _emit_server_retry(attempt: int, max_attempts: int, delay_s: float) -> None: "core_service": core_service, "disc_service": disc_service, "version_switch_service": version_switch_service, + "prune_service": prune_service, "connection_service": connection_service, "startup_healing_service": startup_healing_service, "launch_gate_service": launch_gate_service, diff --git a/py_modules/domain/fetch_generation.py b/py_modules/domain/fetch_generation.py index 570ae57d..0f629356 100644 --- a/py_modules/domain/fetch_generation.py +++ b/py_modules/domain/fetch_generation.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from collections.abc import Sequence + from domain.platform_sync_state import PlatformSyncState from domain.rom import Rom @@ -44,3 +45,16 @@ def count_rows_for_skip(rows: Sequence[Rom], fetch_id: str | None) -> int: if not fetch_id: return len(rows) return sum(1 for row in rows if row.last_fetch_id == fetch_id) + + +def prune_candidate_ids(rows: Sequence[Rom], stamp: PlatformSyncState | None) -> set[int]: + """Return rows absent from a known non-empty completed platform fetch. + + This is discovery only, never deletion authority. A missing/legacy stamp or + an empty completed fetch cannot establish a safe comparison set and yields + no candidates. With a usable generation, every row carrying a different + generation is a candidate, including rows whose ``last_fetch_id`` is NULL. + """ + if stamp is None or not stamp.fetch_id or stamp.rom_count <= 0: + return set() + return {row.rom_id for row in rows if row.last_fetch_id != stamp.fetch_id} diff --git a/py_modules/domain/prune.py b/py_modules/domain/prune.py new file mode 100644 index 00000000..a52bb5f4 --- /dev/null +++ b/py_modules/domain/prune.py @@ -0,0 +1,65 @@ +"""Pure naming, grouping, and option decisions for vanished-ROM cleanup.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from domain.rom import Rom + +_UNSAFE_PACKAGE_RUN = re.compile(r"[^A-Za-z0-9._-]+", re.ASCII) +_SAFE_UUID = re.compile(r"^[A-Za-z0-9-]+$", re.ASCII) + + +def sanitize_package_name(name: object) -> str: + """Return an ASCII path component suitable for the recovery-root name.""" + raw = name if isinstance(name, str) else "" + cleaned = _UNSAFE_PACKAGE_RUN.sub("-", raw).strip("-._") + return cleaned or "decky-plugin" + + +def recovery_bundle_id(timestamp: str, lowest_rom_id: int, operation_id: str) -> str: + """Build a collision-resistant bundle directory name from trusted values.""" + if lowest_rom_id <= 0: + raise ValueError("lowest_rom_id must be positive") + if not _SAFE_UUID.fullmatch(operation_id): + raise ValueError("operation_id must contain only ASCII letters, digits, and hyphens") + safe_timestamp = re.sub(r"[^0-9TZ]", "", timestamp, flags=re.ASCII) + if not safe_timestamp: + raise ValueError("timestamp must contain a UTC date/time") + return f"{safe_timestamp}_{lowest_rom_id}_{operation_id}" + + +def group_rows(rows: Iterable[Rom]) -> list[list[Rom]]: + """Group sibling rows; a NULL group key is always a singleton.""" + grouped: dict[str, list[Rom]] = {} + singletons: list[list[Rom]] = [] + for row in rows: + if row.sibling_group_key is None: + singletons.append([row]) + else: + grouped.setdefault(row.sibling_group_key, []).append(row) + groups = [sorted(group, key=lambda row: row.rom_id) for group in grouped.values()] + groups.extend(singletons) + return sorted(groups, key=lambda group: group[0].rom_id) + + +def selected_prune_ids( + *, + group_ids: Sequence[int], + candidate_ids: set[int], + vanished_ids: set[int], + live_ids: set[int], + remove_rows: bool, + remove_fully_vanished: bool, +) -> set[int]: + """Select rows an option set permits after liveness has been established.""" + all_ids = set(group_ids) + if all_ids and all_ids <= vanished_ids and not live_ids: + return all_ids if remove_fully_vanished else set() + if not live_ids or not remove_rows: + return set() + return candidate_ids & vanished_ids diff --git a/py_modules/domain/save_backup.py b/py_modules/domain/save_backup.py index e2e9a237..b869ffa5 100644 --- a/py_modules/domain/save_backup.py +++ b/py_modules/domain/save_backup.py @@ -60,3 +60,9 @@ def select_backups_to_prune(filename: str, existing: list[str], keep: int) -> li if len(matches) <= keep: return [] return matches[: len(matches) - keep] + + +def is_backup_for(filename: str, candidate: str) -> bool: + """Return whether *candidate* is a quarantine backup of *filename*.""" + name, ext = os.path.splitext(filename) + return re.fullmatch(rf"{re.escape(name)}_{_TS}(?:_\d+)?{re.escape(ext)}", candidate) is not None diff --git a/py_modules/domain/sibling_resolution.py b/py_modules/domain/sibling_resolution.py index e3a6644f..4404dae0 100644 --- a/py_modules/domain/sibling_resolution.py +++ b/py_modules/domain/sibling_resolution.py @@ -36,6 +36,7 @@ from __future__ import annotations import functools +import os import re from typing import Any @@ -56,6 +57,12 @@ # preference — the ranking is then the pure build-time order. AUTO_REGION = "auto" + +def fs_name_stem(fs_name: str) -> str: + """Return the canonical filename label used by sibling ranking.""" + return os.path.splitext(fs_name)[0] + + # Region-rank buckets (lower wins), the first element of a region's sort key: # 0 = the user's preferred region, 1 = a build-time default region, 2 = any # other named region, 3 = the version carries no region. diff --git a/py_modules/lib/prune_gate.py b/py_modules/lib/prune_gate.py new file mode 100644 index 00000000..27e1cb28 --- /dev/null +++ b/py_modules/lib/prune_gate.py @@ -0,0 +1,133 @@ +"""Decorator that blocks conflicting Decky callables during explicit prune.""" + +from __future__ import annotations + +import asyncio +import functools +from dataclasses import dataclass, field +from typing import Any + +_BLOCKED_MESSAGE = "A removed-game cleanup is in progress; wait for it to finish before changing local game data." +_OPERATION_BLOCKED_MESSAGE = ( + "Another local-data operation is in progress; wait for it to finish before starting cleanup." +) +_LEASE_SECONDS = 300.0 + + +@dataclass +class _PruneAdmissionGate: + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + conflicting_operations: int = 0 + leases: dict[str, float] = field(default_factory=dict) + next_lease_id: int = 1 + + +def _gate(owner: object) -> _PruneAdmissionGate: + gate = getattr(owner, "_prune_admission_gate", None) + if gate is None: + gate = _PruneAdmissionGate() + owner.__dict__["_prune_admission_gate"] = gate + return gate + + +def prune_active_blocked(method): + """Return a canonical failure while the wired prune service owns its run claim.""" + + @functools.wraps(method) + async def wrapper(self, *args: Any, **kwargs: Any): + service = getattr(self, "_prune_service", None) + if service is None: + raise RuntimeError( + f"@prune_active_blocked on {method.__name__!r}: _prune_service is unwired; refusing to bypass the gate." + ) + gate = _gate(self) + async with gate.lock: + _expire_leases(gate) + if service.is_active(): + return {"success": False, "reason": "prune_active", "message": _BLOCKED_MESSAGE} + gate.conflicting_operations += 1 + try: + return await method(self, *args, **kwargs) + finally: + async with gate.lock: + gate.conflicting_operations -= 1 + + wrapper._prune_active_blocked = True # type: ignore[attr-defined] + return wrapper + + +def prune_exclusive_start(method): + """Atomically refuse prune admission while a conflicting callable is active.""" + + @functools.wraps(method) + async def wrapper(self, *args: Any, **kwargs: Any): + gate = _gate(self) + async with gate.lock: + _expire_leases(gate) + if gate.conflicting_operations: + return { + "success": False, + "reason": "operation_active", + "message": _OPERATION_BLOCKED_MESSAGE, + } + return await method(self, *args, **kwargs) + + wrapper._prune_exclusive_start = True # type: ignore[attr-defined] + return wrapper + + +async def retain_prune_conflict(owner: object, task: asyncio.Task[Any]) -> None: + """Transfer a callable's conflict claim to detached work until its task ends.""" + gate = _gate(owner) + async with gate.lock: + gate.conflicting_operations += 1 + + async def release() -> None: + async with gate.lock: + gate.conflicting_operations -= 1 + + def done(_task: asyncio.Task[Any]) -> None: + asyncio.get_running_loop().create_task(release()) + + task.add_done_callback(done) + + +async def acquire_prune_conflict_lease(owner: object, key: str) -> str: + """Hold a bounded tokenized claim across a frontend-owned operation.""" + gate = _gate(owner) + async with gate.lock: + _expire_leases(gate) + token = f"{key}:{gate.next_lease_id}" + gate.next_lease_id += 1 + gate.leases[token] = asyncio.get_running_loop().time() + _LEASE_SECONDS + gate.conflicting_operations += 1 + return token + + +async def release_prune_conflict_lease(owner: object, token: str) -> None: + """Release a previously acquired frontend-operation conflict claim.""" + gate = _gate(owner) + async with gate.lock: + _expire_leases(gate) + if token in gate.leases: + del gate.leases[token] + gate.conflicting_operations -= 1 + + +async def renew_prune_conflict_lease(owner: object, token: str) -> bool: + """Extend a live frontend continuation lease; never revive an expired owner.""" + gate = _gate(owner) + async with gate.lock: + _expire_leases(gate) + if token not in gate.leases: + return False + gate.leases[token] = asyncio.get_running_loop().time() + _LEASE_SECONDS + return True + + +def _expire_leases(gate: _PruneAdmissionGate) -> None: + now = asyncio.get_running_loop().time() + expired = [token for token, deadline in gate.leases.items() if deadline <= now] + for token in expired: + del gate.leases[token] + gate.conflicting_operations -= 1 diff --git a/py_modules/lib/url_host.py b/py_modules/lib/url_host.py index 4f94a5b3..82f5f9a6 100644 --- a/py_modules/lib/url_host.py +++ b/py_modules/lib/url_host.py @@ -15,6 +15,7 @@ from __future__ import annotations import urllib.parse +from typing import Any _DEFAULT_PORTS = {"http": 80, "https": 443} @@ -94,3 +95,12 @@ def is_valid_server_url(url: str) -> bool: hostless, or non-http(s) value is rejected. """ return normalize_origin(url.strip()) is not None + + +def romm_namespace(settings: dict[str, Any]) -> str: + """Return the stable server/user namespace destructive RomM proofs belong to.""" + origin = normalize_origin(str(settings.get("romm_url") or "")) or "invalid-origin" + token_origin = normalize_origin(str(settings.get("romm_api_token_origin") or "")) or "no-token-origin" + raw_user = settings.get("romm_user_id") + user = str(raw_user) if raw_user not in {None, ""} else "unknown-user" + return f"{origin}|{token_origin}|{user}" diff --git a/py_modules/models/prune.py b/py_modules/models/prune.py new file mode 100644 index 00000000..3e9b8a0f --- /dev/null +++ b/py_modules/models/prune.py @@ -0,0 +1,70 @@ +"""Wire-neutral data shapes shared by vanished-ROM cleanup services and adapters.""" + +from __future__ import annotations + +from typing import NotRequired, TypedDict + + +class RecoveryArtifact(TypedDict): + """One source copied into a recovery bundle under a generated destination.""" + + source_path: str + safe_root: str + kind: str + rom_id: NotRequired[int] + + +class SourceIdentity(TypedDict): + """Exact no-follow source identity sealed before destructive mutation.""" + + exists: bool + mount_id: int + device: int + inode: int + mode: int + size: int + mtime_ns: int + ctime_ns: int + + +class SourceEntry(TypedDict): + """One descendant held by a no-follow source claim.""" + + identity: SourceIdentity + sha256: NotRequired[str] + + +class SourceClaim(TypedDict): + """A source root and its complete descriptor-inventoried subtree.""" + + source_path: str + safe_root: str + source_identity: SourceIdentity + sha256: str | None + entries: dict[str, SourceEntry] + + +class SealedSourceClaims(TypedDict): + """Claims decoded from one identity-bound validation of a sealed bundle.""" + + claims: dict[str, SourceClaim] + bundle_digest: str + + +class MutationOutcome(TypedDict): + """Truthful result of a durable filesystem mutation attempt.""" + + success: bool + changed: bool + ambiguous: bool + message: str + + +class SteamRecoverySnapshot(TypedDict): + """Backend-owned Steam Input state and files for one shortcut.""" + + user_id: str + user_dir: str + steam_root: str + controller_setting: str | None + artifacts: list[RecoveryArtifact] diff --git a/py_modules/services/downloads.py b/py_modules/services/downloads.py index 802e3790..057e119d 100644 --- a/py_modules/services/downloads.py +++ b/py_modules/services/downloads.py @@ -475,6 +475,10 @@ async def _begin_download(self, rom_id, *, resume: bool): self._control_tokens[rom_id] = control return {"success": True, "message": "Download started"} + def task_for_rom(self, rom_id: int) -> asyncio.Task[None] | None: + """Return the detached task whose lifetime owns this ROM's install write.""" + return self._download_tasks.get(int(rom_id)) + def _partial_tmp_size(self, target_path, rom_detail) -> int: """Bytes already on disk in the partial ``.tmp`` for *target_path*. diff --git a/py_modules/services/protocols/__init__.py b/py_modules/services/protocols/__init__.py index e0530a06..726d6b1a 100644 --- a/py_modules/services/protocols/__init__.py +++ b/py_modules/services/protocols/__init__.py @@ -32,6 +32,7 @@ DeviceForgetFn, DeviceIdProvider, DiscResolver, + InstalledRomFilesRemoverFn, InstalledRomRemoverFn, LaunchGateDriftReader, LaunchGateInstalledChecker, @@ -39,6 +40,7 @@ LaunchGateSaveStatusReader, MigrationPendingFn, PlaytimeScopeNoticeClearFn, + PruneSaveCoordinator, ReachabilityProbeFn, RelaunchOptionsReader, RetryStrategy, @@ -51,6 +53,7 @@ SessionMigrationReader, SessionPlaytimeRecorder, SessionPostExitSync, + VersionSwitcherFn, ) from services.protocols.determinism import Clock, Sleeper, UuidGen from services.protocols.files import ( @@ -59,9 +62,12 @@ DownloadFileStore, FirmwareFileStore, MigrationFileStore, + PruneArtifactStore, + RecoveryBundleStore, RomFileStore, SaveFileStore, SgdbArtworkCache, + SteamRecoveryStore, ) from services.protocols.infra import ( DebugLogger, @@ -147,6 +153,7 @@ "FirmwareCacheRepository", "FirmwareFileStore", "HostnameReader", + "InstalledRomFilesRemoverFn", "InstalledRomRemoverFn", "KvConfigRepository", "LaunchGateDriftReader", @@ -163,7 +170,10 @@ "PlaytimeRepository", "PlaytimeScopeNoticeClearFn", "PluginMetadataReader", + "PruneArtifactStore", + "PruneSaveCoordinator", "ReachabilityProbeFn", + "RecoveryBundleStore", "RelaunchOptionsReader", "RendererGcFn", "RendererRssFn", @@ -206,6 +216,7 @@ "Sleeper", "SteamConfigStore", "SteamGridDbApi", + "SteamRecoveryStore", "SyncRunRepository", "SystemM3uSupportFn", "SystemResolver", @@ -213,4 +224,5 @@ "UnitOfWork", "UnitOfWorkFactory", "UuidGen", + "VersionSwitcherFn", ] diff --git a/py_modules/services/protocols/cross_service.py b/py_modules/services/protocols/cross_service.py index 6395fb77..e5c57f24 100644 --- a/py_modules/services/protocols/cross_service.py +++ b/py_modules/services/protocols/cross_service.py @@ -13,6 +13,9 @@ from typing import TYPE_CHECKING, Any, Protocol if TYPE_CHECKING: + from contextlib import AbstractAsyncContextManager + + from models.prune import SourceClaim from models.state import InstalledRomEntry, ShortcutRegistryEntry from models.sync import ClientSaveState @@ -159,6 +162,18 @@ class InstalledRomRemoverFn(Protocol): async def __call__(self, rom_id: int) -> dict[str, Any]: ... +class InstalledRomFilesRemoverFn(Protocol): + """Filesystem-only installed-ROM removal consumed by explicit prune.""" + + def __call__(self, rom_id: int, claims: dict[str, SourceClaim] | None = None) -> dict[str, Any]: ... + + +class VersionSwitcherFn(Protocol): + """Version-switch authority consumed by explicit prune repointing.""" + + async def __call__(self, app_id: int, target_rom_id: int, allow_stranded: bool) -> dict[str, Any]: ... + + class RomRemoverProvider(Protocol): """Deferred access to the installed-ROM remover consumed by DownloadService. @@ -187,6 +202,17 @@ class ActiveDownloadRomIdsFn(Protocol): def __call__(self) -> set[int]: ... +class PruneSaveCoordinator(Protocol): + """Exact-path save inventory, locking, and quarantine consumed by prune.""" + + def lock_prune_roms(self, rom_ids: list[int]) -> AbstractAsyncContextManager[None]: ... + def inventory_prune_saves(self, purge_rom_ids: list[int]) -> dict[str, Any]: ... + def quarantine_prune_saves( + self, files: list[dict[str, str]], claims: dict[str, SourceClaim] | None = None + ) -> dict[str, Any]: ... + def validate_prune_absences(self, claims: dict[str, SourceClaim]) -> bool: ... + + class AchievementsReader(Protocol): """Achievement data access consumed by GameDetailService.""" diff --git a/py_modules/services/protocols/files.py b/py_modules/services/protocols/files.py index 4f05040f..ac93d48b 100644 --- a/py_modules/services/protocols/files.py +++ b/py_modules/services/protocols/files.py @@ -18,6 +18,15 @@ from collections.abc import Callable from contextlib import AbstractContextManager + from models.prune import ( + MutationOutcome, + RecoveryArtifact, + SealedSourceClaims, + SourceClaim, + SourceIdentity, + SteamRecoverySnapshot, + ) + class DirectoryFileListerFn(Protocol): """Recursively list the absolute paths of every file under a directory. @@ -361,6 +370,18 @@ def remove_tree(self, path: str) -> None: """Recursively delete *path* and all contents.""" ... + def remove_exact(self, path: str, safe_root: str, identity: SourceIdentity) -> bool: + """Remove only the exact sealed no-follow source identity.""" + ... + + def claim_source(self, path: str, safe_root: str) -> SourceClaim: + """Capture the current no-follow source and complete subtree identity.""" + ... + + def remove_claimed(self, path: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + """Remove only the complete claimed source tree and report durable progress.""" + ... + class SaveFileStore(Protocol): """Filesystem seam for local save file operations. @@ -388,6 +409,18 @@ def is_dir(self, path: str) -> bool: """Return True when *path* exists and is a directory.""" ... + def is_symlink(self, path: str) -> bool: + """Return True when *path* is a symbolic link, including dangling links.""" + ... + + def canonical_path(self, path: str) -> str: + """Return the canonical real path used for exact ownership comparison.""" + ... + + def is_within(self, path: str, root: str) -> bool: + """Return whether canonical *path* is contained by canonical *root*.""" + ... + def make_dirs(self, path: str) -> None: """Create *path* and any missing parents. Idempotent.""" ... @@ -407,6 +440,22 @@ def rename(self, src: str, dst: str) -> None: """ ... + def rename_exact(self, src: str, dst: str, safe_root: str, identity: SourceIdentity) -> bool: + """Rename only the exact sealed no-follow source identity.""" + ... + + def claim_source(self, path: str, safe_root: str) -> SourceClaim: + """Capture the current no-follow source and complete subtree identity.""" + ... + + def ensure_directory(self, path: str, safe_root: str) -> None: + """Create a directory through anchored no-follow parents and fsync each creation.""" + ... + + def rename_claimed(self, src: str, dst: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + """Rename only the complete claimed source and report durable progress.""" + ... + def get_mtime(self, path: str) -> float: """Return the mtime of *path* as a Unix timestamp.""" ... @@ -506,3 +555,41 @@ def is_dir(self, path: str) -> bool: def read_bytes(self, path: str) -> bytes: """Return the contents of *path* as raw bytes.""" ... + + +class RecoveryBundleStore(Protocol): + """Build and seal verified recovery bundles under the plugin recovery root.""" + + def root(self) -> str: ... + def free_bytes(self) -> int: ... + def measure_path(self, path: str, safe_root: str) -> int: ... + def validate_sources(self, bundle_path: str, bundle_digest: str | None = None) -> bool: ... + def source_claims(self, bundle_path: str) -> SealedSourceClaims: ... + def seal_bundle( + self, + bundle_id: str, + snapshot: dict[str, object], + artifacts: list[RecoveryArtifact], + readme: str, + playtime_text: str, + ) -> str: ... + + +class PruneArtifactStore(Protocol): + """Own per-ROM cover and SteamGridDB cache artifacts during cleanup.""" + + def recovery_artifacts(self, rom_ids: list[int]) -> list[RecoveryArtifact]: ... + def remove(self, rom_ids: list[int], claims: dict[str, SourceClaim] | None = None) -> MutationOutcome: ... + + +class SteamRecoveryStore(Protocol): + """Own per-shortcut Steam Input/grid recovery files and controller state.""" + + def snapshot(self, app_id: int) -> SteamRecoverySnapshot: ... + def validate_state(self, app_id: int, snapshot: SteamRecoverySnapshot) -> bool: ... + def remove_state( + self, + app_id: int, + snapshot: SteamRecoverySnapshot, + claims: dict[str, SourceClaim], + ) -> MutationOutcome: ... diff --git a/py_modules/services/protocols/persistence.py b/py_modules/services/protocols/persistence.py index 194ce343..b8186314 100644 --- a/py_modules/services/protocols/persistence.py +++ b/py_modules/services/protocols/persistence.py @@ -29,6 +29,10 @@ class PluginMetadataReader(Protocol): documented fallback (``"0.0.0"`` for ``read_version``). """ + def read_metadata(self, plugin_dir: str) -> tuple[str, str]: + """Return ``(name, version)`` from one canonical package read.""" + ... + def read_version(self, plugin_dir: str) -> str: """Return the plugin's declared semantic version. @@ -37,3 +41,7 @@ def read_version(self, plugin_dir: str) -> str: abort on a metadata read. """ ... + + def read_name(self, plugin_dir: str) -> str: + """Return the declared package name, or ``"decky-plugin"`` on failure.""" + ... diff --git a/py_modules/services/prune/__init__.py b/py_modules/services/prune/__init__.py new file mode 100644 index 00000000..835369fd --- /dev/null +++ b/py_modules/services/prune/__init__.py @@ -0,0 +1,5 @@ +"""Explicit, recovery-backed cleanup of local ROM entries absent from RomM.""" + +from services.prune.service import PruneService, PruneServiceConfig + +__all__ = ["PruneService", "PruneServiceConfig"] diff --git a/py_modules/services/prune/_models.py b/py_modules/services/prune/_models.py new file mode 100644 index 00000000..41b905b2 --- /dev/null +++ b/py_modules/services/prune/_models.py @@ -0,0 +1,97 @@ +"""Shared in-memory state for the vanished-ROM cleanup bounded context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from models.prune import SourceClaim, SteamRecoverySnapshot + + +@dataclass(frozen=True) +class PrunePreview: + """Ephemeral local candidate snapshot that authorizes one explicit start.""" + + preview_id: str + scope: Literal["bulk", "rom"] + explicit_rom_id: int | None + candidate_ids: frozenset[int] + fingerprint: tuple[tuple[object, ...], ...] + entries: tuple[dict[str, Any], ...] + free_bytes: int + server_namespace: str + + +@dataclass(frozen=True) +class PruneOptions: + """One-run user choices; never persisted.""" + + repoint_shortcuts: bool + remove_rows: bool + remove_fully_vanished: bool + create_recovery_bundle: bool + include_installed_rom_ids: frozenset[int] + + +@dataclass +class PendingAction: + """The one frontend Steam action the backend is awaiting.""" + + run_id: str + token: str + kind: str + app_id: int | None + expected_bound_rom_id: int | None + target_rom_id: int | None + group_rom_ids: frozenset[int] + future: object + claimed: bool = False + claim_event: object | None = None + expires_at: float = 0.0 + + +@dataclass +class InstalledSelection: + """One preview-bound, incrementally staged installed-content selection.""" + + preview_id: str + selection_id: str + rom_ids: set[int] + finalized: bool = False + + +@dataclass(frozen=True) +class RecoveryHandle: + """Sealed recovery state that finalization must revalidate and consume.""" + + bundle_path: str + snapshot: dict[str, object] + save_inventory: dict[str, Any] + steam_backend: SteamRecoverySnapshot | None + source_claims: dict[str, SourceClaim] + bundle_digest: str + + +@dataclass +class PruneCancellationState: + """Result state captured while propagating one cleanup cancellation.""" + + action_result: dict[str, Any] | None = None + group_result: dict[str, Any] | None = None + child_result: Any = None + child_completed: bool = False + child_fault: BaseException | None = None + + +_CANCELLATION_STATE_ATTR = "_prune_cancellation_state" + + +def cancellation_state(error: BaseException) -> PruneCancellationState: + """Return the cleanup state attached to the original cancellation.""" + state = getattr(error, _CANCELLATION_STATE_ATTR, None) + if isinstance(state, PruneCancellationState): + return state + state = PruneCancellationState() + setattr(error, _CANCELLATION_STATE_ATTR, state) + return state diff --git a/py_modules/services/prune/executor.py b/py_modules/services/prune/executor.py new file mode 100644 index 00000000..e6bcf569 --- /dev/null +++ b/py_modules/services/prune/executor.py @@ -0,0 +1,1462 @@ +"""Per-group state machine for explicit vanished-ROM cleanup.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from functools import partial +from typing import TYPE_CHECKING, Any, Literal, cast + +from domain.prune import selected_prune_ids +from domain.sibling_resolution import AUTO_REGION, fs_name_stem, resolve_group_representative +from lib.errors import RommNotFoundError, classify_error +from lib.list_result import ErrorCode +from lib.url_host import romm_namespace +from services.prune._models import PruneOptions, PrunePreview, RecoveryHandle, cancellation_state + +if TYPE_CHECKING: + import logging + from collections.abc import AsyncIterator + + from domain.rom import Rom + from services.protocols import ( + ActiveDownloadRomIdsFn, + InstalledRomFilesRemoverFn, + PruneArtifactStore, + PruneSaveCoordinator, + RecoveryBundleStore, + RommRomReader, + SaveDriftProbeFn, + SteamRecoveryStore, + VersionSwitcherFn, + ) + from services.prune.recovery import RecoveryCoordinator + from services.prune.registry import PruneRegistry + +_LIVENESS_CONCURRENCY = 4 +_COMPLETION_IDS_PER_GROUP = 50 +_COMPLETION_TEXT_CHARS = 512 +_COMPLETION_PATH_CHARS = 2048 +_COMPLETION_REASON_CHARS = 128 +_COMPLETION_WARNING_CHARS = 256 +_COMPLETION_WARNINGS_PER_GROUP = 5 +_COMPLETION_BUDGET_BYTES = 48 * 1024 + +ActionRequester = Callable[[str, str, dict[str, object], int | None, int | None, set[int]], Awaitable[dict[str, Any]]] + + +@dataclass(frozen=True) +class PruneExecutorConfig: + """Dependencies for one cleanup run's per-group state machine.""" + + loop: asyncio.AbstractEventLoop + logger: logging.Logger + emit: Callable[..., Awaitable[None]] + romm_api: RommRomReader + recovery_store: RecoveryBundleStore + prune_artifacts: PruneArtifactStore + steam_recovery: SteamRecoveryStore + save_coordinator: PruneSaveCoordinator + active_downloads: ActiveDownloadRomIdsFn + drift_probe: SaveDriftProbeFn + remove_installed_files: InstalledRomFilesRemoverFn + switch_version: VersionSwitcherFn + settings: dict[str, Any] + recovery: RecoveryCoordinator + registry: PruneRegistry + request_action: ActionRequester + + +@dataclass +class _MutationLedger: + rows: list[Rom] + app_id: int | None = None + target_rom_id: int | None = None + bundle_path: str | None = None + committed_action: str | None = None + action_ambiguous: bool = False + mutations: list[str] = field(default_factory=list) + ambiguous_mutations: list[str] = field(default_factory=list) + + def has_commit(self) -> bool: + return ( + self.committed_action is not None + or self.action_ambiguous + or bool(self.mutations) + or bool(self.ambiguous_mutations) + ) + + +class PruneExecutor: + """Own liveness, recovery, frontend actions, and final cleanup sequencing.""" + + def __init__(self, *, config: PruneExecutorConfig) -> None: + self._loop = config.loop + self._logger = config.logger + self._emit = config.emit + self._romm_api = config.romm_api + self._recovery_store = config.recovery_store + self._prune_artifacts = config.prune_artifacts + self._steam_recovery = config.steam_recovery + self._save_coordinator = config.save_coordinator + self._active_downloads = config.active_downloads + self._drift_probe = config.drift_probe + self._remove_installed_files = config.remove_installed_files + self._switch_version = config.switch_version + self._settings = config.settings + self._recovery = config.recovery + self._registry = config.registry + self._request_action = config.request_action + self._run_namespace: str | None = None + self._run_preview_id: str | None = None + + async def run(self, run_id: str, preview: PrunePreview, options: PruneOptions) -> None: + """Execute every candidate group and emit bounded terminal chunks.""" + results: list[dict[str, Any]] = [] + self._run_namespace = preview.server_namespace + self._run_preview_id = preview.preview_id + try: + if romm_namespace(self._settings) != preview.server_namespace: + raise RuntimeError("The RomM server or user changed after cleanup preview.") + groups = await self._loop.run_in_executor( + None, self._registry.groups_for_candidates, set(preview.candidate_ids) + ) + total = len(groups) + for index, rows in enumerate(groups, start=1): + await self._emit_progress(run_id, index, total, "checking", rows) + try: + result = await self._run_group( + run_id, + rows, + set(preview.candidate_ids), + options, + index, + total, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.exception(f"Vanished-ROM cleanup group {rows[0].rom_id} failed") + result = self._group_result(rows, "failed", ErrorCode.UNKNOWN.value, str(exc)) + results.append(result) + except asyncio.CancelledError as exc: + result = cancellation_state(exc).group_result + if result is not None: + results.append(result) + await self._finish_run( + run_id, + results, + cancelled=True, + reason="cancelled", + message="Cleanup was cancelled; no unstarted destructive phase was run.", + ) + raise + except Exception as exc: + self._logger.exception("Vanished-ROM cleanup failed") + await self._finish_run( + run_id, + results, + cancelled=False, + reason=ErrorCode.UNKNOWN.value, + message=str(exc), + ) + else: + await self._finish_run(run_id, results, cancelled=False, reason=None, message=None) + + async def _finish_run( + self, + run_id: str, + results: list[dict[str, Any]], + *, + cancelled: bool, + reason: str | None, + message: str | None, + ) -> None: + try: + await self._emit_completion(run_id, results, cancelled=cancelled, reason=reason, message=message) + finally: + self._run_namespace = None + self._run_preview_id = None + + async def _run_group( + self, + run_id: str, + initial_rows: list[Rom], + preview_candidate_ids: set[int], + options: PruneOptions, + index: int, + total: int, + ) -> dict[str, Any]: + ledger = _MutationLedger(initial_rows) + try: + return await self._run_group_inner( + run_id, initial_rows, preview_candidate_ids, options, index, total, ledger + ) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.child_fault is not None: + state.group_result = self._fault_result(ledger, initial_rows, state.child_fault) + elif state.group_result is None and ledger.has_commit(): + state.group_result = self._ledger_result( + ledger, + "cancelled", + "Cleanup was cancelled after a committed or ambiguous action; later groups were not started.", + ) + raise + except Exception as exc: + if ledger.has_commit(): + self._logger.exception(f"Vanished-ROM cleanup group {initial_rows[0].rom_id} failed after mutation") + return self._ledger_result(ledger, ErrorCode.UNKNOWN.value, str(exc)) + raise + + async def _run_group_inner( + self, + run_id: str, + initial_rows: list[Rom], + preview_candidate_ids: set[int], + options: PruneOptions, + index: int, + total: int, + ledger: _MutationLedger, + ) -> dict[str, Any]: + rows = await self._loop.run_in_executor(None, self._registry.reread_group, initial_rows[0].rom_id) + ledger.rows = rows or initial_rows + if not rows: + return self._group_result(initial_rows, "skipped", "local_state_changed", "The local group changed.") + group_ids = {row.rom_id for row in rows} + candidate_ids = group_ids & preview_candidate_ids + bound = [row for row in rows if row.shortcut_app_id is not None] + if len(bound) > 1: + return self._group_result(rows, "skipped", "multiple_bindings", "The group has multiple shortcuts.") + if self._active_downloads() & group_ids: + return self._group_result(rows, "skipped", "download_in_progress", "Cancel active downloads first.") + + verdicts = await self._probe_many(group_ids) + vanished_ids = {rom_id for rom_id, verdict in verdicts.items() if verdict["status"] == "vanished"} + live_ids = {rom_id for rom_id, verdict in verdicts.items() if verdict["status"] == "live"} + uncertain_ids = group_ids - vanished_ids - live_ids + fully_dead = bool(group_ids) and group_ids <= vanished_ids + if not live_ids and uncertain_ids: + namespace_changed = any( + verdicts[rom_id]["reason"] == "server_namespace_changed" for rom_id in uncertain_ids + ) + return self._group_result( + rows, + "skipped", + "server_namespace_changed" if namespace_changed else "liveness_uncertain", + ( + "The RomM server or user changed during exact-ID proof; nothing was removed." + if namespace_changed + else f"RomM could not confirm {len(uncertain_ids)} group member(s); nothing was removed." + ), + ) + + delete_ids = selected_prune_ids( + group_ids=sorted(group_ids), + candidate_ids=candidate_ids, + vanished_ids=vanished_ids, + live_ids=live_ids, + remove_rows=options.remove_rows, + remove_fully_vanished=options.remove_fully_vanished, + ) + bound_row = bound[0] if bound else None + target_id: int | None = None + if bound_row is not None and bound_row.rom_id in vanished_ids and live_ids and options.repoint_shortcuts: + target_id = self._natural_default(rows, live_ids) + if target_id is None: + return self._group_result(rows, "skipped", "no_live_default", "No live default could be selected.") + if bound_row is not None and bound_row.rom_id in delete_ids and live_ids and target_id is None: + delete_ids.remove(bound_row.rom_id) + + whole_game_action = fully_dead and bool(delete_ids) + if not delete_ids and target_id is None: + return self._group_result( + rows, "skipped", "options_excluded", "No confirmed rows matched the selected options." + ) + + drifted = False + if bound_row is not None and bound_row.rom_id in vanished_ids and (target_id is not None or whole_game_action): + drift = await self._drift_probe(bound_row.rom_id) + drifted = bool(drift.get("drifted")) + if drifted and not options.create_recovery_bundle: + return self._group_result( + rows, + "skipped", + "unsynced_saves", + "Unsynced saves require a sealed recovery bundle before changing this shortcut.", + ) + + app_id = bound_row.shortcut_app_id if bound_row is not None else None + frontend_steam: dict[str, object] | None = None + if whole_game_action and app_id is not None and options.create_recovery_bundle: + if bound_row is None: + raise RuntimeError("Bound shortcut state disappeared before snapshot capture") + try: + capture = await self._request_action( + run_id, + "capture_shortcut_snapshot", + {"app_id": app_id}, + bound_row.rom_id, + None, + group_ids, + ) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.action_result is not None: + _, state.group_result = await self._snapshot_outcome( + state.action_result, rows, ledger, bound_row.rom_id, app_id + ) + raise + frontend_steam, result = await self._snapshot_outcome(capture, rows, ledger, bound_row.rom_id, app_id) + if result is not None: + return result + + recovery_ids = set(delete_ids) + if target_id is not None and bound_row is not None: + recovery_ids.add(bound_row.rom_id) + recovery_ids.add(target_id) + handle: RecoveryHandle | None = None + if options.create_recovery_bundle: + await self._emit_progress(run_id, index, total, "creating_recovery", rows) + try: + async with self._stable_save_locks(recovery_ids) as save_inventory: + locked_rows = await self._loop.run_in_executor( + None, self._registry.reread_group, initial_rows[0].rom_id + ) + if not locked_rows or not recovery_ids <= {row.rom_id for row in locked_rows}: + return self._group_result( + rows, "skipped", "local_state_changed", "The local group changed before recovery." + ) + snapshot = await self._loop.run_in_executor( + None, self._recovery.snapshot_state, sorted(recovery_ids), frontend_steam + ) + sealed = await self._shielded( + self._loop.run_in_executor( + None, + lambda: self._recovery.seal( + rows=[row for row in locked_rows if row.rom_id in recovery_ids], + snapshot=snapshot, + save_inventory=save_inventory, + include_installed_rom_ids=set(options.include_installed_rom_ids), + delete_ids=delete_ids, + app_id=app_id if whole_game_action else None, + ), + ) + ) + bundle_path, steam_backend = sealed + sealed_claims = await self._loop.run_in_executor( + None, self._recovery_store.source_claims, bundle_path + ) + handle = RecoveryHandle( + bundle_path, + snapshot, + save_inventory, + steam_backend, + sealed_claims["claims"], + sealed_claims["bundle_digest"], + ) + ledger.bundle_path = bundle_path + except Exception as exc: + self._logger.error(f"Recovery bundle failed for group {min(group_ids)}: {exc}") + return self._group_result(rows, "failed", "recovery_failed", str(exc)) + await self._emit_progress( + run_id, + index, + total, + "recovery_sealed", + rows, + bundle_path=handle.bundle_path, + ) + + if self._active_downloads() & delete_ids: + return self._group_result(rows, "skipped", "download_in_progress", "Cancel active downloads first.") + proof_ids = set(delete_ids) + if target_id is not None: + proof_ids.add(target_id) + if bound_row is not None: + proof_ids.add(bound_row.rom_id) + refreshed = await self._probe_many(proof_ids) + guard = self._fresh_guard( + refreshed, + delete_ids, + target_id, + bound_row.rom_id if target_id is not None and bound_row is not None else None, + ) + if guard is not None: + return self._group_result( + rows, + "skipped", + guard[0], + guard[1], + bundle_path=handle.bundle_path if handle else None, + ) + + if handle is not None: + recovery_guard = await self._recovery_guard( + handle, + recovery_ids, + committed_action=None, + app_id=app_id, + target_id=target_id, + launch_options=None, + ) + if recovery_guard is not None: + return self._group_result( + rows, + "skipped", + "recovery_state_changed", + recovery_guard, + bundle_path=handle.bundle_path, + ) + + committed_action: Literal["repoint_shortcut", "remove_shortcut"] | None = None + launch_options: str | None = None + if target_id is not None and app_id is not None and bound_row is not None: + ledger.app_id = app_id + ledger.committed_action = "repoint_shortcut" + ledger.action_ambiguous = True + try: + switch = await self._shielded(self._switch_version(app_id, target_id, drifted and handle is not None)) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.child_completed and isinstance(state.child_result, dict): + _, state.group_result = self._switch_outcome( + state.child_result, rows, ledger, target_id, app_id, handle + ) + if state.group_result is None: + state.group_result = self._ledger_result( + ledger, + "cancelled", + "Cleanup was cancelled after the version binding changed; later groups were not started.", + ) + raise + launch_options, result = self._switch_outcome(switch, rows, ledger, target_id, app_id, handle) + if result is not None: + return result + if launch_options is None: + raise RuntimeError("Successful version switch did not produce launch options") + committed_action = "repoint_shortcut" + await self._shielded( + self._emit_progress( + run_id, index, total, "repointing", rows, bundle_path=handle.bundle_path if handle else None + ) + ) + try: + action = await self._request_action( + run_id, + "repoint_shortcut", + { + "app_id": app_id, + "target_rom_id": target_id, + "launch_options": launch_options, + "target_installed": bool(switch.get("target_installed")), + }, + bound_row.rom_id, + target_id, + group_ids, + ) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.action_result is not None: + state.group_result = self._repoint_action_outcome(state.action_result, ledger) + if state.group_result is None: + state.group_result = self._ledger_result( + ledger, + "cancelled", + "Cleanup was cancelled after Steam confirmed the repoint; later groups were not started.", + ) + raise + result = self._repoint_action_outcome(action, ledger) + if result is not None: + return result + elif whole_game_action and app_id is not None and bound_row is not None: + await self._emit_progress( + run_id, + index, + total, + "removing_shortcut", + rows, + bundle_path=handle.bundle_path if handle else None, + ) + try: + action = await self._request_action( + run_id, + "remove_shortcut", + { + "app_id": app_id, + **({"expected_snapshot": frontend_steam} if isinstance(frontend_steam, dict) else {}), + }, + bound_row.rom_id, + None, + group_ids, + ) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.action_result is not None: + _, state.group_result = await self._remove_action_outcome( + state.action_result, rows, ledger, bound_row.rom_id, app_id, handle + ) + if state.group_result is None: + state.group_result = self._ledger_result( + ledger, + "cancelled", + "Cleanup was cancelled after Steam confirmed removal; later groups were not started.", + removed_app_id=app_id, + ) + raise + committed_action, result = await self._remove_action_outcome( + action, rows, ledger, bound_row.rom_id, app_id, handle + ) + if result is not None: + return result + + if target_id is not None and not delete_ids: + final_proof = await self._probe_many( + {bound_row.rom_id, target_id} if bound_row is not None else {target_id} + ) + final_guard = self._fresh_guard( + final_proof, + set(), + target_id, + bound_row.rom_id if bound_row is not None else None, + ) + if final_guard is not None: + return self._ledger_result(ledger, final_guard[0], final_guard[1]) + return self._group_result( + rows, + "repointed", + None, + "Repointed the shortcut to the live Default.", + app_id=app_id, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + target_rom_id=target_id, + ) + + return await self._finish_group( + run_id=run_id, + initial_rows=initial_rows, + delete_ids=delete_ids, + target_id=target_id, + app_id=app_id, + fully_dead=fully_dead, + committed_action=committed_action, + handle=handle, + recovery_ids=recovery_ids, + index=index, + total=total, + launch_options=launch_options, + ledger=ledger, + vanished_source_id=bound_row.rom_id if target_id is not None and bound_row is not None else None, + ) + + async def _snapshot_outcome( + self, + capture: dict[str, Any], + rows: list[Rom], + ledger: _MutationLedger, + bound_rom_id: int, + app_id: int, + ) -> tuple[dict[str, object] | None, dict[str, Any] | None]: + if capture.get("success") and capture.get("shortcut_absent") is True: + ledger.app_id = app_id + ledger.committed_action = "remove_shortcut" + try: + reconciled = await self._shielded( + self._loop.run_in_executor(None, self._registry.reconcile_removed_shortcut, bound_rom_id, app_id) + ) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.child_completed and type(state.child_result) is bool: + state.group_result = self._shortcut_absence_result(ledger, state.child_result, app_id) + raise + return None, self._shortcut_absence_result(ledger, bool(reconciled), app_id) + snapshot = capture.get("snapshot") + if not capture.get("success") or not isinstance(snapshot, dict): + return None, self._group_result( + rows, + "failed", + "steam_snapshot_failed", + capture.get("message", "Steam snapshot failed."), + ) + return cast("dict[str, object]", snapshot), None + + def _shortcut_absence_result(self, ledger: _MutationLedger, reconciled: bool, app_id: int) -> dict[str, Any]: + if reconciled and "shortcut_binding" not in ledger.mutations: + ledger.mutations.append("shortcut_binding") + return self._ledger_result( + ledger, + "shortcut_absence_reconciled" if reconciled else "local_state_changed", + ( + "Steam already lacked this shortcut; its local binding was reconciled. Run cleanup again." + if reconciled + else "Steam lacked the shortcut, but its local binding changed before reconciliation." + ), + removed_app_id=app_id if reconciled else None, + ) + + def _switch_outcome( + self, + switch: dict[str, Any], + rows: list[Rom], + ledger: _MutationLedger, + target_id: int, + app_id: int, + handle: RecoveryHandle | None, + ) -> tuple[str | None, dict[str, Any] | None]: + if not switch.get("success"): + ledger.committed_action = None + ledger.action_ambiguous = False + return None, self._group_result( + rows, + "failed", + switch.get("reason", "repoint_failed"), + switch.get("message", "Repoint failed."), + bundle_path=handle.bundle_path if handle else None, + ) + launch_options = switch.get("launch_options") + if switch.get("rom_id") != target_id or switch.get("app_id") != app_id or not isinstance(launch_options, str): + return None, self._ledger_result( + ledger, + "repoint_result_invalid", + "The binding changed but the switch result was incomplete.", + ) + ledger.target_rom_id = target_id + ledger.action_ambiguous = False + if "shortcut_binding" not in ledger.mutations: + ledger.mutations.append("shortcut_binding") + return launch_options, None + + def _repoint_action_outcome(self, action: dict[str, Any], ledger: _MutationLedger) -> dict[str, Any] | None: + if action.get("success"): + return None + if action.get("mutation_attempted") is True or action.get("reason") == "action_ambiguous": + ledger.action_ambiguous = True + return self._ledger_result( + ledger, + "action_ambiguous", + action.get("message", "The binding changed but Steam confirmation is unknown."), + ) + return self._ledger_result( + ledger, + "steam_action_failed", + action.get("message", "The binding changed but Steam confirmation failed."), + ) + + async def _remove_action_outcome( + self, + action: dict[str, Any], + rows: list[Rom], + ledger: _MutationLedger, + bound_rom_id: int, + app_id: int, + handle: RecoveryHandle | None, + ) -> tuple[Literal["remove_shortcut"] | None, dict[str, Any] | None]: + if not action.get("success"): + if action.get("mutation_attempted") is True or action.get("reason") == "action_ambiguous": + ledger.app_id = app_id + ledger.committed_action = "remove_shortcut" + ledger.action_ambiguous = True + return None, self._ledger_result( + ledger, + "action_ambiguous", + "Steam removal was claimed but its outcome is unknown; source data was retained.", + ) + return None, self._group_result( + rows, + "failed", + "steam_action_failed", + action.get("message", "Shortcut removal failed."), + bundle_path=handle.bundle_path if handle else None, + ) + ledger.app_id = app_id + ledger.committed_action = "remove_shortcut" + ledger.action_ambiguous = False + try: + reconciled = await self._shielded( + self._loop.run_in_executor(None, self._registry.reconcile_removed_shortcut, bound_rom_id, app_id) + ) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.child_completed and type(state.child_result) is bool: + _, state.group_result = self._removed_shortcut_reconcile_result( + ledger, state.child_result, rows, app_id, handle + ) + if state.group_result is None: + state.group_result = self._ledger_result( + ledger, + "cancelled", + "Cleanup was cancelled after Steam confirmed removal; later groups were not started.", + removed_app_id=app_id, + ) + raise + return self._removed_shortcut_reconcile_result(ledger, bool(reconciled), rows, app_id, handle) + + def _removed_shortcut_reconcile_result( + self, + ledger: _MutationLedger, + reconciled: bool, + rows: list[Rom], + app_id: int, + handle: RecoveryHandle | None, + ) -> tuple[Literal["remove_shortcut"], dict[str, Any] | None]: + if reconciled: + if "shortcut_binding" not in ledger.mutations: + ledger.mutations.append("shortcut_binding") + return "remove_shortcut", None + return "remove_shortcut", self._group_result( + rows, + "partial", + "local_state_changed", + "Steam removed the shortcut, but its local binding changed before reconciliation.", + app_id=app_id, + removed_app_id=app_id, + bundle_path=handle.bundle_path if handle else None, + committed_action="remove_shortcut", + ) + + async def _finish_group( + self, + *, + run_id: str, + initial_rows: list[Rom], + delete_ids: set[int], + target_id: int | None, + app_id: int | None, + fully_dead: bool, + committed_action: str | None, + handle: RecoveryHandle | None, + recovery_ids: set[int], + index: int, + total: int, + launch_options: str | None, + ledger: _MutationLedger, + vanished_source_id: int | None, + ) -> dict[str, Any]: + refreshed = await self._probe_many( + delete_ids + | ({target_id} if target_id is not None else set()) + | ({vanished_source_id} if vanished_source_id is not None else set()) + ) + guard = self._fresh_guard(refreshed, delete_ids, target_id, vanished_source_id) + if guard is not None: + return self._ledger_or_guard_result(ledger, initial_rows, guard, handle, app_id) + if self._active_downloads() & delete_ids: + return self._ledger_or_guard_result( + ledger, + initial_rows, + ("download_in_progress", "A download became active; source data was retained."), + handle, + app_id, + ) + + rows = await self._loop.run_in_executor(None, self._registry.reread_group, initial_rows[0].rom_id) + if not rows or not delete_ids <= {row.rom_id for row in rows}: + return self._ledger_or_guard_result( + ledger, + initial_rows, + ("local_state_changed", "Local state changed; source data was retained."), + handle, + app_id, + ) + ledger.rows = rows + await self._emit_progress( + run_id, index, total, "removing", rows, bundle_path=handle.bundle_path if handle else None + ) + + async with self._stable_save_locks(recovery_ids) as recovery_inventory: + rows = await self._loop.run_in_executor(None, self._registry.reread_group, initial_rows[0].rom_id) + ledger.rows = rows or initial_rows + expected_app_id = app_id if target_id is not None else None + if not rows or not await self._loop.run_in_executor( + None, + self._registry.validate_deletion_state, + rows, + delete_ids, + target_id, + expected_app_id, + fully_dead, + ): + return self._ledger_or_guard_result( + ledger, + initial_rows, + ("local_state_changed", "Final local revalidation failed before source removal."), + handle, + app_id, + ) + if handle is not None: + state_matches = await self._loop.run_in_executor( + None, + self._recovery.state_matches, + handle.snapshot, + sorted(recovery_ids), + committed_action, + app_id, + target_id, + launch_options, + ) + sources_match = await self._loop.run_in_executor( + None, + partial(self._recovery_store.validate_sources, handle.bundle_path, handle.bundle_digest), + ) + backend_matches = True + if committed_action == "remove_shortcut" and app_id is not None and handle.steam_backend is not None: + backend_matches = await self._loop.run_in_executor( + None, self._steam_recovery.validate_state, app_id, handle.steam_backend + ) + if ( + recovery_inventory != handle.save_inventory + or not state_matches + or not sources_match + or not backend_matches + ): + return self._ledger_or_guard_result( + ledger, + rows, + ( + "recovery_state_changed", + "Local state no longer matches the sealed recovery bundle; source data was retained.", + ), + handle, + app_id, + ) + + delete_inventory = await self._loop.run_in_executor( + None, self._save_coordinator.inventory_prune_saves, sorted(delete_ids) + ) + delete_locks = {int(value) for value in delete_inventory.get("lock_rom_ids", delete_ids)} + held_locks = {int(value) for value in recovery_inventory.get("lock_rom_ids", recovery_ids)} + if not delete_locks <= held_locks: + return self._ledger_or_guard_result( + ledger, + rows, + ("save_ownership_changed", "Save ownership expanded; source data was retained."), + handle, + app_id, + warnings=self._inventory_warnings(delete_inventory), + ) + commit = self._commit_group( + rows=rows, + delete_ids=delete_ids, + target_id=target_id, + app_id=app_id, + fully_dead=fully_dead, + committed_action=committed_action, + handle=handle, + expected_app_id=expected_app_id, + delete_inventory=delete_inventory, + ledger=ledger, + ) + try: + result = await self._shielded(commit) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + if state.child_completed and isinstance(state.child_result, dict): + state.group_result = state.child_result + raise + + try: + await self._emit_progress( + run_id, index, total, "removed", rows, bundle_path=handle.bundle_path if handle else None + ) + except asyncio.CancelledError as exc: + cancellation_state(exc).group_result = result + raise + except Exception as exc: + self._logger.warning(f"Removed-game cleanup final progress delivery failed: {exc}") + return result + + async def _commit_group( + self, + *, + rows: list[Rom], + delete_ids: set[int], + target_id: int | None, + app_id: int | None, + fully_dead: bool, + committed_action: str | None, + handle: RecoveryHandle | None, + expected_app_id: int | None, + delete_inventory: dict[str, Any], + ledger: _MutationLedger, + ) -> dict[str, Any]: + acted_app_id = app_id if committed_action is not None else None + warnings = self._inventory_warnings(delete_inventory) + quarantine = await self._loop.run_in_executor( + None, + self._save_coordinator.quarantine_prune_saves, + delete_inventory["exclusive"], + handle.source_claims if handle is not None else delete_inventory.get("source_claims"), + ) + raw_moved = quarantine.get("moved") + moved = [str(path) for path in raw_moved] if isinstance(raw_moved, list) else [] + if moved: + ledger.mutations.append("save_quarantine") + if quarantine.get("ambiguous") and "save_quarantine" not in ledger.ambiguous_mutations: + ledger.ambiguous_mutations.append("save_quarantine") + if not quarantine.get("success"): + return self._group_result( + rows, + "partial" if ledger.has_commit() else "failed", + "save_quarantine_failed", + quarantine.get("message", "Save quarantine failed."), + app_id=acted_app_id, + removed_app_id=app_id if committed_action == "remove_shortcut" else None, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + target_rom_id=target_id, + ) + + for rom_id in sorted(delete_ids): + claims = handle.source_claims if handle is not None else None + removal = await self._loop.run_in_executor(None, self._remove_installed_files, rom_id, claims) + if removal.get("changed") and "installed_rom_content" not in ledger.mutations: + ledger.mutations.append("installed_rom_content") + if removal.get("ambiguous") and "installed_rom_content" not in ledger.ambiguous_mutations: + ledger.ambiguous_mutations.append("installed_rom_content") + if removal.get("success"): + continue + if removal.get("reason") == "not_installed": + continue + return self._group_result( + rows, + "partial" if ledger.has_commit() else "failed", + "rom_removal_failed", + removal.get("message", "ROM removal failed."), + app_id=acted_app_id, + removed_app_id=app_id if committed_action == "remove_shortcut" else None, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + target_rom_id=target_id, + ) + try: + claims = handle.source_claims if handle is not None else None + artifact_outcome = await self._loop.run_in_executor( + None, self._prune_artifacts.remove, sorted(delete_ids), claims + ) + if artifact_outcome.get("changed"): + ledger.mutations.append("plugin_artifacts") + if artifact_outcome.get("ambiguous"): + ledger.ambiguous_mutations.append("plugin_artifacts") + if not artifact_outcome.get("success"): + raise RuntimeError(artifact_outcome.get("message", "Plugin artifact cleanup failed")) + if committed_action == "remove_shortcut" and app_id is not None and handle is not None: + if handle.steam_backend is None: + raise RuntimeError("Steam recovery identity was not captured") + steam_outcome = await self._loop.run_in_executor( + None, self._steam_recovery.remove_state, app_id, handle.steam_backend, handle.source_claims + ) + if steam_outcome.get("changed"): + ledger.mutations.append("steam_files") + if steam_outcome.get("ambiguous"): + ledger.ambiguous_mutations.append("steam_files") + if not steam_outcome.get("success"): + raise RuntimeError(steam_outcome.get("message", "Steam state cleanup failed")) + except Exception as exc: + return self._group_result( + rows, + "partial" if ledger.has_commit() else "failed", + "artifact_cleanup_failed", + str(exc), + app_id=acted_app_id, + removed_app_id=app_id if committed_action == "remove_shortcut" else None, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + target_rom_id=target_id, + ) + + absence_claims = delete_inventory.get("source_claims") + if not isinstance(absence_claims, dict) or not await self._loop.run_in_executor( + None, self._save_coordinator.validate_prune_absences, absence_claims + ): + return self._group_result( + rows, + "partial" if ledger.has_commit() else "failed", + "save_state_changed", + "A previously absent save appeared before finalization; the aggregate was retained.", + app_id=acted_app_id, + removed_app_id=app_id if committed_action == "remove_shortcut" else None, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + target_rom_id=target_id, + ) + + try: + deleted = await self._loop.run_in_executor( + None, self._registry.delete_rows, rows, delete_ids, target_id, expected_app_id, fully_dead + ) + except Exception: + ledger.mutations.append("database_rows_ambiguous") + raise + if not deleted: + return self._group_result( + rows, + "partial" if ledger.has_commit() else "skipped", + "local_state_changed", + "Final local revalidation failed after filesystem cleanup.", + app_id=acted_app_id, + removed_app_id=app_id if committed_action == "remove_shortcut" else None, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + target_rom_id=target_id, + ) + ledger.mutations.append("database_rows") + + return self._group_result( + rows, + "removed", + None, + f"Removed {len(delete_ids)} confirmed vanished entr{'y' if len(delete_ids) == 1 else 'ies'}.", + removed_rom_ids=sorted(delete_ids), + app_id=acted_app_id, + removed_app_id=app_id if committed_action == "remove_shortcut" else None, + bundle_path=handle.bundle_path if handle else None, + committed_action=committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + target_rom_id=target_id, + ) + + @contextlib.asynccontextmanager + async def _stable_save_locks(self, rom_ids: set[int]) -> AsyncIterator[dict[str, Any]]: + requested = sorted(rom_ids) + for _attempt in range(3): + first = await self._loop.run_in_executor(None, self._save_coordinator.inventory_prune_saves, requested) + lock_ids = sorted({int(value) for value in first.get("lock_rom_ids", requested)}) + async with self._save_coordinator.lock_prune_roms(lock_ids): + current = await self._loop.run_in_executor( + None, self._save_coordinator.inventory_prune_saves, requested + ) + current_locks = sorted({int(value) for value in current.get("lock_rom_ids", requested)}) + if current_locks == lock_ids: + yield current + return + raise RuntimeError("Save ownership kept changing while cleanup acquired locks") + + async def _probe_many(self, rom_ids: set[int]) -> dict[int, dict[str, str]]: + semaphore = asyncio.Semaphore(_LIVENESS_CONCURRENCY) + + async def one(rom_id: int) -> tuple[int, dict[str, str]]: + async with semaphore: + verdict = await self._loop.run_in_executor(None, self._probe_one, rom_id) + return rom_id, verdict + + return dict(await asyncio.gather(*(one(rom_id) for rom_id in sorted(rom_ids)))) + + def _probe_one(self, rom_id: int) -> dict[str, str]: + expected_namespace = self._run_namespace or romm_namespace(self._settings) + if romm_namespace(self._settings) != expected_namespace: + return { + "status": "uncertain", + "reason": "server_namespace_changed", + "message": "The RomM server or user changed before the exact-ID proof.", + } + try: + payload: Any = self._romm_api.get_rom_once(rom_id) + except RommNotFoundError: + if romm_namespace(self._settings) != expected_namespace: + return { + "status": "uncertain", + "reason": "server_namespace_changed", + "message": "The RomM server or user changed during the exact-ID proof.", + } + return {"status": "vanished", "reason": ErrorCode.NOT_FOUND.value, "message": "RomM confirmed 404."} + except Exception as exc: + reason, message = classify_error(exc) + return {"status": "uncertain", "reason": reason, "message": message} + if romm_namespace(self._settings) != expected_namespace: + return { + "status": "uncertain", + "reason": "server_namespace_changed", + "message": "The RomM server or user changed during the exact-ID proof.", + } + payload_id = payload.get("id") if isinstance(payload, dict) else None + if type(payload_id) is int and payload_id == rom_id: + return {"status": "live", "reason": "live", "message": "RomM returned the exact ROM."} + return { + "status": "uncertain", + "reason": "untrustworthy_response", + "message": "RomM returned an empty, malformed, or wrong-id response.", + } + + def _natural_default(self, rows: list[Rom], live_ids: set[int]) -> int | None: + candidates = [ + { + "rom_id": row.rom_id, + "is_main_sibling": row.is_main_sibling, + "regions": list(row.regions), + "revision": row.revision, + "tags": list(row.tags), + "fs_name_no_ext": fs_name_stem(row.fs_name), + } + for row in rows + if row.rom_id in live_ids + ] + try: + return resolve_group_representative( + candidates, + installed_rom_ids=set(), + bound_rom_ids=set(), + preferred_region=self._settings.get("preferred_region", AUTO_REGION), + ) + except (KeyError, ValueError): + return None + + @staticmethod + def _fresh_guard( + verdicts: dict[int, dict[str, str]], + delete_ids: set[int], + target_id: int | None, + vanished_source_id: int | None, + ) -> tuple[str, str] | None: + for rom_id in sorted(delete_ids): + verdict = verdicts[rom_id] + if verdict["status"] != "vanished": + return verdict["reason"], f"ROM {rom_id}: {verdict['message']} Nothing else in this group was removed." + if target_id is not None and verdicts[target_id]["status"] != "live": + verdict = verdicts[target_id] + return verdict["reason"], f"Default target {target_id}: {verdict['message']}" + if vanished_source_id is not None and verdicts[vanished_source_id]["status"] != "vanished": + verdict = verdicts[vanished_source_id] + return verdict["reason"], f"Vanished source {vanished_source_id}: {verdict['message']}" + return None + + async def _recovery_guard( + self, + handle: RecoveryHandle, + recovery_ids: set[int], + *, + committed_action: str | None, + app_id: int | None, + target_id: int | None, + launch_options: str | None, + ) -> str | None: + """Revalidate the sealed intended state before any irreversible action.""" + async with self._stable_save_locks(recovery_ids) as inventory: + state_matches = await self._loop.run_in_executor( + None, + self._recovery.state_matches, + handle.snapshot, + sorted(recovery_ids), + committed_action, + app_id, + target_id, + launch_options, + ) + sources_match = await self._loop.run_in_executor( + None, + partial(self._recovery_store.validate_sources, handle.bundle_path, handle.bundle_digest), + ) + backend_matches = True + if app_id is not None and handle.steam_backend is not None: + backend_matches = await self._loop.run_in_executor( + None, self._steam_recovery.validate_state, app_id, handle.steam_backend + ) + if inventory != handle.save_inventory or not state_matches or not sources_match or not backend_matches: + return "Local state no longer matches the sealed recovery bundle; no Steam action was started." + return None + + @staticmethod + def _inventory_warnings(inventory: dict[str, Any]) -> list[str]: + raw = inventory.get("warnings") + return [str(item) for item in raw] if isinstance(raw, list) else [] + + def _ledger_result( + self, + ledger: _MutationLedger, + reason: str, + message: object, + *, + removed_app_id: int | None = None, + warnings: list[str] | None = None, + ) -> dict[str, Any]: + return self._group_result( + ledger.rows, + "partial", + reason, + message, + app_id=ledger.app_id, + removed_app_id=removed_app_id, + bundle_path=ledger.bundle_path, + committed_action=ledger.committed_action, + mutations=ledger.mutations, + ambiguous_mutations=ledger.ambiguous_mutations, + warnings=warnings, + action_ambiguous=ledger.action_ambiguous, + target_rom_id=ledger.target_rom_id, + ) + + def _ledger_or_guard_result( + self, + ledger: _MutationLedger, + rows: list[Rom], + guard: tuple[str, str], + handle: RecoveryHandle | None, + app_id: int | None, + *, + warnings: list[str] | None = None, + ) -> dict[str, Any]: + if ledger.has_commit(): + return self._ledger_result( + ledger, + guard[0], + guard[1], + removed_app_id=app_id + if ledger.committed_action == "remove_shortcut" and not ledger.action_ambiguous + else None, + warnings=warnings, + ) + return self._group_result( + rows, + "skipped", + guard[0], + guard[1], + bundle_path=handle.bundle_path if handle else None, + warnings=warnings, + ) + + async def _shielded(self, awaitable: Awaitable[Any]) -> Any: + task = asyncio.ensure_future(awaitable) + try: + return await asyncio.shield(task) + except asyncio.CancelledError as exc: + state = cancellation_state(exc) + try: + state.child_result = await task + state.child_completed = True + except asyncio.CancelledError: + raise + except BaseException as child_fault: + state.child_fault = child_fault + raise + + def _fault_result(self, ledger: _MutationLedger, rows: list[Rom], error: BaseException) -> dict[str, Any]: + if ledger.has_commit(): + return self._ledger_result(ledger, ErrorCode.UNKNOWN.value, str(error)) + return self._group_result(rows, "failed", ErrorCode.UNKNOWN.value, str(error)) + + async def _emit_progress( + self, + run_id: str, + current: int, + total: int, + stage: str, + rows: list[Rom], + *, + bundle_path: str | None = None, + ) -> None: + payload: dict[str, object] = { + "run_id": run_id, + "preview_id": self._run_preview_id, + "current": current, + "total": total, + "stage": stage, + "rom_ids": [row.rom_id for row in rows[:_COMPLETION_IDS_PER_GROUP]], + "rom_count": len(rows), + "rom_ids_truncated": len(rows) > _COMPLETION_IDS_PER_GROUP, + "name": (rows[0].name if rows else "")[:_COMPLETION_TEXT_CHARS], + } + if bundle_path is not None: + payload["bundle_path"] = bundle_path[:_COMPLETION_PATH_CHARS] + await self._emit("prune_progress", payload) + + async def _emit_completion( + self, + run_id: str, + results: list[dict[str, Any]], + *, + cancelled: bool, + reason: str | None, + message: str | None, + ) -> None: + failures = [result for result in results if result["status"] in {"failed", "skipped", "partial"}] + removed_count = sum( + int(result.get("removed_count", len(result.get("removed_rom_ids", [])))) for result in results + ) + committed = bool(removed_count) or any( + result.get("committed_action") + or result.get("action_ambiguous") + or result.get("mutations") + or result.get("ambiguous_mutations") + for result in results + ) + run_failed = cancelled or reason is not None + partial = any(result["status"] == "partial" for result in results) or bool( + committed and (failures or run_failed) + ) + publication_required = any( + result.get("committed_action") == "repoint_shortcut" + and not result.get("action_ambiguous") + and type(result.get("app_id")) is int + and type(result.get("target_rom_id")) is int + for result in results + ) + bounded_reason = reason[:_COMPLETION_REASON_CHARS] if reason is not None else None + bounded_message = message[:_COMPLETION_TEXT_CHARS] if message is not None else None + chunks: list[list[dict[str, Any]]] = [] + current: list[dict[str, Any]] = [] + for result in results: + candidate = [*current, result] + probe = self._completion_payload( + run_id, + candidate, + chunk_index=len(chunks), + final=False, + success=not failures and not cancelled and reason is None, + partial=partial, + removed_count=removed_count, + problem_count=len(failures), + reason=bounded_reason, + message=bounded_message, + publication_required=publication_required, + ) + if current and len(json.dumps(probe, ensure_ascii=True).encode("utf-8")) > _COMPLETION_BUDGET_BYTES: + chunks.append(current) + current = [result] + else: + current = candidate + chunks.append(current) + for chunk_index, chunk in enumerate(chunks): + payload = self._completion_payload( + run_id, + chunk, + chunk_index=chunk_index, + final=chunk_index == len(chunks) - 1, + success=not failures and not cancelled and reason is None, + partial=partial, + removed_count=removed_count, + problem_count=len(failures), + reason=bounded_reason, + message=bounded_message, + publication_required=publication_required, + ) + await self._emit("prune_complete", payload) + + def _completion_payload( + self, + run_id: str, + chunk: list[dict[str, Any]], + *, + chunk_index: int, + final: bool, + success: bool, + partial: bool, + removed_count: int, + problem_count: int, + reason: str | None, + message: str | None, + publication_required: bool, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "success": success, + "partial": partial, + "run_id": run_id, + "preview_id": self._run_preview_id, + "chunk_index": chunk_index, + "final": final, + "removed_count": removed_count, + "problem_count": problem_count, + "removed_rom_ids": sorted({int(value) for result in chunk for value in result.get("removed_rom_ids", [])}), + "affected_app_ids": sorted( + {int(result["app_id"]) for result in chunk if type(result.get("app_id")) is int} + ), + "removed_app_ids": sorted( + {int(result["removed_app_id"]) for result in chunk if type(result.get("removed_app_id")) is int} + ), + "results": chunk, + } + if publication_required: + payload["publication_required"] = True + if reason is not None: + payload["reason"] = reason + if message is not None: + payload["message"] = message + return payload + + @staticmethod + def _group_result( + rows: list[Rom], + status: str, + reason: str | None, + message: object, + *, + removed_rom_ids: list[int] | None = None, + app_id: int | None = None, + removed_app_id: int | None = None, + bundle_path: str | None = None, + committed_action: str | None = None, + mutations: list[str] | None = None, + ambiguous_mutations: list[str] | None = None, + warnings: list[str] | None = None, + action_ambiguous: bool = False, + target_rom_id: int | None = None, + ) -> dict[str, Any]: + raw_group_id = rows[0].sibling_group_key or f"rom:{rows[0].rom_id}" + all_rom_ids = [row.rom_id for row in rows] + bounded_removed = (removed_rom_ids or [])[:_COMPLETION_IDS_PER_GROUP] + raw_message = str(message) + result: dict[str, Any] = { + "group_id": raw_group_id[:_COMPLETION_TEXT_CHARS], + "group_id_truncated": len(raw_group_id) > _COMPLETION_TEXT_CHARS, + "rom_ids": all_rom_ids[:_COMPLETION_IDS_PER_GROUP], + "rom_count": len(all_rom_ids), + "rom_ids_truncated": len(all_rom_ids) > _COMPLETION_IDS_PER_GROUP, + "status": status, + "message": raw_message[:_COMPLETION_TEXT_CHARS], + "message_truncated": len(raw_message) > _COMPLETION_TEXT_CHARS, + } + if reason is not None: + raw_reason = str(reason) + result["reason"] = raw_reason[:_COMPLETION_REASON_CHARS] + result["reason_truncated"] = len(raw_reason) > _COMPLETION_REASON_CHARS + if removed_rom_ids is not None: + result["removed_rom_ids"] = bounded_removed + result["removed_count"] = len(removed_rom_ids) + result["removed_rom_ids_truncated"] = len(removed_rom_ids) > _COMPLETION_IDS_PER_GROUP + if app_id is not None: + result["app_id"] = app_id + if removed_app_id is not None: + result["removed_app_id"] = removed_app_id + if bundle_path is not None: + result["bundle_path"] = bundle_path[:_COMPLETION_PATH_CHARS] + result["bundle_path_truncated"] = len(bundle_path) > _COMPLETION_PATH_CHARS + if committed_action is not None: + result["committed_action"] = committed_action + if mutations: + result["mutations"] = [str(item)[:_COMPLETION_REASON_CHARS] for item in mutations] + if ambiguous_mutations: + result["ambiguous_mutations"] = [str(item)[:_COMPLETION_REASON_CHARS] for item in ambiguous_mutations] + if warnings: + bounded_warnings = [ + str(item)[:_COMPLETION_WARNING_CHARS] for item in warnings[:_COMPLETION_WARNINGS_PER_GROUP] + ] + result["warnings"] = bounded_warnings + result["warning_count"] = len(warnings) + result["warnings_omitted"] = len(warnings) > len(bounded_warnings) + result["warnings_truncated"] = any( + len(str(item)) > _COMPLETION_WARNING_CHARS for item in warnings[:_COMPLETION_WARNINGS_PER_GROUP] + ) + if action_ambiguous: + result["action_ambiguous"] = True + if target_rom_id is not None: + result["target_rom_id"] = target_rom_id + return result diff --git a/py_modules/services/prune/preview.py b/py_modules/services/prune/preview.py new file mode 100644 index 00000000..1cd0a50b --- /dev/null +++ b/py_modules/services/prune/preview.py @@ -0,0 +1,153 @@ +"""Local generation-gated candidate discovery and paged preview projection.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from domain.fetch_generation import prune_candidate_ids +from domain.prune import group_rows +from lib.url_host import romm_namespace +from services.prune._models import PrunePreview + +_PREVIEW_TEXT_CHARS = 512 +_PREVIEW_WARNING_CHARS = 1024 +_PREVIEW_BUDGET_BYTES = 48 * 1024 + +if TYPE_CHECKING: + from services.protocols import RecoveryBundleStore, RetroDeckPaths, UnitOfWorkFactory + + +@dataclass(frozen=True) +class PreviewBuilderConfig: + """Dependencies for local-only prune candidate projection.""" + + uow_factory: UnitOfWorkFactory + recovery_store: RecoveryBundleStore + retrodeck_paths: RetroDeckPaths + settings: dict[str, Any] + + +class PreviewBuilder: + """Build immutable candidate snapshots without contacting RomM.""" + + def __init__(self, *, config: PreviewBuilderConfig) -> None: + self._uow_factory = config.uow_factory + self._recovery_store = config.recovery_store + self._retrodeck_paths = config.retrodeck_paths + self._settings = config.settings + + def build(self, preview_id: str, scope: Literal["bulk", "rom"], explicit_rom_id: int | None) -> PrunePreview: + with self._uow_factory() as uow: + rows = list(uow.roms.iter_all()) + installs = {install.rom_id: install for install in uow.rom_installs.iter_all()} + if scope == "rom": + candidate_ids = ( + {explicit_rom_id} if explicit_rom_id is not None and uow.roms.get(explicit_rom_id) else set() + ) + else: + candidate_ids: set[int] = set() + platform_rows: dict[str, list[Any]] = {} + for row in rows: + platform_rows.setdefault(row.platform_slug, []).append(row) + for slug, candidates in platform_rows.items(): + candidate_ids.update(prune_candidate_ids(candidates, uow.platform_sync_state.get(slug))) + + relevant_groups = [ + group for group in group_rows(rows) if candidate_ids.intersection(r.rom_id for r in group) + ] + fingerprint = self._fingerprint(relevant_groups, installs) + + roms_root = self._retrodeck_paths.roms_path() + entries: list[dict[str, Any]] = [] + for group in relevant_groups: + group_id = group[0].sibling_group_key or f"rom:{group[0].rom_id}" + bound_count = sum(row.shortcut_app_id is not None for row in group) + for row in group: + install = installs.get(row.rom_id) + size: int | None = None + warning: str | None = None + if install is not None: + source = install.rom_dir or install.file_path + try: + size = self._recovery_store.measure_path(source, roms_root) + except (OSError, ValueError) as exc: + warning = str(exc) + raw_name = row.name + raw_fs_name = row.fs_name + raw_group_id = str(group_id) + entries.append( + { + "rom_id": row.rom_id, + "name": raw_name[:_PREVIEW_TEXT_CHARS], + "name_truncated": len(raw_name) > _PREVIEW_TEXT_CHARS, + "fs_name": raw_fs_name[:_PREVIEW_TEXT_CHARS], + "fs_name_truncated": len(raw_fs_name) > _PREVIEW_TEXT_CHARS, + "platform_slug": row.platform_slug, + "group_id": raw_group_id[:_PREVIEW_TEXT_CHARS], + "group_id_truncated": len(raw_group_id) > _PREVIEW_TEXT_CHARS, + "group_size": len(group), + "bound_count": bound_count, + "candidate": row.rom_id in candidate_ids, + "installed": install is not None, + "installed_bytes": size, + "warning": warning[:_PREVIEW_WARNING_CHARS] if warning is not None else None, + "warning_truncated": warning is not None and len(warning) > _PREVIEW_WARNING_CHARS, + } + ) + entries.sort(key=lambda entry: (str(entry["platform_slug"]), str(entry["name"]), int(entry["rom_id"]))) + return PrunePreview( + preview_id=preview_id, + scope=scope, + explicit_rom_id=explicit_rom_id, + candidate_ids=frozenset(candidate_ids), + fingerprint=fingerprint, + entries=tuple(entries), + free_bytes=self._recovery_store.free_bytes(), + server_namespace=romm_namespace(self._settings), + ) + + def page(self, preview: PrunePreview, offset: int, limit: int) -> dict[str, Any]: + result: dict[str, Any] = { + "success": True, + "preview_id": preview.preview_id, + "scope": preview.scope, + "items": [], + "offset": offset, + "limit": limit, + "total": len(preview.entries), + "free_bytes": self._recovery_store.free_bytes(), + "recovery_root": self._recovery_store.root(), + } + if not limit: + return result + items: list[dict[str, Any]] = [] + for entry in preview.entries[offset : offset + limit]: + candidate = [*items, entry] + result["items"] = candidate + if len(json.dumps(result, ensure_ascii=True).encode("utf-8")) > _PREVIEW_BUDGET_BYTES: + if not items: + raise ValueError("One cleanup preview entry exceeds the Decky wire budget") + break + items = candidate + result["items"] = items + return result + + @staticmethod + def _fingerprint(groups: list[list[Any]], installs: dict[int, Any]) -> tuple[tuple[object, ...], ...]: + values: list[tuple[object, ...]] = [] + for group in groups: + for row in group: + install = installs.get(row.rom_id) + values.append( + ( + row.rom_id, + row.last_fetch_id, + row.shortcut_app_id, + row.sibling_group_key, + install.file_path if install is not None else None, + install.rom_dir if install is not None else None, + ) + ) + return tuple(sorted(values)) diff --git a/py_modules/services/prune/recovery.py b/py_modules/services/prune/recovery.py new file mode 100644 index 00000000..a6b90833 --- /dev/null +++ b/py_modules/services/prune/recovery.py @@ -0,0 +1,234 @@ +"""Lossless SQLite snapshots and verified recovery-bundle coordination.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any + +from domain.prune import recovery_bundle_id + +if TYPE_CHECKING: + from models.prune import RecoveryArtifact, SteamRecoverySnapshot + + from domain.rom import Rom + from services.protocols import ( + Clock, + PruneArtifactStore, + RecoveryBundleStore, + RetroDeckPaths, + SteamRecoveryStore, + UnitOfWorkFactory, + UuidGen, + ) + + +@dataclass(frozen=True) +class RecoveryCoordinatorConfig: + """Dependencies for pre-cascade state capture and bundle sealing.""" + + uow_factory: UnitOfWorkFactory + recovery_store: RecoveryBundleStore + prune_artifacts: PruneArtifactStore + steam_recovery: SteamRecoveryStore + retrodeck_paths: RetroDeckPaths + clock: Clock + uuid_gen: UuidGen + + +class RecoveryCoordinator: + """Capture all local aggregate state before a prune can mutate it.""" + + def __init__(self, *, config: RecoveryCoordinatorConfig) -> None: + self._uow_factory = config.uow_factory + self._recovery_store = config.recovery_store + self._prune_artifacts = config.prune_artifacts + self._steam_recovery = config.steam_recovery + self._retrodeck_paths = config.retrodeck_paths + self._clock = config.clock + self._uuid_gen = config.uuid_gen + + def snapshot_state(self, rom_ids: list[int], frontend_steam: dict[str, object] | None) -> dict[str, object]: + ids = set(rom_ids) + with self._uow_factory() as uow: + rows = [row for row in uow.roms.iter_all() if row.rom_id in ids] + installs = [install for install in uow.rom_installs.iter_all() if install.rom_id in ids] + metadata = [ + {"rom_id": rom_id, "state": asdict(value)} + for rom_id, value in uow.rom_metadata.iter_all() + if rom_id in ids + ] + save_sync = [ + {"rom_id": rom_id, "state": asdict(value)} + for rom_id, value in uow.rom_save_sync_states.iter_all() + if rom_id in ids + ] + playtime = [ + {"rom_id": rom_id, "state": asdict(value)} for rom_id, value in uow.playtime.iter_all() if rom_id in ids + ] + platforms = [ + asdict(stamp) + for slug in sorted({row.platform_slug for row in rows}) + if (stamp := uow.platform_sync_state.get(slug)) is not None + ] + collections = [ + asdict(stamp) + for stamp in uow.collection_sync_state.iter_all() + if ids.intersection(stamp.member_rom_ids) + ] + return { + "created_at": self._clock.now().isoformat(), + "roms": [asdict(row) for row in rows], + "installs": [asdict(install) for install in installs], + "metadata": metadata, + "save_sync": save_sync, + "playtime": playtime, + "platform_sync_state": platforms, + "collection_sync_state": collections, + "steam": frontend_steam, + "warnings": [], + } + + def state_matches( + self, + expected: dict[str, object], + rom_ids: list[int], + committed_action: str | None = None, + app_id: int | None = None, + target_id: int | None = None, + launch_options: str | None = None, + ) -> bool: + """Compare all database state represented by a sealed recovery snapshot.""" + current = self.snapshot_state(rom_ids, None) + keys = ( + "roms", + "installs", + "metadata", + "save_sync", + "playtime", + "platform_sync_state", + "collection_sync_state", + ) + projected = {key: expected.get(key) for key in keys} + raw_roms = projected.get("roms") + if isinstance(raw_roms, list) and committed_action is not None and app_id is not None: + roms = [dict(row) if isinstance(row, dict) else row for row in raw_roms] + for row in roms: + if not isinstance(row, dict): + continue + if row.get("shortcut_app_id") == app_id: + row["shortcut_app_id"] = None + if committed_action == "repoint_shortcut" and row.get("rom_id") == target_id: + row["shortcut_app_id"] = app_id + row["applied_launch_options"] = launch_options + projected["roms"] = roms + return all(current.get(key) == projected.get(key) for key in keys) + + def seal( + self, + *, + rows: list[Rom], + snapshot: dict[str, object], + save_inventory: dict[str, Any], + include_installed_rom_ids: set[int], + delete_ids: set[int], + app_id: int | None, + ) -> tuple[str, SteamRecoverySnapshot | None]: + rom_ids = [row.rom_id for row in rows] + artifacts: list[RecoveryArtifact] = list(save_inventory["artifacts"]) + artifacts.extend(self._prune_artifacts.recovery_artifacts(sorted(delete_ids))) + roms_root = self._retrodeck_paths.roms_path() + raw_installs = snapshot.get("installs") + installs = raw_installs if isinstance(raw_installs, list) else [] + installs_by_id = { + int(item["rom_id"]): item for item in installs if isinstance(item, dict) and type(item.get("rom_id")) is int + } + for rom_id in sorted(include_installed_rom_ids.intersection(rom_ids)): + install = installs_by_id.get(rom_id) + if install is None: + continue + source = install.get("rom_dir") or install.get("file_path") + if isinstance(source, str) and source: + artifacts.append( + {"source_path": source, "safe_root": roms_root, "kind": "installed_rom", "rom_id": rom_id} + ) + + steam_backend: SteamRecoverySnapshot | None = None + if app_id is not None: + steam_backend = self._steam_recovery.snapshot(app_id) + steam_artifacts = steam_backend["artifacts"] + artifacts.extend(steam_artifacts) + snapshot["steam_backend"] = { + "user_id": steam_backend["user_id"], + "user_dir": steam_backend["user_dir"], + "steam_root": steam_backend["steam_root"], + "controller_setting": steam_backend["controller_setting"], + "artifact_count": len(steam_artifacts), + } + warnings = snapshot.get("warnings") + if isinstance(warnings, list): + warnings.extend(save_inventory["warnings"]) + if save_inventory["shared"]: + warnings.append("Shared current saves were copied but deliberately left in place") + + now = self._clock.now() + bundle_id = recovery_bundle_id(now.strftime("%Y%m%dT%H%M%SZ"), min(rom_ids), self._uuid_gen.uuid4()) + readme = ( + "decky-romm-sync vanished-ROM recovery bundle\n\n" + "This bundle is a verified pre-deletion snapshot for manual recovery.\n" + "There is no automatic restore. Steam-assigned appIds and Steam playtime are recorded,\n" + "but cannot currently be restored to a newly created shortcut. Save states are not included.\n" + "manifest.json is the lossless authority; checksums.sha256 verifies copied files.\n" + ) + playtime_text = self._playtime_text(snapshot) + sealed = self._recovery_store.seal_bundle(bundle_id, snapshot, artifacts, readme, playtime_text) + return sealed, steam_backend + + @staticmethod + def _playtime_text(snapshot: dict[str, object]) -> str: + lines = ["Local playtime snapshot", ""] + entries = snapshot.get("playtime") + if not isinstance(entries, list) or not entries: + lines.append("No local playtime state was present.") + return "\n".join(lines) + "\n" + for entry in entries: + if not isinstance(entry, dict): + continue + raw_state = entry.get("state") + state: dict[str, object] = raw_state if isinstance(raw_state, dict) else {} + pending_sessions = state.get("pending_sessions") + pending_count = len(pending_sessions) if isinstance(pending_sessions, dict) else 0 + lines.extend( + [ + f"ROM {entry.get('rom_id')}", + f" total_seconds: {state.get('total_seconds', 0)}", + f" session_count: {state.get('session_count', 0)}", + f" last_played: {state.get('last_played')}", + f" last_session_duration_sec: {state.get('last_session_duration_sec')}", + f" open_session_start: {state.get('last_session_start')}", + f" open_session_monotonic: {state.get('last_session_start_monotonic')}", + f" pending_sessions: {pending_count}", + ] + ) + if isinstance(pending_sessions, dict): + for start_time, raw_session in sorted(pending_sessions.items()): + session = raw_session if isinstance(raw_session, dict) else {} + lines.extend( + [ + f" start_time: {start_time}", + f" device_id: {session.get('device_id')}", + f" end_time: {session.get('end_time')}", + f" duration_ms: {session.get('duration_ms')}", + f" attempts: {session.get('attempts')}", + ] + ) + steam = snapshot.get("steam") + if isinstance(steam, dict): + lines.extend( + [ + "", + f"Steam appId: {steam.get('app_id')}", + f"Steam lifetime minutes: {steam.get('minutes_playtime_forever')}", + f"Steam last-two-weeks minutes: {steam.get('minutes_playtime_last_two_weeks')}", + ] + ) + return "\n".join(lines) + "\n" diff --git a/py_modules/services/prune/registry.py b/py_modules/services/prune/registry.py new file mode 100644 index 00000000..5d4b8d06 --- /dev/null +++ b/py_modules/services/prune/registry.py @@ -0,0 +1,151 @@ +"""Local registry reads, race validation, and final aggregate deletion.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from domain.prune import group_rows + +if TYPE_CHECKING: + from domain.rom import Rom + from services.protocols import UnitOfWork, UnitOfWorkFactory + + +@dataclass(frozen=True) +class PruneRegistryConfig: + """Persistence dependency for cleanup registry operations.""" + + uow_factory: UnitOfWorkFactory + + +class PruneRegistry: + """Own the short SQLite reads and final delete for a cleanup run.""" + + def __init__(self, *, config: PruneRegistryConfig) -> None: + self._uow_factory = config.uow_factory + + def groups_for_candidates(self, candidate_ids: set[int]) -> list[list[Rom]]: + with self._uow_factory() as uow: + rows = list(uow.roms.iter_all()) + return [group for group in group_rows(rows) if candidate_ids.intersection(row.rom_id for row in group)] + + def reread_group(self, rom_id: int) -> list[Rom]: + with self._uow_factory() as uow: + row = uow.roms.get(rom_id) + if row is None: + return [] + return list(uow.roms.iter_by_group_key(row.sibling_group_key)) if row.sibling_group_key else [row] + + def validate_deletion_state( + self, + expected_rows: list[Rom], + delete_ids: set[int], + target_id: int | None, + app_id: int | None, + fully_dead: bool, + ) -> bool: + with self._uow_factory() as uow: + return self._deletion_state_matches(uow, expected_rows, delete_ids, target_id, app_id, fully_dead) + + def validate_action_state( + self, + kind: str, + expected_bound_rom_id: int, + app_id: int, + target_id: int | None, + group_rom_ids: frozenset[int], + ) -> bool: + """Require the exact action binding and one-binding group immediately before claim.""" + with self._uow_factory() as uow: + anchor = uow.roms.get(expected_bound_rom_id) + if anchor is None: + return False + current = ( + list(uow.roms.iter_by_group_key(anchor.sibling_group_key)) + if anchor.sibling_group_key is not None + else [anchor] + ) + if {row.rom_id for row in current} != set(group_rom_ids): + return False + bindings = [row for row in current if row.shortcut_app_id is not None] + if len(bindings) != 1 or bindings[0].shortcut_app_id != app_id: + return False + bound = uow.roms.get_by_app_id(app_id) + if bound is None: + return False + expected_id = target_id if kind == "repoint_shortcut" else expected_bound_rom_id + return bound.rom_id == expected_id + + def reconcile_removed_shortcut(self, expected_bound_rom_id: int, app_id: int) -> bool: + """Persist Steam's confirmed shortcut absence without deleting source data.""" + with self._uow_factory() as uow: + bound = uow.roms.get_by_app_id(app_id) + if bound is None or bound.rom_id != expected_bound_rom_id: + return False + bound.unbind_shortcut() + uow.roms.save(bound) + return True + + def delete_rows( + self, + expected_rows: list[Rom], + delete_ids: set[int], + target_id: int | None, + app_id: int | None, + fully_dead: bool, + ) -> bool: + with self._uow_factory() as uow: + if not self._deletion_state_matches(uow, expected_rows, delete_ids, target_id, app_id, fully_dead): + return False + for stamp in list(uow.collection_sync_state.iter_all()): + if delete_ids.intersection(stamp.member_rom_ids): + uow.collection_sync_state.delete(stamp.collection_id, stamp.collection_kind) + if fully_dead: + for slug in {row.platform_slug for row in expected_rows if row.rom_id in delete_ids}: + uow.platform_sync_state.delete(slug) + for rom_id in delete_ids: + uow.roms.delete(rom_id) + return True + + @staticmethod + def _deletion_state_matches( + uow: UnitOfWork, + expected_rows: list[Rom], + delete_ids: set[int], + target_id: int | None, + app_id: int | None, + fully_dead: bool, + ) -> bool: + if not expected_rows: + return False + expected = {row.rom_id: row for row in expected_rows} + if not delete_ids <= expected.keys(): + return False + group_key = expected_rows[0].sibling_group_key + if group_key is not None: + current_group = list(uow.roms.iter_by_group_key(group_key)) + if {row.rom_id for row in current_group} != expected.keys(): + return False + else: + singleton = uow.roms.get(expected_rows[0].rom_id) + if singleton is None: + return False + current_group = [singleton] + expected_app_ids = {row.shortcut_app_id for row in expected_rows if row.shortcut_app_id is not None} + current_app_ids = {row.shortcut_app_id for row in current_group if row.shortcut_app_id is not None} + if current_app_ids != expected_app_ids or sum(row.shortcut_app_id is not None for row in current_group) > 1: + return False + current_rows: list[Rom] = [] + for rom_id in delete_ids: + row = uow.roms.get(rom_id) + if row is None or row != expected[rom_id]: + return False + current_rows.append(row) + bound_app_ids = {row.shortcut_app_id for row in current_rows if row.shortcut_app_id is not None} + if target_id is not None: + target = uow.roms.get(target_id) + return target is not None and target.shortcut_app_id == app_id and not bound_app_ids + if fully_dead: + return bound_app_ids == ({app_id} if app_id is not None else set()) + return not bound_app_ids diff --git a/py_modules/services/prune/requests.py b/py_modules/services/prune/requests.py new file mode 100644 index 00000000..5bf5e839 --- /dev/null +++ b/py_modules/services/prune/requests.py @@ -0,0 +1,130 @@ +"""Validation and decoding for vanished-ROM cleanup wire requests.""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +from services.prune._models import PruneOptions + +_MAX_PREVIEW_PAGE = 100 +_MAX_STEAM_SNAPSHOT_BYTES = 64 * 1024 +_MAX_SELECTION_PAGE = 100 + + +def parse_preview_request( + request: object, +) -> tuple[Literal["bulk", "rom"], int | None, str | None, int, int] | dict[str, Any]: + """Validate one local preview page request.""" + if not isinstance(request, dict): + return _failure("invalid_request", "Preview request must be an object.") + scope = request.get("scope") + if scope not in {"bulk", "rom"}: + return _failure("invalid_scope", "Preview scope must be bulk or rom.") + explicit: int | None = None + if scope == "rom": + raw = request.get("rom_id") + if type(raw) is not int or raw <= 0: + return _failure("invalid_rom_id", "A positive ROM id is required.") + explicit = raw + preview_id = request.get("preview_id") + if preview_id is not None and not isinstance(preview_id, str): + return _failure("invalid_preview_id", "Preview id must be a string or null.") + offset = request.get("offset", 0) + limit = request.get("limit", 50) + if type(offset) is not int or offset < 0 or type(limit) is not int or not 0 <= limit <= _MAX_PREVIEW_PAGE: + return _failure("invalid_page", f"Offset must be non-negative and limit 0-{_MAX_PREVIEW_PAGE}.") + return scope, explicit, preview_id, offset, limit + + +def parse_options(request: dict[str, Any], include_installed_rom_ids: frozenset[int]) -> PruneOptions | dict[str, Any]: + """Validate explicit one-run destructive options.""" + keys = ("repoint_shortcuts", "remove_rows", "remove_fully_vanished", "create_recovery_bundle") + if any(type(request.get(key)) is not bool for key in keys): + return _failure("invalid_options", "Every cleanup option must be explicitly true or false.") + if not request["create_recovery_bundle"] and include_installed_rom_ids: + return _failure("invalid_options", "Installed content cannot be selected when recovery is disabled.") + return PruneOptions( + repoint_shortcuts=request["repoint_shortcuts"], + remove_rows=request["remove_rows"], + remove_fully_vanished=request["remove_fully_vanished"], + create_recovery_bundle=request["create_recovery_bundle"], + include_installed_rom_ids=include_installed_rom_ids, + ) + + +def parse_selection_page(request: object) -> tuple[str, str | None, list[int], bool] | dict[str, Any]: + """Validate one bounded page of a preview-bound installed-content selection.""" + if not isinstance(request, dict): + return _failure("invalid_request", "Installed-content selection must be an object.") + preview_id = request.get("preview_id") + selection_id = request.get("selection_id") + rom_ids = request.get("rom_ids") + final = request.get("final") + if not isinstance(preview_id, str) or not preview_id: + return _failure("invalid_preview_id", "Preview id must be a non-empty string.") + if selection_id is not None and (not isinstance(selection_id, str) or not selection_id): + return _failure("invalid_selection_id", "Selection id must be a non-empty string or null.") + if ( + not isinstance(rom_ids, list) + or len(rom_ids) > _MAX_SELECTION_PAGE + or any(type(value) is not int or value <= 0 for value in rom_ids) + ): + return _failure( + "invalid_selection", f"Each selection page may contain 0-{_MAX_SELECTION_PAGE} positive ROM ids." + ) + if type(final) is not bool: + return _failure("invalid_selection", "Selection final must be explicitly true or false.") + return preview_id, selection_id, rom_ids, final + + +def valid_snapshot(snapshot: object, expected_app_id: int | None) -> bool: + """Accept only a bounded complete Steam snapshot for the pending appId.""" + if not isinstance(snapshot, dict) or type(snapshot.get("app_id")) is not int: + return False + if expected_app_id is None or snapshot["app_id"] != expected_app_id: + return False + if any(not isinstance(snapshot.get(key), str) for key in ("name", "exe", "start_dir", "launch_options")): + return False + if not snapshot["name"] or not snapshot["exe"].rstrip('"').endswith("/bin/rom-launcher"): + return False + if any( + key not in snapshot for key in ("minutes_playtime_forever", "minutes_playtime_last_two_weeks", "last_played") + ): + return False + if any( + value is not None and type(value) is not int + for value in ( + snapshot.get("minutes_playtime_forever"), + snapshot.get("minutes_playtime_last_two_weeks"), + snapshot.get("last_played"), + ) + ): + return False + collections = snapshot.get("collections") + if not isinstance(collections, list): + return False + if any( + not isinstance(item, dict) or not isinstance(item.get("id"), str) or not isinstance(item.get("name"), str) + for item in collections + ): + return False + try: + encoded = json.dumps(snapshot, ensure_ascii=True) + except (TypeError, ValueError): + return False + if len(encoded.encode("utf-8")) > _MAX_STEAM_SNAPSHOT_BYTES: + return False + return not _contains_base64(snapshot) + + +def _contains_base64(value: object) -> bool: + if isinstance(value, dict): + return any("base64" in str(key).lower() or _contains_base64(item) for key, item in value.items()) + if isinstance(value, list): + return any(_contains_base64(item) for item in value) + return False + + +def _failure(reason: str, message: str) -> dict[str, Any]: + return {"success": False, "reason": reason, "message": message} diff --git a/py_modules/services/prune/service.py b/py_modules/services/prune/service.py new file mode 100644 index 00000000..341ab7d5 --- /dev/null +++ b/py_modules/services/prune/service.py @@ -0,0 +1,479 @@ +"""Callable facade and ephemeral run state for explicit vanished-ROM cleanup.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from lib.list_result import ErrorCode +from lib.url_host import romm_namespace +from services.prune._models import InstalledSelection, PendingAction, PruneOptions, PrunePreview, cancellation_state +from services.prune.executor import PruneExecutor, PruneExecutorConfig +from services.prune.preview import PreviewBuilder, PreviewBuilderConfig +from services.prune.recovery import RecoveryCoordinator, RecoveryCoordinatorConfig +from services.prune.registry import PruneRegistry, PruneRegistryConfig +from services.prune.requests import parse_options, parse_preview_request, parse_selection_page, valid_snapshot + +if TYPE_CHECKING: + import logging + + from services.protocols import ( + ActiveDownloadRomIdsFn, + Clock, + EventEmitter, + InstalledRomFilesRemoverFn, + PruneArtifactStore, + PruneSaveCoordinator, + RecoveryBundleStore, + RetroDeckPaths, + RommRomReader, + SaveDriftProbeFn, + SteamRecoveryStore, + UnitOfWorkFactory, + UuidGen, + VersionSwitcherFn, + ) + +_ACTION_TIMEOUT_SECONDS = 60.0 + + +@dataclass(frozen=True) +class PruneServiceConfig: + """Frozen composition-root wiring for explicit vanished-ROM cleanup.""" + + loop: asyncio.AbstractEventLoop + logger: logging.Logger + clock: Clock + uuid_gen: UuidGen + emit: EventEmitter + uow_factory: UnitOfWorkFactory + romm_api: RommRomReader + recovery_store: RecoveryBundleStore + prune_artifacts: PruneArtifactStore + steam_recovery: SteamRecoveryStore + retrodeck_paths: RetroDeckPaths + save_coordinator: PruneSaveCoordinator + active_downloads: ActiveDownloadRomIdsFn + drift_probe: SaveDriftProbeFn + remove_installed_files: InstalledRomFilesRemoverFn + switch_version: VersionSwitcherFn + settings: dict[str, Any] + + +class PruneService: + """Own preview consumption, one run claim, and frontend action leases.""" + + def __init__(self, *, config: PruneServiceConfig) -> None: + self._loop = config.loop + self._logger = config.logger + self._clock = config.clock + self._uuid_gen = config.uuid_gen + self._emit = config.emit + self._settings = config.settings + self._recovery_store = config.recovery_store + self._preview_builder = PreviewBuilder( + config=PreviewBuilderConfig( + uow_factory=config.uow_factory, + recovery_store=config.recovery_store, + retrodeck_paths=config.retrodeck_paths, + settings=config.settings, + ) + ) + recovery = RecoveryCoordinator( + config=RecoveryCoordinatorConfig( + uow_factory=config.uow_factory, + recovery_store=config.recovery_store, + prune_artifacts=config.prune_artifacts, + steam_recovery=config.steam_recovery, + retrodeck_paths=config.retrodeck_paths, + clock=config.clock, + uuid_gen=config.uuid_gen, + ) + ) + registry = PruneRegistry(config=PruneRegistryConfig(uow_factory=config.uow_factory)) + self._executor = PruneExecutor( + config=PruneExecutorConfig( + loop=config.loop, + logger=config.logger, + emit=config.emit, + romm_api=config.romm_api, + recovery_store=config.recovery_store, + prune_artifacts=config.prune_artifacts, + steam_recovery=config.steam_recovery, + save_coordinator=config.save_coordinator, + active_downloads=config.active_downloads, + drift_probe=config.drift_probe, + remove_installed_files=config.remove_installed_files, + switch_version=config.switch_version, + settings=config.settings, + recovery=recovery, + registry=registry, + request_action=self._request_action, + ) + ) + self._registry = registry + self._preview: PrunePreview | None = None + self._task: asyncio.Task[None] | None = None + self._admission_task: asyncio.Task[Any] | None = None + self._run_id: str | None = None + self._starting = False + self._closed = False + self._selection: InstalledSelection | None = None + self._pending_action: PendingAction | None = None + self._completed_action_tokens: set[str] = set() + self._release_run_id: str | None = None + self._run_preview_id: str | None = None + self._release_event = asyncio.Event() + self._admission_lock = asyncio.Lock() + self._action_lock = asyncio.Lock() + + def is_active(self) -> bool: + """Return whether admission or execution currently owns the prune claim.""" + return self._starting or self._run_id is not None + + async def get_prune_preview(self, request: object) -> dict[str, Any]: + """Create or page an ephemeral local-only candidate preview.""" + parsed = parse_preview_request(request) + if isinstance(parsed, dict): + return parsed + if self.is_active(): + return self._failure("prune_active", "A removed-game cleanup is already running.") + scope, explicit_rom_id, preview_id, offset, limit = parsed + try: + preview = self._preview + if preview_id is None: + token = self._uuid_gen.uuid4() + preview = await self._loop.run_in_executor( + None, self._preview_builder.build, token, scope, explicit_rom_id + ) + self._preview = preview + self._selection = None + elif ( + preview is None + or preview.preview_id != preview_id + or preview.scope != scope + or preview.explicit_rom_id != explicit_rom_id + or preview.server_namespace != romm_namespace(self._settings) + ): + return self._failure("stale_preview", "This cleanup preview is stale. Scan again before confirming.") + result = self._preview_builder.page(preview, offset, limit) + result["recovery_root"] = self._recovery_store.root() + return result + except Exception as exc: + self._logger.exception("Removed-game cleanup preview failed") + return self._failure(ErrorCode.UNKNOWN.value, str(exc)) + + async def stage_prune_installed_selection(self, request: object) -> dict[str, Any]: + """Append one bounded page to an ephemeral preview-bound selection.""" + parsed = parse_selection_page(request) + if isinstance(parsed, dict): + return parsed + preview_id, selection_id, rom_ids, final = parsed + async with self._admission_lock: + preview = self._preview + if self.is_active() or preview is None or preview.preview_id != preview_id: + return self._failure("stale_preview", "This cleanup preview is stale. Scan again before confirming.") + installed_ids = { + int(entry["rom_id"]) + for entry in preview.entries + if entry.get("installed") is True and type(entry.get("rom_id")) is int + } + if not set(rom_ids) <= installed_ids: + return self._failure( + "invalid_selection", "Selection contains a ROM without disclosed installed content." + ) + selection = self._selection + if selection_id is None: + selection = InstalledSelection(preview_id, self._uuid_gen.uuid4(), set()) + self._selection = selection + elif selection is None or selection.selection_id != selection_id or selection.preview_id != preview_id: + return self._failure("stale_selection", "This installed-content selection is stale.") + if selection.finalized: + return self._failure("selection_finalized", "This installed-content selection is already complete.") + selection.rom_ids.update(rom_ids) + selection.finalized = final + return { + "success": True, + "selection_id": selection.selection_id, + "selected_count": len(selection.rom_ids), + "finalized": selection.finalized, + } + + async def start_prune(self, request: object) -> dict[str, Any]: + """Atomically consume a preview and start one explicit cleanup run.""" + if not isinstance(request, dict) or request.get("confirmed") is not True: + return self._failure("confirmation_required", "Explicit confirmation is required before cleanup.") + raw_selection_id = request.get("installed_selection_id") + if raw_selection_id is not None and not isinstance(raw_selection_id, str): + return self._failure("invalid_selection_id", "Installed selection id must be a string or null.") + selected_ids = frozenset[int]() + if isinstance(raw_selection_id, str): + selection = self._selection + if selection is None or selection.selection_id != raw_selection_id or not selection.finalized: + return self._failure("stale_selection", "Finish staging installed-content selections before cleanup.") + selected_ids = frozenset(selection.rom_ids) + options = parse_options(request, selected_ids) + if isinstance(options, dict): + return options + preview_id = request.get("preview_id") + async with self._admission_lock: + if self._closed: + return self._failure("service_stopping", "Removed-game cleanup is shutting down.") + if self.is_active(): + return self._failure("prune_active", "A removed-game cleanup is already running.") + preview = self._preview + if not isinstance(preview_id, str) or preview is None or preview.preview_id != preview_id: + return self._failure("stale_preview", "This cleanup preview is stale. Scan again before confirming.") + self._starting = True + self._admission_task = asyncio.current_task() + started_run = False + run_id = "" + try: + refreshed = await self._loop.run_in_executor( + None, + self._preview_builder.build, + preview.preview_id, + preview.scope, + preview.explicit_rom_id, + ) + async with self._admission_lock: + if self._closed: + return self._failure("service_stopping", "Removed-game cleanup is shutting down.") + if ( + refreshed.candidate_ids != preview.candidate_ids + or refreshed.fingerprint != preview.fingerprint + or refreshed.server_namespace != preview.server_namespace + ): + self._preview = refreshed + self._selection = None + return self._failure("stale_preview", "Local game state changed. Review a fresh cleanup preview.") + self._preview = None + self._selection = None + run_id = self._uuid_gen.uuid4() + self._run_id = run_id + self._run_preview_id = refreshed.preview_id + self._release_run_id = run_id + self._release_event = asyncio.Event() + self._completed_action_tokens.clear() + self._task = self._loop.create_task(self._run(run_id, refreshed, options)) + started_run = True + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.exception("Removed-game cleanup start validation failed") + return self._failure(ErrorCode.UNKNOWN.value, str(exc)) + finally: + async with self._admission_lock: + self._starting = False + self._admission_task = None + if not started_run: + return self._failure("service_stopping", "Removed-game cleanup did not start.") + response: dict[str, Any] = {"success": True, "run_id": run_id} + response["status"] = "running" + return response + + async def report_prune_action(self, request: object) -> dict[str, Any]: + """Claim or complete the exact frontend action currently awaited by the run.""" + try: + return await self._report_prune_action(request) + except Exception as exc: + self._logger.exception("Removed-game cleanup action report failed") + return self._failure(ErrorCode.UNKNOWN.value, str(exc)) + + async def wait_for_prune_release(self, run_id: object) -> dict[str, Any]: + """Boundedly acknowledge that one terminal run released its exclusive claim.""" + if not isinstance(run_id, str) or not run_id: + return self._failure("invalid_run_id", "Cleanup run id must be a non-empty string.") + if self._release_run_id != run_id or self._release_event.is_set(): + return {"success": True, "message": "Cleanup claim is released."} + try: + await asyncio.wait_for(self._release_event.wait(), timeout=5.0) + except TimeoutError: + return self._failure("release_timeout", "Cleanup claim release was not observed in time.") + return {"success": True, "message": "Cleanup claim is released."} + + async def _report_prune_action(self, request: object) -> dict[str, Any]: + if not isinstance(request, dict): + return self._failure("invalid_request", "Action result must be an object.") + token = request.get("action_token") + run_id = request.get("run_id") + phase = request.get("phase") + if phase not in {"claim", "complete"}: + return self._failure("invalid_request", "Action phase must be claim or complete.") + async with self._action_lock: + if phase == "complete" and isinstance(token, str) and token in self._completed_action_tokens: + return {"success": True, "ignored": True, "message": "Action result was already received."} + pending = self._pending_action + if pending is None or token != pending.token or run_id != pending.run_id: + return self._failure("stale_action", "This cleanup action token is no longer active.") + if self._clock.monotonic() >= pending.expires_at: + return self._failure("stale_action", "This cleanup action token has expired.") + if phase == "claim": + action = request.get("action") + app_id = request.get("app_id") + target_rom_id = request.get("target_rom_id") + if action != pending.kind or app_id != pending.app_id or target_rom_id != pending.target_rom_id: + return self._failure("action_mismatch", "Action claim does not match the pending Steam operation.") + if pending.claimed: + return {"success": True, "ignored": True, "message": "Action token was already claimed."} + if pending.app_id is not None and pending.expected_bound_rom_id is not None: + valid = await self._loop.run_in_executor( + None, + self._registry.validate_action_state, + pending.kind, + pending.expected_bound_rom_id, + pending.app_id, + pending.target_rom_id, + pending.group_rom_ids, + ) + if not valid: + return self._failure( + "local_state_changed", "The shortcut binding changed before the Steam action." + ) + if self._pending_action is not pending or self._clock.monotonic() >= pending.expires_at: + return self._failure("stale_action", "This cleanup action token has expired.") + pending.claimed = True + pending.expires_at = self._clock.monotonic() + _ACTION_TIMEOUT_SECONDS + claim_event = cast("asyncio.Event", pending.claim_event) + claim_event.set() + return {"success": True, "message": "Action token claimed."} + + if not pending.claimed: + return self._failure("action_not_claimed", "Claim the action token before reporting its result.") + if type(request.get("success")) is not bool or not isinstance(request.get("message"), str): + return self._failure("invalid_action_result", "Action success and message fields are required.") + mutation_attempted = request.get("mutation_attempted") + if mutation_attempted is not None and type(mutation_attempted) is not bool: + return self._failure("invalid_action_result", "Action mutation-attempted must be a boolean.") + snapshot = request.get("snapshot") + shortcut_absent = request.get("shortcut_absent") is True + snapshot_required = ( + pending.kind == "capture_shortcut_snapshot" and request["success"] is True and not shortcut_absent + ) + if snapshot_required and snapshot is None: + return self._failure("invalid_snapshot", "The Steam recovery snapshot was missing.") + if shortcut_absent and pending.kind not in {"capture_shortcut_snapshot", "remove_shortcut"}: + return self._failure("invalid_action_result", "This action may not report an absent shortcut.") + if snapshot is not None and ( + pending.kind != "capture_shortcut_snapshot" or not valid_snapshot(snapshot, pending.app_id) + ): + return self._failure("invalid_snapshot", "The Steam recovery snapshot was invalid or too large.") + future = cast("asyncio.Future[dict[str, Any]]", pending.future) + if future.done(): + return {"success": True, "ignored": True, "message": "Action result was already received."} + result = dict(request) + result["claimed"] = pending.claimed + future.set_result(result) + self._completed_action_tokens.add(pending.token) + return {"success": True, "message": "Action result accepted."} + + async def shutdown(self) -> None: + """Cancel unstarted work and await any already-claimed destructive phase.""" + async with self._admission_lock: + self._closed = True + admission_task = self._admission_task + task = self._task + current = asyncio.current_task() + pending = [ + item for item in (admission_task, task) if item is not None and item is not current and not item.done() + ] + for item in pending: + item.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + async def _run(self, run_id: str, preview: PrunePreview, options: PruneOptions) -> None: + try: + await self._executor.run(run_id, preview, options) + finally: + pending = self._pending_action + if pending is not None: + future = cast("asyncio.Future[dict[str, Any]]", pending.future) + if not future.done(): + future.cancel() + self._pending_action = None + self._run_id = None + self._run_preview_id = None + self._release_event.set() + + async def _request_action( + self, + run_id: str, + kind: str, + data: dict[str, object], + expected_bound_rom_id: int | None, + target_rom_id: int | None, + group_rom_ids: set[int], + ) -> dict[str, Any]: + token = self._uuid_gen.uuid4() + future: asyncio.Future[dict[str, Any]] = self._loop.create_future() + claim_event = asyncio.Event() + raw_app_id = data.get("app_id") + app_id = raw_app_id if type(raw_app_id) is int else None + pending = PendingAction( + run_id=run_id, + token=token, + kind=kind, + app_id=app_id, + expected_bound_rom_id=expected_bound_rom_id, + target_rom_id=target_rom_id, + group_rom_ids=frozenset(group_rom_ids), + future=future, + claim_event=claim_event, + expires_at=self._clock.monotonic() + _ACTION_TIMEOUT_SECONDS, + ) + self._pending_action = pending + await self._emit( + "prune_action_required", + { + "run_id": run_id, + "preview_id": self._run_preview_id, + "action_token": token, + "action": kind, + **data, + }, + ) + try: + await asyncio.wait_for(claim_event.wait(), timeout=_ACTION_TIMEOUT_SECONDS) + result = await asyncio.wait_for(asyncio.shield(future), timeout=_ACTION_TIMEOUT_SECONDS) + return self._action_result_or_cancel(result) + except asyncio.CancelledError as exc: + if pending.claimed: + try: + result = await asyncio.wait_for(asyncio.shield(future), timeout=_ACTION_TIMEOUT_SECONDS) + except TimeoutError: + result = self._action_timeout_result(pending) + cancellation_state(exc).action_result = result + raise + except TimeoutError: + return self._action_result_or_cancel(self._action_timeout_result(pending)) + finally: + if self._pending_action is pending: + self._pending_action = None + + @staticmethod + def _action_timeout_result(pending: PendingAction) -> dict[str, Any]: + return { + "success": False, + "reason": "action_ambiguous" if pending.claimed else "action_timeout", + "message": ( + "Steam action was claimed but its outcome is unknown." + if pending.claimed + else "Steam did not claim the action in time." + ), + "claimed": pending.claimed, + } + + @staticmethod + def _action_result_or_cancel(result: dict[str, Any]) -> dict[str, Any]: + task = asyncio.current_task() + if task is not None and task.cancelling(): + cancellation = asyncio.CancelledError() + cancellation_state(cancellation).action_result = result + raise cancellation + return result + + @staticmethod + def _failure(reason: str, message: str) -> dict[str, Any]: + return {"success": False, "reason": reason, "message": message} diff --git a/py_modules/services/rom_removal.py b/py_modules/services/rom_removal.py index 6f787dc8..0da3f150 100644 --- a/py_modules/services/rom_removal.py +++ b/py_modules/services/rom_removal.py @@ -18,6 +18,8 @@ import asyncio import logging + from models.prune import MutationOutcome, SourceClaim + from domain.rom_install import RomInstall from services.protocols import ( DownloadQueueCleanup, @@ -62,7 +64,7 @@ def __init__( self._download_queue_cleanup = config.download_queue_cleanup self._uow_factory = config.uow_factory - def _delete_rom_files(self, install: RomInstall) -> None: + def _delete_rom_files(self, install: RomInstall, claims: dict[str, SourceClaim] | None = None) -> MutationOutcome: """Delete ROM files for an install record. Handles both single-file and multi-file ROMs. A multi-file ROM owns a dedicated per-ROM directory (``rom_dir`` is set) @@ -76,16 +78,46 @@ def _delete_rom_files(self, install: RomInstall) -> None: file_path = install.file_path roms_base = self._retrodeck_paths.roms_path() - if rom_dir and is_safe_rom_path(rom_dir, roms_base) and self._rom_file_store.is_dir(rom_dir): - self._rom_file_store.remove_tree(rom_dir) - elif file_path: + if rom_dir: + if not is_safe_rom_path(rom_dir, roms_base): + raise ValueError(f"Refusing to delete path outside roms directory: {rom_dir}") + if self._rom_file_store.exists(rom_dir) and not self._rom_file_store.is_dir(rom_dir): + raise ValueError(f"Expected installed ROM directory, found another file type: {rom_dir}") + claim = claims.get(rom_dir) if claims is not None else None + if claim is None: + claim = self._rom_file_store.claim_source(rom_dir, roms_base) + return self._rom_file_store.remove_claimed(rom_dir, roms_base, claim) + if file_path: if not is_safe_rom_path(file_path, roms_base): - self._logger.error(f"Refusing to delete path outside roms directory: {file_path}") - return + raise ValueError(f"Refusing to delete path outside roms directory: {file_path}") if self._rom_file_store.is_dir(file_path): - self._rom_file_store.remove_tree(file_path) - elif self._rom_file_store.exists(file_path): - self._rom_file_store.remove_file(file_path) + raise ValueError(f"Expected installed ROM file, found a directory: {file_path}") + claim = claims.get(file_path) if claims is not None else None + if claim is None: + claim = self._rom_file_store.claim_source(file_path, roms_base) + return self._rom_file_store.remove_claimed(file_path, roms_base, claim) + return {"success": True, "changed": False, "ambiguous": False, "message": "No installed path recorded"} + + def delete_rom_files(self, rom_id: int, claims: dict[str, SourceClaim] | None = None) -> dict[str, Any]: + """Delete only installed content, leaving every database row untouched.""" + with self._uow_factory() as uow: + install = uow.rom_installs.get(int(rom_id)) + if install is None: + return {"success": False, "reason": "not_installed", "message": "ROM not installed"} + try: + outcome = self._delete_rom_files(install, claims) + except Exception as exc: + self._logger.error(f"Failed to delete ROM files: {exc}") + return { + "success": False, + "reason": ErrorCode.UNKNOWN.value, + "message": str(exc), + "changed": False, + "ambiguous": False, + } + if not outcome["success"]: + return {"reason": ErrorCode.UNKNOWN.value, **outcome} + return dict(outcome) def _remove_rom_io(self, rom_id: int, install: RomInstall) -> None: """Sync helper for remove_rom — file deletion (outside UoW) then row delete in a short write UoW. @@ -101,7 +133,9 @@ def _remove_rom_io(self, rom_id: int, install: RomInstall) -> None: ``""`` keeps the next sync from re-touching an already-correct shortcut (delta apply, #1383). Third of the five recorded-state writer sites. """ - self._delete_rom_files(install) + outcome = self._delete_rom_files(install) + if not outcome["success"]: + raise RuntimeError(outcome["message"]) with self._uow_factory() as uow: uow.rom_installs.delete(rom_id) rom = uow.roms.get(rom_id) @@ -149,7 +183,9 @@ def _uninstall_all_roms_io(self) -> tuple[int, list[dict[str, str]], list[int]]: successfully_deleted: list[int] = [] for install in installs: try: - self._delete_rom_files(install) + outcome = self._delete_rom_files(install) + if not outcome["success"]: + raise RuntimeError(outcome["message"]) count += 1 successfully_deleted.append(install.rom_id) except Exception as e: diff --git a/py_modules/services/saves/rom_info.py b/py_modules/services/saves/rom_info.py index 296c71e3..b9a3cadb 100644 --- a/py_modules/services/saves/rom_info.py +++ b/py_modules/services/saves/rom_info.py @@ -199,6 +199,20 @@ def find_save_files(self, rom_id: int) -> list[dict[str, str]]: results.append({"path": save_path, "filename": rom_name + ext}) return results + def expected_save_files(self, rom_id: int) -> list[dict[str, str]]: + """Project exact save paths for one installed ROM without broad scanning.""" + info = self.get_rom_save_info(rom_id) + if not info: + return [] + return [ + { + "path": os.path.join(info["saves_dir"], info["rom_name"] + ext), + "filename": info["rom_name"] + ext, + "saves_dir": info["saves_dir"], + } + for ext in get_save_extensions(info["system"]) + ] + def pending_sort_settings(self) -> SaveSortSettings | None: """Return previous save-sort settings if a migration is pending, else None. diff --git a/py_modules/services/saves/service.py b/py_modules/services/saves/service.py index 28c15390..d88b2d66 100644 --- a/py_modules/services/saves/service.py +++ b/py_modules/services/saves/service.py @@ -12,10 +12,13 @@ from __future__ import annotations +import contextlib +import os from typing import TYPE_CHECKING, Any from domain.iso_time import epoch_to_iso from domain.rom_save_sync_state import RomSaveSyncState +from domain.save_backup import is_backup_for from lib.list_result import ErrorCode from services.saves._config import SaveServiceConfig from services.saves._settings import ( @@ -33,6 +36,9 @@ from services.saves.versions import VersionsService, VersionsServiceConfig if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from models.prune import SourceClaim from models.sync import ClientSaveState from services.protocols import UnitOfWorkFactory @@ -257,6 +263,155 @@ def last_sync_hashes(self, rom_id: int) -> dict[str, str | None]: return {} return {filename: state.last_sync_hash for filename, state in save_entry.files.items()} + @contextlib.asynccontextmanager + async def lock_prune_roms(self, rom_ids: list[int]) -> AsyncIterator[None]: + """Hold affected save locks in ascending id order for recovery/removal.""" + async with contextlib.AsyncExitStack() as stack: + for rom_id in sorted({int(value) for value in rom_ids}): + await stack.enter_async_context(self._sync_engine.rom_lock(rom_id)) + yield + + def inventory_prune_saves(self, purge_rom_ids: list[int]) -> dict[str, Any]: + """Build exact-path save ownership and recovery artifacts for a purge set.""" + purge_ids = {int(value) for value in purge_rom_ids} + with self._uow_factory() as uow: + installed_ids = [install.rom_id for install in uow.rom_installs.iter_all()] + persisted_names = { + rom_id: list(state.files) + for rom_id, state in uow.rom_save_sync_states.iter_all() + if rom_id in installed_ids + } + + ownership: dict[str, set[int]] = {} + expected_by_id: dict[int, list[dict[str, str]]] = {} + for rom_id in installed_ids: + expected = self._rom_info.expected_save_files(rom_id) + if expected: + saves_dir = expected[0]["saves_dir"] + known = {item["filename"] for item in expected} + for filename in persisted_names.get(rom_id, []): + if ( + filename not in known + and filename not in {"", ".", ".."} + and os.path.basename(filename) == filename + and "\x00" not in filename + ): + expected.append( + {"path": os.path.join(saves_dir, filename), "filename": filename, "saves_dir": saves_dir} + ) + expected_by_id[rom_id] = expected + for item in expected: + ownership.setdefault(self._save_file_store.canonical_path(item["path"]), set()).add(rom_id) + + saves_root = self._config.retrodeck_paths.saves_path() + artifacts: list[dict[str, object]] = [] + exclusive: list[dict[str, str]] = [] + shared: list[str] = [] + warnings: list[str] = [] + source_claims: dict[str, SourceClaim] = {} + lock_ids = set(purge_ids) + for rom_id in sorted(purge_ids): + expected = expected_by_id.get(rom_id, []) + if not expected: + warnings.append(f"ROM {rom_id}: save path could not be resolved; physical saves were left untouched") + continue + for item in expected: + path = item["path"] + if not self._save_file_store.is_within(path, saves_root): + warnings.append(f"ROM {rom_id}: save path is outside the supported saves root; left untouched") + continue + owners = ownership.get(self._save_file_store.canonical_path(path), {rom_id}) + lock_ids.update(owners) + exists = self._save_file_store.is_file(path) + if owners <= purge_ids: + artifacts.append( + {"source_path": path, "safe_root": saves_root, "kind": "current_save", "rom_id": rom_id} + ) + exclusive.append(item) + source_claims[path] = self._save_file_store.claim_source(path, saves_root) + elif exists: + artifacts.append( + {"source_path": path, "safe_root": saves_root, "kind": "current_save", "rom_id": rom_id} + ) + shared.append(path) + backup_dir = os.path.join(item["saves_dir"], ".romm-backup") + if self._save_file_store.is_symlink(backup_dir) or not self._save_file_store.is_within( + backup_dir, saves_root + ): + raise ValueError(f"ROM {rom_id}: save backup directory is unsafe: {backup_dir}") + for entry in self._save_file_store.listdir(backup_dir): + backup_path = os.path.join(backup_dir, entry) + if is_backup_for(item["filename"], entry) and self._save_file_store.is_file(backup_path): + artifacts.append( + { + "source_path": backup_path, + "safe_root": saves_root, + "kind": "save_backup", + "rom_id": rom_id, + } + ) + return { + "artifacts": artifacts, + "exclusive": exclusive, + "shared": sorted(set(shared)), + "warnings": warnings, + "lock_rom_ids": sorted(lock_ids), + "source_claims": source_claims, + } + + def quarantine_prune_saves( + self, files: list[dict[str, str]], claims: dict[str, SourceClaim] | None = None + ) -> dict[str, Any]: + """Move exclusive current saves through the sanctioned backup funnel.""" + moved: list[str] = [] + saves_root = self._config.retrodeck_paths.saves_path() + try: + for item in files: + backup_dir = os.path.join(item["saves_dir"], ".romm-backup") + if ( + not self._save_file_store.is_within(item["path"], saves_root) + or not self._save_file_store.is_within(backup_dir, saves_root) + or self._save_file_store.is_symlink(backup_dir) + ): + raise ValueError(f"Unsafe save quarantine destination: {backup_dir}") + claim = claims.get(item["path"]) if claims is not None else None + if claim is None: + claim = self._save_file_store.claim_source(item["path"], saves_root) + outcome = self._sync_engine.quarantine_claimed_file( + item["saves_dir"], item["filename"], claim=claim, safe_root=saves_root + ) + if outcome["changed"]: + moved += [item["path"]] + if not outcome["success"]: + return { + "success": False, + "reason": "save_quarantine_failed", + "message": outcome["message"], + "moved": moved, + "ambiguous": outcome["ambiguous"], + } + except Exception as exc: + return { + "success": False, + "reason": "save_quarantine_failed", + "message": str(exc), + "moved": moved, + "ambiguous": False, + } + return {"success": True, "moved": moved, "ambiguous": False} + + def validate_prune_absences(self, claims: dict[str, SourceClaim]) -> bool: + """Require every quarantined purge-owned path to remain absent before cascade.""" + saves_root = self._config.retrodeck_paths.saves_path() + try: + for path in claims: + current = self._save_file_store.claim_source(path, saves_root) + if current["source_identity"]["exists"]: + return False + except Exception: + return False + return True + # ------------------------------------------------------------------ # Sync orchestration (delegated to SyncEngine) # ------------------------------------------------------------------ diff --git a/py_modules/services/saves/sync_engine/engine.py b/py_modules/services/saves/sync_engine/engine.py index 91a27188..7fe353c0 100644 --- a/py_modules/services/saves/sync_engine/engine.py +++ b/py_modules/services/saves/sync_engine/engine.py @@ -69,6 +69,8 @@ import logging from collections.abc import Iterator + from models.prune import MutationOutcome, SourceClaim, SourceIdentity + from domain.save_layout import SaveLayout from services.protocols import ( ActiveCoreReader, @@ -314,9 +316,39 @@ def do_download_save( """Download a save file from server (delegate to :class:`MatrixExecutor`).""" self._matrix.do_download_save(server_save, saves_dir, filename, save_state, device_id, system, default_slot) - def quarantine_local_file(self, saves_dir: str, filename: str) -> bool: + def quarantine_local_file( + self, + saves_dir: str, + filename: str, + *, + preserve_history: bool = False, + expected_identity: SourceIdentity | None = None, + safe_root: str | None = None, + ) -> bool: """Back up a local save into ``.romm-backup`` (delegate to :class:`MatrixExecutor`).""" - return self._matrix.quarantine_local_file(saves_dir, filename) + return self._matrix.quarantine_local_file( + saves_dir, + filename, + preserve_history=preserve_history, + expected_identity=expected_identity, + safe_root=safe_root, + ) + + def quarantine_claimed_file( + self, + saves_dir: str, + filename: str, + *, + claim: SourceClaim, + safe_root: str, + ) -> MutationOutcome: + """Durably quarantine an exact save claim (delegate to :class:`MatrixExecutor`).""" + return self._matrix.quarantine_claimed_file( + saves_dir, + filename, + claim=claim, + safe_root=safe_root, + ) def do_upload_save( self, diff --git a/py_modules/services/saves/sync_engine/matrix.py b/py_modules/services/saves/sync_engine/matrix.py index 0860c1f0..6e6c4758 100644 --- a/py_modules/services/saves/sync_engine/matrix.py +++ b/py_modules/services/saves/sync_engine/matrix.py @@ -40,6 +40,8 @@ import logging from collections.abc import Iterator + from models.prune import MutationOutcome, SourceClaim, SourceIdentity + from services.protocols import ( Clock, DebugLogger, @@ -311,7 +313,15 @@ def do_download_save( self.update_file_sync_state(save_state, filename, server_save, local_path, system, default_slot=default_slot) self._log_debug(f"Downloaded save: {filename}") - def quarantine_local_file(self, saves_dir: str, filename: str) -> bool: + def quarantine_local_file( + self, + saves_dir: str, + filename: str, + *, + preserve_history: bool = False, + expected_identity: SourceIdentity | None = None, + safe_root: str | None = None, + ) -> bool: """Move a local save file aside into ``.romm-backup`` before it is destroyed. The single source of truth for the save-file backup discipline: both the @@ -330,22 +340,52 @@ def quarantine_local_file(self, saves_dir: str, filename: str) -> bool: ``False`` when there was nothing at *filename* to back up. """ local_path = os.path.join(saves_dir, filename) - if not self._save_file_store.is_file(local_path): + if expected_identity is None and not self._save_file_store.is_file(local_path): return False backup_dir = os.path.join(saves_dir, ".romm-backup") + if self._save_file_store.is_symlink(backup_dir) or not self._save_file_store.is_within(backup_dir, saves_dir): + raise ValueError(f"Unsafe save backup directory: {backup_dir}") self._save_file_store.make_dirs(backup_dir) ts = self._clock.now().strftime("%Y%m%d_%H%M%S") existing = set(self._save_file_store.listdir(backup_dir)) backup = backup_name(filename, ts, existing) - self._save_file_store.rename(local_path, os.path.join(backup_dir, backup)) + backup_path = os.path.join(backup_dir, backup) + if expected_identity is not None: + if safe_root is None: + raise ValueError("A safe root is required for exact save quarantine") + self._save_file_store.rename_exact(local_path, backup_path, safe_root, expected_identity) + else: + self._save_file_store.rename(local_path, backup_path) # Bound the recovery net: keep only the newest N backups per save file (#974). existing.add(backup) - for stale in select_backups_to_prune(filename, list(existing), _BACKUP_RETENTION): - if stale == backup: - continue # never prune the backup just created this call (#974 — would destroy the save) - self._save_file_store.remove_file(os.path.join(backup_dir, stale)) + if not preserve_history: + for stale in select_backups_to_prune(filename, list(existing), _BACKUP_RETENTION): + if stale == backup: + continue # never prune the backup just created this call (#974 — would destroy the save) + self._save_file_store.remove_file(os.path.join(backup_dir, stale)) return True + def quarantine_claimed_file( + self, + saves_dir: str, + filename: str, + *, + claim: SourceClaim, + safe_root: str, + ) -> MutationOutcome: + """Durably quarantine one exact claimed save through anchored directories.""" + local_path = os.path.join(saves_dir, filename) + backup_dir = os.path.join(saves_dir, ".romm-backup") + self._save_file_store.ensure_directory(backup_dir, safe_root) + ts = self._clock.now().strftime("%Y%m%d_%H%M%S") + backup = backup_name(filename, ts, set(self._save_file_store.listdir(backup_dir))) + return self._save_file_store.rename_claimed( + local_path, + os.path.join(backup_dir, backup), + safe_root, + claim, + ) + @staticmethod def _resolve_upload_slot(save_state: RomSaveSyncState, default_slot: str | None = None) -> str | None: """The slot field to send with an upload; ``None`` only for an explicit-legacy save. diff --git a/py_modules/services/version_switch.py b/py_modules/services/version_switch.py index 6bb2856e..f8e89d88 100644 --- a/py_modules/services/version_switch.py +++ b/py_modules/services/version_switch.py @@ -29,14 +29,13 @@ from __future__ import annotations import asyncio -import os from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from domain.rom import Rom from domain.shortcut_data import extract_version_metadata from domain.sibling_group import compute_sibling_group_key, target_in_sibling_group -from domain.sibling_resolution import AUTO_REGION, resolve_group_representative +from domain.sibling_resolution import AUTO_REGION, fs_name_stem, resolve_group_representative from domain.version_metadata import VersionMetadata from lib.errors import RommNotFoundError, classify_error from lib.list_result import ErrorCode @@ -136,11 +135,6 @@ class _SwitchContext: group_member_ids: frozenset[int] -def _fs_name_no_ext(fs_name: str) -> str: - """Filename stem for a local row — ``fs_name_no_ext`` is not a DB column.""" - return os.path.splitext(fs_name)[0] - - class VersionSwitchService: """Version-picker reads (``get_version_list``) and writes (``switch_version``).""" @@ -322,7 +316,7 @@ def _read_local_group(self, app_id: int) -> _LocalGroup | None: _MemberView( rom_id=r.rom_id, name=r.name, - label=_fs_name_no_ext(r.fs_name), + label=fs_name_stem(r.fs_name), regions=list(r.regions), languages=list(r.languages), revision=r.revision, @@ -414,10 +408,15 @@ def _build_version_list( ] if len(entries) <= 1: + bound_version = entries[0] if entries and entries[0]["rom_id"] == local.bound_rom_id else None + if bound_version is not None: + bound_version["active"] = True + bound_version["is_default"] = not bound_version["vanished"] return { "multi_version": False, "server_query_failed": server_query_failed, "bound_vanished": bound_vanished, + "bound_version": bound_version, } default_candidate_ids = {e["rom_id"] for e in entries if e["switchable"] and not e["vanished"]} @@ -778,7 +777,7 @@ def _read_switch_context(self, app_id: int, target_rom_id: int) -> _SwitchContex return _SwitchContext( bound_rom_id=bound.rom_id, bound_installed=uow.rom_installs.get(bound.rom_id) is not None, - bound_label=_fs_name_no_ext(bound.fs_name), + bound_label=fs_name_stem(bound.fs_name), group_key=group_key, platform_slug=bound.platform_slug, target_is_local=target_local is not None, diff --git a/src/api/backend.ts b/src/api/backend.ts index 02477013..6eafa823 100755 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -53,6 +53,17 @@ export interface BackendResult { romm_version?: string; /** Set when a callable was rejected because a RetroDECK migration is pending. */ blocked_by_migration?: boolean; + prune_lease_token?: string; +} + +export interface CallableFailure { + success: false; + reason: string; + message: string; +} + +export function isCallableFailure(value: object): value is CallableFailure { + return "success" in value && value.success === false; } export interface CachedGameDetail { @@ -145,9 +156,11 @@ export const clearCompletedDownloads = callable<[], { success: boolean; cleared: export const getInstalledRom = callable<[number], InstalledRom | null>("get_installed_rom"); export const evaluateLaunch = callable<[number], LaunchVerdict>("evaluate_launch"); export const checkLocalDrift = callable<[number], { drifted: boolean; rom_id: number }>("check_local_drift"); -export const getRomRelaunchOptions = callable<[number], { app_id: number; launch_options: string } | null>( - "get_rom_relaunch_options", -); +export type RelaunchOptionsResult = + | { success: true; app_id: number; launch_options: string; prune_lease_token: string } + | { success: false; reason: string; message: string } + | null; +export const getRomRelaunchOptions = callable<[number], RelaunchOptionsResult>("get_rom_relaunch_options"); export const probeReachability = callable<[], { online: boolean }>("probe_reachability"); export const refreshSaveStatus = callable<[number], { success: boolean }>("refresh_save_status"); export const removeRom = callable<[number], BackendResult>("remove_rom"); @@ -203,6 +216,7 @@ export const removePlatformShortcuts = callable< app_ids?: number[]; rom_ids?: (string | number)[]; platform_name?: string; + prune_lease_token?: string; reason?: string; message?: string; blocked_by_migration?: boolean; @@ -221,6 +235,7 @@ export const removeAllShortcuts = callable< message?: string; app_ids?: number[]; rom_ids?: (string | number)[]; + prune_lease_token?: string; blocked_by_migration?: boolean; } >("remove_all_shortcuts"); @@ -254,9 +269,10 @@ export const cleanupOrphanedGridImages = callable< blocked_by_migration?: boolean; } >("cleanup_orphaned_grid_images"); -export const getSgdbArtworkBase64 = callable<[number, number], { base64: string | null; no_api_key?: boolean }>( - "get_sgdb_artwork_base64", -); +export const getSgdbArtworkBase64 = callable< + [number, number], + { base64: string | null; no_api_key?: boolean; prune_lease_token?: string } +>("get_sgdb_artwork_base64"); /** A single SGDB game candidate for the manual picker. */ export interface SgdbCandidate { @@ -285,9 +301,17 @@ export const reportUnitResults = callable< [Record, string, number | string, number], { success: boolean; count: number; ignored?: boolean } >("report_unit_results"); -export const reportRemovalResults = callable<[(string | number)[]], { success: boolean; message: string }>( - "report_removal_results", +export const reportRemovalResults = callable< + [(string | number)[], string | null], + { success: boolean; message: string } +>("report_removal_results"); +export const releasePruneConflictLease = callable<[string], { success: boolean; message: string }>( + "release_prune_conflict_lease", ); +export const renewPruneConflictLease = callable< + [string], + { success: boolean; reason?: "stale_lease"; message: string } +>("renew_prune_conflict_lease"); export const reconcileShortcuts = callable< [number[]], { success: boolean; reason?: string; message: string; unbound_count?: number } @@ -307,6 +331,7 @@ export const uninstallAllRoms = callable< reason?: string; message?: string; blocked_by_migration?: boolean; + prune_lease_token?: string; } >("uninstall_all_roms"); export const saveSgdbApiKey = callable<[string], { success: boolean; message: string }>("save_sgdb_api_key"); @@ -338,7 +363,13 @@ export interface RebakeItem { export const setSystemCore = callable< [string, string], - { success: boolean; message?: string; bios_status?: BiosStatus; rebake_items?: RebakeItem[] } + { + success: boolean; + message?: string; + bios_status?: BiosStatus; + rebake_items?: RebakeItem[]; + prune_lease_token?: string; + } >("set_system_core"); /** @@ -354,6 +385,7 @@ export interface GameCoreApplyResult { success: boolean; launch_options?: string; app_id?: number | null; + prune_lease_token?: string; reason?: string; message?: string; } @@ -407,6 +439,7 @@ export interface SelectDiscResult { selected?: string | null; reason?: string; message?: string; + prune_lease_token?: string; } // Per-game disc pick (#865). Keyed by rom_id — the DB pin survives @@ -456,13 +489,16 @@ export interface VersionInfo { * live `sibling_roms` view could not be fetched, so the list is local-only * (partial-success carve-out). `bound_vanished` is required even on a * single-version/unknown result so a bound-id 404 is never lost when the picker - * itself does not render. A 404 on the bound id is NOT a query failure — the + * itself does not render. A synced singleton vanished binding carries + * `bound_version` so the frontend can render its focused cleanup action without + * inventing a version menu. A 404 on the bound id is NOT a query failure — the * server answered — and the picker does not feed that entity verdict into the * global connection state (#1570). */ export interface VersionList { multi_version: boolean; versions?: VersionInfo[]; + bound_version?: VersionInfo | null; server_query_failed?: boolean; bound_vanished: boolean; } @@ -481,6 +517,7 @@ export interface SwitchVersionSuccess { target_installed: boolean; launch_options: string; app_id: number; + prune_lease_token?: string; } /** @@ -531,6 +568,131 @@ export type SwitchVersionResult = SwitchVersionSuccess | SwitchVersionUnsyncedSa export const getVersionList = callable<[number], VersionList>("get_version_list"); export const switchVersion = callable<[number, number, boolean], SwitchVersionResult>("switch_version"); +export type PruneScope = "bulk" | "rom"; + +export interface PrunePreviewRequest { + scope: PruneScope; + rom_id: number | null; + preview_id: string | null; + offset: number; + limit: number; +} + +export interface PrunePreviewItem { + rom_id: number; + name: string; + name_truncated: boolean; + fs_name: string; + fs_name_truncated: boolean; + platform_slug: string; + group_id: string; + group_id_truncated: boolean; + group_size: number; + bound_count: number; + candidate: boolean; + installed: boolean; + installed_bytes: number | null; + warning: string | null; + warning_truncated: boolean; +} + +export interface PrunePreviewResult { + success: boolean; + reason?: string; + message?: string; + preview_id?: string; + scope?: PruneScope; + items?: PrunePreviewItem[]; + offset?: number; + limit?: number; + total?: number; + free_bytes?: number; + recovery_root?: string | null; + blocked_by_migration?: boolean; +} + +export interface StartPruneRequest { + preview_id: string; + confirmed: boolean; + repoint_shortcuts: boolean; + remove_rows: boolean; + remove_fully_vanished: boolean; + create_recovery_bundle: boolean; + installed_selection_id: string | null; +} + +export interface StagePruneInstalledSelectionRequest { + preview_id: string; + selection_id: string | null; + rom_ids: number[]; + final: boolean; +} + +export interface StartPruneResult { + success: boolean; + run_id?: string; + status?: "running"; + reason?: string; + message?: string; + blocked_by_migration?: boolean; +} + +export interface PruneSteamSnapshot { + app_id: number; + name: string; + exe: string; + start_dir: string; + launch_options: string; + minutes_playtime_forever: number | null; + minutes_playtime_last_two_weeks: number | null; + last_played: number | null; + collections: Array<{ id: string; name: string }>; +} + +export interface ClaimPruneActionRequest { + phase: "claim"; + run_id: string; + action_token: string; + action: "capture_shortcut_snapshot" | "repoint_shortcut" | "remove_shortcut"; + app_id: number; + target_rom_id: number | null; +} + +export interface CompletePruneActionRequest { + phase: "complete"; + run_id: string; + action_token: string; + success: boolean; + reason?: string; + message: string; + snapshot?: PruneSteamSnapshot; + shortcut_absent?: boolean; + mutation_attempted?: boolean; +} + +export type ReportPruneActionRequest = ClaimPruneActionRequest | CompletePruneActionRequest; + +export const getPrunePreview = callable<[PrunePreviewRequest], PrunePreviewResult>("get_prune_preview"); +export const stagePruneInstalledSelection = callable< + [StagePruneInstalledSelectionRequest], + { + success: boolean; + selection_id?: string; + selected_count?: number; + finalized?: boolean; + reason?: string; + message?: string; + } +>("stage_prune_installed_selection"); +export const startPrune = callable<[StartPruneRequest], StartPruneResult>("start_prune"); +export const reportPruneAction = callable< + [ReportPruneActionRequest], + { success: boolean; ignored?: boolean; reason?: string; message: string } +>("report_prune_action"); +export const waitForPruneRelease = callable<[string], { success: boolean; reason?: string; message: string }>( + "wait_for_prune_release", +); + export const saveLogLevel = callable<[string], { success: boolean }>("save_log_level"); // Preferred sibling-group region (ADR-0021 §3). "auto" = build-time default // order; any RomM region string heads the ranking on the next sync. @@ -557,7 +719,14 @@ export const getMetadataCachePage = callable<[number, number], { items: Record>("get_app_id_rom_id_map"); -export const getInstalledRelaunchOptions = callable<[], { app_id: number; launch_options: string }[]>( +export type InstalledRelaunchOptionsResult = + | { + success: true; + items: { app_id: number; launch_options: string }[]; + prune_lease_token: string | null; + } + | { success: false; reason: string; message: string }; +export const getInstalledRelaunchOptions = callable<[], InstalledRelaunchOptionsResult>( "get_installed_relaunch_options", ); @@ -573,7 +742,8 @@ export const ensureDeviceRegistered = callable<[], { success: boolean; device_id ); export const listDevices = callable<[], ListDevicesResponse>("list_devices"); -export const getSaveStatus = callable<[number], SaveStatus>("get_save_status"); +export type SaveStatusResult = SaveStatus | CallableFailure; +export const getSaveStatus = callable<[number], SaveStatusResult>("get_save_status"); export const preLaunchSync = callable< [number], { @@ -672,7 +842,8 @@ export const getAllPlaytime = callable< // means the server was unreachable and these are the local fallback. export const reconcilePlaytime = callable< [number], - { total_seconds: number; session_count: number; last_played: string | null; server_query_failed: boolean } + | { total_seconds: number; session_count: number; last_played: string | null; server_query_failed: boolean } + | { success: false; reason: string; message: string } >("reconcile_playtime"); // RetroDECK path-resolution health for the QAM banner — discriminated status diff --git a/src/components/CustomPlayButton.test.tsx b/src/components/CustomPlayButton.test.tsx index e555cb1e..913276af 100644 --- a/src/components/CustomPlayButton.test.tsx +++ b/src/components/CustomPlayButton.test.tsx @@ -91,6 +91,7 @@ import { isAppRunning } from "../utils/runningApps"; import { showOfflineDriftModal } from "../components/OfflineDriftModal"; import { showFallbackLaunchModal } from "../components/FallbackLaunchModal"; import { handleConflicts } from "../components/SyncConflictModal"; +import { mountPruneLeasePlugin, releaseAllPruneLeases } from "../utils/pruneLease"; import type { SyncConflict, SaveStatus } from "../types"; function mockCachedDetail(overrides: Partial = {}): void { @@ -1320,6 +1321,23 @@ describe("CustomPlayButton — resolve conflict reads the known conflict (#1276) ); await utils.findByText("Resolve Conflict"); }); + + it("a prune-active getSaveStatus failure keeps the visible conflict", async () => { + vi.mocked(backend.getSaveStatus).mockResolvedValue({ + success: false, + reason: "prune_active", + message: "Cleanup is active.", + }); + + const utils = await renderInConflict(); + await act(async () => { + (await utils.findByText("Resolve Conflict")).click(); + }); + + expect(vi.mocked(handleConflicts)).not.toHaveBeenCalled(); + expect(toaster.toast).toHaveBeenCalledWith({ title: "RomM Sync", body: "Cleanup is active." }); + await utils.findByText("Resolve Conflict"); + }); }); // --------------------------------------------------------------------------- @@ -1814,7 +1832,12 @@ describe("CustomPlayButton — pre-launch relaunch re-confirm (#1150)", () => { } it("re-confirms launch_options (getRomRelaunchOptions → setLaunchOptionsConfirmed) BEFORE RunGame", async () => { - vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ app_id: 100, launch_options: RELAUNCH_COMMAND }); + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 100, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "launch-lease", + }); await clickPlay(); @@ -1862,10 +1885,10 @@ describe("CustomPlayButton — pre-launch relaunch re-confirm (#1150)", () => { logSpy.mockRestore(); }); - it("a hung getRomRelaunchOptions falls through to the launch after the timeout (button never trapped)", async () => { + it("a hung getRomRelaunchOptions aborts launch after the timeout and restores Play", async () => { // The Decky callable bridge can hang forever on a wedged backend. The fetch - // is bounded by a 3s Promise.race; on timeout the re-confirm is skipped and - // the launch still fires — the button must not stay stuck on "Launching…". + // is bounded by a 3s Promise.race; on timeout the launch is aborted and the + // button must not stay stuck on "Launching…". // RTL's findBy* deadlocks under fake timers, so render + settle to "Play" // under REAL timers, then switch to fake timers right before the click so the // 3s re-confirm timeout fires without a real wait (kept fast). @@ -1875,30 +1898,65 @@ describe("CustomPlayButton — pre-launch relaunch re-confirm (#1150)", () => { try { mockCachedDetail(); - const { findByText } = render(); + const { findByText, getByText } = render(); const playBtn = await findByText("Play"); vi.useFakeTimers(); await act(async () => { playBtn.click(); - // The gate chain up to dispatchLaunch is microtask-driven (no setTimeout); - // advancing past 3000ms flushes those microtasks and fires the re-confirm - // timeout that unblocks the hung fetch. + // Let the gate chain schedule the re-confirm timeout before advancing it. + for (let index = 0; index < 12; index++) await Promise.resolve(); + }); + expect(backend.getRomRelaunchOptions).toHaveBeenCalledWith(42); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); - // The hung fetch timed out → re-confirm skipped (no set), logged, and the - // launch STILL fired. RunGame is the proof the button escaped "Launching…". + // The hung fetch timed out → no set and no launch. Returning to Play proves + // the current component is not trapped in its optimistic launching state. expect(vi.mocked(setLaunchOptionsConfirmed)).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("CustomPlayButton: launch_options re-confirm failed"), + expect.stringContaining("CustomPlayButton: launch_options re-confirm timed out"), ); - expect(vi.mocked(SteamClient.Apps.RunGame)).toHaveBeenCalledWith("gid-1", "", -1, 100); + expect(vi.mocked(SteamClient.Apps.RunGame)).not.toHaveBeenCalled(); + expect(getByText("Play")).toBeInTheDocument(); } finally { vi.useRealTimers(); logSpy.mockRestore(); } }); + + it("plugin teardown while relaunch options are pending releases the late token and never calls RunGame", async () => { + let resolveFetch!: (value: Awaited>) => void; + vi.mocked(backend.getRomRelaunchOptions).mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + + try { + await clickPlay(); + await waitFor(() => expect(backend.getRomRelaunchOptions).toHaveBeenCalledWith(42)); + await releaseAllPruneLeases(); + resolveFetch({ + success: true, + app_id: 100, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "late-plugin-launch-lease", + }); + await act(async () => { + for (let index = 0; index < 8; index++) await Promise.resolve(); + }); + + expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("late-plugin-launch-lease"); + expect(setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + expect(SteamClient.Apps.RunGame).not.toHaveBeenCalled(); + } finally { + mountPruneLeasePlugin(); + } + }); }); // --------------------------------------------------------------------------- diff --git a/src/components/CustomPlayButton.tsx b/src/components/CustomPlayButton.tsx index 71aab6bc..66876675 100644 --- a/src/components/CustomPlayButton.tsx +++ b/src/components/CustomPlayButton.tsx @@ -26,6 +26,7 @@ import { debugLog, preLaunchSync, getSaveStatus, + isCallableFailure, logError, isSaveTrackingConfigured, getSaveSetupInfo, @@ -53,6 +54,14 @@ import type { DownloadProgressEvent, DownloadCompleteEvent, DownloadFailedEvent import { SAVEFILES_IN_CONTENT_DIR_REASON } from "../types"; import { detach } from "../utils/detach"; import { setLaunchOptionsConfirmed } from "../utils/steamShortcuts"; +import { + capturePruneLeaseAdmission, + isPruneLeaseAdmissionCurrent, + mountPruneLeaseOwner, + releasePruneLeasesByOwner, + withPruneLease, + type PruneLeaseAdmission, +} from "../utils/pruneLease"; import { reconfirmLaunchOptions } from "../utils/launchOptionsReconcile"; import { saveSyncToastBody } from "../utils/saveSyncToast"; @@ -106,6 +115,7 @@ interface CustomPlayButtonProps { // Prettier from relocating the trailing comment into the body (which would break the suppression). // prettier-ignore export const CustomPlayButton: FC = ({ appId }) => { // NOSONAR(typescript:S3776) — remaining cc is the per-state render branching (download/dl_complete/uninstalling/launching/syncing/conflict/play each return a distinct button shape); the gate chain now lives in runLaunchGate, not here. + const leaseOwner = `custom-play-button:${appId}`; const [state, setState] = useState("loading"); const [romId, setRomId] = useState(null); const [romName, setRomName] = useState(""); @@ -121,6 +131,13 @@ export const CustomPlayButton: FC = ({ appId }) => { // N const containerRef = useRef(null); const transitionTimerRef = useRef | null>(null); + useEffect(() => { + mountPruneLeaseOwner(leaseOwner); + return () => { + detach(releasePruneLeasesByOwner(leaseOwner)); + }; + }, [leaseOwner]); + // Hide the native PlaySection via CSS while this component is mounted useEffect(() => { const cls = basicAppDetailsSectionStylerClasses?.PlaySection; @@ -514,12 +531,20 @@ export const CustomPlayButton: FC = ({ appId }) => { // N // shared skip-set immediately before RunGame so this RunGame does NOT re-enter // the global watcher and re-gate a launch that already ran the funnel (the // double-gate fix C1). - const dispatchLaunch = async (gameId: string) => { + const dispatchLaunch = async (gameId: string, admission: PruneLeaseAdmission) => { + if (!isPruneLeaseAdmissionCurrent(admission)) return; setState("launching"); // Heal any mid-session launch_options drift on this shortcut before launch - // (#1150) via the shared bounded-race re-confirm. Best-effort: a hang, a - // null item, or a failure still launches — no worse than today. - if (romId) await reconfirmLaunchOptions(romId, appId, "CustomPlayButton"); + // (#1150) via the shared bounded-race re-confirm. Ordinary I/O failures stay + // best-effort; timeout or plugin teardown cancels this launch. + if (romId) { + const reconfirm = await reconfirmLaunchOptions(romId, appId, "CustomPlayButton", admission); + if (reconfirm.status === "cancelled") return; + if (reconfirm.status === "timeout") { + setState("play"); + return; + } + } markLaunchSkipped(appId); SteamClient.Apps.RunGame(gameId, "", -1, 100); }; @@ -565,11 +590,12 @@ export const CustomPlayButton: FC = ({ appId }) => { // N if (state === "syncing" || state === "launching") return; // debounce const overview = appStore.GetAppOverviewByAppID(appId); const gameId = overview?.GetGameID?.() ?? String(appId); + const admission = capturePruneLeaseAdmission(leaseOwner); detach(debugLog(`CustomPlayButton: handlePlay appId=${appId} gameId=${gameId}`)); // Non-RomM / unresolved ROM — nothing to gate, launch straight through. if (!romId) { - await dispatchLaunch(gameId); + await dispatchLaunch(gameId, admission); return; } @@ -583,7 +609,7 @@ export const CustomPlayButton: FC = ({ appId }) => { // N // resulting RunGame doesn't re-enter the interceptor and re-gate either. if (getActiveSessionRomId() === romId || isAppRunning(appId)) { detach(debugLog(`CustomPlayButton: appId=${appId} already running — skipping pre-launch sync`)); - await dispatchLaunch(gameId); + await dispatchLaunch(gameId, admission); return; } @@ -598,7 +624,7 @@ export const CustomPlayButton: FC = ({ appId }) => { // N // fast reachability check), and `actOnVerdict` signals back "retry". try { let verdict = await runLaunchGate(appId, romId, makePlayButtonOps(romId)); - while ((await actOnVerdict(verdict, gameId, romId)) === "retry") { + while ((await actOnVerdict(verdict, gameId, romId, admission)) === "retry") { verdict = await runLaunchGate(appId, romId, makePlayButtonOps(romId)); } } catch (e) { @@ -613,10 +639,15 @@ export const CustomPlayButton: FC = ({ appId }) => { // N // Returns "retry" only from the offline-drift branch when the user asks to // re-probe — `handlePlay` loops on that and re-runs the gate; every other // outcome returns "done". - const actOnVerdict = async (verdict: GateVerdict, gameId: string, rid: number): Promise<"done" | "retry"> => { + const actOnVerdict = async ( + verdict: GateVerdict, + gameId: string, + rid: number, + admission: PruneLeaseAdmission, + ): Promise<"done" | "retry"> => { switch (verdict.decision) { case "allow": - await dispatchLaunch(gameId); + await dispatchLaunch(gameId, admission); return "done"; case "abort": case "block": @@ -633,13 +664,13 @@ export const CustomPlayButton: FC = ({ appId }) => { // N } // Conflicts resolved — notify sibling components to refresh, then launch. globalThis.dispatchEvent(new CustomEvent("romm_data_changed", { detail: { type: "save_sync", rom_id: rid } })); - await dispatchLaunch(gameId); + await dispatchLaunch(gameId, admission); return "done"; } case "offline_drift": { const choice = await showOfflineDriftModal(); if (choice === "start_anyway") { - await dispatchLaunch(gameId); + await dispatchLaunch(gameId, admission); return "done"; } if (choice === "retry") { @@ -655,7 +686,7 @@ export const CustomPlayButton: FC = ({ appId }) => { // N case "sync_failed": { const proceed = await showFallbackLaunchModal(verdict.message); if (proceed) { - await dispatchLaunch(gameId); + await dispatchLaunch(gameId, admission); return "done"; } setState("play"); @@ -730,6 +761,13 @@ export const CustomPlayButton: FC = ({ appId }) => { // N new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 15000)), ]); + if (isCallableFailure(result)) { + detach(debugLog(`CustomPlayButton: resolve conflict deferred: ${result.message}`)); + toaster.toast({ title: "RomM Sync", body: result.message }); + setState("conflict"); + return; + } + // A failed status read leaves every file "unknown" and an empty server // list; treating that as "resolved" would drop the user back to Play // believing the conflict was cleared. Surface it and stay in conflict, @@ -816,13 +854,23 @@ export const CustomPlayButton: FC = ({ appId }) => { // N if (!romId) return; detach(debugLog(`CustomPlayButton: uninstalling romId=${romId}`)); try { + const admission = capturePruneLeaseAdmission(leaseOwner); const result = await removeRom(romId); if (result.success) { // Reset the now-stale launch command to the uninstalled "" placeholder so a // raced-past not_installed launch execs `bin/rom-launcher` with no args (clean // exit 1) instead of a stale `flatpak run … ""` (#1051). Best-effort: // a launch-options hiccup must not turn a successful uninstall into an error. - await setLaunchOptionsConfirmed(appId, "").catch(() => false); + await withPruneLease( + result.prune_lease_token, + "ROM uninstall", + async (signal) => { + if (signal.aborted) return; + await setLaunchOptionsConfirmed(appId, "").catch(() => false); + }, + leaseOwner, + admission, + ); globalThis.dispatchEvent(new CustomEvent("romm_rom_uninstalled", { detail: { rom_id: romId } })); toaster.toast({ title: "RomM Sync", body: `${romName || "ROM"} uninstalled` }); // Dark pulse transition before showing Download button diff --git a/src/components/DangerZone.test.tsx b/src/components/DangerZone.test.tsx index c1b740a0..b7228fd4 100644 --- a/src/components/DangerZone.test.tsx +++ b/src/components/DangerZone.test.tsx @@ -355,6 +355,7 @@ describe("DangerZone", () => { app_ids: [11, 12], rom_ids: [1, 2], platform_name: "Super Nintendo", + prune_lease_token: "platform-removal-lease", }); const { getByText } = render(); await flushAsync(); @@ -371,8 +372,11 @@ describe("DangerZone", () => { expect(vi.mocked(backend.removePlatformShortcuts)).toHaveBeenCalledWith("snes"); expect(vi.mocked(removeShortcut)).toHaveBeenCalledWith(11); expect(vi.mocked(removeShortcut)).toHaveBeenCalledWith(12); - expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([1, 2]); - expect(vi.mocked(clearPlatformCollection)).toHaveBeenCalledWith("Super Nintendo"); + expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([1, 2], "platform-removal-lease"); + expect(vi.mocked(clearPlatformCollection)).toHaveBeenCalledWith("Super Nintendo", expect.any(AbortSignal)); + expect(vi.mocked(clearPlatformCollection).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(backend.reportRemovalResults).mock.invocationCallOrder[0]!, + ); }); it("falls back to p.name for clearPlatformCollection when platform_name is empty", async () => { @@ -394,7 +398,77 @@ describe("DangerZone", () => { await Promise.resolve(); await Promise.resolve(); }); - expect(vi.mocked(clearPlatformCollection)).toHaveBeenCalledWith("Super Nintendo"); + expect(vi.mocked(clearPlatformCollection)).toHaveBeenCalledWith("Super Nintendo", expect.any(AbortSignal)); + }); + + it("unmount aborts future removal work but retains the backend lease until the started Steam call settles", async () => { + vi.mocked(backend.getRegistryPlatforms).mockResolvedValue({ + platforms: [{ slug: "snes", name: "Super Nintendo", count: 1 }], + }); + vi.mocked(backend.removePlatformShortcuts).mockResolvedValue({ + success: true, + app_ids: [11], + rom_ids: [1], + platform_name: "Super Nintendo", + prune_lease_token: "danger-zone-lease", + }); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + let settle!: () => void; + const pendingCollection = new Promise((resolve) => { + settle = resolve; + }); + vi.mocked(clearPlatformCollection).mockReturnValueOnce(pendingCollection); + const view = render(); + await flushAsync(); + fireEvent.click(view.getByText("Super Nintendo (1)")); + lastShownModalProps<{ onRemoveShortcuts?: () => void }>()?.onRemoveShortcuts?.(); + await waitFor(() => expect(clearPlatformCollection).toHaveBeenCalled()); + + view.unmount(); + await Promise.resolve(); + expect(backend.releasePruneConflictLease).not.toHaveBeenCalledWith("danger-zone-lease"); + + settle(); + await waitFor(() => expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("danger-zone-lease")); + expect(clearPlatformCollection).toHaveBeenCalledTimes(1); + expect(backend.reportRemovalResults).not.toHaveBeenCalled(); + }); + + it("releases a late backend token without mutating Steam after unmount", async () => { + setupOnePlatform(); + let resolveRemoval!: (value: { + success: true; + app_ids: number[]; + rom_ids: number[]; + platform_name: string; + prune_lease_token: string; + }) => void; + vi.mocked(backend.removePlatformShortcuts).mockImplementation( + () => + new Promise((resolve) => { + resolveRemoval = resolve; + }), + ); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + const view = render(); + await flushAsync(); + fireEvent.click(view.getByText("Super Nintendo (2)")); + lastShownModalProps<{ onRemoveShortcuts?: () => void }>()?.onRemoveShortcuts?.(); + await waitFor(() => expect(backend.removePlatformShortcuts).toHaveBeenCalledWith("snes")); + + view.unmount(); + resolveRemoval({ + success: true, + app_ids: [11], + rom_ids: [1], + platform_name: "Super Nintendo", + prune_lease_token: "late-danger-zone-lease", + }); + + await waitFor(() => expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("late-danger-zone-lease")); + expect(removeShortcut).not.toHaveBeenCalled(); + expect(clearPlatformCollection).not.toHaveBeenCalled(); + expect(backend.reportRemovalResults).not.toHaveBeenCalled(); }); it("skips reportRemovalResults when rom_ids is empty", async () => { @@ -677,6 +751,7 @@ describe("DangerZone", () => { message: "Removed 5", app_ids: [10, 20], rom_ids: [1, 2], + prune_lease_token: "all-removal-lease", }); const { getByText, container } = render(); await flushAsync(); @@ -695,8 +770,11 @@ describe("DangerZone", () => { expect(vi.mocked(backend.removeAllShortcuts)).toHaveBeenCalledTimes(1); expect(vi.mocked(removeShortcut)).toHaveBeenCalledWith(10); expect(vi.mocked(removeShortcut)).toHaveBeenCalledWith(20); - expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([1, 2]); + expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([1, 2], "all-removal-lease"); expect(vi.mocked(clearAllRomMCollections)).toHaveBeenCalled(); + expect(vi.mocked(clearAllRomMCollections).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(backend.reportRemovalResults).mock.invocationCallOrder[0]!, + ); expect(container.textContent).toContain("Removed 5"); }); @@ -748,7 +826,7 @@ describe("DangerZone", () => { // 2 is in both lists → removed once, not twice: three calls total. expect(vi.mocked(removeShortcut)).toHaveBeenCalledTimes(3); // rom_ids stay the backend set exactly — orphans have no DB row. - expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([50, 51]); + expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([50, 51], null); // Non-vacuous: the one orphan not in the backend list is logged. expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("1 live-scanned RomM shortcut")); logSpy.mockRestore(); @@ -946,7 +1024,7 @@ describe("DangerZone", () => { }); // The breather elapsed → the last removal ran, THEN the post-removal steps. expect(vi.mocked(removeShortcut)).toHaveBeenCalledTimes(26); - expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([1, 2]); + expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([1, 2], null); expect(vi.mocked(clearPlatformCollection)).toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -985,7 +1063,7 @@ describe("DangerZone", () => { }); expect(vi.mocked(removeShortcut)).toHaveBeenCalledTimes(26); expect(vi.mocked(getLiveRomMShortcutAppIds)).toHaveBeenCalled(); - expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([7]); + expect(vi.mocked(backend.reportRemovalResults)).toHaveBeenCalledWith([7], null); expect(vi.mocked(clearAllRomMCollections)).toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -1813,8 +1891,8 @@ describe("DangerZone", () => { expect(getByText("Remove All RomM Shortcuts")).toBeDisabled(); expect(getByText("Uninstall All Installed ROMs")).toBeDisabled(); expect(getByText("Remove Orphaned Grid Images")).toBeDisabled(); - // All three disabled buttons carry the hint description. - expect(getAllByText(HINT)).toHaveLength(3); + // All four destructive actions carry the hint description. + expect(getAllByText(HINT)).toHaveLength(4); // Clicking the disabled buttons must not arm the confirm flow. fireEvent.click(getByText("Remove All RomM Shortcuts")); fireEvent.click(getByText("Uninstall All Installed ROMs")); diff --git a/src/components/DangerZone.tsx b/src/components/DangerZone.tsx index a430a2b4..91a0b382 100755 --- a/src/components/DangerZone.tsx +++ b/src/components/DangerZone.tsx @@ -37,6 +37,18 @@ import { formatUninstallStatus } from "../utils/formatters"; import type { RegistryPlatform } from "../types"; import { detach } from "../utils/detach"; import { fuzzyMatch } from "../utils/fuzzyMatch"; +import { RemovedGamesCleanupSection } from "./RemovedGamesCleanup"; +import { + capturePruneLeaseAdmission, + isPruneLeaseCancelled, + mountPruneLeaseOwner, + releasePruneLeasesByOwner, + withPruneLease, +} from "../utils/pruneLease"; +import { withTimeout } from "../utils/withTimeout"; + +const REMOVAL_REPORT_TIMEOUT_MS = 15000; +const DANGER_ZONE_LEASE_OWNER = "danger-zone"; const DEFAULT_WHITELIST_PATTERNS: string[] = [ "retrodeck", @@ -199,6 +211,7 @@ const ShortcutRemovalSection: FC = ({ runRemoval(async (onProgress) => { setActionStatus(`Removing ${p.name} shortcuts...`); try { + const admission = capturePruneLeaseAdmission(DANGER_ZONE_LEASE_OWNER); const result = await removePlatformShortcuts(p.slug); // The @migration_blocked / @sync_active_blocked gates short-circuit to // { success: false, message, ... } with no app_ids/rom_ids — surface @@ -207,11 +220,24 @@ const ShortcutRemovalSection: FC = ({ setActionStatus(result.message ?? "Failed to remove shortcuts"); return; } - await removeShortcutsPaced(result.app_ids ?? [], onProgress); - if (result.rom_ids?.length) { - await reportRemovalResults(result.rom_ids); - } - await clearPlatformCollection(result.platform_name || p.name); + await withPruneLease( + result.prune_lease_token, + "Platform shortcut removal", + async (signal) => { + await removeShortcutsPaced(result.app_ids ?? [], onProgress, signal); + if (isPruneLeaseCancelled(signal)) return; + await clearPlatformCollection(result.platform_name || p.name, signal); + if (isPruneLeaseCancelled(signal)) return; + if (result.rom_ids?.length || result.prune_lease_token) { + await withTimeout( + reportRemovalResults(result.rom_ids ?? [], result.prune_lease_token ?? null), + REMOVAL_REPORT_TIMEOUT_MS, + ); + } + }, + DANGER_ZONE_LEASE_OWNER, + admission, + ); setActionStatus(`Removed ${p.count} ${p.name} game${p.count === 1 ? "" : "s"}`); await refreshPlatforms(); loadNonSteamApps(); @@ -273,6 +299,7 @@ const ShortcutRemovalSection: FC = ({ setStatus("Removing all shortcuts..."); let removedCount = 0; try { + const admission = capturePruneLeaseAdmission(DANGER_ZONE_LEASE_OWNER); const result = await removeAllShortcuts(); if (!result.success) { // A gate refusal (@sync_active_blocked / @migration_blocked) carries @@ -285,30 +312,47 @@ const ShortcutRemovalSection: FC = ({ // so the backend never returns them. The live exe-ownership scan sees // them; remove the UNION so no orphan is left behind (#1381). const backendAppIds = result.app_ids ?? []; - await removeShortcutsPaced(backendAppIds, onProgress); const removed = new Set(backendAppIds); - const liveAppIds = await getLiveRomMShortcutAppIds(); - if (liveAppIds === null) { - // The scan could not run (Steam's shortcut store was unreadable) — - // fall back to the backend-bound list alone rather than skip removal. - logWarn("Live RomM shortcut scan unavailable — removed backend-bound shortcuts only."); - } else { - const orphans = liveAppIds.filter((appId) => !removed.has(appId)); - logInfo(`Remove-all: ${orphans.length} live-scanned RomM shortcut(s) were not in the backend list.`); - // Continue the counter across the orphan sweep: offset by the backend - // count so "removed of total" stays cumulative (orphans are usually none). - await removeShortcutsPaced(orphans, (done, orphanTotal) => - onProgress(backendAppIds.length + done, backendAppIds.length + orphanTotal), - ); - for (const appId of orphans) removed.add(appId); - } - removedCount = removed.size; - // rom_ids are backend DB rows — orphans have none, so report only the - // backend set exactly as before. - if (result.rom_ids?.length) { - await reportRemovalResults(result.rom_ids); - } - await clearAllRomMCollections(); + await withPruneLease( + result.prune_lease_token, + "All-shortcut removal", + async (signal) => { + await removeShortcutsPaced(backendAppIds, onProgress, signal); + if (isPruneLeaseCancelled(signal)) return; + const liveAppIds = await getLiveRomMShortcutAppIds(); + if (isPruneLeaseCancelled(signal)) return; + if (liveAppIds === null) { + // The scan could not run (Steam's shortcut store was unreadable) — + // fall back to the backend-bound list alone rather than skip removal. + logWarn("Live RomM shortcut scan unavailable — removed backend-bound shortcuts only."); + } else { + const orphans = liveAppIds.filter((appId) => !removed.has(appId)); + logInfo(`Remove-all: ${orphans.length} live-scanned RomM shortcut(s) were not in the backend list.`); + // Continue the counter across the orphan sweep: offset by the backend + // count so "removed of total" stays cumulative (orphans are usually none). + await removeShortcutsPaced( + orphans, + (done, orphanTotal) => onProgress(backendAppIds.length + done, backendAppIds.length + orphanTotal), + signal, + ); + if (isPruneLeaseCancelled(signal)) return; + for (const appId of orphans) removed.add(appId); + } + removedCount = removed.size; + await clearAllRomMCollections(signal); + if (isPruneLeaseCancelled(signal)) return; + // rom_ids are backend DB rows — orphans have none, so report only the + // backend set exactly as before. + if (result.rom_ids?.length || result.prune_lease_token) { + await withTimeout( + reportRemovalResults(result.rom_ids ?? [], result.prune_lease_token ?? null), + REMOVAL_REPORT_TIMEOUT_MS, + ); + } + }, + DANGER_ZONE_LEASE_OWNER, + admission, + ); setStatus(result.message ?? "All shortcuts removed"); } } catch { @@ -326,6 +370,7 @@ const ShortcutRemovalSection: FC = ({ } try { setUninstallStatus("Uninstalling..."); + const admission = capturePruneLeaseAdmission(DANGER_ZONE_LEASE_OWNER); const result = await uninstallAllRoms(); if (!result.success && result.app_ids === undefined) { // A gate refusal (@sync_active_blocked / @migration_blocked) carries no @@ -339,9 +384,17 @@ const ShortcutRemovalSection: FC = ({ // `flatpak run … ""` into a deleted path (#1146, mirrors the // single-ROM fix in #1051). Batched to avoid serializing the per-shortcut // confirm-poll timeouts; best-effort — a failed confirm is logged, not fatal. - await batchConfirmLaunchOptions( - (result.app_ids ?? []).map((appId) => ({ app_id: appId, launch_options: "" })), - "uninstall-all", + await withPruneLease( + result.prune_lease_token, + "Bulk uninstall", + (signal) => + batchConfirmLaunchOptions( + (result.app_ids ?? []).map((appId) => ({ app_id: appId, launch_options: "" })), + "uninstall-all", + signal, + ), + DANGER_ZONE_LEASE_OWNER, + admission, ); setUninstallStatus(formatUninstallStatus(result.removed_count ?? 0, (result.errors ?? []).length)); } @@ -891,6 +944,7 @@ export const DangerZone: FC = ({ onBack }) => { }; useEffect(() => { + mountPruneLeaseOwner(DANGER_ZONE_LEASE_OWNER); // eslint-disable-next-line react-hooks/set-state-in-effect -- initial async data loads on mount are the standard React pattern; the rule is overzealous here refreshPlatforms().catch((e) => logError(`Failed to refresh platforms: ${e}`)); loadNonSteamApps(); @@ -901,6 +955,9 @@ export const DangerZone: FC = ({ onBack }) => { setSettingsLoaded(true); }) .catch((e) => logError(`Failed to load whitelist settings: ${e}`)); + return () => { + detach(releasePruneLeasesByOwner(DANGER_ZONE_LEASE_OWNER)); + }; }, []); const persistWhitelist = (newDisabled: string[], newCustom: string[]) => { @@ -934,6 +991,8 @@ export const DangerZone: FC = ({ onBack }) => { )} + + = ({ size ); export const DiscSelector: FC = ({ appId }) => { + const leaseOwner = `disc-selector:${appId}`; const [selection, setSelection] = useState(null); // Locally-tracked pin: `selected` echoed by a successful selectDisc. Mirrors // the persisted `roms.selected_disc` (null = following the default). @@ -79,6 +86,7 @@ export const DiscSelector: FC = ({ appId }) => { // Initial load: resolve rom_id from cache (instant), then fetch selection. useEffect(() => { + mountPruneLeaseOwner(leaseOwner); let cancelled = false; async function init() { @@ -96,8 +104,9 @@ export const DiscSelector: FC = ({ appId }) => { detach(init()); return () => { cancelled = true; + detach(releasePruneLeasesByOwner(leaseOwner)); }; - }, [appId]); + }, [appId, leaseOwner]); // Re-fetch on download_complete (a newly installed ROM may now be multi-disc); // hide on uninstall. @@ -128,15 +137,26 @@ export const DiscSelector: FC = ({ appId }) => { const rid = romIdRef.current; if (rid == null) return; try { + const admission = capturePruneLeaseAdmission(leaseOwner); const result = await selectDisc(rid, data); - if (result.success) { - if (result.launch_options !== undefined) { - await setLaunchOptionsConfirmed(appId, result.launch_options); - } - setSelected(result.selected ?? null); - } else { - toaster.toast({ title: "RomM Sync", body: result.message || "Failed to select disc" }); - } + await withPruneLease( + result.prune_lease_token, + "DiscSelector", + async (signal) => { + if (result.success) { + if (result.launch_options !== undefined) { + if (signal.aborted) return; + await setLaunchOptionsConfirmed(appId, result.launch_options); + } + if (signal.aborted) return; + setSelected(result.selected ?? null); + } else { + toaster.toast({ title: "RomM Sync", body: result.message || "Failed to select disc" }); + } + }, + leaseOwner, + admission, + ); } catch (e) { // Observable catch effect: surface the failure so the user knows the pick // didn't take, and leave `selected` unchanged (revert to the prior pin). diff --git a/src/components/RemovedGamesCleanup.test.tsx b/src/components/RemovedGamesCleanup.test.tsx new file mode 100644 index 00000000..4aa749b1 --- /dev/null +++ b/src/components/RemovedGamesCleanup.test.tsx @@ -0,0 +1,455 @@ +import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import { createElement, type ReactElement } from "react"; +import { toaster } from "@decky/api"; +import { showModal } from "@decky/ui"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as backend from "../api/backend"; +import { resetPruneState, setPruneComplete, setPruneProgress } from "../utils/pruneStore"; +import { openRemovedGamesCleanupModal, RemovedGamesCleanupSection } from "./RemovedGamesCleanup"; + +const preview: backend.PrunePreviewResult = { + success: true, + preview_id: "preview-1", + scope: "bulk", + items: [ + { + rom_id: 7, + name: "Removed Game", + name_truncated: false, + fs_name: "Removed Game.gba", + fs_name_truncated: false, + platform_slug: "gba", + group_id: "group-1", + group_id_truncated: false, + group_size: 1, + bound_count: 0, + candidate: true, + installed: true, + installed_bytes: 200, + warning: null, + warning_truncated: false, + }, + ], + offset: 0, + limit: 50, + total: 1, + free_bytes: 100, + recovery_root: "/home/deck/decky-romm-sync-recovery", +}; + +function shownModal(): ReactElement { + const calls = vi.mocked(showModal).mock.calls; + const element = calls[calls.length - 1]?.[0] as ReactElement | undefined; + if (!element) throw new Error("Expected cleanup modal"); + return element; +} + +describe("RemovedGamesCleanup", () => { + beforeEach(() => { + vi.mocked(backend.getPrunePreview).mockReset(); + vi.mocked(backend.stagePruneInstalledSelection).mockReset(); + vi.mocked(backend.startPrune).mockReset(); + vi.mocked(showModal).mockReset(); + vi.mocked(toaster.toast).mockReset(); + resetPruneState(); + vi.mocked(backend.getPrunePreview).mockResolvedValue(preview); + vi.mocked(backend.stagePruneInstalledSelection).mockResolvedValue({ + success: true, + selection_id: "selection-1", + selected_count: 1, + finalized: true, + }); + vi.mocked(backend.startPrune).mockResolvedValue({ success: true, run_id: "run-1", status: "running" }); + }); + + it("performs the first local scan before showing the confirmation modal", async () => { + await expect(openRemovedGamesCleanupModal()).resolves.toBe(true); + + expect(vi.mocked(backend.getPrunePreview)).toHaveBeenCalledWith({ + scope: "bulk", + rom_id: null, + preview_id: null, + offset: 0, + limit: 50, + }); + expect(showModal).toHaveBeenCalledTimes(1); + }); + + it("returns false and shows no modal when the scan is empty", async () => { + vi.mocked(backend.getPrunePreview).mockResolvedValue({ ...preview, items: [], total: 0 }); + + await expect(openRemovedGamesCleanupModal(7)).resolves.toBe(false); + + expect(vi.mocked(backend.getPrunePreview)).toHaveBeenCalledWith({ + scope: "rom", + rom_id: 7, + preview_id: null, + offset: 0, + limit: 50, + }); + expect(showModal).not.toHaveBeenCalled(); + }); + + it("uses safe option defaults and blocks confirmation when selected content exceeds free space", async () => { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + const toggles = modal.getAllByTestId("toggle-input") as HTMLInputElement[]; + + expect(toggles.map((toggle) => toggle.checked)).toEqual([true, true, false, true, false]); + const confirm = modal.getByRole("button", { name: "Confirm Cleanup" }) as HTMLButtonElement; + expect(confirm.disabled).toBe(false); + expect(modal.container.textContent).toContain("Installed content is not backed up"); + + fireEvent.click(toggles[4]!); + + expect(confirm.disabled).toBe(true); + expect(modal.container.textContent).toContain("Not enough free space."); + }); + + it("submits all temporary options and re-enables confirmation after a failed start", async () => { + vi.mocked(backend.startPrune).mockRejectedValueOnce(new Error("bridge offline")); + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + const confirm = modal.getByRole("button", { name: "Confirm Cleanup" }) as HTMLButtonElement; + + fireEvent.click(confirm); + await waitFor(() => expect(modal.container.textContent).toContain("bridge offline")); + + expect(confirm.disabled).toBe(false); + expect(vi.mocked(backend.startPrune)).toHaveBeenCalledWith({ + preview_id: "preview-1", + confirmed: true, + repoint_shortcuts: true, + remove_rows: true, + remove_fully_vanished: false, + create_recovery_bundle: true, + installed_selection_id: null, + }); + }); + + it("stays open and renders live progress through completion", async () => { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + const confirm = modal.getByRole("button", { name: "Confirm Cleanup" }) as HTMLButtonElement; + + fireEvent.click(confirm); + await waitFor(() => expect(modal.container.textContent).toContain("Cleanup running...")); + expect(confirm.disabled).toBe(true); + + act(() => { + setPruneProgress({ + run_id: "run-1", + preview_id: "preview-1", + current: 1, + total: 2, + stage: "creating_recovery", + rom_ids: [7], + name: "Removed Game", + }); + }); + expect(modal.container.textContent).toContain("creating recovery · 1 of 2 · Removed Game"); + + act(() => { + setPruneComplete({ + success: true, + partial: false, + run_id: "run-1", + preview_id: "preview-1", + removed_rom_ids: [7], + affected_app_ids: [], + results: [{ group_id: "group-1", rom_ids: [7], status: "removed", message: "Removed." }], + }); + }); + expect(modal.container.textContent).toContain("1 removed; 0 skipped, partial, or failed."); + expect(modal.getByRole("button", { name: "Close" })).toBeTruthy(); + }); + + it("adopts a matching run frame when the successful start response is lost", async () => { + vi.useFakeTimers(); + vi.mocked(backend.startPrune).mockImplementation(() => new Promise(() => {})); + try { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + fireEvent.click(modal.getByRole("button", { name: "Confirm Cleanup" })); + await act(async () => Promise.resolve()); + + act(() => { + setPruneProgress({ + run_id: "adopted-run", + preview_id: "preview-1", + current: 1, + total: 2, + stage: "checking", + rom_ids: [7], + name: "Removed Game", + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + expect(modal.container.textContent).toContain("Cleanup running..."); + expect(modal.container.textContent).toContain("checking · 1 of 2 · Removed Game"); + expect(modal.container.textContent).not.toContain("Cleanup could not start"); + } finally { + vi.useRealTimers(); + } + }); + + it("shows an enabled Close control immediately when completion wins the start-response race", async () => { + vi.mocked(backend.startPrune).mockImplementation(() => new Promise(() => {})); + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + fireEvent.click(modal.getByRole("button", { name: "Confirm Cleanup" })); + await act(async () => Promise.resolve()); + + act(() => { + setPruneComplete({ + success: true, + partial: false, + run_id: "event-first-run", + preview_id: "preview-1", + removed_rom_ids: [7], + affected_app_ids: [], + results: [], + }); + }); + + const close = modal.getByRole("button", { name: "Close" }) as HTMLButtonElement; + expect(close.disabled).toBe(false); + expect(modal.queryByRole("button", { name: "Cancel" })).toBeNull(); + expect(modal.container.textContent).toContain("1 removed"); + }); + + it("surfaces scan rejection and re-enables the Danger Zone action", async () => { + vi.mocked(backend.getPrunePreview).mockRejectedValue(new Error("offline")); + const section = render(createElement(RemovedGamesCleanupSection)); + const button = section.getByRole("button", { name: "Clean Up Removed RomM Games" }) as HTMLButtonElement; + + await act(async () => { + fireEvent.click(button); + }); + await waitFor(() => + expect(toaster.toast).toHaveBeenCalledWith({ + title: "RomM Sync", + body: "Could not scan removed RomM games.", + }), + ); + + expect(button.disabled).toBe(false); + }); + + it("requires every preview page to be disclosed before confirmation", async () => { + vi.mocked(backend.getPrunePreview) + .mockResolvedValueOnce({ ...preview, total: 2 }) + .mockResolvedValueOnce({ + ...preview, + offset: 1, + total: 2, + items: [{ ...preview.items![0]!, rom_id: 8, candidate: false, name: "Current sibling" }], + }); + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + const confirm = modal.getByRole("button", { name: "Confirm Cleanup" }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + expect(modal.container.textContent).toContain("Load every page before confirming"); + + fireEvent.click(modal.getByRole("button", { name: "Load more (1 of 2)" })); + await waitFor(() => expect(modal.container.textContent).toContain("Current sibling")); + expect(confirm.disabled).toBe(false); + }); + + it("clears and disables installed-content selections when recovery is off", async () => { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + let toggles = modal.getAllByTestId("toggle-input") as HTMLInputElement[]; + fireEvent.click(toggles[4]!); + expect(toggles[4]!.checked).toBe(true); + + fireEvent.click(toggles[3]!); + await waitFor(() => { + toggles = modal.getAllByTestId("toggle-input") as HTMLInputElement[]; + const content = toggles[toggles.length - 1]!; + expect(content.checked).toBe(false); + expect(modal.container.textContent).toContain("Selected ROM-content recovery estimate: 0 B"); + }); + expect(modal.container.textContent).toContain("Installed content is not backed up"); + }); + + it("allows a repoint-only run and resets an old completion when opening", async () => { + setPruneComplete({ + success: true, + partial: false, + run_id: "old", + preview_id: "preview-old", + removed_rom_ids: [99], + affected_app_ids: [], + results: [], + }); + await openRemovedGamesCleanupModal(7); + const modal = render(shownModal()); + const toggles = modal.getAllByTestId("toggle-input") as HTMLInputElement[]; + fireEvent.click(toggles[1]!); + const confirm = modal.getByRole("button", { name: "Confirm Cleanup" }) as HTMLButtonElement; + expect(confirm.disabled).toBe(false); + + fireEvent.click(confirm); + await waitFor(() => expect(backend.startPrune).toHaveBeenCalled()); + expect(vi.mocked(backend.startPrune).mock.calls[0]?.[0].remove_rows).toBe(false); + expect(modal.container.textContent).toContain("selected unavailable ROM"); + }); + + it("refreshes the free-space snapshot without replacing the preview", async () => { + vi.mocked(backend.getPrunePreview) + .mockResolvedValueOnce(preview) + .mockResolvedValueOnce({ ...preview, items: [], limit: 0, free_bytes: 500 }); + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + + fireEvent.click(modal.getByRole("button", { name: "Refresh free space" })); + await waitFor(() => expect(modal.container.textContent).toContain("Free at target: 500 B")); + expect(vi.mocked(backend.getPrunePreview).mock.calls[1]?.[0]).toMatchObject({ + preview_id: "preview-1", + limit: 0, + }); + }); + + it("stages every installed selection above 256 in bounded pages", async () => { + const items = Array.from({ length: 257 }, (_, index) => ({ + ...preview.items![0]!, + rom_id: index + 1, + installed_bytes: 0, + name: `Game ${index + 1}`, + })); + vi.mocked(backend.getPrunePreview).mockResolvedValue({ ...preview, items, total: items.length, free_bytes: 1 }); + vi.mocked(backend.stagePruneInstalledSelection).mockImplementation(async (request) => ({ + success: true, + selection_id: "selection-many", + selected_count: request.rom_ids[request.rom_ids.length - 1] ?? 0, + finalized: request.final, + })); + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + const toggles = modal.getAllByTestId("toggle-input") as HTMLInputElement[]; + act(() => { + for (const toggle of toggles.slice(4)) toggle.click(); + }); + + fireEvent.click(modal.getByRole("button", { name: "Confirm Cleanup" })); + await waitFor(() => expect(backend.startPrune).toHaveBeenCalled()); + + const pages = vi.mocked(backend.stagePruneInstalledSelection).mock.calls.map(([request]) => request); + expect(pages.map((page) => page.rom_ids.length)).toEqual([100, 100, 57]); + expect(pages.map((page) => page.selection_id)).toEqual([null, "selection-many", "selection-many"]); + expect(pages.map((page) => page.final)).toEqual([false, false, true]); + expect(vi.mocked(backend.startPrune).mock.calls[0]?.[0].installed_selection_id).toBe("selection-many"); + }, 30_000); + + it("surfaces bounded save warnings in terminal details", async () => { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + fireEvent.click(modal.getByRole("button", { name: "Confirm Cleanup" })); + await waitFor(() => expect(modal.container.textContent).toContain("Cleanup running...")); + + act(() => { + setPruneComplete({ + success: false, + partial: true, + run_id: "run-1", + preview_id: "preview-1", + removed_rom_ids: [], + affected_app_ids: [], + results: [ + { + group_id: "group-1", + rom_ids: [7], + status: "partial", + message: "Local cleanup was incomplete.", + message_truncated: true, + warnings: ["Shared save was retained."], + warning_count: 7, + warnings_omitted: true, + warnings_truncated: true, + }, + ], + }); + }); + + expect(modal.container.textContent).toContain("Warning: Shared save was retained."); + expect(modal.container.textContent).toContain("6 additional warning(s) omitted."); + expect(modal.container.textContent).toContain("One or more displayed warnings were shortened."); + expect(modal.container.textContent).toContain("Detail was shortened"); + const details = modal.getByRole("region", { name: "Cleanup details" }); + expect(details.getAttribute("tabindex")).toBe("0"); + expect(modal.getByRole("status").textContent).not.toContain("Shared save was retained"); + }); + + it("does not call omitted short warnings shortened", async () => { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + fireEvent.click(modal.getByRole("button", { name: "Confirm Cleanup" })); + await waitFor(() => expect(modal.container.textContent).toContain("Cleanup running...")); + + act(() => { + setPruneComplete({ + success: false, + partial: true, + run_id: "run-1", + preview_id: "preview-1", + removed_rom_ids: [], + affected_app_ids: [], + results: [ + { + group_id: "group-1", + rom_ids: [7], + status: "partial", + message: "Local cleanup was incomplete.", + warnings: ["One", "Two", "Three", "Four", "Five"], + warning_count: 6, + warnings_omitted: true, + warnings_truncated: false, + }, + ], + }); + }); + + expect(modal.container.textContent).toContain("1 additional warning(s) omitted."); + expect(modal.container.textContent).not.toContain("displayed warnings were shortened"); + }); + + it("surfaces warnings from successful removals and a run-level terminal error", async () => { + await openRemovedGamesCleanupModal(); + const modal = render(shownModal()); + fireEvent.click(modal.getByRole("button", { name: "Confirm Cleanup" })); + await waitFor(() => expect(modal.container.textContent).toContain("Cleanup running...")); + + act(() => { + setPruneComplete({ + success: false, + partial: true, + run_id: "run-1", + preview_id: "preview-1", + reason: "unknown", + message: "The run stopped after committed work.", + removed_rom_ids: [7], + affected_app_ids: [], + results: [ + { + group_id: "group-1", + rom_ids: [7], + status: "removed", + message: "Removed.", + warnings: ["A shared save was retained."], + warning_count: 1, + warnings_truncated: true, + }, + ], + }); + }); + + expect(modal.container.textContent).toContain("unknown: The run stopped after committed work."); + expect(modal.container.textContent).toContain("Warning: A shared save was retained."); + expect(modal.container.textContent).toContain("One or more displayed warnings were shortened."); + expect(modal.container.textContent).not.toContain("0 additional warning(s)"); + }); +}); diff --git a/src/components/RemovedGamesCleanup.tsx b/src/components/RemovedGamesCleanup.tsx new file mode 100644 index 00000000..88ec98f1 --- /dev/null +++ b/src/components/RemovedGamesCleanup.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState, FC } from "react"; +import { toaster } from "@decky/api"; +import { + ButtonItem, + DialogButton, + Field, + Focusable, + ModalRoot, + PanelSection, + PanelSectionRow, + ToggleField, + showModal, +} from "@decky/ui"; +import { + getPrunePreview, + startPrune, + stagePruneInstalledSelection, + logError, + type PrunePreviewItem, + type PrunePreviewRequest, + type PrunePreviewResult, + type PruneScope, +} from "../api/backend"; +import { detach } from "../utils/detach"; +import { beginPrunePreview, beginPruneRun, getPruneState, onPruneStateChange } from "../utils/pruneStore"; +import { getSyncProgress, onSyncProgressChange } from "../utils/syncProgress"; +import { withTimeout } from "../utils/withTimeout"; + +const PAGE_SIZE = 50; +const SELECTION_PAGE_SIZE = 100; +const PRUNE_CALLABLE_TIMEOUT_MS = 15000; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes / 1024; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index++; + } + return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`; +} + +function requestFor( + scope: PruneScope, + romId: number | null, + previewId: string | null, + offset: number, +): PrunePreviewRequest { + return { scope, rom_id: romId, preview_id: previewId, offset, limit: PAGE_SIZE }; +} + +interface CleanupModalProps { + initial: PrunePreviewResult; + scope: PruneScope; + romId: number | null; + closeModal?: () => void; +} + +const CleanupModal: FC = ({ initial, scope, romId, closeModal }) => { + const [items, setItems] = useState(initial.items ?? []); + const [loadingMore, setLoadingMore] = useState(false); + const [starting, setStarting] = useState(false); + const [runStarted, setRunStarted] = useState(false); + const [status, setStatus] = useState(""); + const [repoint, setRepoint] = useState(true); + const [removeRows, setRemoveRows] = useState(true); + const [removeDeadGames, setRemoveDeadGames] = useState(false); + const [recovery, setRecovery] = useState(true); + const [confirmWithoutRecovery, setConfirmWithoutRecovery] = useState(false); + const [includedContent, setIncludedContent] = useState>(new Set()); + const [freeBytes, setFreeBytes] = useState(initial.free_bytes ?? 0); + const [pruneState, setPruneState] = useState(getPruneState()); + + useEffect(() => { + const unsubscribe = onPruneStateChange(() => setPruneState(getPruneState())); + return () => { + unsubscribe(); + }; + }, []); + + const total = initial.total ?? items.length; + const selectedBytes = items.reduce( + (sum, item) => sum + (includedContent.has(item.rom_id) ? (item.installed_bytes ?? 0) : 0), + 0, + ); + const unknownSelectedSize = items.some( + (item) => includedContent.has(item.rom_id) && item.installed && item.installed_bytes === null, + ); + const insufficientSpace = recovery && (unknownSelectedSize || selectedBytes > freeBytes); + const destructiveConfirmed = recovery || confirmWithoutRecovery; + const allEntriesLoaded = items.length === total; + const progress = pruneState.progress; + const complete = pruneState.complete; + const runInFlight = complete === null && (starting || pruneState.runId !== null); + const canStart = + !runInFlight && + complete === null && + allEntriesLoaded && + !insufficientSpace && + destructiveConfirmed && + (repoint || removeRows || removeDeadGames); + + const toggleContent = (romIdToToggle: number, checked: boolean): void => { + setIncludedContent((current) => { + const next = new Set(current); + if (checked) next.add(romIdToToggle); + else next.delete(romIdToToggle); + return next; + }); + }; + + const loadMore = async (): Promise => { + if (!initial.preview_id || items.length >= total) return; + setLoadingMore(true); + try { + const next = await withTimeout( + getPrunePreview(requestFor(scope, romId, initial.preview_id, items.length)), + PRUNE_CALLABLE_TIMEOUT_MS, + ); + if (!next.success) { + setStatus(next.message ?? "Could not load more candidates."); + return; + } + setItems((current) => [...current, ...(next.items ?? [])]); + if (typeof next.free_bytes === "number") setFreeBytes(next.free_bytes); + } catch (e) { + setStatus(`Could not load more candidates: ${e}`); + } finally { + setLoadingMore(false); + } + }; + + const refreshFreeSpace = async (): Promise => { + if (!initial.preview_id) return; + try { + const refreshed = await withTimeout( + getPrunePreview({ + scope, + rom_id: romId, + preview_id: initial.preview_id, + offset: 0, + limit: 0, + }), + PRUNE_CALLABLE_TIMEOUT_MS, + ); + if (!refreshed.success || typeof refreshed.free_bytes !== "number") { + setStatus(refreshed.message ?? "Could not refresh recovery space."); + return; + } + setFreeBytes(refreshed.free_bytes); + } catch (e) { + setStatus(`Could not refresh recovery space: ${e}`); + } + }; + + const start = async (): Promise => { + if (!canStart || !initial.preview_id) return; + setStarting(true); + setStatus( + includedContent.size + ? `Staging ${includedContent.size} installed-content selection(s)...` + : "Starting cleanup...", + ); + try { + let selectionId: string | null = null; + const selected = [...includedContent]; + for (let offset = 0; offset < selected.length; offset += SELECTION_PAGE_SIZE) { + const page = selected.slice(offset, offset + SELECTION_PAGE_SIZE); + const staged: { + success: boolean; + selection_id?: string; + message?: string; + } = await withTimeout( + stagePruneInstalledSelection({ + preview_id: initial.preview_id, + selection_id: selectionId, + rom_ids: page, + final: offset + page.length >= selected.length, + }), + PRUNE_CALLABLE_TIMEOUT_MS, + ); + if (!staged.success || !staged.selection_id) { + setStatus(staged.message ?? "Installed-content selections could not be staged."); + return; + } + selectionId = staged.selection_id; + } + const result = await withTimeout( + startPrune({ + preview_id: initial.preview_id, + confirmed: true, + repoint_shortcuts: repoint, + remove_rows: removeRows, + remove_fully_vanished: removeDeadGames, + create_recovery_bundle: recovery, + installed_selection_id: selectionId, + }), + PRUNE_CALLABLE_TIMEOUT_MS, + ); + if (!result.success) { + setStatus(result.message ?? "Cleanup could not start."); + return; + } + beginPruneRun(result.run_id!, initial.preview_id); + setRunStarted(true); + setStatus("Cleanup running..."); + } catch (e) { + const adopted = getPruneState(); + if (adopted.runId !== null) { + setRunStarted(true); + setStatus(adopted.complete ? "Cleanup completed." : "Cleanup running..."); + } else { + setStatus(`Cleanup could not start: ${e}`); + } + } finally { + setStarting(false); + } + }; + + return ( + +
+
Clean Up Removed RomM Games
+
+ {scope === "bulk" + ? `${total} local group entr${total === 1 ? "y" : "ies"} may be affected by candidates absent from a completed RomM fetch.` + : `${total} local group entr${total === 1 ? "y" : "ies"} may be affected by cleanup of the selected unavailable ROM.`}{" "} + Every entry is checked again by exact ID; only a confirmed 404 can be removed. +
+ +
+ + + + { + setRecovery(checked); + if (checked) setConfirmWithoutRecovery(false); + else setIncludedContent(new Set()); + }} + /> + {!recovery && ( + + )} + +
Candidates
+ {items.map((item) => ( +
+
{item.name || item.fs_name || `ROM ${item.rom_id}`}
+ {(item.name_truncated || item.fs_name_truncated || item.group_id_truncated || item.warning_truncated) && ( +
+ One or more display fields were shortened to keep this preview page within the Decky wire limit. +
+ )} +
+ {item.platform_slug} · ROM {item.rom_id} · group of {item.group_size} ·{" "} + {item.candidate ? "cleanup candidate" : "group member disclosed for whole-game removal"} +
+ {item.warning &&
{item.warning}
} + {item.installed && ( + toggleContent(item.rom_id, checked)} + /> + )} + {item.installed && (!recovery || !includedContent.has(item.rom_id)) && ( +
+ Installed content is not backed up but will still be deleted if this row is removed. +
+ )} +
+ ))} + {items.length < total && ( + detach(loadMore())}> + {loadingMore ? "Loading..." : `Load more (${items.length} of ${total})`} + + )} + {!allEntriesLoaded && ( +
+ Load every page before confirming so all potentially removed group members and installed content are + disclosed. +
+ )} +
+ +
+ Selected ROM-content recovery estimate: {formatBytes(selectedBytes)} · Free at target:{" "} + {formatBytes(freeBytes)} +
+ This is a lower bound. The backend remeasures mandatory saves, histories, caches, and Steam files before any + mutation. +
+
+ Large installed-content selections are staged in bounded pages before cleanup; every checked item remains + part of this run. +
+ detach(refreshFreeSpace())}> + Refresh free space + + {insufficientSpace && ( +
+ {unknownSelectedSize ? "A selected installed ROM has no safe measurable size." : "Not enough free space."} +
+ )} +
+ {progress && ( +
+ {progress.stage.replace(/_/g, " ")} · {progress.current} of {progress.total} · {progress.name} +
+ )} + {complete && ( +
+
+ {complete.removed_count ?? complete.removed_rom_ids.length} removed;{" "} + {complete.problem_count ?? + complete.results.filter((item) => ["partial", "failed", "skipped"].includes(item.status)).length}{" "} + skipped, partial, or failed. +
+ + {complete.message && ( +
+ {complete.reason ? `${complete.reason}: ` : ""} + {complete.message} +
+ )} + {complete.results + .filter( + (item) => + ["partial", "failed", "skipped"].includes(item.status) || + (item.warnings?.length ?? 0) > 0 || + item.warnings_omitted || + item.warnings_truncated, + ) + .map((item) => ( +
+ {item.group_id}: {item.message} + {item.message_truncated &&
Detail was shortened to fit the Decky wire limit.
} + {item.warnings?.map((warning) => ( +
Warning: {warning}
+ ))} + {item.warnings_omitted && (item.warning_count ?? 0) > (item.warnings?.length ?? 0) && ( +
+ {(item.warning_count ?? 0) - (item.warnings?.length ?? 0)} additional warning(s) omitted. +
+ )} + {item.warnings_truncated &&
One or more displayed warnings were shortened.
} +
+ ))} +
+
+ )} + {status && !complete && ( +
+ {status} +
+ )} +
+ closeModal?.()}> + {runStarted || complete !== null ? "Close" : "Cancel"} + + detach(start())}> + {starting ? "Starting..." : progress ? `${progress.stage.replace(/_/g, " ")}...` : "Confirm Cleanup"} + +
+
+
+ ); +}; + +export async function openRemovedGamesCleanupModal(romId?: number): Promise { + const scope: PruneScope = romId === undefined ? "bulk" : "rom"; + const result = await withTimeout( + getPrunePreview(requestFor(scope, romId ?? null, null, 0)), + PRUNE_CALLABLE_TIMEOUT_MS, + ); + if (!result.success) throw new Error(result.message ?? "Cleanup scan failed."); + if ((result.total ?? 0) === 0) return false; + beginPrunePreview(result.preview_id!); + showModal(); + return true; +} + +export const RemovedGamesCleanupSection: FC = () => { + const [scanning, setScanning] = useState(false); + const [syncRunning, setSyncRunning] = useState(getSyncProgress().running); + const [pruneState, setPruneState] = useState(getPruneState()); + + useEffect(() => { + const unsubscribeSync = onSyncProgressChange(() => setSyncRunning(getSyncProgress().running)); + const unsubscribePrune = onPruneStateChange(() => setPruneState(getPruneState())); + return () => { + unsubscribeSync(); + unsubscribePrune(); + }; + }, []); + + const scan = async (): Promise => { + setScanning(true); + try { + if (!(await openRemovedGamesCleanupModal())) { + toaster.toast({ title: "RomM Sync", body: "No removed RomM entries were found." }); + } + } catch (e) { + logError(`Removed-game cleanup scan failed: ${e}`); + toaster.toast({ title: "RomM Sync", body: "Could not scan removed RomM games." }); + } finally { + setScanning(false); + } + }; + + const progress = pruneState.progress; + const complete = pruneState.complete; + return ( + + + detach(scan())} + > + {scanning ? "Scanning..." : "Clean Up Removed RomM Games"} + + + {progress && ( + + + + )} + {complete && ( + + ["partial", "failed", "skipped"].includes(item.status)).length} skipped, partial, or failed`} + description={[ + complete.message, + ...complete.results + .filter((item) => item.status !== "removed" || (item.warnings?.length ?? 0) > 0) + .flatMap((item) => [item.message, ...(item.warnings ?? []).map((warning) => `Warning: ${warning}`)]), + ] + .filter(Boolean) + .join(" · ")} + /> + + )} + + ); +}; diff --git a/src/components/RomMGameInfoPanel.test.tsx b/src/components/RomMGameInfoPanel.test.tsx index 2558f0f0..ff003878 100644 --- a/src/components/RomMGameInfoPanel.test.tsx +++ b/src/components/RomMGameInfoPanel.test.tsx @@ -948,6 +948,65 @@ describe("RomMGameInfoPanel", () => { expect(latest?.saveStatus?.files[0]?.filename).toBe("FROM_DISPATCH.srm"); }); + it("save_sync: prune-active status preserves the previously displayed save state", async () => { + vi.mocked(backend.isSaveTrackingConfigured).mockResolvedValue({ configured: true, active_slot: "main" }); + await mountWithRomId(33); + const knownStatus = { + rom_id: 33, + files: [ + { + filename: "KNOWN_CONFLICT.srm", + status: "conflict" as const, + local_path: null, + local_hash: null, + local_mtime: null, + local_size: null, + server_save_id: null, + server_file_name: null, + server_emulator: null, + server_updated_at: null, + server_size: null, + last_sync_at: null, + }, + ], + playtime: { + total_seconds: 0, + session_count: 0, + last_session_start: null, + last_session_duration_sec: null, + last_played: null, + }, + device_id: "d", + last_sync_check_at: null, + conflicts: [], + }; + await act(async () => { + globalThis.dispatchEvent( + new CustomEvent("romm_data_changed", { + detail: { type: "save_sync", rom_id: 33, save_status: knownStatus }, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + vi.mocked(backend.getSaveStatus).mockResolvedValue({ + success: false, + reason: "prune_active", + message: "Cleanup is active.", + }); + + await act(async () => { + globalThis.dispatchEvent(new CustomEvent("romm_data_changed", { detail: { type: "save_sync", rom_id: 33 } })); + await Promise.resolve(); + await Promise.resolve(); + globalThis.dispatchEvent(new CustomEvent("romm_tab_switch", { detail: { tab: "saves" } })); + await Promise.resolve(); + }); + + const latest = capturedSavesTab[capturedSavesTab.length - 1]; + expect(latest?.saveStatus?.files[0]?.filename).toBe("KNOWN_CONFLICT.srm"); + }); + it("save_sync: mismatching rom_id → early return", async () => { await mountWithRomId(33); vi.mocked(backend.getSaveStatus).mockClear(); diff --git a/src/components/RomMGameInfoPanel.tsx b/src/components/RomMGameInfoPanel.tsx index 1cbd1e73..e6802c59 100644 --- a/src/components/RomMGameInfoPanel.tsx +++ b/src/components/RomMGameInfoPanel.tsx @@ -27,6 +27,7 @@ import { checkPlatformBios, getPlatformCoreInfo, getSaveStatus, + isCallableFailure, getArtworkBase64, getAchievements, getAchievementProgress, @@ -495,7 +496,9 @@ export const RomMGameInfoPanel: FC = ({ appId }) => { // } const romId = romIdRef.current; if (!romId) return; - const updatedStatus = await getSaveStatus(romId).catch((): SaveStatus | null => null); + const result = await getSaveStatus(romId).catch(() => null); + if (result && isCallableFailure(result)) return; + const updatedStatus: SaveStatus | null = result; const conflicts: SyncConflict[] = updatedStatus?.conflicts ?? []; setState((prev) => ({ ...prev, @@ -509,8 +512,9 @@ export const RomMGameInfoPanel: FC = ({ appId }) => { // if (detail.rom_id && detail.rom_id !== romIdRef.current) return; const romId = romIdRef.current; if (!romId) return; - const updatedStatus: SaveStatus | null = - detail.save_status ?? (await getSaveStatus(romId).catch((): SaveStatus | null => null)); + const result = detail.save_status ?? (await getSaveStatus(romId).catch(() => null)); + if (result && isCallableFailure(result)) return; + const updatedStatus: SaveStatus | null = result; const conflicts: SyncConflict[] = updatedStatus?.conflicts ?? []; setState((prev) => ({ ...prev, diff --git a/src/components/RomMPlaySection.test.tsx b/src/components/RomMPlaySection.test.tsx index 1da0e364..df06d40c 100644 --- a/src/components/RomMPlaySection.test.tsx +++ b/src/components/RomMPlaySection.test.tsx @@ -1186,6 +1186,21 @@ describe("RomMPlaySection", () => { expect(vi.mocked(updatePlaytimeDisplay)).not.toHaveBeenCalled(); }); + it("a resolved prune gate failure never writes a playtime payload", async () => { + vi.mocked(cachedStore.getCachedGameDetail).mockResolvedValue({ found: true, rom_id: 78 }); + vi.mocked(backend.reconcilePlaytime).mockResolvedValue({ + success: false, + reason: "prune_active", + message: "Cleanup is active.", + }); + + render(); + await flushAsync(); + + expect(vi.mocked(updatePlaytimeDisplay)).not.toHaveBeenCalled(); + expect(vi.mocked(backend.debugLog)).toHaveBeenCalledWith(expect.stringContaining("playtime reconcile deferred")); + }); + it("#869 — a romm_playtime_changed event refreshes PLAYTIME on the same mount (no remount)", async () => { useDistinctPlaytimeFormatter(); // Mount with a stale local total of 10 minutes. @@ -1397,6 +1412,31 @@ describe("RomMPlaySection", () => { ); }); + it("save_sync_settings: prune-active status leaves the existing display untouched", async () => { + vi.mocked(cachedStore.getCachedGameDetail).mockResolvedValue({ found: true, rom_id: 55 }); + render(); + await flushAsync(); + vi.mocked(playSectionUtils.applySaveSyncDisplay).mockClear(); + vi.mocked(backend.getSaveStatus).mockResolvedValue({ + success: false, + reason: "prune_active", + message: "Cleanup is active.", + }); + + await act(async () => { + globalThis.dispatchEvent( + new CustomEvent("romm_data_changed", { + detail: { type: "save_sync_settings", save_sync_enabled: true }, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backend.getSaveStatus).toHaveBeenCalledWith(55); + expect(playSectionUtils.applySaveSyncDisplay).not.toHaveBeenCalled(); + }); + it("save_sync_settings: enabled=true with no rom_id → skips getSaveStatus", async () => { // cached.found=false → romIdRef stays null render(); @@ -2306,7 +2346,10 @@ describe("RomMPlaySection", () => { vi.mocked(backend.removeRom).mockResolvedValue({ success: true, message: "removed", + prune_lease_token: "uninstall-lease", }); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + vi.mocked(setLaunchOptionsConfirmed).mockClear(); vi.mocked(toaster.toast).mockClear(); const listener = vi.fn(); globalThis.addEventListener("romm_rom_uninstalled", listener); @@ -2322,6 +2365,11 @@ describe("RomMPlaySection", () => { await uninstall.props.onClick?.(); }); expect(vi.mocked(backend.removeRom)).toHaveBeenCalledWith(42); + expect(setLaunchOptionsConfirmed).toHaveBeenCalledWith(testAppId, ""); + expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("uninstall-lease"); + expect(vi.mocked(setLaunchOptionsConfirmed).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(backend.releasePruneConflictLease).mock.invocationCallOrder[0]!, + ); const ev = listener.mock.calls[0]?.[0] as CustomEvent; expect(ev.detail).toEqual({ rom_id: 42 }); expect(vi.mocked(toaster.toast)).toHaveBeenCalledWith(expect.objectContaining({ body: "Mario uninstalled" })); diff --git a/src/components/RomMPlaySection.tsx b/src/components/RomMPlaySection.tsx index 065e4276..caf190aa 100644 --- a/src/components/RomMPlaySection.tsx +++ b/src/components/RomMPlaySection.tsx @@ -29,7 +29,7 @@ import { DiscSelector } from "./DiscSelector"; import { VersionPicker } from "./VersionPicker"; import { WarningCard } from "./WarningCard"; import { SgdbGamePickerModalContent } from "./SgdbGamePickerModal"; -import { applyArtwork } from "../utils/artwork"; +import { applyArtwork, cancelArtworkApply } from "../utils/artwork"; import { hasAnySaveConflict } from "../utils/saveStatus"; import { saveSyncToastBody } from "../utils/saveSyncToast"; import { scrollToTop } from "../utils/scrollHelpers"; @@ -40,6 +40,7 @@ import { testConnection, probeReachability, getSaveStatus, + isCallableFailure, getBiosStatus, getPlatformCoreInfo, getSgdbResolution, @@ -56,6 +57,14 @@ import { logError, } from "../api/backend"; import { setLaunchOptionsConfirmed } from "../utils/steamShortcuts"; +import { + capturePruneLeaseAdmission, + isPruneLeaseCancelled, + mountPruneLeaseOwner, + releasePruneLeasesByOwner, + withPruneLease, + type PruneLeaseAdmission, +} from "../utils/pruneLease"; import { updatePlaytimeDisplay } from "../patches/metadataPatches"; import { buildEmulatorMenu } from "../utils/emulatorMenu"; import type { BiosStatus, DownloadCompleteEvent, EmulatorOption, SaveStatus } from "../types"; @@ -298,6 +307,7 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS // Cache-first load: render instantly from cached data, then check connection in background useEffect(() => { + mountPruneLeaseOwner(`game-detail:${appId}`); let cancelled = false; detach(loadCached(appId, () => cancelled, romIdRef, setInfo)); @@ -311,7 +321,9 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS if (enabled) { const rid = romIdRef.current; if (rid) { - const saveStatus = await getSaveStatus(rid).catch((): SaveStatus | null => null); + const result = await getSaveStatus(rid).catch(() => null); + if (result && isCallableFailure(result)) return; + const saveStatus: SaveStatus | null = result; const { status: ss, label: sl } = applySaveSyncDisplay(saveStatus?.save_sync_display, saveStatus); setInfo((prev) => ({ ...prev, @@ -362,8 +374,9 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS if (!romId) return; // If event specifies a rom_id, skip if it's not for this game if (detail.rom_id && romIdRef.current && detail.rom_id !== romIdRef.current) return; - const saveStatus: SaveStatus | null = - detail.save_status ?? (await getSaveStatus(romId).catch((): SaveStatus | null => null)); + const fetched = detail.save_status ?? (await getSaveStatus(romId).catch(() => null)); + if (fetched && isCallableFailure(fetched)) return; + const saveStatus: SaveStatus | null = fetched; const { status: saveSyncStatus, label: saveSyncLabel } = applySaveSyncDisplay( saveStatus?.save_sync_display, saveStatus, @@ -441,6 +454,8 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS return () => { cancelled = true; + detach(cancelArtworkApply(appId)); + detach(releasePruneLeasesByOwner(`game-detail:${appId}`)); globalThis.removeEventListener("romm_data_changed", onDataChanged); removeEventListener("download_complete", onDownloadComplete); globalThis.removeEventListener("romm_rom_uninstalled", onUninstalled); @@ -461,6 +476,10 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS try { const saveStatus = await getSaveStatus(romId); if (isCancelled) return; + if (isCallableFailure(saveStatus)) { + detach(debugLog(`RomMPlaySection: background save check deferred: ${saveStatus.message}`)); + return; + } const hasConflict = hasAnySaveConflict(saveStatus); globalThis.dispatchEvent( new CustomEvent("romm_data_changed", { @@ -566,6 +585,10 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS try { const result = await reconcilePlaytime(rid); if (isCancelled()) return; + if ("success" in result) { + detach(debugLog(`RomMPlaySection: playtime reconcile deferred: ${result.message}`)); + return; + } if (!result.server_query_failed) { // Connected: adopt the restored cross-device last_played (#1294) and // refresh the display from it. Set BEFORE updatePlaytimeDisplay so the @@ -610,6 +633,10 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS try { const saveStatus = await getSaveStatus(rid); if (isCancelled()) return; + if (isCallableFailure(saveStatus)) { + detach(debugLog(`RomMPlaySection: content-dir probe deferred: ${saveStatus.message}`)); + return; + } const inContentDir = saveStatus.savefiles_in_content_dir === true; setInfo((prev) => ({ ...prev, savefilesInContentDir: inContentDir })); } catch (e) { @@ -831,9 +858,21 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS if (actionPending || !info.romId) return; setActionPending("uninstall"); try { + const admission = capturePruneLeaseAdmission(`game-detail:${appId}`); const result = await removeRom(info.romId); if (result.success) { - globalThis.dispatchEvent(new CustomEvent("romm_rom_uninstalled", { detail: { rom_id: info.romId } })); + await withPruneLease( + result.prune_lease_token, + "Game detail uninstall", + async (signal) => { + if (isPruneLeaseCancelled(signal)) return; + await setLaunchOptionsConfirmed(appId, "").catch(() => false); + if (isPruneLeaseCancelled(signal)) return; + globalThis.dispatchEvent(new CustomEvent("romm_rom_uninstalled", { detail: { rom_id: info.romId } })); + }, + `game-detail:${appId}`, + admission, + ); toaster.toast({ title: "RomM Sync", body: `${info.romName || "ROM"} uninstalled` }); } else { toaster.toast({ title: "RomM Sync", body: result.message || "Uninstall failed" }); @@ -925,25 +964,30 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS romId: number, platformSlug: string, successBody: string, + admission: PruneLeaseAdmission, ) => { - if (!result.success) { - toaster.toast({ title: "RomM Sync", body: result.message || "Failed to set core" }); - return; - } - // Installed + bound: confirm the re-baked launch_options landed before - // claiming success. app_id can be null/undefined for an unbound ROM. - if (result.launch_options !== undefined && result.app_id != null) { - const confirmed = await setLaunchOptionsConfirmed(result.app_id, result.launch_options); - if (!confirmed) { - // Never toast success on an unconfirmed bake. Keep the DB row — a Steam - // restart (or the next migration/re-sync) re-bakes from the override. - toaster.toast({ title: "RomM Sync", body: "Core saved — restart Steam to apply" }); + await withPruneLease(result.prune_lease_token, "Core selection", async (signal) => { + if (!result.success) { + toaster.toast({ title: "RomM Sync", body: result.message || "Failed to set core" }); return; } - } - // Confirmed (or uninstalled/unbound: nothing to confirm) → success. - toaster.toast({ title: "RomM Sync", body: successBody }); - await refreshCoreDisplay(romId, platformSlug); + // Installed + bound: confirm the re-baked launch_options landed before + // claiming success. app_id can be null/undefined for an unbound ROM. + if (result.launch_options !== undefined && result.app_id != null) { + if (isPruneLeaseCancelled(signal)) return; + const confirmed = await setLaunchOptionsConfirmed(result.app_id, result.launch_options); + if (isPruneLeaseCancelled(signal)) return; + if (!confirmed) { + // Never toast success on an unconfirmed bake. Keep the DB row — a Steam + // restart (or the next migration/re-sync) re-bakes from the override. + toaster.toast({ title: "RomM Sync", body: "Core saved — restart Steam to apply" }); + return; + } + } + // Confirmed (or uninstalled/unbound: nothing to confirm) → success. + toaster.toast({ title: "RomM Sync", body: successBody }); + await refreshCoreDisplay(romId, platformSlug); + }, `game-detail:${appId}`, admission); }; const handleChangeGameCore = async (coreLabel: string) => { @@ -952,9 +996,10 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS const platformSlug = info.platformSlug; detach(debugLog(`handleChangeGameCore: romId=${romId} coreLabel=${coreLabel}`)); try { + const admission = capturePruneLeaseAdmission(`game-detail:${appId}`); const result = await setGameCore(romId, coreLabel); detach(debugLog(`handleChangeGameCore: result success=${result.success}`)); - await applyCoreResult(result, romId, platformSlug, `Core set to ${coreLabel}`); + await applyCoreResult(result, romId, platformSlug, `Core set to ${coreLabel}`, admission); } catch { toaster.toast({ title: "RomM Sync", body: "Failed to set core" }); } @@ -966,9 +1011,10 @@ export const RomMPlaySection: FC = ({ appId }) => { // NOS const platformSlug = info.platformSlug; detach(debugLog(`handleResetGameCore: romId=${romId}`)); try { + const admission = capturePruneLeaseAdmission(`game-detail:${appId}`); const result = await clearGameCore(romId); detach(debugLog(`handleResetGameCore: result success=${result.success}`)); - await applyCoreResult(result, romId, platformSlug, "Now following the system core"); + await applyCoreResult(result, romId, platformSlug, "Now following the system core", admission); } catch { toaster.toast({ title: "RomM Sync", body: "Failed to reset core" }); } diff --git a/src/components/SystemPage.tsx b/src/components/SystemPage.tsx index 0eff199f..12f3147b 100644 --- a/src/components/SystemPage.tsx +++ b/src/components/SystemPage.tsx @@ -16,7 +16,6 @@ import { deletePlatformBios, setSystemCore, debugLog, - logError, } from "../api/backend"; import type { FirmwarePlatformExt } from "../types"; import { scrollToTop } from "../utils/scrollHelpers"; @@ -24,7 +23,13 @@ import { biosColorForLevel } from "../utils/biosColor"; import { detach } from "../utils/detach"; import { getEventTarget } from "../utils/events"; import { buildEmulatorMenu } from "../utils/emulatorMenu"; -import { setLaunchOptionsConfirmed } from "../utils/steamShortcuts"; +import { + capturePruneLeaseAdmission, + mountPruneLeaseOwner, + releasePruneLeasesByOwner, + withPruneLease, +} from "../utils/pruneLease"; +import { batchConfirmLaunchOptions } from "../utils/launchOptionsReconcile"; /** * Build the per-platform summary label/description from the backend BIOS @@ -97,6 +102,7 @@ interface SystemPageProps { * one call. */ export const SystemPage: FC = ({ onBack }) => { + const leaseOwner = "system-page"; const [biosPlatforms, setBiosPlatforms] = useState([]); const [biosLoading, setBiosLoading] = useState(true); const [biosError, setBiosError] = useState(""); @@ -124,8 +130,12 @@ export const SystemPage: FC = ({ onBack }) => { // Load System data (core + BIOS) on mount — this page IS the System view. useEffect(() => { + mountPruneLeaseOwner(leaseOwner); // eslint-disable-next-line react-hooks/set-state-in-effect -- initial async data loads on mount are the standard React pattern; the rule is overzealous here detach(refreshSystem()); + return () => { + detach(releasePruneLeasesByOwner(leaseOwner)); + }; }, []); const handleDownloadAll = async (platformSlug: string) => { @@ -206,6 +216,7 @@ export const SystemPage: FC = ({ onBack }) => { const label = pickedLabel === defaultEmulator?.label ? "" : pickedLabel; detach(debugLog(`setSystemCore: slug=${platform.platform_slug} label=${label} (selected=${pickedLabel})`)); try { + const admission = capturePruneLeaseAdmission(leaseOwner); const result = await setSystemCore(platform.platform_slug, label); detach(debugLog(`setSystemCore: result success=${result.success}`)); if (result.success) { @@ -215,23 +226,13 @@ export const SystemPage: FC = ({ onBack }) => { // Mirrors the migration_relaunch_options fan-out in index.tsx // (bounded-concurrency batches so a platform with many ROMs doesn't // serialize worst-case per-shortcut confirm-poll timeouts). - const items = result.rebake_items ?? []; - const CONCURRENCY = 10; - for (let i = 0; i < items.length; i += CONCURRENCY) { - const batch = items.slice(i, i + CONCURRENCY); - await Promise.all( - batch.map(async (item) => { - try { - const ok = await setLaunchOptionsConfirmed(item.app_id, item.launch_options); - if (!ok) { - logError(`setSystemCore: failed to confirm launch options for appId ${item.app_id}`); - } - } catch (e) { - logError(`setSystemCore: failed to set launch options for appId ${item.app_id}: ${e}`); - } - }), - ); - } + await withPruneLease( + result.prune_lease_token, + "setSystemCore", + (signal) => batchConfirmLaunchOptions(result.rebake_items ?? [], "setSystemCore", signal), + leaseOwner, + admission, + ); await refreshSystem(); globalThis.dispatchEvent( new CustomEvent("romm_data_changed", { diff --git a/src/components/VersionPicker.test.tsx b/src/components/VersionPicker.test.tsx index ffea139f..cebb7117 100644 --- a/src/components/VersionPicker.test.tsx +++ b/src/components/VersionPicker.test.tsx @@ -59,10 +59,14 @@ vi.mock("../utils/steamShortcuts", () => ({ vi.mock("./UnsyncedSavesSwitchModal", () => ({ showUnsyncedSavesModal: vi.fn(), })); +vi.mock("./RemovedGamesCleanup", () => ({ + openRemovedGamesCleanupModal: vi.fn(), +})); import { invalidateCachedGameDetail } from "../utils/cachedGameDetailStore"; import { setLaunchOptionsConfirmed } from "../utils/steamShortcuts"; import { showUnsyncedSavesModal } from "./UnsyncedSavesSwitchModal"; +import { openRemovedGamesCleanupModal } from "./RemovedGamesCleanup"; const APP_ID = 100; @@ -197,6 +201,7 @@ describe("VersionPicker — non-switchable rows (#1359)", () => { vi.mocked(backend.switchVersion).mockReset(); vi.mocked(backend.fetchCoverBase64).mockResolvedValue({ base64: null }); vi.mocked(toaster.toast).mockReset(); + vi.mocked(openRemovedGamesCleanupModal).mockReset().mockResolvedValue(true); }); // A group whose second version is a RomM sibling from a DIFFERENT local group @@ -330,6 +335,52 @@ describe("VersionPicker — vanished retained rows (#1570)", () => { expect(backend.switchVersion).toHaveBeenCalledWith(APP_ID, 2, false); }); + + it("offers local cleanup only for a synced vanished row and scopes it to that ROM", async () => { + vi.mocked(backend.getVersionList).mockResolvedValue(listWithBoundVanished()); + + const { menu } = await renderAndOpen(); + const cleanup = within(menu.container).getByText("Remove local data..."); + await act(async () => { + fireEvent.click(cleanup.closest('[role="menuitem"]')!); + await Promise.resolve(); + }); + + expect(openRemovedGamesCleanupModal).toHaveBeenCalledWith(1); + expect(within(menu.container).getAllByText("Remove local data...")).toHaveLength(1); + }); + + it("offers local cleanup for a synced singleton vanished binding", async () => { + const bound = multiVersionList().versions![0]!; + vi.mocked(backend.getVersionList).mockResolvedValue({ + multi_version: false, + server_query_failed: false, + bound_vanished: true, + bound_version: { ...bound, vanished: true }, + }); + vi.mocked(openRemovedGamesCleanupModal).mockResolvedValue(true); + const picker = render(); + const cleanup = await picker.findByRole("button", { name: "Remove local data..." }); + + fireEvent.click(cleanup); + await waitFor(() => expect(openRemovedGamesCleanupModal).toHaveBeenCalledWith(1)); + }); + + it("surfaces an inline cleanup-preview failure", async () => { + vi.mocked(backend.getVersionList).mockResolvedValue(listWithBoundVanished()); + vi.mocked(openRemovedGamesCleanupModal).mockRejectedValue(new Error("offline")); + const log = vi.spyOn(backend, "logError").mockImplementation(() => {}); + + const { menu } = await renderAndOpen(); + await act(async () => { + fireEvent.click(within(menu.container).getByText("Remove local data...").closest('[role="menuitem"]')!); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("offline")); + expect(toaster.toast).toHaveBeenCalledWith({ title: "RomM Sync", body: "Could not prepare local cleanup." }); + }); }); describe("VersionPicker — liveness connection signals (#1570)", () => { @@ -542,6 +593,84 @@ describe("VersionPicker — per-version covers (#1346)", () => { logWarnSpy.mockRestore(); } }); + + it("unmount aborts a delayed post-switch cover and releases only after its fetch settles", async () => { + const setArt = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("SteamClient", { Apps: { SetCustomArtworkForApp: setArt } }); + vi.mocked(backend.switchVersion).mockResolvedValue({ + success: true, + rom_id: 2, + target_installed: true, + launch_options: "cmd", + app_id: APP_ID, + prune_lease_token: "version-lease", + }); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + vi.mocked(backend.fetchCoverBase64).mockResolvedValue({ base64: null }); + const { r, menu } = await renderAndOpen(); + await waitFor(() => expect(backend.fetchCoverBase64).toHaveBeenCalledTimes(3)); + let resolveCover!: (value: { base64: string }) => void; + vi.mocked(backend.fetchCoverBase64) + .mockReset() + .mockImplementation( + () => + new Promise((resolve) => { + resolveCover = resolve; + }), + ); + + fireEvent.click(within(menu.container).getByText("Game (Japan)")); + await waitFor(() => expect(backend.fetchCoverBase64).toHaveBeenCalledWith(2)); + r.unmount(); + await Promise.resolve(); + expect(backend.releasePruneConflictLease).not.toHaveBeenCalledWith("version-lease"); + resolveCover({ base64: "LATE" }); + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + await waitFor(() => expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("version-lease")); + + expect(setArt).not.toHaveBeenCalled(); + expect(invalidateCachedGameDetail).not.toHaveBeenCalled(); + }); + + it("releases a switch token that arrives after unmount without writing Steam", async () => { + vi.mocked(backend.fetchCoverBase64).mockResolvedValue({ base64: null }); + let resolveSwitch!: (value: { + success: true; + rom_id: number; + target_installed: true; + launch_options: string; + app_id: number; + prune_lease_token: string; + }) => void; + vi.mocked(backend.switchVersion).mockImplementation( + () => + new Promise((resolve) => { + resolveSwitch = resolve; + }), + ); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + const { r, menu } = await renderAndOpen(); + vi.mocked(backend.fetchCoverBase64).mockClear(); + + fireEvent.click(within(menu.container).getByText("Game (Japan)")); + await waitFor(() => expect(backend.switchVersion).toHaveBeenCalledWith(APP_ID, 2, false)); + r.unmount(); + resolveSwitch({ + success: true, + rom_id: 2, + target_installed: true, + launch_options: "cmd", + app_id: APP_ID, + prune_lease_token: "late-version-lease", + }); + + await waitFor(() => expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("late-version-lease")); + expect(setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + expect(backend.fetchCoverBase64).not.toHaveBeenCalledWith(2); + expect(invalidateCachedGameDetail).not.toHaveBeenCalled(); + }); }); /** Capture the events dispatched on `romm_data_changed` while `fn` runs. */ @@ -1094,6 +1223,87 @@ describe("VersionPicker — unsynced-saves soft-block", () => { expect(dispatched.some((e) => e.detail?.type === "version_switched")).toBe(true); }); + it("unmount during save sync stops before the successor switch commits", async () => { + let resolveSync!: (value: { success: true; message: string; synced: number }) => void; + vi.mocked(backend.switchVersion).mockResolvedValue(block); + vi.mocked(backend.syncRomSaves).mockImplementation( + () => + new Promise((resolve) => { + resolveSync = resolve; + }), + ); + vi.mocked(showUnsyncedSavesModal).mockResolvedValue("sync_and_switch"); + const { r, menu } = await renderAndOpen(); + + fireEvent.click(within(menu.container).getByText("Game (Japan)")); + await waitFor(() => expect(backend.syncRomSaves).toHaveBeenCalledWith(1)); + r.unmount(); + resolveSync({ success: true, message: "", synced: 1 }); + await act(async () => { + for (let index = 0; index < 8; index++) await Promise.resolve(); + }); + + expect(backend.switchVersion).toHaveBeenCalledTimes(1); + expect(setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + expect(invalidateCachedGameDetail).not.toHaveBeenCalled(); + }); + + it("unmount during the unsynced-save modal stops before Switch anyway commits", async () => { + let resolveChoice!: (value: "switch_anyway") => void; + vi.mocked(backend.switchVersion).mockResolvedValue(block); + vi.mocked(showUnsyncedSavesModal).mockImplementation( + () => + new Promise((resolve) => { + resolveChoice = resolve; + }), + ); + const { r, menu } = await renderAndOpen(); + + fireEvent.click(within(menu.container).getByText("Game (Japan)")); + await waitFor(() => expect(showUnsyncedSavesModal).toHaveBeenCalled()); + r.unmount(); + resolveChoice("switch_anyway"); + await act(async () => { + for (let index = 0; index < 8; index++) await Promise.resolve(); + }); + + expect(backend.switchVersion).toHaveBeenCalledTimes(1); + expect(setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + }); + + it("a genuine remount admits its own switch while the old modal chain stays stale", async () => { + let resolveOldChoice!: (value: "switch_anyway") => void; + vi.mocked(backend.switchVersion) + .mockResolvedValueOnce(block) + .mockResolvedValueOnce(block) + .mockResolvedValueOnce(successResult); + vi.mocked(showUnsyncedSavesModal) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldChoice = resolve; + }), + ) + .mockResolvedValueOnce("switch_anyway"); + + const oldPicker = await renderAndOpen(); + fireEvent.click(within(oldPicker.menu.container).getByText("Game (Japan)")); + await waitFor(() => expect(showUnsyncedSavesModal).toHaveBeenCalledTimes(1)); + oldPicker.r.unmount(); + + const currentPicker = await renderAndOpen(); + await clickRow(currentPicker.menu.container, "Game (Japan)"); + resolveOldChoice("switch_anyway"); + await act(async () => { + for (let index = 0; index < 8; index++) await Promise.resolve(); + }); + + expect(backend.switchVersion).toHaveBeenCalledTimes(3); + expect(backend.switchVersion).toHaveBeenNthCalledWith(2, APP_ID, 2, false); + expect(backend.switchVersion).toHaveBeenNthCalledWith(3, APP_ID, 2, true); + expect(setLaunchOptionsConfirmed).toHaveBeenCalledWith(APP_ID, ""); + }); + it("routes a version_vanished 'Switch anyway' retry through the common refusal path", async () => { const vanished = { success: false as const, diff --git a/src/components/VersionPicker.tsx b/src/components/VersionPicker.tsx index 132828e0..b670ec74 100644 --- a/src/components/VersionPicker.tsx +++ b/src/components/VersionPicker.tsx @@ -21,7 +21,7 @@ * nothing (the null-gate pattern). */ -import { useState, useEffect, useRef, FC, ReactNode } from "react"; +import { useState, useEffect, useRef, FC, Fragment, ReactNode } from "react"; import { toaster, addEventListener, removeEventListener } from "@decky/api"; import { Menu, MenuItem, showContextMenu, DialogButton } from "@decky/ui"; import { FaChevronDown, FaCompactDisc, FaLayerGroup } from "react-icons/fa"; @@ -31,7 +31,6 @@ import { syncRomSaves, refreshSaveStatus, fetchCoverBase64, - invalidateCachedGameDetail, logError, logWarn, } from "../api/backend"; @@ -43,12 +42,20 @@ import type { SwitchVersionUnsyncedSaves, } from "../api/backend"; import { reportServerReachable } from "../utils/connectionState"; -import { setLaunchOptionsConfirmed } from "../utils/steamShortcuts"; +import { applyCommittedVersionSwitch } from "../utils/versionSwitchApplication"; import { showUnsyncedSavesModal } from "./UnsyncedSavesSwitchModal"; import { getEventTarget } from "../utils/events"; import { detach } from "../utils/detach"; +import { + capturePruneLeaseAdmission, + isPruneLeaseAdmissionCurrent, + mountPruneLeaseOwner, + releasePruneLeasesByOwner, + type PruneLeaseAdmission, +} from "../utils/pruneLease"; import type { RommDataChangedDetail, RommRomUninstalledDetail } from "../types/events"; import type { DownloadCompleteEvent, DownloadFailedEvent } from "../types"; +import { openRemovedGamesCleanupModal } from "./RemovedGamesCleanup"; interface VersionPickerProps { appId: number; @@ -94,6 +101,7 @@ const Badge: FC<{ text: string; tone: "accent" | "muted" | "good" }> = ({ text, }; export const VersionPicker: FC = ({ appId }) => { + const leaseOwner = `version-picker:${appId}`; const [versionList, setVersionList] = useState(null); // In-flight switch guard (#1345 round-2 / E): a switch rebinds the shortcut and // then relies on the version_switched re-fetch to refresh the (now stale) list. @@ -117,6 +125,7 @@ export const VersionPicker: FC = ({ appId }) => { // effect owns the loader lifetime for this appId. All request sources share one // generation so only the latest completion can publish list/reachability state. useEffect(() => { + mountPruneLeaseOwner(leaseOwner); let cancelled = false; const load = async (source: "normal" | "vanished_refusal" = "normal"): Promise => { const requestId = ++listRequestIdRef.current; @@ -156,6 +165,11 @@ export const VersionPicker: FC = ({ appId }) => { const detail = (e as CustomEvent).detail; if (detail.type === "version_switched" && detail.app_id === appId) { detach(load()); + } else if ( + detail.type === "rom_pruned" && + (detail.app_ids.includes(appId) || detail.rom_ids.some((romId) => memberIdsRef.current.has(romId))) + ) { + detach(load()); } }; globalThis.addEventListener("romm_data_changed", onDataChanged); @@ -177,13 +191,14 @@ export const VersionPicker: FC = ({ appId }) => { return () => { cancelled = true; + detach(releasePruneLeasesByOwner(leaseOwner)); if (loadVersionListRef.current?.load === load) loadVersionListRef.current = null; globalThis.removeEventListener("romm_data_changed", onDataChanged); removeEventListener("download_complete", dlComplete); removeEventListener("download_failed", dlFailed); globalThis.removeEventListener("romm_rom_uninstalled", onUninstalled); }; - }, [appId]); + }, [appId, leaseOwner]); // Lazily fetch a cover for every version once the list is known, via the // cache-first fetchCoverBase64 (#1346): a synced version resolves from the @@ -223,36 +238,15 @@ export const VersionPicker: FC = ({ appId }) => { // write fails or throws. A missed confirm only leaves the shortcut on a stale // command; it self-heals at the next startup/sync reconcile, so we warn and // nudge the user rather than reporting the whole switch as failed. - const applySwitchSuccess = async (result: SwitchVersionSuccess): Promise => { - let confirmed = false; - try { - confirmed = await setLaunchOptionsConfirmed(result.app_id, result.launch_options); - } catch (e) { - logError(`VersionPicker: launch-options confirm threw for rom ${result.rom_id} (appId ${result.app_id}): ${e}`); - } + const applySwitchSuccess = async (result: SwitchVersionSuccess, admission: PruneLeaseAdmission): Promise => { + const confirmed = await applyCommittedVersionSwitch( + result, + (romId, cover) => setCovers((prev) => ({ ...prev, [romId]: cover })), + admission, + ); if (!confirmed) { - logError(`VersionPicker: could not confirm launch options for rom ${result.rom_id} (appId ${result.app_id})`); toaster.toast({ title: "RomM Sync", body: "Switched — re-switch if launch fails" }); } - // Publish the newly active version's cover onto the Steam shortcut so the - // grid art tracks the binding (#1346). Cache-first and best-effort — a - // missing/unfetchable cover leaves the old art in place and never disturbs - // the already-committed switch. - try { - const cover = await fetchCoverBase64(result.rom_id); - if (cover.base64) { - setCovers((prev) => ({ ...prev, [result.rom_id]: cover.base64! })); - await SteamClient.Apps.SetCustomArtworkForApp(result.app_id, cover.base64, "png", 0); - } - } catch (e) { - logWarn(`VersionPicker: cover apply after switch failed for rom ${result.rom_id}: ${e}`); - } - invalidateCachedGameDetail(appId); - globalThis.dispatchEvent( - new CustomEvent("romm_data_changed", { - detail: { type: "version_switched", app_id: appId, rom_id: result.rom_id }, - }), - ); }; const refreshAfterVanishedRefusal = (): Promise => { @@ -272,7 +266,11 @@ export const VersionPicker: FC = ({ appId }) => { // sync failed, sync surfaced conflicts, or the retry blocked again — aborts // with a short toast and re-runs the save-status refresh so the conflict UI // surfaces through the normal save_status_updated loop. - const syncThenSwitch = async (unsyncedRomId: number, target: VersionInfo): Promise => { + const syncThenSwitch = async ( + unsyncedRomId: number, + target: VersionInfo, + admission: PruneLeaseAdmission, + ): Promise => { const abort = (body: string): void => { // Every sync-then-switch failure is terminal for this attempt — release the // in-flight guard so the trigger re-enables (it never reaches a reload). @@ -284,8 +282,10 @@ export const VersionPicker: FC = ({ appId }) => { ), ); }; + if (!isPruneLeaseAdmissionCurrent(admission)) return; try { const sync = await syncRomSaves(unsyncedRomId); + if (!isPruneLeaseAdmissionCurrent(admission)) return; if (!sync.success) { abort("Couldn't sync saves — try again"); return; @@ -294,9 +294,10 @@ export const VersionPicker: FC = ({ appId }) => { abort("Resolve save conflicts first"); return; } + if (!isPruneLeaseAdmissionCurrent(admission)) return; const retry = await switchVersion(appId, target.rom_id, false); if (retry.success) { - await applySwitchSuccess(retry); + await applySwitchSuccess(retry, admission); } else if (retry.reason === "unsynced_saves") { // The sync ran but the version still reports drift (a partial upload or a // race) — say so instead of the generic "couldn't switch". @@ -305,6 +306,7 @@ export const VersionPicker: FC = ({ appId }) => { handleSwitchFailure(retry); } } catch (e) { + if (!isPruneLeaseAdmissionCurrent(admission)) return; logError(`VersionPicker: sync-then-switch failed: ${e}`); abort("Couldn't sync saves — try again"); } @@ -322,10 +324,11 @@ export const VersionPicker: FC = ({ appId }) => { // (defense-in-depth), so switch_version's rejection can never reach a toast. if (!target.switchable) return; setSwitching(true); + const admission = capturePruneLeaseAdmission(leaseOwner); try { const result = await switchVersion(appId, target.rom_id, false); if (result.success) { - await applySwitchSuccess(result); + await applySwitchSuccess(result, admission); return; } if (result.reason === "unsynced_saves") { @@ -335,6 +338,7 @@ export const VersionPicker: FC = ({ appId }) => { versionName: result.unsynced_version_name, serverReachable: result.server_reachable, }); + if (!isPruneLeaseAdmissionCurrent(admission)) return; if (choice === "cancel") { setSwitching(false); return; @@ -342,7 +346,7 @@ export const VersionPicker: FC = ({ appId }) => { if (choice === "sync_and_switch") { // syncThenSwitch owns the guard from here: it clears on abort and leaves // it set on its own success (its reload clears it). - await syncThenSwitch(result.unsynced_rom_id, target); + await syncThenSwitch(result.unsynced_rom_id, target, admission); return; } // "Switch anyway" — the override skips the stranding gate; strand the @@ -350,7 +354,7 @@ export const VersionPicker: FC = ({ appId }) => { // user switches back). const forced = await switchVersion(appId, target.rom_id, true); if (forced.success) { - await applySwitchSuccess(forced); + await applySwitchSuccess(forced, admission); } else { handleSwitchFailure(forced); } @@ -366,8 +370,29 @@ export const VersionPicker: FC = ({ appId }) => { } }; - // Single-version / unknown / unbound → render nothing (zero footprint). - if (!versionList?.multi_version || !versionList.versions || versionList.versions.length === 0) return null; + const openCleanup = (romId: number): void => { + detach( + openRemovedGamesCleanupModal(romId) + .then((opened) => { + if (!opened) toaster.toast({ title: "RomM Sync", body: "This local entry already changed." }); + }) + .catch((error) => { + logError(`VersionPicker: cleanup preview failed for rom ${romId}: ${error}`); + toaster.toast({ title: "RomM Sync", body: "Could not prepare local cleanup." }); + }), + ); + }; + + if (!versionList?.multi_version) { + const bound = versionList?.bound_version; + if (!versionList?.bound_vanished || !bound?.synced) return null; + return ( + openCleanup(bound.rom_id)}> + Remove local data... + + ); + } + if (!versionList.versions || versionList.versions.length === 0) return null; const versions = versionList.versions; const active = versions.find((v) => v.active); @@ -411,27 +436,34 @@ export const VersionPicker: FC = ({ appId }) => { showContextMenu( {versions.map((v) => ( - detach(handleSwitch(v))}> - - {rowCover(v)} - {v.label || v.name || String(v.rom_id)} - {v.is_default ? : null} - {v.installed ? : null} - {v.switchable && !v.synced ? : null} - {rowAvailabilityHint(v)} - {v.active ? : null} - - + + detach(handleSwitch(v))}> + + {rowCover(v)} + {v.label || v.name || String(v.rom_id)} + {v.is_default ? : null} + {v.installed ? : null} + {v.switchable && !v.synced ? : null} + {rowAvailabilityHint(v)} + {v.active ? : null} + + + {v.vanished && v.synced ? ( + openCleanup(v.rom_id)}> + Remove local data... + + ) : null} + ))} , getEventTarget(e), diff --git a/src/index.test.tsx b/src/index.test.tsx index 9567ba62..eec45c37 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -12,7 +12,7 @@ */ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { act } from "@testing-library/react"; +import { act, waitFor } from "@testing-library/react"; import { toaster } from "@decky/api"; import { emitDeckyEvent, deckyEventListenerCount } from "./test-utils/decky-api-mock"; import { @@ -20,7 +20,10 @@ import { getAllPlaytime, getAppIdRomIdMap, getInstalledRelaunchOptions, + invalidateCachedGameDetail, getMetadataCachePage, + releasePruneConflictLease, + waitForPruneRelease, } from "./api/backend"; import { getSettingsResetState, setSettingsResetState } from "./utils/settingsResetStore"; import { getDownloadState, setDownloads } from "./utils/downloadStore"; @@ -29,6 +32,7 @@ import { estimateApplySeconds } from "./utils/syncEstimate"; import { resetEta, weightedCoarseFraction } from "./utils/syncEta"; import { recordSyncCreated, resetSyncDelta, getSyncDelta } from "./utils/syncDeltaStore"; import { resetSyncCancel } from "./utils/syncManager"; +import { beginPrunePreview, beginPruneRun, getPruneState, resetPruneState } from "./utils/pruneStore"; import type { DownloadCompleteEvent, DownloadProgressEvent, @@ -42,6 +46,7 @@ vi.mock("./patches/gameDetailPatch", () => ({ registerGameDetailPatch: vi.fn(), unregisterGameDetailPatch: vi.fn(), registerRomMAppId: vi.fn(), + unregisterRomMAppId: vi.fn(), })); vi.mock("./patches/metadataPatches", () => ({ registerMetadataPatches: vi.fn(), @@ -57,6 +62,16 @@ vi.mock("./utils/sessionManager", () => ({ initSessionManager: vi.fn().mockResolvedValue(undefined), destroySessionManager: vi.fn(), })); + +const handlePruneAction = vi.fn().mockResolvedValue(undefined); +vi.mock("./utils/pruneActions", () => ({ + handlePruneAction: (...args: unknown[]) => handlePruneAction(...args), + cancelPruneActions: vi.fn(), +})); +const publishCommittedVersionSwitch = vi.fn().mockResolvedValue(undefined); +vi.mock("./utils/versionSwitchApplication", () => ({ + publishCommittedVersionSwitch: (...args: unknown[]) => publishCommittedVersionSwitch(...args), +})); vi.mock("./utils/syncManager", () => ({ initUnitSyncManager: vi.fn(() => () => {}), resetSyncCancel: vi.fn(), @@ -89,13 +104,14 @@ vi.mock("./api/backend", async () => { const actual = await vi.importActual("./api/backend"); return { ...actual, + invalidateCachedGameDetail: vi.fn(), logError: (...args: unknown[]) => logError(...args), logInfo: vi.fn(), }; }); import { applyAllPlaytime, registerMetadataPatches, applyAllMetadata } from "./patches/metadataPatches"; -import { registerRomMAppId } from "./patches/gameDetailPatch"; +import { registerRomMAppId, unregisterRomMAppId } from "./patches/gameDetailPatch"; import definePluginResult from "./index"; // `definePlugin` is stubbed in test-setup to return its factory unchanged, so @@ -116,6 +132,15 @@ beforeEach(() => { // The sync-progress store is a real module — reset it so an etaSeconds set by // one test's sync_plan doesn't leak into the next. setSyncProgress({ running: false, stage: "", current: 0, total: 0, message: "" }); + resetPruneState(); + handlePruneAction.mockClear(); + publishCommittedVersionSwitch.mockClear(); + vi.mocked(waitForPruneRelease).mockReset().mockResolvedValue({ + success: true, + message: "Cleanup claim is released.", + }); + vi.mocked(releasePruneConflictLease).mockReset().mockResolvedValue({ success: true, message: "released" }); + vi.mocked(invalidateCachedGameDetail).mockClear(); // The global afterEach's vi.unstubAllGlobals wipes the Steam ambient globals // after the file's first test; several sync_complete paths read SteamClient / // appStore, so default them to no-ops here. @@ -123,6 +148,257 @@ beforeEach(() => { vi.stubGlobal("appStore", { GetAppOverviewByAppID: () => null, allApps: [] }); }); +describe("index.tsx — persistent prune listeners", () => { + it("handles tokenized Steam actions at the plugin root and unregisters on dismount", async () => { + const plugin = pluginFactory(); + beginPrunePreview("preview-1"); + const action = { + run_id: "run-1", + preview_id: "preview-1", + action_token: "token-1", + action: "remove_shortcut" as const, + app_id: 9001, + }; + expect(deckyEventListenerCount("prune_action_required")).toBe(1); + + await act(async () => { + emitDeckyEvent("prune_action_required", action); + await Promise.resolve(); + }); + + expect(handlePruneAction).toHaveBeenCalledWith(action); + plugin.onDismount(); + expect(deckyEventListenerCount("prune_action_required")).toBe(0); + expect(deckyEventListenerCount("prune_progress")).toBe(0); + expect(deckyEventListenerCount("prune_complete")).toBe(0); + }); + + it("stores progress and completion, invalidates affected details, and emits a refresh", async () => { + const plugin = pluginFactory(); + beginPrunePreview("preview-1"); + const changed = vi.fn(); + globalThis.addEventListener("romm_data_changed", changed); + + act(() => { + emitDeckyEvent("prune_progress", { + run_id: "run-1", + preview_id: "preview-1", + current: 1, + total: 2, + stage: "checking", + rom_ids: [7], + name: "Removed Game", + }); + emitDeckyEvent("prune_complete", { + success: true, + partial: false, + run_id: "run-1", + preview_id: "preview-1", + removed_rom_ids: [7], + affected_app_ids: [9001], + removed_app_ids: [9001], + results: [{ group_id: "group-1", rom_ids: [7], status: "removed", message: "Removed." }], + }); + }); + + expect(getPruneState().progress).toBeNull(); + expect(getPruneState().complete?.removed_rom_ids).toEqual([7]); + expect(invalidateCachedGameDetail).toHaveBeenCalledWith(9001); + expect(unregisterRomMAppId).toHaveBeenCalledWith(9001); + expect(changed).toHaveBeenCalledTimes(1); + expect(toaster.toast).toHaveBeenCalledWith({ title: "RomM Sync", body: "Removed 1 local entry." }); + + globalThis.removeEventListener("romm_data_changed", changed); + plugin.onDismount(); + }); + + it("a foreign or duplicate terminal frame has no root side effects", () => { + const plugin = pluginFactory(); + const changed = vi.fn(); + globalThis.addEventListener("romm_data_changed", changed); + vi.mocked(unregisterRomMAppId).mockClear(); + beginPrunePreview("preview-current"); + beginPruneRun("current", "preview-current"); + const frame = { + success: true, + partial: false, + run_id: "old", + preview_id: "preview-old", + chunk_index: 0, + final: true, + removed_rom_ids: [7], + affected_app_ids: [9001], + removed_app_ids: [9001], + results: [{ group_id: "group-1", rom_ids: [7], status: "removed" as const, message: "Removed." }], + }; + + act(() => { + emitDeckyEvent("prune_complete", frame); + emitDeckyEvent("prune_complete", frame); + }); + + expect(getPruneState().runId).toBe("current"); + expect(invalidateCachedGameDetail).not.toHaveBeenCalled(); + expect(unregisterRomMAppId).not.toHaveBeenCalled(); + expect(changed).not.toHaveBeenCalled(); + globalThis.removeEventListener("romm_data_changed", changed); + plugin.onDismount(); + }); + + it("surfaces a zero-row committed partial instead of reporting that nothing changed", () => { + const plugin = pluginFactory(); + beginPrunePreview("preview-partial"); + + act(() => { + emitDeckyEvent("prune_complete", { + success: false, + partial: true, + run_id: "run-partial", + preview_id: "preview-partial", + removed_count: 0, + problem_count: 1, + removed_rom_ids: [], + affected_app_ids: [9001], + removed_app_ids: [9001], + results: [ + { + group_id: "group-1", + rom_ids: [7], + status: "partial", + committed_action: "remove_shortcut", + message: "Steam removed the shortcut, but local cleanup was retained.", + }, + ], + }); + }); + + expect(toaster.toast).toHaveBeenCalledWith({ + title: "RomM Sync", + body: "Shortcut removal committed; local cleanup incomplete.", + subtext: "Steam removed the shortcut, but local cleanup was retained.", + }); + plugin.onDismount(); + }); + + it("publishes a known committed partial repoint after terminal completion", async () => { + const plugin = pluginFactory(); + beginPrunePreview("preview-repoint"); + let release: ((value: { success: true; message: string }) => void) | undefined; + vi.mocked(waitForPruneRelease).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + act(() => { + emitDeckyEvent("prune_complete", { + success: false, + partial: true, + run_id: "run-repoint-partial", + preview_id: "preview-repoint", + publication_required: true, + prune_lease_token: "publication-lease", + removed_rom_ids: [], + affected_app_ids: [9001], + results: [ + { + group_id: "group-1", + rom_ids: [7, 8], + status: "partial", + committed_action: "repoint_shortcut", + app_id: 9001, + target_rom_id: 8, + message: "The shortcut changed; source data was retained.", + }, + ], + }); + }); + await flush(); + + expect(publishCommittedVersionSwitch).not.toHaveBeenCalled(); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("publication-lease"); + release?.({ success: true, message: "released" }); + await flush(); + expect(publishCommittedVersionSwitch).toHaveBeenCalledWith(9001, 8, undefined, expect.any(AbortSignal)); + expect(releasePruneConflictLease).toHaveBeenCalledWith("publication-lease"); + plugin.onDismount(); + }); + + it("does not publish an ambiguous repoint outcome", async () => { + const plugin = pluginFactory(); + beginPrunePreview("preview-ambiguous"); + + act(() => { + emitDeckyEvent("prune_complete", { + success: false, + partial: true, + run_id: "run-repoint-ambiguous", + preview_id: "preview-ambiguous", + removed_rom_ids: [], + affected_app_ids: [9001], + results: [ + { + group_id: "group-1", + rom_ids: [7, 8], + status: "partial", + committed_action: "repoint_shortcut", + action_ambiguous: true, + app_id: 9001, + target_rom_id: 8, + message: "The repoint outcome is unknown.", + }, + ], + }); + }); + await flush(); + + expect(publishCommittedVersionSwitch).not.toHaveBeenCalled(); + expect(toaster.toast).toHaveBeenCalledWith({ + title: "RomM Sync", + body: "Shortcut repoint outcome is uncertain; source data was retained.", + subtext: "The repoint outcome is unknown.", + }); + plugin.onDismount(); + }); + + it("fails closed when a committed repoint terminal frame has no publication lease", async () => { + const plugin = pluginFactory(); + beginPrunePreview("preview-missing-publication-lease"); + + act(() => { + emitDeckyEvent("prune_complete", { + success: true, + partial: false, + run_id: "run-missing-publication-lease", + preview_id: "preview-missing-publication-lease", + publication_required: true, + removed_rom_ids: [7], + affected_app_ids: [9001], + results: [ + { + group_id: "group-1", + rom_ids: [7, 8], + status: "repointed", + committed_action: "repoint_shortcut", + app_id: 9001, + target_rom_id: 8, + message: "Repointed.", + }, + ], + }); + }); + await flush(); + + expect(waitForPruneRelease).not.toHaveBeenCalledWith("run-missing-publication-lease"); + expect(publishCommittedVersionSwitch).not.toHaveBeenCalled(); + expect(logError).toHaveBeenCalledWith( + "Cleanup publication was skipped because its continuation lease was missing.", + ); + plugin.onDismount(); + }); +}); + describe("index.tsx — download_complete launch-options sync", () => { beforeEach(() => { setLaunchOptionsConfirmed.mockClear(); @@ -328,6 +604,34 @@ describe("index.tsx — sync_stale listener", () => { } plugin.onDismount(); }); + + it("holds its own event lease through a paced tail when sync_complete never arrives", async () => { + const plugin = pluginFactory(); + await flush(); + vi.mocked(releasePruneConflictLease).mockClear(); + const remove = Array.from({ length: 26 }, (_, i) => ({ rom_id: i + 1, app_id: 2000 + i })); + + vi.useFakeTimers(); + try { + act(() => { + emitDeckyEvent<[SyncStaleData]>("sync_stale", { remove, prune_lease_token: "standalone-stale-lease" }); + }); + await act(async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }); + expect(removeShortcut).toHaveBeenCalledTimes(25); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("standalone-stale-lease"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + expect(removeShortcut).toHaveBeenCalledTimes(26); + await vi.waitFor(() => expect(releasePruneConflictLease).toHaveBeenCalledWith("standalone-stale-lease")); + } finally { + vi.useRealTimers(); + plugin.onDismount(); + } + }); }); describe("index.tsx — migration_relaunch_options listener", () => { @@ -396,6 +700,12 @@ describe("index.tsx — migration_relaunch_options listener", () => { }); describe("index.tsx — startup launch-options reconcile (#1043)", () => { + const relaunchOptions = (items: { app_id: number; launch_options: string }[]) => ({ + success: true as const, + items, + prune_lease_token: items.length > 0 ? "installed-lease" : null, + }); + beforeEach(() => { setLaunchOptionsConfirmed.mockClear(); setLaunchOptionsConfirmed.mockResolvedValue(true); @@ -409,10 +719,12 @@ describe("index.tsx — startup launch-options reconcile (#1043)", () => { }); it("confirm-sets launch options for each reconciled item after init", async () => { - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([ - { app_id: 100, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/a.bin"' }, - { app_id: 200, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/b.bin"' }, - ]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue( + relaunchOptions([ + { app_id: 100, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/a.bin"' }, + { app_id: 200, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/b.bin"' }, + ]), + ); const plugin = pluginFactory(); await flush(); @@ -423,7 +735,7 @@ describe("index.tsx — startup launch-options reconcile (#1043)", () => { }); it("never confirm-sets when there is nothing installed to reconcile", async () => { - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue(relaunchOptions([])); const plugin = pluginFactory(); await flush(); @@ -434,9 +746,9 @@ describe("index.tsx — startup launch-options reconcile (#1043)", () => { it("surfaces a startup_reconcile-prefixed logError when a confirm returns false", async () => { setLaunchOptionsConfirmed.mockResolvedValue(false); - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([ - { app_id: 100, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/a.bin"' }, - ]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue( + relaunchOptions([{ app_id: 100, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/a.bin"' }]), + ); const plugin = pluginFactory(); await flush(); @@ -461,10 +773,18 @@ describe("index.tsx — startup launch-options reconcile (#1043)", () => { describe("index.tsx — sync_complete launch-options reconcile (#1151)", () => { type SyncCompletePayload = { platform_app_ids: Record; + romm_collection_app_ids?: Record; total_games: number; cancelled?: boolean; + prune_lease_token?: string; }; + const relaunchOptions = (items: { app_id: number; launch_options: string }[]) => ({ + success: true as const, + items, + prune_lease_token: items.length > 0 ? "installed-lease" : null, + }); + beforeEach(() => { setLaunchOptionsConfirmed.mockClear(); setLaunchOptionsConfirmed.mockResolvedValue(true); @@ -475,7 +795,7 @@ describe("index.tsx — sync_complete launch-options reconcile (#1151)", () => { // The startup reconcile fires on factory init; default it to an empty set // so each test isolates the sync_complete-triggered reconcile below. vi.mocked(getInstalledRelaunchOptions).mockReset(); - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue(relaunchOptions([])); }); it("re-confirms launch options for every installed+bound ROM after a sync", async () => { @@ -483,9 +803,9 @@ describe("index.tsx — sync_complete launch-options reconcile (#1151)", () => { await flush(); // settle the startup reconcile (empty set) setLaunchOptionsConfirmed.mockClear(); vi.mocked(getInstalledRelaunchOptions).mockClear(); - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([ - { app_id: 100, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/a.bin"' }, - ]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue( + relaunchOptions([{ app_id: 100, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/a.bin"' }]), + ); act(() => { emitDeckyEvent<[SyncCompletePayload]>("sync_complete", { @@ -507,9 +827,9 @@ describe("index.tsx — sync_complete launch-options reconcile (#1151)", () => { await flush(); setLaunchOptionsConfirmed.mockClear(); vi.mocked(getInstalledRelaunchOptions).mockClear(); - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([ - { app_id: 200, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/b.bin"' }, - ]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue( + relaunchOptions([{ app_id: 200, launch_options: 'flatpak run net.retrodeck.retrodeck "/roms/b.bin"' }]), + ); act(() => { emitDeckyEvent<[SyncCompletePayload]>("sync_complete", { @@ -547,6 +867,144 @@ describe("index.tsx — sync_complete launch-options reconcile (#1151)", () => { ); plugin.onDismount(); }); + + it("holds the sync event lease until collection and sibling Steam continuations settle", async () => { + let finishCollections: (() => void) | undefined; + createOrUpdateCollections.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCollections = resolve; + }), + ); + const plugin = pluginFactory(); + await flush(); + vi.mocked(releasePruneConflictLease).mockClear(); + + act(() => { + emitDeckyEvent<[SyncCompletePayload]>("sync_complete", { + platform_app_ids: { SNES: [100] }, + total_games: 1, + prune_lease_token: "sync-complete-lease", + }); + }); + await vi.waitFor(() => + expect(createOrUpdateCollections).toHaveBeenCalledWith({ SNES: [100] }, undefined, expect.any(AbortSignal)), + ); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("sync-complete-lease"); + + finishCollections?.(); + await vi.waitFor(() => expect(releasePruneConflictLease).toHaveBeenCalledWith("sync-complete-lease")); + plugin.onDismount(); + }); + + it("plugin dismount defers sync lease release until a started collection save settles", async () => { + let finishCollections: (() => void) | undefined; + createOrUpdateCollections.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCollections = resolve; + }), + ); + const plugin = pluginFactory(); + await flush(); + vi.mocked(releasePruneConflictLease).mockClear(); + createOrUpdateRomMCollections.mockClear(); + + act(() => { + emitDeckyEvent<[SyncCompletePayload]>("sync_complete", { + platform_app_ids: { SNES: [100] }, + romm_collection_app_ids: { Favorites: [100] }, + total_games: 1, + prune_lease_token: "dismount-sync-lease", + }); + }); + await vi.waitFor(() => expect(createOrUpdateCollections).toHaveBeenCalled()); + + plugin.onDismount(); + await Promise.resolve(); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("dismount-sync-lease"); + + finishCollections?.(); + await vi.waitFor(() => expect(releasePruneConflictLease).toHaveBeenCalledWith("dismount-sync-lease")); + expect(createOrUpdateRomMCollections).not.toHaveBeenCalled(); + }); + + it("plugin dismount releases an installed-reconcile token that arrives afterward", async () => { + const plugin = pluginFactory(); + await flush(); + setLaunchOptionsConfirmed.mockClear(); + vi.mocked(getInstalledRelaunchOptions).mockReset(); + let resolveReconcile!: (value: ReturnType) => void; + vi.mocked(getInstalledRelaunchOptions).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveReconcile = resolve; + }), + ); + + act(() => { + emitDeckyEvent<[SyncCompletePayload]>("sync_complete", { + platform_app_ids: {}, + total_games: 0, + prune_lease_token: "outer-sync-lease", + }); + }); + await waitFor(() => expect(getInstalledRelaunchOptions).toHaveBeenCalled()); + plugin.onDismount(); + resolveReconcile({ + success: true, + items: [{ app_id: 100, launch_options: "cmd" }], + prune_lease_token: "late-installed-lease", + }); + + await vi.waitFor(() => expect(releasePruneConflictLease).toHaveBeenCalledWith("late-installed-lease")); + expect(setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + }); + + it("holds the sync event lease until the paced sync_stale tail settles", async () => { + const plugin = pluginFactory(); + await flush(); + vi.mocked(releasePruneConflictLease).mockClear(); + removeShortcut.mockClear(); + const remove = Array.from({ length: 26 }, (_, index) => ({ rom_id: index + 1, app_id: 1000 + index })); + + vi.useFakeTimers(); + try { + act(() => { + emitDeckyEvent<[SyncStaleData]>("sync_stale", { remove, prune_lease_token: "stale-event-lease" }); + }); + await act(async () => { + for (let index = 0; index < 40; index++) await Promise.resolve(); + }); + expect(removeShortcut).toHaveBeenCalledTimes(25); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("stale-event-lease"); + + act(() => { + emitDeckyEvent<[SyncCompletePayload]>("sync_complete", { + platform_app_ids: {}, + total_games: 0, + prune_lease_token: "stale-tail-lease", + }); + }); + await act(async () => { + for (let index = 0; index < 20; index++) await Promise.resolve(); + }); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("stale-tail-lease"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + expect(removeShortcut).toHaveBeenCalledTimes(26); + await vi.waitFor(() => expect(releasePruneConflictLease).toHaveBeenCalledWith("stale-tail-lease")); + expect(releasePruneConflictLease).toHaveBeenCalledWith("stale-event-lease"); + expect( + vi.mocked(releasePruneConflictLease).mock.calls.filter(([token]) => token === "stale-event-lease"), + ).toHaveLength(1); + } finally { + vi.useRealTimers(); + plugin.onDismount(); + } + }); }); describe("index.tsx — sync_complete registers RomM appIds (#1205)", () => { @@ -570,7 +1028,11 @@ describe("index.tsx — sync_complete registers RomM appIds (#1205)", () => { vi.mocked(getAppIdRomIdMap).mockResolvedValue({}); vi.mocked(getSettingsResetNotice).mockResolvedValue({ pending: false, backed_up_to: null }); vi.mocked(getInstalledRelaunchOptions).mockReset(); - vi.mocked(getInstalledRelaunchOptions).mockResolvedValue([]); + vi.mocked(getInstalledRelaunchOptions).mockResolvedValue({ + success: true, + items: [], + prune_lease_token: null, + }); // Empty collectionStore so the detached stale-cleanup is a no-op here. vi.stubGlobal("collectionStore", { userCollections: [] }); }); @@ -715,7 +1177,7 @@ describe("index.tsx — sync_complete stale-collection cleanup (#1040)", () => { emitSyncComplete({ platform_app_ids: { "Nintendo 64": [1] }, total_games: 1 }); await flush(); - expect(clearPlatformCollection).toHaveBeenCalledWith("Super Nintendo"); + expect(clearPlatformCollection).toHaveBeenCalledWith("Super Nintendo", expect.any(AbortSignal)); expect(faves.Delete).toHaveBeenCalledTimes(1); plugin.onDismount(); }); @@ -736,7 +1198,7 @@ describe("index.tsx — sync_complete stale-collection cleanup (#1040)", () => { expect(faves.Delete).not.toHaveBeenCalled(); // Non-vacuous: the stale SNES platform IS still cleaned, so cleanup ran. - expect(clearPlatformCollection).toHaveBeenCalledWith("Super Nintendo"); + expect(clearPlatformCollection).toHaveBeenCalledWith("Super Nintendo", expect.any(AbortSignal)); expect(snes.Delete).not.toHaveBeenCalled(); // platform delete routes via clearPlatformCollection plugin.onDismount(); }); @@ -814,7 +1276,7 @@ describe("index.tsx — sync_complete stale-collection cleanup (#1040)", () => { // The additive create/update path is NOT gated on cancel — the platforms // that DID complete still get their collections. - expect(createOrUpdateCollections).toHaveBeenCalledWith({ "Nintendo 64": [1] }); + expect(createOrUpdateCollections).toHaveBeenCalledWith({ "Nintendo 64": [1] }, undefined, expect.any(AbortSignal)); plugin.onDismount(); }); }); diff --git a/src/index.tsx b/src/index.tsx index 4f751472..c0cf3fb1 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,7 +13,12 @@ import { estimatePlanSeconds } from "./utils/syncEstimate"; import { beginEtaRun } from "./utils/syncEta"; import { updateDownload, getDownloadState, removeDownload } from "./utils/downloadStore"; import { handleGlobalDownloadFailure } from "./utils/downloadFailure"; -import { registerGameDetailPatch, unregisterGameDetailPatch, registerRomMAppId } from "./patches/gameDetailPatch"; +import { + registerGameDetailPatch, + unregisterGameDetailPatch, + registerRomMAppId, + unregisterRomMAppId, +} from "./patches/gameDetailPatch"; import { registerMetadataPatches, unregisterMetadataPatches, @@ -31,8 +36,10 @@ import { getSaveSortMigrationStatus, getInstalledRelaunchOptions, testConnection, + invalidateCachedGameDetail, logError, logInfo, + waitForPruneRelease, } from "./api/backend"; import { createOrUpdateCollections, @@ -62,8 +69,20 @@ import type { import { setLaunchOptionsConfirmed } from "./utils/steamShortcuts"; import { removeShortcutsPaced } from "./utils/shortcutRemoval"; import { batchConfirmLaunchOptions } from "./utils/launchOptionsReconcile"; +import { + capturePruneLeaseAdmission, + isPruneLeaseCancelled, + mountPruneLeasePlugin, + releaseAllPruneLeases, + withPruneLease, +} from "./utils/pruneLease"; import { withTimeout } from "./utils/withTimeout"; import { fetchMetadataCachePages } from "./utils/metadataCache"; +import { cancelPruneActions, handlePruneAction } from "./utils/pruneActions"; +import type { PruneActionRequired } from "./utils/pruneActions"; +import { admitPruneFrame, setPruneComplete, setPruneProgress } from "./utils/pruneStore"; +import type { PruneComplete, PruneProgress } from "./utils/pruneStore"; +import { publishCommittedVersionSwitch } from "./utils/versionSwitchApplication"; type Page = "main" | "settings" | "library" | "data" | "downloads" | "system"; @@ -182,6 +201,8 @@ function registerAppIds(map: Record): void { } export default definePlugin(() => { + mountPruneLeasePlugin(); + const pluginAdmission = capturePruneLeaseAdmission(); registerGameDetailPatch(); registerLaunchInterceptor(); @@ -196,6 +217,8 @@ export default definePlugin(() => { const METADATA_PAGE_SIZE = 500; let initAttempt = 0; let initDone = false; + let syncContinuationController = new AbortController(); + let staleRemovalTail: Promise = Promise.resolve(); async function loadAppIdsAndMetadata() { const appIdMap = await withTimeout(getAppIdRomIdMap(), CALLABLE_TIMEOUT); @@ -256,8 +279,16 @@ export default definePlugin(() => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `initDone` is flipped to true inside the awaited `loadAppIdsAndMetadata()`; TS's control-flow analysis can't see that cross-function mutation and narrows it to the `false` literal here. The guard is real: it gates the reconcile on the loop having actually reached a reachable backend. if (initDone) { try { - const items = await getInstalledRelaunchOptions(); - await batchConfirmLaunchOptions(items, "startup_reconcile"); + const result = await getInstalledRelaunchOptions(); + if (result.success) { + await withPruneLease( + result.prune_lease_token, + "Startup reconcile", + (signal) => batchConfirmLaunchOptions(result.items, "startup_reconcile", signal), + "Startup reconcile", + pluginAdmission, + ); + } } catch (e) { logError(`startup_reconcile: failed to reconcile launch options: ${e}`); } @@ -370,7 +401,9 @@ export default definePlugin(() => { * "restart Steam" nudge to the completion toast (#1383). */ restart_recommended?: boolean; + prune_lease_token?: string; }) => { + const syncAdmission = capturePruneLeaseAdmission(); logInfo(`sync_complete received: ${data.total_games} games, cancelled=${data.cancelled ?? false}`); const { body, duration } = buildSyncCompleteToast(data, getSyncDelta()); @@ -403,133 +436,120 @@ export default definePlugin(() => { registerAppIds(data.platform_app_ids); registerAppIds(data.romm_collection_app_ids ?? {}); - // Re-confirm launch_options for every installed+bound ROM after a sync. A - // normal sync skips unchanged platforms (no per-unit sync_apply_unit emit), - // so a shortcut whose launch_options drifted on an unchanged platform is - // otherwise healed only on the next plugin reload (startup reconcile) or - // Play press — never by the sync itself (#1151). Reuse the startup-reconcile - // mechanism: idempotent + appId-safe, runs regardless of cancellation. - detach( - (async () => { - try { - const items = await getInstalledRelaunchOptions(); - await batchConfirmLaunchOptions(items, "sync_reconcile"); - } catch (e) { - logError(`sync_reconcile: failed to reconcile launch options: ${e}`); + const reconcileLaunchOptions = async (signal: AbortSignal): Promise => { + try { + const result = await getInstalledRelaunchOptions(); + if (result.success) { + await withPruneLease( + result.prune_lease_token, + "Installed reconcile", + (innerSignal) => { + if (isPruneLeaseCancelled(innerSignal)) return Promise.resolve(); + return batchConfirmLaunchOptions(result.items, "sync_reconcile", signal); + }, + "Installed reconcile", + syncAdmission, + ); } - })(), - ); - - // Create/update platform and RomM Steam collections + clean stale ones - detach( - (async () => { - try { - // Create/update platform collections - if (Object.keys(data.platform_app_ids).length > 0) { - await createOrUpdateCollections(data.platform_app_ids); - } + } catch (e) { + logError(`sync_reconcile: failed to reconcile launch options: ${e}`); + } + }; - if (data.romm_collection_app_ids && Object.keys(data.romm_collection_app_ids).length > 0) { - await createOrUpdateRomMCollections(data.romm_collection_app_ids); + const reconcileCollections = async (signal: AbortSignal): Promise => { + try { + if (Object.keys(data.platform_app_ids).length > 0) { + await createOrUpdateCollections(data.platform_app_ids, undefined, signal); + } + if (isPruneLeaseCancelled(signal)) return; + if (data.romm_collection_app_ids && Object.keys(data.romm_collection_app_ids).length > 0) { + await createOrUpdateRomMCollections(data.romm_collection_app_ids, undefined, signal); + } + if (isPruneLeaseCancelled(signal)) return; + if (!data.cancelled && typeof collectionStore !== "undefined") { + const hostname = await getHostname(); + if (isPruneLeaseCancelled(signal)) return; + const suffix = ` (${hostname})`; + const activePlatforms = new Set(Object.keys(data.platform_app_ids).map((name) => name.toLowerCase())); + for (const collection of collectionStore.userCollections.filter((candidate) => { + if (!candidate.displayName.startsWith("RomM: ") || candidate.displayName.slice(6).startsWith("[")) + return false; + if (!candidate.displayName.endsWith(suffix)) return false; + return !activePlatforms.has( + candidate.displayName + .slice(6) + .replace(/\s\([^)]+\)$/, "") + .toLowerCase(), + ); + })) { + const platformName = collection.displayName.slice(6).replace(/\s\([^)]+\)$/, ""); + logInfo(`Removing stale platform collection "${collection.displayName}"`); + await clearPlatformCollection(platformName, signal); + if (isPruneLeaseCancelled(signal)) return; } - - // Stale-collection cleanup deletes any RomM collection not in - // data.platform_app_ids / romm_collection_app_ids. On a cancelled run - // those maps are PARTIAL (only the platforms reached before the cancel, - // empty if the cancel fired before unit 1), so treating them as the - // authoritative active-set would delete collections for unreached - // platforms — wiping library organization. Only run cleanup on a - // completed sync. The additive create/update above stays safe on a - // partial run. (#1040) - if (!data.cancelled && typeof collectionStore !== "undefined") { - const hostname = await getHostname(); - const suffix = ` (${hostname})`; - - // Clean stale platform collections. Name comparison is - // case-insensitive: Steam's collection identity is case-insensitive, - // so a case-variant that IS active must not be treated as stale and - // deleted (which would orphan its games, #1569). - const activePlatforms = new Set(Object.keys(data.platform_app_ids).map((n) => n.toLowerCase())); - const stalePlatform = collectionStore.userCollections.filter((c) => { - if (!c.displayName.startsWith("RomM: ")) return false; - const afterPrefix = c.displayName.slice(6); - if (afterPrefix.startsWith("[")) return false; // Skip RomM collections - if (!c.displayName.endsWith(suffix)) return false; // Only this machine - const platformName = afterPrefix.replace(/\s\([^)]+\)$/, ""); - return !activePlatforms.has(platformName.toLowerCase()); - }); - for (const c of stalePlatform) { - const afterPrefix = c.displayName.slice(6); - const platformName = afterPrefix.replace(/\s\([^)]+\)$/, ""); - logInfo(`Removing stale platform collection "${c.displayName}"`); - await clearPlatformCollection(platformName); - } - - // Clean stale RomM collection-based collections. Name comparison is - // case-insensitive (Steam's case-insensitive collection identity), so a - // case-variant that IS active is kept, not deleted/orphaned (#1569). - const activeNames = new Set(Object.keys(data.romm_collection_app_ids ?? {}).map((n) => n.toLowerCase())); - const rommCollectionPattern = /^RomM: \[([^\]]+)\]/; - const staleRomm = collectionStore.userCollections.filter((c) => { - if (!c.displayName.startsWith("RomM: [")) return false; - if (!c.displayName.endsWith(suffix)) return false; - const match = rommCollectionPattern.exec(c.displayName); - return match ? !activeNames.has(match[1]!.toLowerCase()) : false; // group 1 present whenever match is non-null - }); - for (const c of staleRomm) { - logInfo(`Removing stale RomM collection "${c.displayName}"`); - await c.Delete(); - } + const activeNames = new Set( + Object.keys(data.romm_collection_app_ids ?? {}).map((name) => name.toLowerCase()), + ); + const pattern = /^RomM: \[([^\]]+)\]/; + for (const collection of collectionStore.userCollections.filter((candidate) => { + if (!candidate.displayName.startsWith("RomM: [") || !candidate.displayName.endsWith(suffix)) return false; + const match = pattern.exec(candidate.displayName); + return match ? !activeNames.has(match[1]!.toLowerCase()) : false; + })) { + logInfo(`Removing stale RomM collection "${collection.displayName}"`); + if (isPruneLeaseCancelled(signal)) return; + await collection.Delete(); } - } catch (e) { - logError(`Failed to manage RomM collections: ${e}`); } - })(), - ); + } catch (e) { + logError(`Failed to manage RomM collections: ${e}`); + } + }; - // Re-apply playtime to Steam UI (app IDs may have changed after re-sync) - detach( - (async () => { - try { - const [{ playtime }, appIdMap] = await Promise.all([getAllPlaytime(), getAppIdRomIdMap()]); - await applyAllPlaytime(playtime, appIdMap); - } catch (e) { - logError(`Failed to re-apply playtime after sync: ${e}`); - } - })(), - ); + const reconcilePlaytime = async (signal: AbortSignal): Promise => { + try { + const [{ playtime }, appIdMap] = await Promise.all([getAllPlaytime(), getAppIdRomIdMap()]); + if (isPruneLeaseCancelled(signal)) return; + await applyAllPlaytime(playtime, appIdMap, signal); + } catch (e) { + logError(`Failed to re-apply playtime after sync: ${e}`); + } + }; - // Re-apply overview metadata (controller badge / metacritic score / store - // categories) to the freshly-synced ROMs. Init runs this pass once on mount, - // but onSyncComplete otherwise never re-runs it or refreshes the module-level - // metadata cache — so a ROM synced this session misses those overview fields - // until the next plugin mount (#1207). Re-fetch the full paged cache + the - // appId map, re-register the patches with the fresh data (replacing the - // init-time state), and re-apply. Mirrors the playtime re-apply above and - // runs on EVERY sync_complete, cancelled included: a partial run's committed - // units still have fresh metadata and applyAllMetadata is idempotent. - // Detached with its own try/catch so a re-fetch failure never breaks the - // toast, collections, or playtime paths. - // - // Known last-writer-wins (deliberately no generation guard): if init is still - // on its #1206 retry ladder when a sync completes, a late init registration - // can land after this one and overwrite the fresher sync-time cache — a ROM - // synced this session then falls back to pre-fix behavior until the next - // sync_complete or mount. Self-healing and never below the old baseline, so - // it isn't worth the guard. + const reconcileMetadata = async (signal: AbortSignal): Promise => { + try { + const [cache, appIdMap] = await Promise.all([ + fetchMetadataCachePages(METADATA_PAGE_SIZE, CALLABLE_TIMEOUT), + getAppIdRomIdMap(), + ]); + if (isPruneLeaseCancelled(signal)) return; + registerMetadataPatches(cache, appIdMap); + await applyAllMetadata(signal); + } catch (e) { + logError(`Failed to re-apply metadata after sync: ${e}`); + } + }; + + const continuationController = syncContinuationController; + const staleRemovals = staleRemovalTail; detach( - (async () => { + withPruneLease(data.prune_lease_token, "Sync completion", async (leaseSignal) => { + const abort = () => continuationController.abort(); + if (leaseSignal.aborted) abort(); + else leaseSignal.addEventListener("abort", abort, { once: true }); try { - const [cache, appIdMap] = await Promise.all([ - fetchMetadataCachePages(METADATA_PAGE_SIZE, CALLABLE_TIMEOUT), - getAppIdRomIdMap(), + const signal = continuationController.signal; + await Promise.all([ + staleRemovals, + reconcileLaunchOptions(signal), + reconcileCollections(signal), + reconcilePlaytime(signal), + reconcileMetadata(signal), ]); - registerMetadataPatches(cache, appIdMap); - await applyAllMetadata(); - } catch (e) { - logError(`Failed to re-apply metadata after sync: ${e}`); + } finally { + leaseSignal.removeEventListener("abort", abort); } - })(), + }), ); }; @@ -549,6 +569,8 @@ export default definePlugin(() => { // ``sync_plan`` arrives once per run with the full work queue (info only // for now — future PR adds a per-platform progress view). const syncPlanListener = addEventListener<[SyncPlanData]>("sync_plan", (data: SyncPlanData) => { + syncContinuationController.abort(); + syncContinuationController = new AbortController(); // sync_plan fires once per run, before any unit — reset the per-run delta // so the terminal toast counts only this run's created/removed shortcuts. resetSyncDelta(); @@ -608,7 +630,7 @@ export default definePlugin(() => { // shortcut by the ``app_id`` the backend captured BEFORE unbinding the // row. Resolving rom_id→app_id here (via getExistingRomMShortcuts) would // race the backend unbind and find nothing, orphaning the shortcut. - const syncStaleListener = addEventListener<[SyncStaleData]>("sync_stale", async (data: SyncStaleData) => { + const syncStaleListener = addEventListener<[SyncStaleData]>("sync_stale", (data: SyncStaleData) => { if (!Array.isArray(data.remove) || data.remove.length === 0) return; // Collect the valid app_ids and record the "removed" delta for each UP FRONT — // synchronously, before the first paced breather. recordSyncRemoved is a cheap, @@ -627,8 +649,22 @@ export default definePlugin(() => { recordSyncRemoved(app_id); } } - await removeShortcutsPaced(appIds); - logInfo(`sync_stale: removed ${data.remove.length} stale shortcuts`); + const continuationController = syncContinuationController; + const previousTail = staleRemovalTail; + staleRemovalTail = withPruneLease(data.prune_lease_token, "Sync stale removal", async (leaseSignal) => { + const abort = () => continuationController.abort(); + if (leaseSignal.aborted) abort(); + else leaseSignal.addEventListener("abort", abort, { once: true }); + try { + await previousTail; + const signal = continuationController.signal; + if (isPruneLeaseCancelled(signal)) return; + await removeShortcutsPaced(appIds, undefined, signal); + if (!isPruneLeaseCancelled(signal)) logInfo(`sync_stale: removed ${data.remove.length} stale shortcuts`); + } finally { + leaseSignal.removeEventListener("abort", abort); + } + }); }); // ``sync_collections`` arrives at the end of the per-unit run with the @@ -712,7 +748,10 @@ export default definePlugin(() => { detach( (async () => { try { - const ok = await setLaunchOptionsConfirmed(appId, data.launch_options); + const ok = await withPruneLease(data.prune_lease_token, "Download completion", async (signal) => { + if (isPruneLeaseCancelled(signal)) return false; + return setLaunchOptionsConfirmed(appId, data.launch_options); + }); if (!ok) { logError(`download_complete: failed to confirm launch options for rom ${data.rom_id} (appId ${appId})`); } @@ -773,12 +812,15 @@ export default definePlugin(() => { // After a RetroDECK-home migration the backend rewrites each installed ROM's // launch command to the new path and emits the new command per shortcut. // Confirm-set each so existing shortcuts launch from the migrated location. - const migrationRelaunchListener = addEventListener<[{ items: { app_id: number; launch_options: string }[] }]>( - "migration_relaunch_options", - (data) => { - detach(batchConfirmLaunchOptions(data.items, "migration_relaunch_options")); - }, - ); + const migrationRelaunchListener = addEventListener< + [{ items: { app_id: number; launch_options: string }[]; prune_lease_token?: string }] + >("migration_relaunch_options", (data) => { + detach( + withPruneLease(data.prune_lease_token, "RetroDECK migration", (signal) => + batchConfirmLaunchOptions(data.items, "migration_relaunch_options", signal), + ), + ); + }); // Server retry-ladder progress (#1345): the backend emits one frame per HTTP // retry so the saves surfaces can show "Connecting to RomM… (attempt N/M)". @@ -791,12 +833,114 @@ export default definePlugin(() => { }, ); + // Destructive cleanup actions must keep running even when the Danger Zone or + // game-detail picker unmounts. The backend emits one tokenized action at a + // time; this root handler owns every Steam API mutation and reports the exact + // token outcome before backend filesystem/SQLite finalization can proceed. + const publishPruneSwitches = async ( + runId: string, + pending: Array<{ appId: number; romId: number }>, + leaseToken: string | undefined, + ): Promise => { + if (!leaseToken) { + logError("Cleanup publication was skipped because its continuation lease was missing."); + return; + } + await withPruneLease( + leaseToken, + "Cleanup repoint publication", + async (signal) => { + let lastMessage = "Cleanup claim release was not confirmed."; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const released = await withTimeout(waitForPruneRelease(runId), 6000); + if (released.success) { + for (const item of pending) { + if (isPruneLeaseCancelled(signal)) return; + await publishCommittedVersionSwitch(item.appId, item.romId, undefined, signal); + } + return; + } + lastMessage = released.message; + } catch (e) { + lastMessage = e instanceof Error ? e.message : String(e); + } + if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 100)); + } + logError(`Cleanup publication could not confirm claim release: ${lastMessage}`); + }, + "root", + pluginAdmission, + ); + }; + + const pruneActionListener = addEventListener<[PruneActionRequired]>( + "prune_action_required", + (action: PruneActionRequired) => { + if (!admitPruneFrame(action.preview_id, action.run_id)) return; + detach(handlePruneAction(action)); + }, + ); + + const pruneProgressListener = addEventListener<[PruneProgress]>("prune_progress", (progress: PruneProgress) => + setPruneProgress(progress), + ); + + const pruneCompleteListener = addEventListener<[PruneComplete]>("prune_complete", (result: PruneComplete) => { + const completed = setPruneComplete(result); + if (!completed) return; + for (const appId of completed.affected_app_ids) invalidateCachedGameDetail(appId); + for (const appId of completed.removed_app_ids ?? []) unregisterRomMAppId(appId); + globalThis.dispatchEvent( + new CustomEvent("romm_data_changed", { + detail: { + type: "rom_pruned", + app_ids: completed.affected_app_ids, + rom_ids: completed.removed_rom_ids, + }, + }), + ); + cancelPruneActions(); + const publications: Array<{ appId: number; romId: number }> = []; + for (const item of completed.results) { + if ( + item.committed_action === "repoint_shortcut" && + !item.action_ambiguous && + item.app_id !== undefined && + item.target_rom_id !== undefined + ) { + publications.push({ appId: item.app_id, romId: item.target_rom_id }); + } + } + if (publications.length) { + detach(publishPruneSwitches(completed.run_id, publications, completed.prune_lease_token)); + } + const removed = completed.removed_count ?? completed.removed_rom_ids.length; + const skipped = + completed.problem_count ?? + completed.results.filter((item) => ["failed", "skipped", "partial"].includes(item.status)).length; + const ambiguousPartial = completed.results.find((item) => item.status === "partial" && item.action_ambiguous); + const committedPartial = completed.results.find((item) => item.status === "partial" && item.committed_action); + toaster.toast({ + title: "RomM Sync", + body: ambiguousPartial + ? `${ambiguousPartial.committed_action === "remove_shortcut" ? "Shortcut removal" : "Shortcut repoint"} outcome is uncertain; source data was retained.` + : committedPartial + ? `${committedPartial.committed_action === "remove_shortcut" ? "Shortcut removal" : "Shortcut repoint"} committed; local cleanup incomplete.` + : removed + ? `Removed ${removed} local entr${removed === 1 ? "y" : "ies"}${skipped ? `; ${skipped} group(s) skipped` : ""}.` + : completed.message || "No removed RomM games were cleaned up.", + subtext: ambiguousPartial?.message ?? committedPartial?.message ?? completed.message, + }); + }); + return { name: "RomM Sync", icon: , content: , alwaysRender: true, onDismount() { + syncContinuationController.abort(); destroySessionManager(); unregisterLaunchInterceptor(); unregisterGameDetailPatch(); @@ -815,6 +959,11 @@ export default definePlugin(() => { removeEventListener("save_status_updated", saveStatusListener); removeEventListener("migration_relaunch_options", migrationRelaunchListener); removeEventListener("server_retry_progress", serverRetryListener); + removeEventListener("prune_action_required", pruneActionListener); + cancelPruneActions(); + detach(releaseAllPruneLeases()); + removeEventListener("prune_progress", pruneProgressListener); + removeEventListener("prune_complete", pruneCompleteListener); }, }; }); diff --git a/src/patches/gameDetailPatch.tsx b/src/patches/gameDetailPatch.tsx index e3a12584..b60555c0 100755 --- a/src/patches/gameDetailPatch.tsx +++ b/src/patches/gameDetailPatch.tsx @@ -158,6 +158,10 @@ export function registerRomMAppId(appId: number) { rommAppIds.add(appId); } +export function unregisterRomMAppId(appId: number) { + rommAppIds.delete(appId); +} + export function isRomMAppId(appId: number): boolean { return rommAppIds.has(appId); } diff --git a/src/patches/metadataPatches.test.ts b/src/patches/metadataPatches.test.ts index 8d886a31..ce637c1e 100644 --- a/src/patches/metadataPatches.test.ts +++ b/src/patches/metadataPatches.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { registerMetadataPatches, applyAllMetadata } from "./metadataPatches"; +import { registerMetadataPatches, applyAllMetadata, applyAllPlaytime, updatePlaytimeDisplay } from "./metadataPatches"; import type { RomMetadata } from "../types"; // RomMetadata has several required fields; build a full object and override the @@ -86,4 +86,50 @@ describe("applyAllMetadata (#1203 readiness retry)", () => { expect([...ov.m_setStoreCategories].sort((a, b) => a - b)).toEqual([5, 7]); expect(ov.controller_support).toBe(2); }); + + it("does not retry metadata mutations after cancellation", async () => { + const ov = makeOverview(100); + vi.mocked(appStore.GetAppOverviewByAppID) + .mockReturnValueOnce(null) + .mockReturnValue(ov as unknown as SteamAppOverview); + registerMetadataPatches({ "10": makeMeta({ average_rating: 70 }) }, APP_ID_MAP); + const controller = new AbortController(); + + const done = applyAllMetadata(controller.signal); + controller.abort(); + await vi.advanceTimersByTimeAsync(1000); + await done; + + expect(appStore.GetAppOverviewByAppID).toHaveBeenCalledTimes(1); + expect(ov.controller_support).toBe(0); + }); + + it("does not retry playtime mutations after cancellation", async () => { + const overview = { minutes_playtime_forever: 0, rt_last_time_played: 0 }; + vi.mocked(appStore.GetAppOverviewByAppID) + .mockReturnValueOnce(null) + .mockReturnValue(overview as unknown as SteamAppOverview); + const controller = new AbortController(); + + const done = applyAllPlaytime({ "10": { total_seconds: 600 } }, APP_ID_MAP, controller.signal); + controller.abort(); + await vi.advanceTimersByTimeAsync(1000); + await done; + + expect(appStore.GetAppOverviewByAppID).toHaveBeenCalledTimes(1); + expect(overview.minutes_playtime_forever).toBe(0); + }); +}); + +describe("updatePlaytimeDisplay", () => { + it("rejects non-finite totals without mutating the Steam overview", () => { + const overview = { minutes_playtime_forever: 12, rt_last_time_played: 34 }; + vi.stubGlobal("appStore", { + GetAppOverviewByAppID: vi.fn().mockReturnValue(overview), + allApps: [], + }); + + expect(updatePlaytimeDisplay(100, Number.NaN, false)).toBe(false); + expect(overview).toEqual({ minutes_playtime_forever: 12, rt_last_time_played: 34 }); + }); }); diff --git a/src/patches/metadataPatches.ts b/src/patches/metadataPatches.ts index b77c6407..17d9c5d5 100644 --- a/src/patches/metadataPatches.ts +++ b/src/patches/metadataPatches.ts @@ -67,9 +67,10 @@ export function registerMetadataPatches(cache: Record, appI * Attempt one pass of metadata mutations over the given appIds. Returns the * appIds whose appStore overview wasn't available yet, so the caller can retry. */ -function tryApplyMetadata(appIds: number[]): number[] { +function tryApplyMetadata(appIds: number[], signal?: AbortSignal): number[] { const notApplied: number[] = []; for (const appId of appIds) { + if (signal?.aborted) break; const meta = getMetadataForAppId(appId); if (!meta) continue; // no metadata for this app — nothing to apply, not a retry case if (!applyDirectMutations(appId, meta)) notApplied.push(appId); @@ -87,7 +88,7 @@ function tryApplyMetadata(appIds: number[]): number[] { * retry. Idempotent (re-applying the same values is safe), so retries can't * corrupt state. Call after {@link registerMetadataPatches}. */ -export async function applyAllMetadata(): Promise { +export async function applyAllMetadata(signal?: AbortSignal): Promise { let pending = [...registeredAppIds].filter((appId) => getMetadataForAppId(appId) != null); if (pending.length === 0) return; @@ -98,8 +99,9 @@ export async function applyAllMetadata(): Promise { // attempt < delays.length (loop guard) ⇒ index in bounds await new Promise((r) => setTimeout(r, delays[attempt])); } + if (signal?.aborted) return; - pending = tryApplyMetadata(pending); + pending = tryApplyMetadata(pending, signal); if (pending.length > 0 && attempt < delays.length - 1) { detach( @@ -134,6 +136,10 @@ export function unregisterMetadataPatches() { * Returns true if the write succeeded, false if the overview wasn't available. */ export function updatePlaytimeDisplay(appId: number, totalSeconds: number, updateLastPlayed = true): boolean { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + detach(debugLog(`updatePlaytimeDisplay: appId=${appId} invalid total=${totalSeconds}, skipping`)); + return false; + } const overview = appStore.GetAppOverviewByAppID(appId); if (!overview) { detach(debugLog(`updatePlaytimeDisplay: appId=${appId} overview=null, skipping`)); @@ -171,9 +177,10 @@ type PlaytimeItem = { appId: number; totalSeconds: number }; * Attempt one pass of playtime writes. Returns items whose appStore overview * wasn't available yet, so the caller can retry them after a delay. */ -function tryWritePlaytime(items: PlaytimeItem[]): PlaytimeItem[] { +function tryWritePlaytime(items: PlaytimeItem[], signal?: AbortSignal): PlaytimeItem[] { const notWritten: PlaytimeItem[] = []; for (const item of items) { + if (signal?.aborted) break; if (!updatePlaytimeDisplay(item.appId, item.totalSeconds, false)) { notWritten.push(item); } @@ -190,6 +197,7 @@ function tryWritePlaytime(items: PlaytimeItem[]): PlaytimeItem[] { export async function applyAllPlaytime( playtimeMap: Record, appIdMap: Record, + signal?: AbortSignal, ) { // Build rom_id -> app_id reverse lookup const romIdToAppId: Record = {}; @@ -221,8 +229,9 @@ export async function applyAllPlaytime( // attempt < delays.length (loop guard) ⇒ index in bounds await new Promise((r) => setTimeout(r, delays[attempt])); } + if (signal?.aborted) return; - pending = tryWritePlaytime(pending); + pending = tryWritePlaytime(pending, signal); if (pending.length > 0 && attempt < delays.length - 1) { detach( diff --git a/src/test-setup.ts b/src/test-setup.ts index 14375220..cd50e30b 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -119,12 +119,18 @@ vi.mock("@decky/ui", () => { children, style, onButtonDown, + role, + tabIndex, + "aria-label": ariaLabel, }: AnyProps & { style?: unknown; onButtonDown?: (evt: unknown) => void }) => createElement( "div", { "data-testid": "focusable", style, + role, + tabIndex, + "aria-label": ariaLabel, ref: (el: HTMLDivElement | null) => { if (!el) return; const prev = (el as unknown as { _deckyButtonDown?: EventListener })._deckyButtonDown; diff --git a/src/types/downloads.ts b/src/types/downloads.ts index 25586fd3..fc3c5198 100644 --- a/src/types/downloads.ts +++ b/src/types/downloads.ts @@ -50,6 +50,7 @@ export interface DownloadCompleteEvent { launch_options: string; /** Whether the just-finished transfer was resumable (carried for store parity). */ resumable?: boolean; + prune_lease_token?: string; } export interface DownloadFailedEvent { diff --git a/src/types/events.d.ts b/src/types/events.d.ts index 8f3b00bb..695df0db 100644 --- a/src/types/events.d.ts +++ b/src/types/events.d.ts @@ -18,7 +18,8 @@ export type RommDataChangedDetail = | { type: "bios"; platform_slug: string } | { type: "core_changed"; platform_slug: string } | { type: "cover_refreshed"; rom_id: number } - | { type: "version_switched"; app_id: number; rom_id: number }; + | { type: "version_switched"; app_id: number; rom_id: number } + | { type: "rom_pruned"; app_ids: number[]; rom_ids: number[] }; export interface RommRomUninstalledDetail { rom_id: number; diff --git a/src/types/steam.d.ts b/src/types/steam.d.ts index 0ba124f3..fd07ad88 100755 --- a/src/types/steam.d.ts +++ b/src/types/steam.d.ts @@ -53,6 +53,8 @@ interface SteamAppDetails { // The shortcut's executable path. Used as the RomM ownership marker: // shortcuts whose exe ends in `/bin/rom-launcher` are ours. strShortcutExe?: string; + strDisplayName?: string; + strShortcutStartDir?: string; } interface SteamPerClientData { diff --git a/src/types/sync.ts b/src/types/sync.ts index e8d2decd..8e938098 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -374,6 +374,7 @@ export interface SyncStaleData { * Steam shortcut to remove. */ remove: { rom_id: number; app_id: number }[]; + prune_lease_token?: string; } export interface SyncCollectionsData { diff --git a/src/utils/artwork.test.ts b/src/utils/artwork.test.ts index 3e2f099c..214ffb1e 100644 --- a/src/utils/artwork.test.ts +++ b/src/utils/artwork.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import * as backend from "../api/backend"; -import { applyArtwork } from "./artwork"; +import { applyArtwork, cancelArtworkApply } from "./artwork"; describe("applyArtwork", () => { beforeEach(() => { @@ -51,6 +51,53 @@ describe("applyArtwork", () => { expect(vi.mocked(SteamClient.Apps.SetShortcutIcon)).toHaveBeenCalledWith(5000, "/grid/5000_icon.png"); }); + it("holds every SGDB fetch lease through the final Steam artwork write", async () => { + const write = deferred(); + vi.mocked(backend.getSgdbArtworkBase64) + .mockResolvedValueOnce({ base64: "AA==", no_api_key: false, prune_lease_token: "lease-1" }) + .mockResolvedValueOnce({ base64: null, no_api_key: false, prune_lease_token: "lease-2" }) + .mockResolvedValueOnce({ base64: null, no_api_key: false, prune_lease_token: "lease-3" }) + .mockResolvedValueOnce({ base64: null, no_api_key: false, prune_lease_token: "lease-4" }); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + vi.mocked(SteamClient.Apps.SetCustomArtworkForApp).mockImplementationOnce(() => write.promise); + + const applying = applyArtwork(42, 5000); + await vi.waitFor(() => expect(SteamClient.Apps.SetCustomArtworkForApp).toHaveBeenCalled()); + expect(backend.releasePruneConflictLease).not.toHaveBeenCalled(); + + write.resolve(undefined); + await expect(applying).resolves.toBe(1); + expect( + vi + .mocked(backend.releasePruneConflictLease) + .mock.calls.map(([token]) => token) + .sort(), + ).toEqual(["lease-1", "lease-2", "lease-3", "lease-4"]); + }); + + it("component cancellation retains artwork leases until a pending Steam write settles", async () => { + const write = deferred(); + vi.mocked(backend.getSgdbArtworkBase64).mockResolvedValue({ + base64: "AA==", + no_api_key: false, + prune_lease_token: "artwork-lease", + }); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + vi.mocked(SteamClient.Apps.SetCustomArtworkForApp).mockImplementationOnce(() => write.promise); + + const applying = applyArtwork(42, 5000); + await vi.waitFor(() => expect(SteamClient.Apps.SetCustomArtworkForApp).toHaveBeenCalled()); + const cancelling = cancelArtworkApply(5000); + await Promise.resolve(); + expect(backend.releasePruneConflictLease).not.toHaveBeenCalledWith("artwork-lease"); + + write.resolve(undefined); + await applying; + await cancelling; + expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("artwork-lease"); + expect(SteamClient.Apps.SetCustomArtworkForApp).toHaveBeenCalledTimes(1); + }); + it("does not call SetShortcutIcon when saveShortcutIcon reports failure", async () => { vi.mocked(backend.getSgdbArtworkBase64) .mockResolvedValueOnce({ base64: null, no_api_key: false }) diff --git a/src/utils/artwork.ts b/src/utils/artwork.ts index d9c94ec6..d9f9c908 100644 --- a/src/utils/artwork.ts +++ b/src/utils/artwork.ts @@ -8,6 +8,13 @@ import { getSgdbArtworkBase64, saveShortcutIcon, debugLog } from "../api/backend"; import { detach } from "./detach"; +import { + capturePruneLeaseAdmission, + isPruneLeaseCancelled, + mountPruneLeaseOwner, + releasePruneLeasesByOwner, + withPruneLeases, +} from "./pruneLease"; /** * Newest-apply-wins guard. Each appId's most recent applyArtwork call claims a @@ -26,8 +33,10 @@ const artworkGenerations = new Map(); * (Steam owns shortcuts.vdf in memory and clobbers external writes). Returns * whether the icon was actually applied — a save failure or a missing path is not. */ -async function applyIcon(appId: number, base64: string): Promise { +async function applyIcon(appId: number, base64: string, signal: AbortSignal): Promise { + if (isPruneLeaseCancelled(signal)) return false; const iconResult = await saveShortcutIcon(appId, base64); + if (isPruneLeaseCancelled(signal)) return false; if (iconResult.success && iconResult.icon_path) { SteamClient.Apps.SetShortcutIcon(appId, iconResult.icon_path); return true; @@ -39,6 +48,9 @@ async function applyIcon(appId: number, base64: string): Promise { * Returns count of successfully applied images, or -1 when no SGDB API * key is configured. */ export async function applyArtwork(romId: number, appId: number): Promise { + const leaseOwner = `artwork:${appId}`; + mountPruneLeaseOwner(leaseOwner); + const admission = capturePruneLeaseAdmission(leaseOwner); const generation = (artworkGenerations.get(appId) ?? 0) + 1; artworkGenerations.set(appId, generation); const superseded = (): boolean => artworkGenerations.get(appId) !== generation; @@ -50,35 +62,53 @@ export async function applyArtwork(romId: number, appId: number): Promise ({ base64: null, no_api_key: false })), ]); - if (results.some((r) => r.no_api_key)) return -1; + const leaseTokens = results.map((result) => ("prune_lease_token" in result ? result.prune_lease_token : undefined)); + if (results.some((r) => r.no_api_key)) { + return withPruneLeases(leaseTokens, "Artwork apply", async () => -1, leaseOwner, admission); + } - let applied = 0; - // A later apply for this appId can start during any of the awaits above/below, - // so re-check before every Steam write. Once superseded, go silent (log once, - // keep whatever this call already wrote) instead of overwriting the newer art. - const bail = (): number => { - detach(debugLog(`applyArtwork: superseded for appId ${appId}, skipping stale writes`)); - return applied; - }; + return withPruneLeases( + leaseTokens, + "Artwork apply", + async (signal) => { + let applied = 0; + // A later apply for this appId can start during any of the awaits above/below, + // so re-check before every Steam write. Once superseded, go silent (log once, + // keep whatever this call already wrote) instead of overwriting the newer art. + const bail = (): number => { + detach(debugLog(`applyArtwork: superseded for appId ${appId}, skipping stale writes`)); + return applied; + }; - // SGDB types 1-3 (hero / logo / wide grid) map 1:1 to Steam capsule assetTypes 1-3. - const customArt: Array<[base64: string | null, assetType: number]> = [ - [results[0].base64, 1], - [results[1].base64, 2], - [results[2].base64, 3], - ]; - for (const [base64, assetType] of customArt) { - if (!base64) continue; - if (superseded()) return bail(); - await SteamClient.Apps.SetCustomArtworkForApp(appId, base64, "png", assetType); - applied++; - } + // SGDB types 1-3 (hero / logo / wide grid) map 1:1 to Steam capsule assetTypes 1-3. + const customArt: Array<[base64: string | null, assetType: number]> = [ + [results[0].base64, 1], + [results[1].base64, 2], + [results[2].base64, 3], + ]; + for (const [base64, assetType] of customArt) { + if (!base64) continue; + if (isPruneLeaseCancelled(signal)) return applied; + if (superseded()) return bail(); + await SteamClient.Apps.SetCustomArtworkForApp(appId, base64, "png", assetType); + applied++; + } - // Type 4 = icon (a distinct apply path — see applyIcon). - if (results[3].base64) { - if (superseded()) return bail(); - if (await applyIcon(appId, results[3].base64)) applied++; - } + // Type 4 = icon (a distinct apply path — see applyIcon). + if (results[3].base64) { + if (isPruneLeaseCancelled(signal)) return applied; + if (superseded()) return bail(); + if (await applyIcon(appId, results[3].base64, signal)) applied++; + } + + return applied; + }, + leaseOwner, + admission, + ); +} - return applied; +export async function cancelArtworkApply(appId: number): Promise { + artworkGenerations.set(appId, (artworkGenerations.get(appId) ?? 0) + 1); + await releasePruneLeasesByOwner(`artwork:${appId}`); } diff --git a/src/utils/collections.test.ts b/src/utils/collections.test.ts index d0aa1825..9ad3e1ac 100644 --- a/src/utils/collections.test.ts +++ b/src/utils/collections.test.ts @@ -83,6 +83,28 @@ describe("createOrUpdateCollections (platform) — case-insensitive identity (#1 expect(NewUnsavedCollection).not.toHaveBeenCalled(); expect(existing.Save).toHaveBeenCalledTimes(1); }); + + it("does not begin another collection mutation after cancellation", async () => { + let settle!: () => void; + const first = fakeCollection("RomM: First (test)"); + const pendingSave = new Promise((resolve) => { + settle = resolve; + }); + first.Save.mockReturnValueOnce(pendingSave); + const second = fakeCollection("RomM: Second (test)"); + vi.stubGlobal("collectionStore", { userCollections: [first, second], NewUnsavedCollection: vi.fn() }); + const controller = new AbortController(); + const applying = createOrUpdateCollections({ First: [1], Second: [2] }, undefined, controller.signal); + await Promise.resolve(); + + controller.abort(); + settle(); + await applying; + + expect(first.Save).toHaveBeenCalledTimes(1); + expect(second.dnd.AddApps).not.toHaveBeenCalled(); + expect(second.Save).not.toHaveBeenCalled(); + }); }); describe("clearPlatformCollection — case-insensitive identity (#1569)", () => { diff --git a/src/utils/collections.ts b/src/utils/collections.ts index 825d60cb..3fda53da 100755 --- a/src/utils/collections.ts +++ b/src/utils/collections.ts @@ -40,6 +40,7 @@ function getOverviews(appIds: number[]): AppStoreOverview[] { export async function createOrUpdateCollections( platformAppIds: Record, onProgress?: (current: number, total: number, name: string) => void, + signal?: AbortSignal, ): Promise { try { if (typeof collectionStore === "undefined") { @@ -48,6 +49,7 @@ export async function createOrUpdateCollections( } const hostname = await getHostname(); + if (signal?.aborted) return; logInfo( `Creating/updating collections for platforms: ${Object.keys(platformAppIds).join(", ")} (hostname: ${hostname})`, ); @@ -55,6 +57,7 @@ export async function createOrUpdateCollections( const entries = Object.entries(platformAppIds); let idx = 0; for (const [platformName, appIds] of entries) { + if (signal?.aborted) return; idx++; onProgress?.(idx, entries.length, platformName); const collectionName = `RomM: ${platformName} (${hostname})`; @@ -72,14 +75,19 @@ export async function createOrUpdateCollections( logInfo(`Updating collection "${collectionName}" with ${appIds.length} apps`); const existingApps = existing.allApps; if (existingApps.length > 0) { + if (signal?.aborted) return; existing.AsDragDropCollection().RemoveApps(existingApps); } + if (signal?.aborted) return; existing.AsDragDropCollection().AddApps(overviews); + if (signal?.aborted) return; await existing.Save(); } else { logInfo(`Creating collection "${collectionName}" with ${appIds.length} apps`); const collection = collectionStore.NewUnsavedCollection(collectionName, undefined, []); + if (signal?.aborted) return; collection.AsDragDropCollection().AddApps(overviews); + if (signal?.aborted) return; await collection.Save(); } logInfo(`Successfully saved collection "${collectionName}"`); @@ -95,6 +103,7 @@ export async function createOrUpdateCollections( export async function createOrUpdateRomMCollections( collectionAppIds: Record, onProgress?: (current: number, total: number, name: string) => void, + signal?: AbortSignal, ): Promise { try { if (typeof collectionStore === "undefined") { @@ -103,11 +112,13 @@ export async function createOrUpdateRomMCollections( } const hostname = await getHostname(); + if (signal?.aborted) return; logInfo(`Creating/updating RomM collections: ${Object.keys(collectionAppIds).join(", ")} (hostname: ${hostname})`); const entries = Object.entries(collectionAppIds); let idx = 0; for (const [collName, appIds] of entries) { + if (signal?.aborted) return; idx++; onProgress?.(idx, entries.length, collName); const collectionName = `RomM: [${collName}] (${hostname})`; @@ -125,14 +136,19 @@ export async function createOrUpdateRomMCollections( logInfo(`Updating RomM collection "${collectionName}" with ${appIds.length} apps`); const existingApps = existing.allApps; if (existingApps.length > 0) { + if (signal?.aborted) return; existing.AsDragDropCollection().RemoveApps(existingApps); } + if (signal?.aborted) return; existing.AsDragDropCollection().AddApps(overviews); + if (signal?.aborted) return; await existing.Save(); } else { logInfo(`Creating RomM collection "${collectionName}" with ${appIds.length} apps`); const collection = collectionStore.NewUnsavedCollection(collectionName, undefined, []); + if (signal?.aborted) return; collection.AsDragDropCollection().AddApps(overviews); + if (signal?.aborted) return; await collection.Save(); } logInfo(`Successfully saved RomM collection "${collectionName}"`); @@ -145,13 +161,14 @@ export async function createOrUpdateRomMCollections( } } -export async function clearPlatformCollection(platformName: string): Promise { +export async function clearPlatformCollection(platformName: string, signal?: AbortSignal): Promise { try { if (typeof collectionStore === "undefined") { logWarn("collectionStore not available, cannot clear platform collection"); return; } const hostname = await getHostname(); + if (signal?.aborted) return; const scopedName = `RomM: ${platformName} (${hostname})`; const legacyName = `RomM: ${platformName}`; @@ -165,6 +182,7 @@ export async function clearPlatformCollection(platformName: string): Promise c.displayName.toLowerCase() === scopedTarget); if (scoped) { logInfo(`Deleting collection "${scopedName}" (id=${scoped.id})`); + if (signal?.aborted) return; await scoped.Delete(); } @@ -172,6 +190,7 @@ export async function clearPlatformCollection(platformName: string): Promise c.displayName.toLowerCase() === legacyTarget); if (legacy) { logInfo(`Deleting legacy collection "${legacyName}" (id=${legacy.id})`); + if (signal?.aborted) return; await legacy.Delete(); } @@ -183,13 +202,14 @@ export async function clearPlatformCollection(platformName: string): Promise { +export async function clearAllRomMCollections(signal?: AbortSignal): Promise { try { if (typeof collectionStore === "undefined") { logWarn("collectionStore not available, cannot clear collections"); return; } const hostname = await getHostname(); + if (signal?.aborted) return; const suffix = ` (${hostname})`; const lowerSuffix = suffix.toLowerCase(); @@ -213,6 +233,7 @@ export async function clearAllRomMCollections(): Promise { logInfo(`Deleting ${rommCollections.length} RomM collections (hostname: ${hostname})`); for (const c of rommCollections) { + if (signal?.aborted) return; logInfo(`Deleting collection "${c.displayName}" (id=${c.id})`); await c.Delete(); } diff --git a/src/utils/launchInterceptor.test.ts b/src/utils/launchInterceptor.test.ts index 50019b09..6ba82361 100644 --- a/src/utils/launchInterceptor.test.ts +++ b/src/utils/launchInterceptor.test.ts @@ -11,6 +11,7 @@ import * as fallbackLaunchModal from "../components/FallbackLaunchModal"; import * as coreChangeModal from "../components/CoreChangeModal"; import * as steamShortcuts from "./steamShortcuts"; import { registerLaunchInterceptor, unregisterLaunchInterceptor } from "./launchInterceptor"; +import { mountPruneLeasePlugin, releaseAllPruneLeases } from "./pruneLease"; import type { GateVerdict, LaunchGateOps } from "./launchGate"; import type { SyncConflict } from "../types"; @@ -35,6 +36,8 @@ vi.mock("../api/backend", () => ({ // The shared reconcile helper (real module) pulls the single-ROM command here // before each watcher relaunch (#1152). getRomRelaunchOptions: vi.fn(), + releasePruneConflictLease: vi.fn(), + renewPruneConflictLease: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), })); @@ -162,7 +165,14 @@ describe("launchInterceptor — full funnel watcher", () => { // The shared relaunch re-confirm (#1152) runs on every relaunch; default it // to a resolved command + a clean confirm-set so the existing verdict tests // exercise the happy path without per-test wiring. - vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ app_id: 1234, launch_options: "flatpak run x" }); + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 1234, + launch_options: "flatpak run x", + prune_lease_token: "launch-lease", + }); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + vi.mocked(backend.renewPruneConflictLease).mockResolvedValue({ success: true, message: "renewed" }); vi.mocked(steamShortcuts.setLaunchOptionsConfirmed).mockResolvedValue(true); }); @@ -523,15 +533,20 @@ describe("launchInterceptor — full funnel watcher", () => { // --------------------------------------------------------------------------- // #1152 — the watcher's relaunch path re-confirms launch_options just before // RunGame, mirroring the Play-button funnel via the shared - // `reconfirmLaunchOptions` helper. Best-effort: a null/rejected/hung re-confirm - // still relaunches (the launch must never be trapped). + // `reconfirmLaunchOptions` helper. Null/rejected responses remain best-effort; + // a timeout or stale plugin admission aborts the relaunch. // --------------------------------------------------------------------------- describe("relaunch launch_options re-confirm (#1152)", () => { const RELAUNCH_COMMAND = 'flatpak run net.retrodeck.retrodeck "/roms/snes/g.rom"'; it("allow → re-confirms (getRomRelaunchOptions → setLaunchOptionsConfirmed) BEFORE RunGame", async () => { vi.mocked(launchGate.runLaunchGate).mockResolvedValue({ decision: "allow" }); - vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ app_id: 1234, launch_options: RELAUNCH_COMMAND }); + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 1234, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "launch-lease", + }); registerLaunchInterceptor(); const handler = captureHandler(); @@ -551,7 +566,12 @@ describe("launchInterceptor — full funnel watcher", () => { it("markLaunchSkipped fires immediately before RunGame (re-confirm doesn't disturb the skip→run order)", async () => { vi.mocked(launchGate.runLaunchGate).mockResolvedValue({ decision: "allow" }); - vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ app_id: 1234, launch_options: RELAUNCH_COMMAND }); + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 1234, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "launch-lease", + }); registerLaunchInterceptor(); const handler = captureHandler(); @@ -596,10 +616,71 @@ describe("launchInterceptor — full funnel watcher", () => { expect(runGameMock()).toHaveBeenCalledWith("gid-7", "", -1, 100); }); + it("a timed-out re-confirm keeps the cancelled watcher launch blocked", async () => { + vi.useFakeTimers(); + try { + vi.mocked(backend.getRomRelaunchOptions).mockReturnValue(new Promise(() => {})); + registerLaunchInterceptor(); + captureHandler()(77, "1234", "LaunchApp", 0); + + await vi.advanceTimersByTimeAsync(0); + expect(backend.getRomRelaunchOptions).toHaveBeenCalledWith(42); + await vi.advanceTimersByTimeAsync(3000); + + expect(backend.logError).toHaveBeenCalledWith( + expect.stringContaining("Watcher: launch_options re-confirm timed out"), + ); + expect(steamShortcuts.setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + expect(runGameMock()).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("plugin teardown while re-confirm is pending releases a late token and never calls RunGame", async () => { + let resolveFetch!: (value: Awaited>) => void; + let remounted = false; + vi.mocked(backend.getRomRelaunchOptions).mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + try { + registerLaunchInterceptor(); + captureHandler()(77, "1234", "LaunchApp", 0); + await flush(); + expect(backend.getRomRelaunchOptions).toHaveBeenCalledWith(42); + + await releaseAllPruneLeases(); + mountPruneLeasePlugin(); + remounted = true; + resolveFetch({ + success: true, + app_id: 1234, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "late-watcher-launch-lease", + }); + await flush(); + + expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("late-watcher-launch-lease"); + expect(steamShortcuts.setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + expect(runGameMock()).not.toHaveBeenCalled(); + } finally { + if (!remounted) mountPruneLeasePlugin(); + } + }); + it("conflict resolved → re-confirms then relaunches (shared path covers every relaunch branch)", async () => { vi.mocked(launchGate.runLaunchGate).mockResolvedValue({ decision: "conflict", conflicts: [conflict()] }); vi.mocked(syncConflictModal.handleConflicts).mockResolvedValue("resolved"); - vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ app_id: 1234, launch_options: RELAUNCH_COMMAND }); + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 1234, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "launch-lease", + }); registerLaunchInterceptor(); const handler = captureHandler(); diff --git a/src/utils/launchInterceptor.ts b/src/utils/launchInterceptor.ts index 69888167..7e658364 100644 --- a/src/utils/launchInterceptor.ts +++ b/src/utils/launchInterceptor.ts @@ -39,6 +39,7 @@ import { isAppRunning } from "./runningApps"; import { runLaunchGate, markLaunchSkipped, consumeLaunchSkip } from "./launchGate"; import type { GateVerdict, LaunchGateOps, PreLaunchSyncOutcome } from "./launchGate"; import { reconfirmLaunchOptions } from "./launchOptionsReconcile"; +import { capturePruneLeaseAdmission, isPruneLeaseAdmissionCurrent, type PruneLeaseAdmission } from "./pruneLease"; import { applyLaunchGateSetupOutcome, resolveSaveSetupOutcome } from "./saveSetup"; import { showCoreChangeModal } from "../components/CoreChangeModal"; import { handleConflicts } from "../components/SyncConflictModal"; @@ -188,13 +189,14 @@ function bareRelaunch(appId: number): void { } /** Relaunch a previously-cancelled, now-approved launch. Heals any mid-session - * `launch_options` drift on the shortcut first (shared bounded-race re-confirm, - * best-effort — a hang/null/failure still relaunches), then marks the appId as + * `launch_options` drift on the shortcut first (shared bounded-race re-confirm; + * ordinary I/O failure is best-effort, timeout/lifecycle cancellation stops), then marks the appId as * skipped immediately before this RunGame so it doesn't re-enter the watcher * and re-gate. The re-confirm runs in the already-detached post-cancel portion, * so it only adds a bounded (≤3s) wait to the cancel→relaunch window. */ -async function relaunch(appId: number, romId: number): Promise { - await reconfirmLaunchOptions(romId, appId, "Watcher"); +async function relaunch(appId: number, romId: number, admission: PruneLeaseAdmission): Promise { + const reconfirm = await reconfirmLaunchOptions(romId, appId, "Watcher", admission); + if (reconfirm.status === "cancelled" || reconfirm.status === "timeout") return; bareRelaunch(appId); } @@ -207,10 +209,15 @@ async function relaunch(appId: number, romId: number): Promise { * re-probes via the fast reachability check); every other outcome returns * "done". */ -async function handleWatcherVerdict(verdict: GateVerdict, appId: number, romId: number): Promise<"done" | "retry"> { +async function handleWatcherVerdict( + verdict: GateVerdict, + appId: number, + romId: number, + admission: PruneLeaseAdmission, +): Promise<"done" | "retry"> { switch (verdict.decision) { case "allow": - await relaunch(appId, romId); + await relaunch(appId, romId, admission); return "done"; case "abort": // The user saw setup/core UI and declined — already cancelled, nothing to do. @@ -225,18 +232,18 @@ async function handleWatcherVerdict(verdict: GateVerdict, appId: number, romId: if (resolution === "cancel") return "done"; // Conflicts resolved — notify sibling components to refresh, then relaunch. globalThis.dispatchEvent(new CustomEvent("romm_data_changed", { detail: { type: "save_sync", rom_id: romId } })); - await relaunch(appId, romId); + await relaunch(appId, romId, admission); return "done"; } case "offline_drift": { const choice = await showOfflineDriftModal(); - if (choice === "start_anyway") await relaunch(appId, romId); + if (choice === "start_anyway") await relaunch(appId, romId, admission); if (choice === "retry") return "retry"; return "done"; } case "sync_failed": { const proceed = await showFallbackLaunchModal(verdict.message); - if (proceed) await relaunch(appId, romId); + if (proceed) await relaunch(appId, romId, admission); return "done"; } } @@ -250,12 +257,12 @@ async function handleWatcherVerdict(verdict: GateVerdict, appId: number, romId: * path; still offline + drift re-shows the offline modal. A gate throw fails * open to `allow` so a gate bug never traps the user's already-cancelled launch. */ -async function runWatcherGate(appId: number, romId: number): Promise { +async function runWatcherGate(appId: number, romId: number, admission: PruneLeaseAdmission): Promise { let verdict = await runLaunchGate(appId, romId, makeWatcherOps(romId)).catch((e): GateVerdict => { logError(`Watcher gate threw (failing open to allow): ${e}`); return { decision: "allow" }; }); - while ((await handleWatcherVerdict(verdict, appId, romId)) === "retry") { + while ((await handleWatcherVerdict(verdict, appId, romId, admission)) === "retry") { verdict = await runLaunchGate(appId, romId, makeWatcherOps(romId)).catch((e): GateVerdict => { logError(`Watcher gate threw (failing open to allow): ${e}`); return { decision: "allow" }; @@ -314,6 +321,7 @@ export function registerLaunchInterceptor(): void { // against the un-pausable launch: from here the launch is stopped and we // relaunch only on approval. SteamClient.Apps.CancelGameAction(gameActionId); + const admission = capturePruneLeaseAdmission(); detach( (async () => { @@ -331,6 +339,7 @@ export function registerLaunchInterceptor(): void { // unknown appId is not ours to gate — relaunch and bail. const romId = getAppIdRomIdMapSnapshot()[String(appId)]; if (romId == null) { + if (!isPruneLeaseAdmissionCurrent(admission)) return; bareRelaunch(appId); return; } @@ -338,6 +347,7 @@ export function registerLaunchInterceptor(): void { // The funnel assumes an installed ROM. Not installed → hard block // (no relaunch): the ROM is gone. if (!(await isRomInstalled(appId, romId))) { + if (!isPruneLeaseAdmissionCurrent(admission)) return; toaster.toast({ title: "RomM Sync", body: "ROM not downloaded. Open the plugin to download it first.", @@ -345,13 +355,15 @@ export function registerLaunchInterceptor(): void { return; } - await runWatcherGate(appId, romId); + await runWatcherGate(appId, romId, admission); } catch (e) { - // Any unexpected error must NEVER trap the user's game — relaunch. + // An unexpected error must not trap a current launch. A stale launch + // belongs to a torn-down generation and must remain cancelled. // Use the bare relaunch (no re-confirm): the failure may be the // re-confirm's own dependency, and the priority here is escaping the // cancelled state, not healing drift. logError(`Launch interceptor error: ${e}`); + if (!isPruneLeaseAdmissionCurrent(admission)) return; bareRelaunch(appId); } })(), diff --git a/src/utils/launchOptionsReconcile.test.ts b/src/utils/launchOptionsReconcile.test.ts index 31905d2f..9c31817a 100644 --- a/src/utils/launchOptionsReconcile.test.ts +++ b/src/utils/launchOptionsReconcile.test.ts @@ -14,6 +14,7 @@ describe("batchConfirmLaunchOptions", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(steamShortcuts.setLaunchOptionsConfirmed).mockResolvedValue(true); + vi.mocked(backend.releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); }); it("no-ops on an empty list (no confirm, no log)", async () => { @@ -59,6 +60,24 @@ describe("batchConfirmLaunchOptions", () => { expect(msg).toContain("migration_relaunch_options: failed to set launch options for appId 2"); expect(msg).toContain("boom"); }); + + it("does not begin a later Steam batch after cancellation", async () => { + let settle!: () => void; + const firstBatch = new Promise((resolve) => { + settle = () => resolve(true); + }); + vi.mocked(steamShortcuts.setLaunchOptionsConfirmed).mockReturnValue(firstBatch); + const controller = new AbortController(); + const applying = batchConfirmLaunchOptions(items(12), "setSystemCore", controller.signal); + await Promise.resolve(); + expect(steamShortcuts.setLaunchOptionsConfirmed).toHaveBeenCalledTimes(10); + + controller.abort(); + settle(); + await applying; + + expect(steamShortcuts.setLaunchOptionsConfirmed).toHaveBeenCalledTimes(10); + }); }); describe("reconfirmLaunchOptions", () => { @@ -70,9 +89,14 @@ describe("reconfirmLaunchOptions", () => { }); it("confirm-sets the resolved command onto the appId when an item is returned", async () => { - vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ app_id: 100, launch_options: RELAUNCH_COMMAND }); + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 100, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "launch-lease", + }); - await reconfirmLaunchOptions(42, 100, "CustomPlayButton"); + await expect(reconfirmLaunchOptions(42, 100, "CustomPlayButton")).resolves.toEqual({ status: "ready" }); expect(vi.mocked(backend.getRomRelaunchOptions)).toHaveBeenCalledWith(42); expect(vi.mocked(steamShortcuts.setLaunchOptionsConfirmed)).toHaveBeenCalledWith(100, RELAUNCH_COMMAND); @@ -82,7 +106,7 @@ describe("reconfirmLaunchOptions", () => { it("skips the confirm-set on a null item but does not throw or log", async () => { vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue(null); - await expect(reconfirmLaunchOptions(42, 100, "Watcher")).resolves.toBeUndefined(); + await expect(reconfirmLaunchOptions(42, 100, "Watcher")).resolves.toEqual({ status: "ready" }); expect(vi.mocked(backend.getRomRelaunchOptions)).toHaveBeenCalledWith(42); expect(vi.mocked(steamShortcuts.setLaunchOptionsConfirmed)).not.toHaveBeenCalled(); @@ -92,7 +116,9 @@ describe("reconfirmLaunchOptions", () => { it("logs a non-vacuous error with the context prefix when the fetch rejects; never throws", async () => { vi.mocked(backend.getRomRelaunchOptions).mockRejectedValue(new Error("offline")); - await expect(reconfirmLaunchOptions(42, 100, "Watcher")).resolves.toBeUndefined(); + await expect(reconfirmLaunchOptions(42, 100, "Watcher")).resolves.toEqual({ + status: "best_effort_failure", + }); expect(vi.mocked(steamShortcuts.setLaunchOptionsConfirmed)).not.toHaveBeenCalled(); const msg = vi.mocked(backend.logError).mock.calls[0]![0]; @@ -103,14 +129,35 @@ describe("reconfirmLaunchOptions", () => { it("carries the caller's context prefix (CustomPlayButton) into the failure log", async () => { vi.mocked(backend.getRomRelaunchOptions).mockRejectedValue(new Error("boom")); - await reconfirmLaunchOptions(42, 100, "CustomPlayButton"); + await expect(reconfirmLaunchOptions(42, 100, "CustomPlayButton")).resolves.toEqual({ + status: "best_effort_failure", + }); expect(vi.mocked(backend.logError)).toHaveBeenCalledWith( expect.stringContaining("CustomPlayButton: launch_options re-confirm failed"), ); }); - it("a hung fetch falls through after the 3s timeout: no set, logged, resolves (never hangs the caller)", async () => { + it("keeps a Steam write failure best-effort and releases its lease", async () => { + vi.mocked(backend.getRomRelaunchOptions).mockResolvedValue({ + success: true, + app_id: 100, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "write-failure-lease", + }); + vi.mocked(steamShortcuts.setLaunchOptionsConfirmed).mockRejectedValue(new Error("steam unavailable")); + + await expect(reconfirmLaunchOptions(42, 100, "Watcher")).resolves.toEqual({ + status: "best_effort_failure", + }); + + expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("write-failure-lease"); + expect(backend.logError).toHaveBeenCalledWith( + expect.stringContaining("Watcher: launch_options re-confirm failed (launching anyway)"), + ); + }); + + it("a hung fetch returns a distinct timeout without starting a Steam write", async () => { vi.useFakeTimers(); try { // Never resolves — simulates a wedged backend / hung callable bridge. @@ -119,14 +166,45 @@ describe("reconfirmLaunchOptions", () => { const pending = reconfirmLaunchOptions(42, 100, "CustomPlayButton"); // Advancing past the 3s race fires the timeout reject without a real wait. await vi.advanceTimersByTimeAsync(3000); - await expect(pending).resolves.toBeUndefined(); + await expect(pending).resolves.toEqual({ status: "timeout" }); expect(vi.mocked(steamShortcuts.setLaunchOptionsConfirmed)).not.toHaveBeenCalled(); expect(vi.mocked(backend.logError)).toHaveBeenCalledWith( - expect.stringContaining("CustomPlayButton: launch_options re-confirm failed"), + expect.stringContaining("CustomPlayButton: launch_options re-confirm timed out"), ); } finally { vi.useRealTimers(); } }); + + it("observes a lease-bearing response that arrives after timeout and releases it without a Steam write", async () => { + vi.useFakeTimers(); + try { + let resolveFetch!: (value: Awaited>) => void; + vi.mocked(backend.getRomRelaunchOptions).mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const pending = reconfirmLaunchOptions(42, 100, "Watcher"); + await vi.advanceTimersByTimeAsync(3000); + await expect(pending).resolves.toEqual({ status: "timeout" }); + expect(backend.releasePruneConflictLease).not.toHaveBeenCalled(); + + resolveFetch({ + success: true, + app_id: 100, + launch_options: RELAUNCH_COMMAND, + prune_lease_token: "late-timeout-lease", + }); + await vi.advanceTimersByTimeAsync(0); + + expect(backend.releasePruneConflictLease).toHaveBeenCalledWith("late-timeout-lease"); + expect(steamShortcuts.setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/utils/launchOptionsReconcile.ts b/src/utils/launchOptionsReconcile.ts index d07b8fa6..eb18de51 100644 --- a/src/utils/launchOptionsReconcile.ts +++ b/src/utils/launchOptionsReconcile.ts @@ -1,36 +1,92 @@ import { getRomRelaunchOptions, logError } from "../api/backend"; import { setLaunchOptionsConfirmed } from "./steamShortcuts"; +import { + capturePruneLeaseAdmission, + isPruneLeaseAdmissionCurrent, + PruneLeaseAdmissionCancelled, + releasePruneLease, + withPruneLease, + type PruneLeaseAdmission, +} from "./pruneLease"; // Apply in bounded-concurrency batches (mirrors getExistingRomMShortcuts) so a // reconcile touching many ROMs doesn't serialize worst-case per-shortcut // confirm-poll timeouts. const CONCURRENCY = 10; -/** Bound on the single-ROM relaunch-options fetch before a launch. The Decky - * callable bridge can hang indefinitely on a wedged backend, so the read is - * raced against this timeout — a hang falls through to the launch instead of - * trapping the caller (mirrors index.tsx's withTimeout). */ +/** Bound on the single-ROM relaunch-options fetch before a launch. */ const RECONFIRM_FETCH_TIMEOUT_MS = 3000; +export type RelaunchOptionsReconfirmResult = + { status: "ready" } | { status: "best_effort_failure" } | { status: "timeout" } | { status: "cancelled" }; + /** * Heal any mid-session `launch_options` drift on one shortcut right before a * launch: pull the ROM's resolved command (`get_rom_relaunch_options`) and - * confirm-set it onto the shortcut's appId. Best-effort — a hang (bounded by a - * 3s race), a `null` item, or a thrown error is logged via `logError` with the - * `context` prefix and never blocks the caller; the launch proceeds regardless. - * Shared by the Play-button funnel and the direct-launch watcher relaunch path. + * confirm-set it onto the shortcut's appId. Ordinary fetch/write failures remain + * best-effort. Lifecycle cancellation and timeout are explicit launch-stopping + * results; a timed-out callable stays observed so a late lease is released. */ -export async function reconfirmLaunchOptions(romId: number, appId: number, context: string): Promise { +export async function reconfirmLaunchOptions( + romId: number, + appId: number, + context: string, + admission: PruneLeaseAdmission = capturePruneLeaseAdmission(), +): Promise { + if (!isPruneLeaseAdmissionCurrent(admission)) return { status: "cancelled" }; + const fetchOutcome = getRomRelaunchOptions(romId).then( + (item) => ({ kind: "result" as const, item }), + (error: unknown) => ({ kind: "error" as const, error }), + ); + let timer!: ReturnType; + const timeout = new Promise<{ kind: "timeout" }>((resolve) => { + timer = setTimeout(() => resolve({ kind: "timeout" }), RECONFIRM_FETCH_TIMEOUT_MS); + }); + const outcome = await Promise.race([fetchOutcome, timeout]); + if (outcome.kind === "timeout") { + logError(`${context}: launch_options re-confirm timed out (launch cancelled)`); + void fetchOutcome + .then(async (late) => { + if (late.kind === "result" && late.item?.success) { + await releasePruneLease(late.item.prune_lease_token, `${context}: timed-out launch_options re-confirm`); + } + }) + .catch((error: unknown) => logError(`${context}: late launch_options re-confirm cleanup failed: ${error}`)); + return { status: isPruneLeaseAdmissionCurrent(admission) ? "timeout" : "cancelled" }; + } + clearTimeout(timer); + if (outcome.kind === "error") { + if (!isPruneLeaseAdmissionCurrent(admission)) return { status: "cancelled" }; + logError(`${context}: launch_options re-confirm failed (launching anyway): ${outcome.error}`); + return { status: "best_effort_failure" }; + } + const item = outcome.item; + if (!item) { + return { status: isPruneLeaseAdmissionCurrent(admission) ? "ready" : "cancelled" }; + } + if (!item.success) { + if (!isPruneLeaseAdmissionCurrent(admission)) return { status: "cancelled" }; + logError(`${context}: launch_options re-confirm failed (launching anyway): ${item.message}`); + return { status: "best_effort_failure" }; + } try { - const item = await Promise.race([ - getRomRelaunchOptions(romId), - new Promise((_, reject) => - setTimeout(() => reject(new Error("get_rom_relaunch_options timed out")), RECONFIRM_FETCH_TIMEOUT_MS), - ), - ]); - if (item) await setLaunchOptionsConfirmed(appId, item.launch_options); + await withPruneLease( + item.prune_lease_token, + context, + async (signal) => { + if (signal.aborted) return; + await setLaunchOptionsConfirmed(appId, item.launch_options); + }, + context, + admission, + ); + return { status: isPruneLeaseAdmissionCurrent(admission) ? "ready" : "cancelled" }; } catch (e) { + if (e instanceof PruneLeaseAdmissionCancelled || !isPruneLeaseAdmissionCurrent(admission)) { + return { status: "cancelled" }; + } logError(`${context}: launch_options re-confirm failed (launching anyway): ${e}`); + return { status: "best_effort_failure" }; } } @@ -44,12 +100,14 @@ export async function reconfirmLaunchOptions(romId: number, appId: number, conte export async function batchConfirmLaunchOptions( items: { app_id: number; launch_options: string }[], context: string, + signal?: AbortSignal, ): Promise { if (!Array.isArray(items) || items.length === 0) return; for (let i = 0; i < items.length; i += CONCURRENCY) { const batch = items.slice(i, i + CONCURRENCY); await Promise.all( batch.map(async (item) => { + if (signal?.aborted) return; try { const ok = await setLaunchOptionsConfirmed(item.app_id, item.launch_options); if (!ok) { diff --git a/src/utils/pruneActions.test.ts b/src/utils/pruneActions.test.ts new file mode 100644 index 00000000..3d50048e --- /dev/null +++ b/src/utils/pruneActions.test.ts @@ -0,0 +1,478 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as backend from "../api/backend"; +import { cancelPruneActions, handlePruneAction } from "./pruneActions"; +import { + getAppDetails, + isRomMShortcutDetails, + removeShortcutConfirmedOutcome, + setLaunchOptionsConfirmed, +} from "./steamShortcuts"; + +vi.mock("./steamShortcuts", () => ({ + getAppDetails: vi.fn(), + isRomMShortcutDetails: vi.fn(), + removeShortcutConfirmedOutcome: vi.fn(), + setLaunchOptionsConfirmed: vi.fn(), +})); + +describe("handlePruneAction", () => { + beforeEach(() => { + vi.mocked(backend.reportPruneAction).mockReset(); + vi.mocked(backend.switchVersion).mockReset(); + vi.mocked(backend.fetchCoverBase64).mockReset(); + vi.mocked(getAppDetails).mockReset(); + vi.mocked(isRomMShortcutDetails).mockReset(); + vi.mocked(removeShortcutConfirmedOutcome).mockReset(); + vi.mocked(setLaunchOptionsConfirmed).mockReset(); + vi.mocked(backend.reportPruneAction).mockResolvedValue({ success: true, message: "accepted" }); + vi.mocked(isRomMShortcutDetails).mockReturnValue(true); + vi.mocked(getAppDetails).mockResolvedValue({ + strDisplayName: "Removed Game", + strShortcutExe: "/plugin/bin/rom-launcher", + strShortcutStartDir: "/plugin", + strLaunchOptions: "launch-command", + }); + vi.stubGlobal("collectionStore", { + userCollections: [], + deckDesktopApps: { apps: new Map([[9001, {}]]) }, + }); + vi.stubGlobal("appStore", { GetAppOverviewByAppID: vi.fn(() => ({})) }); + }); + + it("captures bounded Steam state without file bytes", async () => { + vi.mocked(getAppDetails).mockResolvedValue({ + strDisplayName: "Removed Game", + strShortcutExe: "/plugin/bin/rom-launcher", + strShortcutStartDir: "/plugin", + strLaunchOptions: "launch-command", + }); + vi.stubGlobal("collectionStore", { + deckDesktopApps: { apps: new Map([[9001, {}]]) }, + userCollections: [ + { id: "favorites", displayName: "Favorites", apps: new Set([9001]) }, + { id: "other", displayName: "Other", apps: new Set([42]) }, + ], + }); + vi.stubGlobal("appStore", { + GetAppOverviewByAppID: vi.fn(() => ({ + minutes_playtime_forever: 120, + minutes_playtime_last_two_weeks: 15, + rt_last_time_played: 1234, + })), + }); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-1", + action: "capture_shortcut_snapshot", + app_id: 9001, + }); + + expect(backend.reportPruneAction).toHaveBeenCalledWith({ + phase: "complete", + run_id: "run-1", + action_token: "token-1", + success: true, + message: "Steam shortcut state captured.", + snapshot: { + app_id: 9001, + name: "Removed Game", + exe: "/plugin/bin/rom-launcher", + start_dir: "/plugin", + launch_options: "launch-command", + minutes_playtime_forever: 120, + minutes_playtime_last_two_weeks: 15, + last_played: 1234, + collections: [{ id: "favorites", name: "Favorites" }], + }, + }); + expect(JSON.stringify(vi.mocked(backend.reportPruneAction).mock.calls[0]?.[0])).not.toContain("base64"); + }); + + it("fails closed when complete collection state exceeds the snapshot budget", async () => { + vi.mocked(getAppDetails).mockResolvedValue({ + strDisplayName: "é".repeat(2048), + strShortcutExe: "é".repeat(2048), + strShortcutStartDir: "é".repeat(2048), + strLaunchOptions: "é".repeat(2048), + }); + vi.stubGlobal("collectionStore", { + deckDesktopApps: { apps: new Map([[9001, {}]]) }, + userCollections: Array.from({ length: 256 }, (_, index) => ({ + id: `id-${index}-${"é".repeat(512)}`, + displayName: `name-${index}-${"é".repeat(512)}`, + apps: new Set([9001]), + })), + }); + vi.stubGlobal("appStore", { GetAppOverviewByAppID: vi.fn(() => ({})) }); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-large", + action: "capture_shortcut_snapshot", + app_id: 9001, + }); + + const completion = vi + .mocked(backend.reportPruneAction) + .mock.calls.map(([request]) => request) + .find((request) => request.phase === "complete"); + expect(completion).toMatchObject({ + phase: "complete", + success: false, + reason: "steam_action_failed", + message: expect.stringContaining("too large"), + }); + }); + + it("fails closed when Steam playtime state is unavailable", async () => { + vi.mocked(getAppDetails).mockResolvedValue({ + strDisplayName: "Removed Game", + strShortcutExe: "/plugin/bin/rom-launcher", + strShortcutStartDir: "/plugin", + strLaunchOptions: "launch-command", + }); + vi.stubGlobal("collectionStore", { userCollections: [], deckDesktopApps: { apps: new Map([[9001, {}]]) } }); + vi.stubGlobal("appStore", undefined); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-incomplete", + action: "capture_shortcut_snapshot", + app_id: 9001, + }); + + expect( + vi + .mocked(backend.reportPruneAction) + .mock.calls.map(([request]) => request) + .find((request) => request.phase === "complete"), + ).toMatchObject({ success: false, message: expect.stringContaining("unavailable") }); + }); + + it("confirms the exact repoint launch command and defers writer side effects until completion", async () => { + vi.mocked(backend.switchVersion).mockResolvedValue({ + success: true, + app_id: 9001, + rom_id: 8, + target_installed: true, + launch_options: "target-command", + }); + vi.mocked(setLaunchOptionsConfirmed).mockResolvedValue(true); + vi.mocked(backend.fetchCoverBase64).mockResolvedValue({ base64: "cover" }); + const setArtwork = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("SteamClient", { Apps: { SetCustomArtworkForApp: setArtwork } }); + vi.mocked(getAppDetails).mockResolvedValue({ strShortcutExe: "/plugin/bin/rom-launcher" }); + const changed = vi.fn(); + globalThis.addEventListener("romm_data_changed", changed); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-2", + action: "repoint_shortcut", + app_id: 9001, + target_rom_id: 8, + launch_options: "target-command", + target_installed: true, + }); + + expect(backend.switchVersion).not.toHaveBeenCalled(); + expect(setLaunchOptionsConfirmed).toHaveBeenCalledWith(9001, "target-command"); + expect(setArtwork).not.toHaveBeenCalled(); + expect(backend.fetchCoverBase64).not.toHaveBeenCalled(); + expect(changed).not.toHaveBeenCalled(); + expect(backend.reportPruneAction).toHaveBeenCalledWith({ + phase: "complete", + run_id: "run-1", + action_token: "token-2", + success: true, + message: "Shortcut repointed and launch command confirmed.", + }); + globalThis.removeEventListener("romm_data_changed", changed); + }); + + it("reports shortcut-removal confirmation failure instead of finalizing", async () => { + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "attempted_unconfirmed" }); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-3", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(backend.reportPruneAction).toHaveBeenCalledWith({ + phase: "complete", + run_id: "run-1", + action_token: "token-3", + success: false, + reason: "steam_action_failed", + message: "Steam did not confirm owned-shortcut removal", + mutation_attempted: true, + }); + }); + + it("logs a rejected action report after preserving the failure payload", async () => { + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "attempted_unconfirmed" }); + vi.mocked(backend.reportPruneAction).mockRejectedValue(new Error("bridge offline")); + const log = vi.spyOn(backend, "logError").mockImplementation(() => {}); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-4", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("token-4")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("bridge offline")); + }); + + it("deduplicates duplicate action delivery before Steam mutation", async () => { + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "confirmed" }); + const action = { + run_id: "run-1", + action_token: "token-duplicate", + action: "remove_shortcut" as const, + app_id: 9001, + }; + + await Promise.all([handlePruneAction(action), handlePruneAction(action)]); + + expect(removeShortcutConfirmedOutcome).toHaveBeenCalledTimes(1); + expect(backend.reportPruneAction).toHaveBeenCalledTimes(2); + }); + + it("does not touch Steam when the backend rejects the pre-action claim", async () => { + vi.mocked(backend.reportPruneAction).mockResolvedValue({ + success: false, + reason: "stale_action", + message: "expired", + }); + + await handlePruneAction({ + run_id: "run-old", + action_token: "token-stale", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(removeShortcutConfirmedOutcome).not.toHaveBeenCalled(); + expect(backend.reportPruneAction).toHaveBeenCalledTimes(1); + }); + + it("retries only the report after a successful Steam mutation", async () => { + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "confirmed" }); + vi.mocked(backend.reportPruneAction) + .mockResolvedValueOnce({ success: true, message: "claimed" }) + .mockRejectedValueOnce(new Error("bridge reset")) + .mockResolvedValueOnce({ success: true, message: "accepted" }); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-retry", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(removeShortcutConfirmedOutcome).toHaveBeenCalledTimes(1); + expect(backend.reportPruneAction).toHaveBeenCalledTimes(3); + expect(vi.mocked(backend.reportPruneAction).mock.calls[1]?.[0]).toEqual( + vi.mocked(backend.reportPruneAction).mock.calls[2]?.[0], + ); + }); + + it("recovers when the first successful claim response is lost", async () => { + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "confirmed" }); + vi.mocked(backend.reportPruneAction) + .mockRejectedValueOnce(new Error("claim response lost")) + .mockResolvedValue({ success: true, message: "accepted" }); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-lost-claim-response", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(removeShortcutConfirmedOutcome).toHaveBeenCalledTimes(1); + expect(backend.reportPruneAction).toHaveBeenCalledTimes(3); + expect(vi.mocked(backend.reportPruneAction).mock.calls[0]?.[0].phase).toBe("claim"); + expect(vi.mocked(backend.reportPruneAction).mock.calls[1]?.[0].phase).toBe("claim"); + }); + + it("reports a confirmed already-absent shortcut without repeating removal", async () => { + vi.stubGlobal("collectionStore", { userCollections: [], deckDesktopApps: { apps: new Map() } }); + + await handlePruneAction({ + run_id: "run-retry", + action_token: "token-already-absent", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(removeShortcutConfirmedOutcome).not.toHaveBeenCalled(); + expect(backend.reportPruneAction).toHaveBeenLastCalledWith({ + phase: "complete", + run_id: "run-retry", + action_token: "token-already-absent", + success: true, + message: "Steam confirmed the shortcut is already absent.", + shortcut_absent: true, + }); + }); + + it("does not remove a shortcut whose complete sealed snapshot drifted", async () => { + await handlePruneAction({ + run_id: "run-1", + action_token: "token-snapshot-drift", + action: "remove_shortcut", + app_id: 9001, + expected_snapshot: { + app_id: 9001, + name: "Earlier Name", + exe: "/plugin/bin/rom-launcher", + start_dir: "/plugin", + launch_options: "launch-command", + minutes_playtime_forever: null, + minutes_playtime_last_two_weeks: null, + last_played: null, + collections: [], + }, + }); + + expect(removeShortcutConfirmedOutcome).not.toHaveBeenCalled(); + expect(backend.reportPruneAction).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "complete", + success: false, + message: "Steam shortcut state changed after recovery was sealed", + }), + ); + }); + + it.each(["remove_shortcut", "repoint_shortcut"] as const)( + "reads ownership after the %s claim resolves", + async (kind) => { + let resolveClaim: ((value: { success: true; message: string }) => void) | undefined; + vi.mocked(backend.reportPruneAction).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveClaim = resolve; + }), + ); + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "confirmed" }); + vi.mocked(setLaunchOptionsConfirmed).mockResolvedValue(true); + const action = + kind === "remove_shortcut" + ? ({ run_id: "run-1", action_token: `token-${kind}`, action: kind, app_id: 9001 } as const) + : ({ + run_id: "run-1", + action_token: `token-${kind}`, + action: kind, + app_id: 9001, + target_rom_id: 8, + launch_options: "target-command", + target_installed: true, + } as const); + const pending = handlePruneAction(action); + await vi.waitFor(() => expect(resolveClaim).toBeDefined()); + vi.mocked(isRomMShortcutDetails).mockReturnValue(false); + resolveClaim?.({ success: true, message: "claimed" }); + await pending; + + expect(getAppDetails).toHaveBeenCalledTimes(1); + expect(removeShortcutConfirmedOutcome).not.toHaveBeenCalled(); + expect(setLaunchOptionsConfirmed).not.toHaveBeenCalled(); + expect(backend.reportPruneAction).toHaveBeenLastCalledWith( + expect.objectContaining({ phase: "complete", success: false, message: expect.stringContaining("not owned") }), + ); + }, + ); + + it("reports ambiguity safely when every completion attempt is lost after Steam removal", async () => { + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "confirmed" }); + vi.mocked(backend.reportPruneAction) + .mockResolvedValueOnce({ success: true, message: "claimed" }) + .mockRejectedValue(new Error("bridge offline")); + const log = vi.spyOn(backend, "logError").mockImplementation(() => {}); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-all-completions-lost", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(removeShortcutConfirmedOutcome).toHaveBeenCalledTimes(1); + expect(backend.reportPruneAction).toHaveBeenCalledTimes(4); + expect(log).toHaveBeenCalledWith(expect.stringContaining("token-all-completions-lost")); + }); + + it("cancels queued actions before they can mutate Steam", async () => { + let resolveClaim: ((value: { success: true; message: string }) => void) | undefined; + vi.mocked(backend.reportPruneAction).mockImplementation( + () => + new Promise((resolve) => { + resolveClaim = resolve; + }), + ); + const pending = handlePruneAction({ + run_id: "run-1", + action_token: "token-cancelled", + action: "remove_shortcut", + app_id: 9001, + }); + await vi.waitFor(() => expect(resolveClaim).toBeDefined()); + + cancelPruneActions(); + resolveClaim?.({ success: true, message: "claimed" }); + vi.mocked(backend.reportPruneAction).mockResolvedValue({ success: true, message: "accepted" }); + await pending; + + expect(removeShortcutConfirmedOutcome).not.toHaveBeenCalled(); + }); + + it("starts a fresh queue generation when an earlier callable never settles", async () => { + vi.useFakeTimers(); + vi.mocked(backend.reportPruneAction).mockImplementationOnce(() => new Promise(() => {})); + vi.mocked(removeShortcutConfirmedOutcome).mockResolvedValue({ status: "confirmed" }); + const wedged = handlePruneAction({ + run_id: "run-old", + action_token: "token-wedged", + action: "remove_shortcut", + app_id: 9001, + }); + await Promise.resolve(); + expect(backend.reportPruneAction).toHaveBeenCalledTimes(1); + + cancelPruneActions(); + vi.mocked(backend.reportPruneAction).mockResolvedValue({ success: true, message: "accepted" }); + await handlePruneAction({ + run_id: "run-new", + action_token: "token-fresh", + action: "remove_shortcut", + app_id: 9001, + }); + + expect(removeShortcutConfirmedOutcome).toHaveBeenCalledTimes(1); + await vi.runAllTimersAsync(); + await wedged; + vi.useRealTimers(); + }); + + it("rejects an unknown runtime discriminant without claiming or mutating", async () => { + const log = vi.spyOn(backend, "logError").mockImplementation(() => {}); + + await handlePruneAction({ + run_id: "run-1", + action_token: "token-unknown", + action: "future_destructive_action", + app_id: 9001, + } as never); + + expect(backend.reportPruneAction).not.toHaveBeenCalled(); + expect(removeShortcutConfirmedOutcome).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("Ignored an invalid prune action event."); + }); +}); diff --git a/src/utils/pruneActions.ts b/src/utils/pruneActions.ts new file mode 100644 index 00000000..da0e493b --- /dev/null +++ b/src/utils/pruneActions.ts @@ -0,0 +1,280 @@ +import { logError, reportPruneAction } from "../api/backend"; +import type { + CompletePruneActionRequest, + PruneSteamSnapshot, + ReportPruneActionRequest, + SwitchVersionSuccess, +} from "../api/backend"; +import { + getAppDetails, + isRomMShortcutDetails, + removeShortcutConfirmedOutcome, + setLaunchOptionsConfirmed, +} from "./steamShortcuts"; +import { withTimeout } from "./withTimeout"; + +type PruneAction = + | { run_id: string; action_token: string; action: "capture_shortcut_snapshot"; app_id: number } + | { + run_id: string; + action_token: string; + action: "repoint_shortcut"; + app_id: number; + target_rom_id: number; + launch_options: string; + target_installed: boolean; + } + | { + run_id: string; + action_token: string; + action: "remove_shortcut"; + app_id: number; + expected_snapshot?: PruneSteamSnapshot; + }; + +export type PruneActionRequired = PruneAction & { preview_id: string }; + +const SNAPSHOT_BUDGET_BYTES = 56 * 1024; +const REPORT_ATTEMPTS = 3; +const REPORT_TIMEOUT_MS = 5000; +const handledTokens = new Set(); +let actionQueue: Promise = Promise.resolve(); +let actionGeneration = 0; + +function asciiJsonSize(value: unknown): number { + let bytes = 0; + for (const char of JSON.stringify(value)) { + const codePoint = char.codePointAt(0) ?? 0; + bytes += codePoint <= 0x7f ? 1 : codePoint <= 0xffff ? 6 : 12; + } + return bytes; +} + +function isPruneAction(value: unknown): value is PruneAction { + if (!value || typeof value !== "object") return false; + const item = value as Record; + if (typeof item.run_id !== "string" || typeof item.action_token !== "string" || typeof item.app_id !== "number") { + return false; + } + if (item.action === "capture_shortcut_snapshot") return true; + if (item.action === "remove_shortcut") { + return ( + item.expected_snapshot === undefined || + (item.expected_snapshot !== null && typeof item.expected_snapshot === "object") + ); + } + return ( + item.action === "repoint_shortcut" && + typeof item.target_rom_id === "number" && + typeof item.launch_options === "string" && + typeof item.target_installed === "boolean" + ); +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string") throw new Error(`Steam shortcut ${field} was unavailable`); + return value; +} + +async function captureShortcutSnapshot(appId: number): Promise { + if (typeof collectionStore === "undefined" || typeof appStore === "undefined") { + throw new Error("Steam shortcut, collection, or playtime state was unavailable"); + } + const liveApps = collectionStore.deckDesktopApps?.apps; + if (!liveApps) throw new Error("Steam shortcut store was unavailable"); + if (!liveApps.has(appId)) throw new Error("Steam shortcut is absent"); + const details = await getAppDetails(appId); + if (!isRomMShortcutDetails(details)) throw new Error("The live shortcut is not owned by RomM Sync"); + const overview = appStore.GetAppOverviewByAppID(appId); + if (!details || !overview) throw new Error("Steam shortcut playtime state was unavailable"); + const collections = collectionStore.userCollections + .filter((collection) => collection.apps.has(appId)) + .map((collection) => ({ + id: requireString(collection.id, "collection id"), + name: requireString(collection.displayName, "collection name"), + })) + .sort((left, right) => left.id.localeCompare(right.id)); + const snapshot: PruneSteamSnapshot = { + app_id: appId, + name: requireString(details.strDisplayName ?? overview.strDisplayName, "name"), + exe: requireString(details.strShortcutExe, "executable"), + start_dir: requireString(details.strShortcutStartDir, "start directory"), + launch_options: requireString(details.strLaunchOptions ?? details.LaunchOptions, "launch options"), + minutes_playtime_forever: overview.minutes_playtime_forever ?? null, + minutes_playtime_last_two_weeks: overview.minutes_playtime_last_two_weeks ?? null, + last_played: overview.rt_last_time_played ?? null, + collections, + }; + if (asciiJsonSize(snapshot) > SNAPSHOT_BUDGET_BYTES) { + throw new Error("Complete Steam shortcut state is too large for a safe recovery snapshot"); + } + return snapshot; +} + +async function reportWithRetry(request: ReportPruneActionRequest): Promise { + let lastMessage = "Action report was rejected."; + for (let attempt = 1; attempt <= REPORT_ATTEMPTS; attempt++) { + try { + const result = await withTimeout(reportPruneAction(request), REPORT_TIMEOUT_MS); + if (result.success) return true; + lastMessage = result.message; + if (result.reason === "stale_action" || result.reason === "local_state_changed") break; + } catch (e) { + lastMessage = e instanceof Error ? e.message : String(e); + } + if (attempt < REPORT_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, attempt * 100)); + } + } + logError(`Failed to report prune action ${request.action_token}: ${lastMessage}`); + return false; +} + +function claimAction(action: PruneAction): Promise { + return reportWithRetry({ + phase: "claim", + run_id: action.run_id, + action_token: action.action_token, + action: action.action, + app_id: action.app_id, + target_rom_id: action.action === "repoint_shortcut" ? action.target_rom_id : null, + }); +} + +function assertCurrent(generation: number): void { + if (generation !== actionGeneration) throw new Error("Cleanup action was cancelled before Steam mutation"); +} + +async function executePruneAction(action: PruneAction, generation: number): Promise { + let claimed = false; + let mutationAttempted = false; + const claim = async (): Promise => { + if (claimed) return true; + claimed = await claimAction(action); + return claimed; + }; + let report: CompletePruneActionRequest; + try { + assertCurrent(generation); + if (action.action === "capture_shortcut_snapshot") { + if (!(await claim())) return; + assertCurrent(generation); + try { + report = { + phase: "complete", + run_id: action.run_id, + action_token: action.action_token, + success: true, + message: "Steam shortcut state captured.", + snapshot: await captureShortcutSnapshot(action.app_id), + }; + } catch (error) { + if (error instanceof Error && error.message === "Steam shortcut is absent") { + report = { + phase: "complete", + run_id: action.run_id, + action_token: action.action_token, + success: true, + message: "Steam confirmed the shortcut is already absent.", + shortcut_absent: true, + }; + } else { + throw error; + } + } + } else if (action.action === "repoint_shortcut") { + if (!(await claim())) return; + assertCurrent(generation); + const details = await getAppDetails(action.app_id); + if (!isRomMShortcutDetails(details)) throw new Error("The live shortcut is not owned by RomM Sync"); + assertCurrent(generation); + const result: SwitchVersionSuccess = { + success: true, + app_id: action.app_id, + rom_id: action.target_rom_id, + target_installed: action.target_installed, + launch_options: action.launch_options, + }; + mutationAttempted = true; + if (!(await setLaunchOptionsConfirmed(result.app_id, result.launch_options))) { + throw new Error("Steam did not confirm the target launch command"); + } + report = { + phase: "complete", + run_id: action.run_id, + action_token: action.action_token, + success: true, + message: "Shortcut repointed and launch command confirmed.", + }; + } else { + if (!(await claim())) return; + assertCurrent(generation); + let fresh: PruneSteamSnapshot; + try { + fresh = await captureShortcutSnapshot(action.app_id); + } catch (error) { + if (error instanceof Error && error.message === "Steam shortcut is absent") { + report = { + phase: "complete", + run_id: action.run_id, + action_token: action.action_token, + success: true, + message: "Steam confirmed the shortcut is already absent.", + shortcut_absent: true, + }; + if (!(await claim())) return; + await reportWithRetry(report); + return; + } + throw error; + } + if (action.expected_snapshot && JSON.stringify(fresh) !== JSON.stringify(action.expected_snapshot)) { + throw new Error("Steam shortcut state changed after recovery was sealed"); + } + assertCurrent(generation); + const removal = await removeShortcutConfirmedOutcome(action.app_id, 3000, true); + mutationAttempted = removal.status === "attempted_unconfirmed" || removal.status === "confirmed"; + if (removal.status !== "confirmed") { + throw new Error("Steam did not confirm owned-shortcut removal"); + } + report = { + phase: "complete", + run_id: action.run_id, + action_token: action.action_token, + success: true, + message: "Steam confirmed shortcut removal.", + }; + } + } catch (e) { + report = { + phase: "complete", + run_id: action.run_id, + action_token: action.action_token, + success: false, + reason: "steam_action_failed", + message: e instanceof Error ? e.message : String(e), + ...(mutationAttempted ? { mutation_attempted: true } : {}), + }; + } + if (!(await claim())) return; + await reportWithRetry(report); +} + +export function handlePruneAction(value: PruneAction): Promise { + if (!isPruneAction(value)) { + logError("Ignored an invalid prune action event."); + return Promise.resolve(); + } + if (handledTokens.has(value.action_token)) return Promise.resolve(); + handledTokens.add(value.action_token); + const generation = actionGeneration; + const queued = actionQueue.then(() => executePruneAction(value, generation)); + actionQueue = queued.catch(() => {}); + return queued; +} + +export function cancelPruneActions(): void { + actionGeneration++; + handledTokens.clear(); + actionQueue = Promise.resolve(); +} diff --git a/src/utils/pruneLease.test.ts b/src/utils/pruneLease.test.ts new file mode 100644 index 00000000..a921f675 --- /dev/null +++ b/src/utils/pruneLease.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { logError, releasePruneConflictLease, renewPruneConflictLease } from "../api/backend"; +import { + capturePruneLeaseAdmission, + maintainPruneLease, + mountPruneLeaseOwner, + mountPruneLeasePlugin, + releaseAllPruneLeases, + releasePruneLease, + releasePruneLeasesByOwner, + withPruneLease, +} from "./pruneLease"; + +vi.mock("../api/backend", () => ({ + logError: vi.fn(), + releasePruneConflictLease: vi.fn(), + renewPruneConflictLease: vi.fn(), +})); + +beforeEach(() => { + mountPruneLeasePlugin(); +}); + +afterEach(async () => { + await releaseAllPruneLeases(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +it("rejects and releases a lease-bearing response that arrives after owner teardown", async () => { + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + mountPruneLeaseOwner("danger-zone"); + const admission = capturePruneLeaseAdmission("danger-zone"); + const operation = vi.fn().mockResolvedValue(undefined); + + await releasePruneLeasesByOwner("danger-zone"); + await expect(withPruneLease("late-owner", "Late owner", operation, "danger-zone", admission)).rejects.toThrow( + "cancelled before lease registration", + ); + + expect(operation).not.toHaveBeenCalled(); + expect(releasePruneConflictLease).toHaveBeenCalledWith("late-owner"); +}); + +it("an old plugin generation stays stale after a genuine remount", async () => { + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + const oldAdmission = capturePruneLeaseAdmission(); + const operation = vi.fn().mockResolvedValue(undefined); + + await releaseAllPruneLeases(); + mountPruneLeasePlugin(); + await expect(withPruneLease("late-plugin", "Late plugin", operation, "root", oldAdmission)).rejects.toThrow( + "cancelled before lease registration", + ); + + expect(operation).not.toHaveBeenCalled(); + expect(releasePruneConflictLease).toHaveBeenCalledWith("late-plugin"); +}); + +it("admits work only after the owner is genuinely mounted again", async () => { + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + mountPruneLeaseOwner("version-picker:42"); + const staleAdmission = capturePruneLeaseAdmission("version-picker:42"); + await releasePruneLeasesByOwner("version-picker:42"); + mountPruneLeaseOwner("version-picker:42"); + const currentAdmission = capturePruneLeaseAdmission("version-picker:42"); + const operation = vi.fn().mockResolvedValue("applied"); + + await expect( + withPruneLease("stale", "Stale version", operation, "version-picker:42", staleAdmission), + ).rejects.toThrow("cancelled before lease registration"); + await expect( + withPruneLease("current", "Current version", operation, "version-picker:42", currentAdmission), + ).resolves.toBe("applied"); + + expect(operation).toHaveBeenCalledTimes(1); +}); + +it("renews active ownership and stops heartbeats before release", async () => { + vi.useFakeTimers(); + vi.mocked(renewPruneConflictLease).mockResolvedValue({ success: true, message: "renewed" }); + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + const release = maintainPruneLease("lease-1", "Long rebake"); + + await vi.advanceTimersByTimeAsync(180_000); + expect(renewPruneConflictLease).toHaveBeenCalledTimes(3); + + await release(); + await vi.advanceTimersByTimeAsync(120_000); + expect(renewPruneConflictLease).toHaveBeenCalledTimes(3); + expect(releasePruneConflictLease).toHaveBeenCalledWith("lease-1"); +}); + +it("bounds a lost release acknowledgement and logs the failed release", async () => { + vi.useFakeTimers(); + vi.mocked(releasePruneConflictLease).mockImplementation(() => new Promise(() => {})); + + const release = releasePruneLease("lease-1", "Core selection"); + await vi.advanceTimersByTimeAsync(5000); + await release; + + expect(logError).toHaveBeenCalledWith( + "Core selection: failed to release prune lease: Error: callable timed out after 5000ms", + ); +}); + +it("releases every lease owned by an unmounted component and stops its heartbeats", async () => { + vi.useFakeTimers(); + vi.mocked(renewPruneConflictLease).mockResolvedValue({ success: true, message: "renewed" }); + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + maintainPruneLease("lease-1", "Artwork", "game-detail:42"); + maintainPruneLease("lease-2", "Core", "game-detail:42"); + + await releasePruneLeasesByOwner("game-detail:42"); + await vi.advanceTimersByTimeAsync(120_000); + + expect(releasePruneConflictLease).toHaveBeenCalledWith("lease-1"); + expect(releasePruneConflictLease).toHaveBeenCalledWith("lease-2"); + expect(renewPruneConflictLease).not.toHaveBeenCalled(); +}); + +it("owner teardown aborts a delayed continuation before its next write", async () => { + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + let resume!: () => void; + let wroteAfterAwait = false; + const continuation = withPruneLease( + "lease-delayed", + "Version switch", + async (signal) => { + await new Promise((resolve) => { + resume = resolve; + }); + if (!signal.aborted) wroteAfterAwait = true; + }, + "version-picker:42", + ); + await Promise.resolve(); + + const teardown = releasePruneLeasesByOwner("version-picker:42"); + await Promise.resolve(); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("lease-delayed"); + resume(); + await continuation; + await teardown; + + expect(wroteAfterAwait).toBe(false); + expect(releasePruneConflictLease).toHaveBeenCalledWith("lease-delayed"); +}); + +it("owner teardown retains backend exclusion until a started non-cancellable operation settles", async () => { + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + let settle!: () => void; + const continuation = withPruneLease( + "lease-started", + "Artwork", + () => + new Promise((resolve) => { + settle = resolve; + }), + "artwork:42", + ); + await Promise.resolve(); + + const teardown = releasePruneLeasesByOwner("artwork:42"); + await Promise.resolve(); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("lease-started"); + + settle(); + await continuation; + await teardown; + expect(releasePruneConflictLease).toHaveBeenCalledWith("lease-started"); +}); + +it("plugin teardown releases all registered frontend leases", async () => { + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + maintainPruneLease("lease-1", "Artwork", "artwork:42"); + maintainPruneLease("lease-2", "Sync", "root"); + + await releaseAllPruneLeases(); + + expect( + vi + .mocked(releasePruneConflictLease) + .mock.calls.map(([token]) => token) + .sort(), + ).toEqual(["lease-1", "lease-2"]); +}); + +it("stops renewing an unresolved continuation at the five-minute bound", async () => { + vi.useFakeTimers(); + vi.mocked(renewPruneConflictLease).mockResolvedValue({ success: true, message: "renewed" }); + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + + const operation = withPruneLease("lease-hung", "Hung continuation", () => new Promise(() => {})); + const rejection = expect(operation).rejects.toThrow("callable timed out after 300000ms"); + await vi.advanceTimersByTimeAsync(300_001); + await rejection; + const renewalsAtTimeout = vi.mocked(renewPruneConflictLease).mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + + expect(renewalsAtTimeout).toBeGreaterThan(0); + expect(renewPruneConflictLease).toHaveBeenCalledTimes(renewalsAtTimeout); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("lease-hung"); +}); + +it("timeout aborts future work and releases only after the underlying operation settles", async () => { + vi.useFakeTimers(); + vi.mocked(renewPruneConflictLease).mockResolvedValue({ success: true, message: "renewed" }); + vi.mocked(releasePruneConflictLease).mockResolvedValue({ success: true, message: "released" }); + let resume!: () => void; + let wroteAfterAwait = false; + const operation = withPruneLease("lease-timeout", "Timed continuation", async (signal) => { + await new Promise((resolve) => { + resume = resolve; + }); + if (!signal.aborted) wroteAfterAwait = true; + }); + const rejection = expect(operation).rejects.toThrow("callable timed out after 300000ms"); + + await vi.advanceTimersByTimeAsync(300_001); + await rejection; + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("lease-timeout"); + + resume(); + await vi.advanceTimersByTimeAsync(0); + expect(wroteAfterAwait).toBe(false); + expect(releasePruneConflictLease).toHaveBeenCalledWith("lease-timeout"); +}); + +it("a refused renewal aborts future writes and abandons the refused token", async () => { + vi.useFakeTimers(); + vi.mocked(renewPruneConflictLease).mockResolvedValue({ success: false, message: "expired" }); + let resume!: () => void; + let wroteAfterAwait = false; + const operation = withPruneLease("lease-refused", "Refused continuation", async (signal) => { + await new Promise((resolve) => { + resume = resolve; + }); + if (!signal.aborted) wroteAfterAwait = true; + }); + + await vi.advanceTimersByTimeAsync(60_000); + resume(); + await operation; + + expect(wroteAfterAwait).toBe(false); + expect(releasePruneConflictLease).not.toHaveBeenCalledWith("lease-refused"); +}); diff --git a/src/utils/pruneLease.ts b/src/utils/pruneLease.ts new file mode 100644 index 00000000..ead3a676 --- /dev/null +++ b/src/utils/pruneLease.ts @@ -0,0 +1,203 @@ +import { logError, releasePruneConflictLease, renewPruneConflictLease } from "../api/backend"; +import { withTimeout } from "./withTimeout"; + +const RELEASE_TIMEOUT_MS = 5000; +const RENEW_INTERVAL_MS = 60_000; +const CONTINUATION_TIMEOUT_MS = 5 * 60_000; + +interface ActiveLease { + abortController: AbortController | undefined; + context: string; + owner: string; + interval: ReturnType; + settlement: Promise | undefined; +} + +const activeLeases = new Map(); +const ownerGenerations = new Map(); +let pluginGeneration = 0; +let pluginMounted = true; + +export interface PruneLeaseAdmission { + pluginGeneration: number; + owner?: string; + ownerGeneration?: number; +} + +class UnsettledContinuation { + constructor(readonly error: unknown) {} +} + +export class PruneLeaseAdmissionCancelled extends Error {} + +export function isPruneLeaseCancelled(signal: AbortSignal | undefined): boolean { + return signal?.aborted ?? false; +} + +export function mountPruneLeasePlugin(): void { + pluginGeneration++; + pluginMounted = true; +} + +export function mountPruneLeaseOwner(owner: string): void { + const current = ownerGenerations.get(owner); + if (current?.mounted) return; + ownerGenerations.set(owner, { generation: (current?.generation ?? 0) + 1, mounted: true }); +} + +export function capturePruneLeaseAdmission(owner?: string): PruneLeaseAdmission { + const current = owner === undefined ? undefined : ownerGenerations.get(owner); + return { + pluginGeneration, + ...(owner === undefined ? {} : { owner, ownerGeneration: current?.generation ?? 0 }), + }; +} + +export function isPruneLeaseAdmissionCurrent(admission: PruneLeaseAdmission): boolean { + if (!pluginMounted || admission.pluginGeneration !== pluginGeneration) return false; + if (admission.owner === undefined) return true; + const current = ownerGenerations.get(admission.owner); + return current?.mounted === true && current.generation === admission.ownerGeneration; +} + +/** Bounded best-effort release for frontend-owned prune conflict leases. */ +export async function releasePruneLease(token: string, context: string): Promise { + try { + await withTimeout(releasePruneConflictLease(token), RELEASE_TIMEOUT_MS); + } catch (e) { + logError(`${context}: failed to release prune lease: ${e}`); + } +} + +async function finishLease(token: string, releaseBackend: boolean): Promise { + const active = activeLeases.get(token); + if (!active) return; + activeLeases.delete(token); + clearInterval(active.interval); + if (releaseBackend) await releasePruneLease(token, active.context); +} + +function retireLeaseAfterSettlement(token: string): Promise { + const active = activeLeases.get(token); + if (!active) return Promise.resolve(); + activeLeases.delete(token); + clearInterval(active.interval); + active.abortController?.abort(); + const release = () => releasePruneLease(token, active.context); + return active.settlement ? active.settlement.then(release) : release(); +} + +/** Keep a frontend continuation demonstrably live until its bounded release. */ +export function maintainPruneLease( + token: string, + context: string, + owner = context, + abortController?: AbortController, + settlement?: Promise, +): () => Promise { + const renew = async (): Promise => { + if (!activeLeases.has(token)) return; + try { + const result = await withTimeout(renewPruneConflictLease(token), RELEASE_TIMEOUT_MS); + if (!result.success) { + logError(`${context}: prune lease renewal was refused: ${result.message}`); + activeLeases.get(token)?.abortController?.abort(); + await finishLease(token, false); + } + } catch (e) { + logError(`${context}: failed to renew prune lease: ${e}`); + } + }; + const interval = setInterval(() => void renew(), RENEW_INTERVAL_MS); + activeLeases.set(token, { abortController, context, owner, interval, settlement }); + return () => finishLease(token, true); +} + +export async function releasePruneLeasesByOwner(owner: string): Promise { + const current = ownerGenerations.get(owner); + ownerGenerations.set(owner, { generation: (current?.generation ?? 0) + 1, mounted: false }); + const owned = [...activeLeases].filter(([, lease]) => lease.owner === owner); + for (const [, lease] of owned) lease.abortController?.abort(); + const tokens = owned.map(([token]) => token); + await Promise.all(tokens.map(retireLeaseAfterSettlement)); +} + +export async function releaseAllPruneLeases(): Promise { + pluginGeneration++; + pluginMounted = false; + for (const [owner, current] of ownerGenerations) { + ownerGenerations.set(owner, { generation: current.generation + 1, mounted: false }); + } + for (const lease of activeLeases.values()) lease.abortController?.abort(); + await Promise.all([...activeLeases].map(([token]) => retireLeaseAfterSettlement(token))); +} + +async function boundedContinuation(promise: Promise): Promise<{ result: T; settled: true }> { + const settled = promise.then( + (result) => ({ kind: "result" as const, result }), + (error: unknown) => ({ kind: "error" as const, error }), + ); + let timer!: ReturnType; + const timeout = new Promise<{ kind: "timeout"; error: Error }>((resolve) => { + timer = setTimeout( + () => resolve({ kind: "timeout", error: new Error(`callable timed out after ${CONTINUATION_TIMEOUT_MS}ms`) }), + CONTINUATION_TIMEOUT_MS, + ); + }); + try { + const outcome = await Promise.race([settled, timeout]); + if (outcome.kind === "timeout") throw new UnsettledContinuation(outcome.error); + if (outcome.kind === "error") throw outcome.error; + return { result: outcome.result, settled: true }; + } finally { + clearTimeout(timer); + } +} + +export async function withPruneLeases( + tokens: Array, + context: string, + operation: (signal: AbortSignal) => Promise, + owner = context, + admission: PruneLeaseAdmission = capturePruneLeaseAdmission(), +): Promise { + const uniqueTokens = [...new Set(tokens.filter((token): token is string => !!token))]; + if (!isPruneLeaseAdmissionCurrent(admission)) { + await Promise.all(uniqueTokens.map((token) => releasePruneLease(token, context))); + throw new PruneLeaseAdmissionCancelled(`${context}: continuation was cancelled before lease registration`); + } + const abortController = new AbortController(); + const operationPromise = Promise.resolve().then(() => operation(abortController.signal)); + const settlement = operationPromise.then( + () => undefined, + () => undefined, + ); + const releases = uniqueTokens.map((token) => ({ + token, + release: maintainPruneLease(token, context, owner, abortController, settlement), + })); + let timedOut = false; + try { + return (await boundedContinuation(operationPromise)).result; + } catch (caught) { + if (caught instanceof UnsettledContinuation) { + timedOut = true; + abortController.abort(); + for (const { token } of releases) void retireLeaseAfterSettlement(token); + throw caught.error; + } + throw caught; + } finally { + if (!timedOut) await Promise.all(releases.map(({ release }) => release())); + } +} + +export function withPruneLease( + token: string | null | undefined, + context: string, + operation: (signal: AbortSignal) => Promise, + owner = context, + admission?: PruneLeaseAdmission, +): Promise { + return withPruneLeases([token], context, operation, owner, admission); +} diff --git a/src/utils/pruneStore.test.ts b/src/utils/pruneStore.test.ts new file mode 100644 index 00000000..af8b4bbc --- /dev/null +++ b/src/utils/pruneStore.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + admitPruneFrame, + beginPrunePreview, + beginPruneRun, + getPruneState, + resetPruneState, + setPruneComplete, + setPruneProgress, +} from "./pruneStore"; + +describe("pruneStore", () => { + beforeEach(resetPruneState); + + function begin(runId: string, previewId = "preview-1"): void { + beginPrunePreview(previewId); + beginPruneRun(runId, previewId); + } + + it("ignores late frames from an older run", () => { + begin("new"); + setPruneProgress({ + run_id: "old", + preview_id: "preview-1", + current: 1, + total: 1, + stage: "removed", + rom_ids: [1], + name: "old", + }); + expect(getPruneState().progress).toBeNull(); + }); + + it("a fresh preview adopts only a matching backend run before the start response", () => { + beginPrunePreview("preview-new"); + + expect( + setPruneComplete({ + success: true, + partial: false, + run_id: "old", + preview_id: "preview-old", + removed_rom_ids: [1], + affected_app_ids: [101], + results: [], + }), + ).toBeNull(); + expect(getPruneState()).toEqual({ runId: null, progress: null, complete: null }); + + setPruneProgress({ + run_id: "new", + preview_id: "preview-new", + current: 1, + total: 1, + stage: "checking", + rom_ids: [2], + name: "new", + }); + expect(getPruneState().runId).toBe("new"); + }); + + it("assembles bounded completion chunks and ignores duplicate chunks", () => { + begin("run-1"); + const first = { + success: true, + partial: false, + run_id: "run-1", + preview_id: "preview-1", + chunk_index: 0, + final: false, + removed_count: 2, + problem_count: 0, + removed_rom_ids: [1], + affected_app_ids: [101], + removed_app_ids: [], + results: [{ group_id: "one", rom_ids: [1], status: "removed" as const, message: "removed" }], + }; + expect(setPruneComplete(first)).toBeNull(); + expect(setPruneComplete(first)).toBeNull(); + const complete = setPruneComplete({ + ...first, + chunk_index: 1, + final: true, + removed_rom_ids: [2], + affected_app_ids: [102], + results: [{ group_id: "two", rom_ids: [2], status: "removed", message: "removed" }], + }); + expect(complete?.removed_rom_ids).toEqual([1, 2]); + expect(complete?.affected_app_ids).toEqual([101, 102]); + expect(complete?.results).toHaveLength(2); + }); + + it("a new run clears a previous completion", () => { + begin("old", "preview-old"); + setPruneComplete({ + success: true, + partial: false, + run_id: "old", + preview_id: "preview-old", + removed_rom_ids: [], + affected_app_ids: [], + results: [], + }); + begin("new", "preview-new"); + expect(getPruneState()).toEqual({ runId: "new", progress: null, complete: null }); + }); + + it("waits for every earlier chunk when the final chunk arrives first", () => { + begin("run-2"); + const final = { + success: true, + partial: false, + run_id: "run-2", + preview_id: "preview-1", + chunk_index: 1, + final: true, + removed_rom_ids: [2], + affected_app_ids: [102], + results: [{ group_id: "two", rom_ids: [2], status: "removed" as const, message: "removed" }], + }; + + expect(setPruneComplete(final)).toBeNull(); + expect(getPruneState().complete).toBeNull(); + const complete = setPruneComplete({ + ...final, + chunk_index: 0, + final: false, + removed_rom_ids: [1], + affected_app_ids: [101], + results: [{ group_id: "one", rom_ids: [1], status: "removed", message: "removed" }], + }); + + expect(complete?.removed_rom_ids).toEqual([1, 2]); + expect(complete?.results.map((item) => item.group_id)).toEqual(["one", "two"]); + }); + + it("keeps a completed run terminal when delayed same-run frames arrive", () => { + begin("run-terminal"); + const terminal = setPruneComplete({ + success: true, + partial: false, + run_id: "run-terminal", + preview_id: "preview-1", + removed_rom_ids: [7], + affected_app_ids: [70], + results: [], + }); + + setPruneProgress({ + run_id: "run-terminal", + preview_id: "preview-1", + current: 1, + total: 1, + stage: "late", + rom_ids: [7], + name: "late", + }); + expect( + setPruneComplete({ + success: false, + partial: true, + run_id: "run-terminal", + preview_id: "preview-1", + chunk_index: 1, + removed_rom_ids: [], + affected_app_ids: [], + results: [], + }), + ).toBeNull(); + expect(admitPruneFrame("preview-1", "run-terminal")).toBe(false); + + expect(getPruneState()).toEqual({ runId: "run-terminal", progress: null, complete: terminal }); + }); +}); diff --git a/src/utils/pruneStore.ts b/src/utils/pruneStore.ts new file mode 100644 index 00000000..ce7458b1 --- /dev/null +++ b/src/utils/pruneStore.ts @@ -0,0 +1,161 @@ +export interface PruneProgress { + run_id: string; + preview_id: string; + current: number; + total: number; + stage: string; + rom_ids: number[]; + rom_count?: number; + rom_ids_truncated?: boolean; + name: string; + bundle_path?: string; +} + +export interface PruneGroupResult { + group_id: string; + group_id_truncated?: boolean; + rom_ids: number[]; + rom_count?: number; + rom_ids_truncated?: boolean; + status: "removed" | "repointed" | "partial" | "failed" | "skipped"; + reason?: string; + message: string; + message_truncated?: boolean; + removed_rom_ids?: number[]; + removed_count?: number; + removed_rom_ids_truncated?: boolean; + app_id?: number; + removed_app_id?: number; + bundle_path?: string; + committed_action?: "repoint_shortcut" | "remove_shortcut"; + action_ambiguous?: boolean; + mutations?: string[]; + ambiguous_mutations?: string[]; + warnings?: string[]; + warning_count?: number; + warnings_omitted?: boolean; + warnings_truncated?: boolean; + target_rom_id?: number; +} + +export interface PruneComplete { + success: boolean; + partial: boolean; + run_id: string; + preview_id: string; + chunk_index?: number; + final?: boolean; + removed_count?: number; + problem_count?: number; + publication_required?: boolean; + prune_lease_token?: string; + removed_rom_ids: number[]; + affected_app_ids: number[]; + removed_app_ids?: number[]; + results: PruneGroupResult[]; + reason?: string; + message?: string; +} + +type Listener = () => void; +let activeRunId: string | null = null; +let progress: PruneProgress | null = null; +let complete: PruneComplete | null = null; +const receivedChunks = new Map(); +let terminalChunkIndex: number | null = null; +let pendingPreviewId: string | null = null; +const listeners = new Set(); + +function notify(): void { + for (const listener of listeners) listener(); +} + +function unique(values: number[]): number[] { + return [...new Set(values)].sort((a, b) => a - b); +} + +export function beginPruneRun(runId: string, previewId: string): void { + if (activeRunId === runId) return; + if (pendingPreviewId !== previewId) return; + activeRunId = runId; + pendingPreviewId = null; + progress = null; + complete = null; + receivedChunks.clear(); + terminalChunkIndex = null; + notify(); +} + +export function admitPruneFrame(previewId: string, runId: string): boolean { + if (complete !== null) return false; + if (activeRunId !== null) return activeRunId === runId; + if (pendingPreviewId === null || pendingPreviewId !== previewId) return false; + activeRunId = runId; + pendingPreviewId = null; + notify(); + return true; +} + +export function setPruneProgress(value: PruneProgress): void { + if (!admitPruneFrame(value.preview_id, value.run_id)) return; + progress = value; + complete = null; + notify(); +} + +export function setPruneComplete(value: PruneComplete): PruneComplete | null { + if (!admitPruneFrame(value.preview_id, value.run_id)) return null; + const chunkIndex = value.chunk_index ?? 0; + if (receivedChunks.has(chunkIndex)) return null; + receivedChunks.set(chunkIndex, value); + if (value.final !== false) terminalChunkIndex = chunkIndex; + if (terminalChunkIndex === null) return null; + for (let index = 0; index <= terminalChunkIndex; index++) { + if (!receivedChunks.has(index)) return null; + } + const chunks = Array.from({ length: terminalChunkIndex + 1 }, (_, index) => receivedChunks.get(index)!); + const terminal = receivedChunks.get(terminalChunkIndex)!; + progress = null; + complete = { + ...terminal, + removed_rom_ids: unique(chunks.flatMap((chunk) => chunk.removed_rom_ids)), + affected_app_ids: unique(chunks.flatMap((chunk) => chunk.affected_app_ids)), + removed_app_ids: unique(chunks.flatMap((chunk) => chunk.removed_app_ids ?? [])), + results: chunks.flatMap((chunk) => chunk.results), + }; + notify(); + return complete; +} + +export function getPruneState(): { + runId: string | null; + progress: PruneProgress | null; + complete: PruneComplete | null; +} { + return { runId: activeRunId, progress, complete }; +} + +export function onPruneStateChange(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function resetPruneState(): void { + activeRunId = null; + progress = null; + complete = null; + receivedChunks.clear(); + terminalChunkIndex = null; + pendingPreviewId = null; + notify(); +} + +export function beginPrunePreview(previewId: string): void { + activeRunId = null; + progress = null; + complete = null; + receivedChunks.clear(); + terminalChunkIndex = null; + pendingPreviewId = previewId; + notify(); +} diff --git a/src/utils/sectionRefresh.test.ts b/src/utils/sectionRefresh.test.ts index 92b5bf78..f978152b 100644 --- a/src/utils/sectionRefresh.test.ts +++ b/src/utils/sectionRefresh.test.ts @@ -117,6 +117,20 @@ describe("refreshActiveSlotInBackground", () => { expect(setter).not.toHaveBeenCalled(); }); + it("preserves the active slot on a prune-active callable failure", async () => { + vi.mocked(backend.getSaveStatus).mockResolvedValueOnce({ + success: false, + reason: "prune_active", + message: "Cleanup is active.", + }); + const setter = vi.fn(); + + refreshActiveSlotInBackground(1, () => false, setter); + await flushMicrotasks(); + + expect(setter).not.toHaveBeenCalled(); + }); + it("falls back to null when active_slot is missing", async () => { vi.mocked(backend.getSaveStatus).mockResolvedValueOnce({ active_slot: null, diff --git a/src/utils/sectionRefresh.ts b/src/utils/sectionRefresh.ts index 096b1585..ce7b6031 100644 --- a/src/utils/sectionRefresh.ts +++ b/src/utils/sectionRefresh.ts @@ -8,7 +8,14 @@ */ import type { Dispatch, SetStateAction } from "react"; -import { getSaveStatus, getBiosStatus, getPlatformCoreInfo, getAchievementProgress, debugLog } from "../api/backend"; +import { + getSaveStatus, + getBiosStatus, + getPlatformCoreInfo, + getAchievementProgress, + debugLog, + isCallableFailure, +} from "../api/backend"; import { extractBiosInfo, extractCoreInfo, type BiosInfoFields, type CoreInfoFields } from "./playSection"; interface ActiveSlotFields { @@ -27,7 +34,7 @@ export function refreshActiveSlotInBackground( ): void { getSaveStatus(romId) .then((saveStatus) => { - if (!cancelled() && "active_slot" in saveStatus) { + if (!cancelled() && !isCallableFailure(saveStatus) && "active_slot" in saveStatus) { setter((prev) => ({ ...prev, activeSlot: saveStatus.active_slot ?? null })); } }) diff --git a/src/utils/shortcutRemoval.test.ts b/src/utils/shortcutRemoval.test.ts index 4051f490..1a5d86d0 100644 --- a/src/utils/shortcutRemoval.test.ts +++ b/src/utils/shortcutRemoval.test.ts @@ -44,4 +44,23 @@ describe("shortcutRemoval — removeShortcutsPaced", () => { [3, 3], ]); }); + + it("does not start another Steam removal after its abort signal fires", async () => { + let settle!: () => void; + removeShortcut.mockImplementationOnce( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + const controller = new AbortController(); + const removal = removeShortcutsPaced([11, 22], undefined, controller.signal); + await Promise.resolve(); + + controller.abort(); + settle(); + await removal; + + expect(removeShortcut.mock.calls.map((call) => call[0])).toEqual([11]); + }); }); diff --git a/src/utils/shortcutRemoval.ts b/src/utils/shortcutRemoval.ts index fc2a0658..8cdbd642 100644 --- a/src/utils/shortcutRemoval.ts +++ b/src/utils/shortcutRemoval.ts @@ -25,6 +25,7 @@ const REMOVAL_CHUNK_DELAY_MS = 50; export async function removeShortcutsPaced( appIds: number[], onProgress?: (removed: number, total: number) => void, + signal?: AbortSignal, ): Promise { const total = appIds.length; await pacedForEach(appIds, (appId) => removeShortcut(appId), { @@ -33,5 +34,6 @@ export async function removeShortcutsPaced( // Only thread the hook when a caller wants one (exactOptionalPropertyTypes // forbids passing an explicit ``undefined`` for an optional property). ...(onProgress ? { onProgress: (completed: number) => onProgress(completed, total) } : {}), + ...(signal ? { isCancelled: () => signal.aborted } : {}), }); } diff --git a/src/utils/steamShortcuts.test.ts b/src/utils/steamShortcuts.test.ts index 93d6a1d1..0da36d47 100644 --- a/src/utils/steamShortcuts.test.ts +++ b/src/utils/steamShortcuts.test.ts @@ -4,6 +4,8 @@ import { addShortcut, getExistingRomMShortcuts, getLiveRomMShortcutAppIds, + removeShortcutConfirmed, + removeShortcutConfirmedOutcome, setLaunchOptionsConfirmed, } from "./steamShortcuts"; import type { SyncAddItem } from "../types"; @@ -72,6 +74,94 @@ describe("setLaunchOptionsConfirmed", () => { }); }); +describe("removeShortcutConfirmed", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("removes one shortcut and confirms absence from the live store", async () => { + const apps = new Map([[77, {}]]); + const remove = vi.fn((appId: number) => apps.delete(appId)); + vi.stubGlobal("collectionStore", { deckDesktopApps: { apps } }); + vi.stubGlobal("SteamClient", { + Apps: { + RemoveShortcut: remove, + RegisterForAppDetails: (_appId: number, callback: (details: SteamAppDetails) => void) => { + queueMicrotask(() => callback({ strShortcutExe: "/plugin/bin/rom-launcher" })); + return { unregister: vi.fn() }; + }, + }, + }); + + await expect(removeShortcutConfirmed(77)).resolves.toBe(true); + expect(remove).toHaveBeenCalledWith(77); + }); + + it("refuses without mutating when the live shortcut store is unreadable", async () => { + const remove = vi.fn(); + vi.stubGlobal("collectionStore", { deckDesktopApps: undefined }); + vi.stubGlobal("SteamClient", { Apps: { RemoveShortcut: remove } }); + + await expect(removeShortcutConfirmed(77)).resolves.toBe(false); + expect(remove).not.toHaveBeenCalled(); + }); + + it("refuses a live appId whose executable is not RomM-owned", async () => { + const apps = new Map([[77, {}]]); + const remove = vi.fn(); + vi.stubGlobal("collectionStore", { deckDesktopApps: { apps } }); + vi.stubGlobal("SteamClient", { + Apps: { + RemoveShortcut: remove, + RegisterForAppDetails: (_appId: number, callback: (details: SteamAppDetails) => void) => { + queueMicrotask(() => callback({ strShortcutExe: "/usr/bin/foreign-game" })); + return { unregister: vi.fn() }; + }, + }, + }); + + await expect(removeShortcutConfirmed(77)).resolves.toBe(false); + expect(remove).not.toHaveBeenCalled(); + }); + + it("times out when Steam never removes the shortcut from its live store", async () => { + vi.useFakeTimers(); + const apps = new Map([[77, {}]]); + const remove = vi.fn(); + vi.stubGlobal("collectionStore", { deckDesktopApps: { apps } }); + vi.stubGlobal("SteamClient", { + Apps: { + RemoveShortcut: remove, + RegisterForAppDetails: (_appId: number, callback: (details: SteamAppDetails) => void) => { + queueMicrotask(() => callback({ strShortcutExe: "/plugin/bin/rom-launcher" })); + return { unregister: vi.fn() }; + }, + }, + }); + + const result = removeShortcutConfirmed(77, 200); + await vi.advanceTimersByTimeAsync(200); + + await expect(result).resolves.toBe(false); + expect(apps.has(77)).toBe(true); + }); + + it("reports an attempted-but-unconfirmed outcome when the store becomes unreadable", async () => { + const apps = new Map([[77, {}]]); + const store: { deckDesktopApps?: { apps: Map } } = { deckDesktopApps: { apps } }; + const remove = vi.fn(() => { + delete store.deckDesktopApps; + }); + vi.stubGlobal("collectionStore", store); + vi.stubGlobal("SteamClient", { Apps: { RemoveShortcut: remove } }); + + await expect(removeShortcutConfirmedOutcome(77, 200, true)).resolves.toEqual({ + status: "attempted_unconfirmed", + }); + expect(remove).toHaveBeenCalledWith(77); + }); +}); + describe("getLiveRomMShortcutAppIds", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/src/utils/steamShortcuts.ts b/src/utils/steamShortcuts.ts index 0d35f7fe..c3ecbb68 100755 --- a/src/utils/steamShortcuts.ts +++ b/src/utils/steamShortcuts.ts @@ -10,6 +10,13 @@ import { delay } from "./pacedOps"; */ const ROM_LAUNCHER_SUFFIX = "/bin/rom-launcher"; +export function isRomMShortcutDetails(details: SteamAppDetails | null): boolean { + return ( + typeof details?.strShortcutExe === "string" && + details.strShortcutExe.replace(/^"|"$/g, "").endsWith(ROM_LAUNCHER_SUFFIX) + ); +} + const HEARTBEAT_INTERVAL_MS = 10_000; /** @@ -19,7 +26,7 @@ const HEARTBEAT_INTERVAL_MS = 10_000; * details before the app's data loads — those early ``undefined`` fires are * ignored). */ -function getAppDetails(appId: number, timeoutMs = 2000): Promise { +export function getAppDetails(appId: number, timeoutMs = 2000): Promise { return new Promise((resolve) => { let resolved = false; // Declared with `let` BEFORE RegisterForAppDetails so a (hypothetical) @@ -254,3 +261,48 @@ export function removeShortcut(appId: number): void { logError(`Failed to remove shortcut ${appId}: ${e}`); } } + +/** Remove one shortcut and require Steam's live store to confirm its absence. */ +function readDesktopAppStore(): Map | null { + if (typeof collectionStore === "undefined") return null; + return collectionStore.deckDesktopApps?.apps ?? null; +} + +export async function removeShortcutConfirmed( + appId: number, + timeoutMs = 3000, + ownershipAlreadyChecked = false, +): Promise { + return (await removeShortcutConfirmedOutcome(appId, timeoutMs, ownershipAlreadyChecked)).status === "confirmed"; +} + +export interface ShortcutRemovalOutcome { + status: "confirmed" | "not_attempted" | "attempted_unconfirmed"; +} + +/** Distinguish a pre-mutation refusal from an unconfirmed mutation attempt. */ +export async function removeShortcutConfirmedOutcome( + appId: number, + timeoutMs = 3000, + ownershipAlreadyChecked = false, +): Promise { + const store = readDesktopAppStore(); + if (!store?.has(appId)) return { status: "not_attempted" }; + if (!ownershipAlreadyChecked && !isRomMShortcutDetails(await getAppDetails(appId))) { + return { status: "not_attempted" }; + } + try { + SteamClient.Apps.RemoveShortcut(appId); + } catch (e) { + logError(`Failed to remove shortcut ${appId}: ${e}`); + return { status: "not_attempted" }; + } + const deadline = Date.now() + timeoutMs; + for (;;) { + const apps = readDesktopAppStore(); + if (!apps) return { status: "attempted_unconfirmed" }; + if (!apps.has(appId)) return { status: "confirmed" }; + if (Date.now() >= deadline) return { status: "attempted_unconfirmed" }; + await delay(100); + } +} diff --git a/src/utils/versionSwitchApplication.ts b/src/utils/versionSwitchApplication.ts new file mode 100644 index 00000000..3c89c5d7 --- /dev/null +++ b/src/utils/versionSwitchApplication.ts @@ -0,0 +1,65 @@ +import { + fetchCoverBase64, + invalidateCachedGameDetail, + logError, + logWarn, + type SwitchVersionSuccess, +} from "../api/backend"; +import { setLaunchOptionsConfirmed } from "./steamShortcuts"; +import { isPruneLeaseCancelled, withPruneLease, type PruneLeaseAdmission } from "./pruneLease"; + +export async function applyCommittedVersionSwitch( + result: SwitchVersionSuccess, + onCover?: (romId: number, cover: string) => void, + admission?: PruneLeaseAdmission, +): Promise { + return withPruneLease( + result.prune_lease_token, + "Version switch", + async (signal) => { + let confirmed = false; + if (isPruneLeaseCancelled(signal)) return confirmed; + try { + confirmed = await setLaunchOptionsConfirmed(result.app_id, result.launch_options); + } catch (e) { + logError( + `Version switch: launch-options confirm threw for rom ${result.rom_id} (appId ${result.app_id}): ${e}`, + ); + } + if (!confirmed) { + logError(`Version switch: could not confirm launch options for rom ${result.rom_id} (appId ${result.app_id})`); + } + if (!isPruneLeaseCancelled(signal)) { + await publishCommittedVersionSwitch(result.app_id, result.rom_id, onCover, signal); + } + return confirmed; + }, + `version-picker:${result.app_id}`, + admission, + ); +} + +export async function publishCommittedVersionSwitch( + appId: number, + romId: number, + onCover?: (romId: number, cover: string) => void, + signal?: AbortSignal, +): Promise { + try { + const cover = await fetchCoverBase64(romId); + if (signal?.aborted) return; + if (cover.base64) { + onCover?.(romId, cover.base64); + await SteamClient.Apps.SetCustomArtworkForApp(appId, cover.base64, "png", 0); + } + } catch (e) { + logWarn(`Version switch: cover apply after switch failed for rom ${romId}: ${e}`); + } + if (signal?.aborted) return; + invalidateCachedGameDetail(appId); + globalThis.dispatchEvent( + new CustomEvent("romm_data_changed", { + detail: { type: "version_switched", app_id: appId, rom_id: romId }, + }), + ); +} diff --git a/tests/_factories.py b/tests/_factories.py index 93234865..a4fb7884 100644 --- a/tests/_factories.py +++ b/tests/_factories.py @@ -79,5 +79,7 @@ class TestablePlugin(Plugin): instance = TestablePlugin() instance._migration_service = MagicMock() instance._migration_service.is_retrodeck_migration_pending.return_value = False + instance._prune_service = MagicMock() + instance._prune_service.is_active.return_value = False instance._debug_logger = lambda msg: None return instance diff --git a/tests/adapters/repositories/test_rom.py b/tests/adapters/repositories/test_rom.py index 4b9f0404..ba43eab7 100644 --- a/tests/adapters/repositories/test_rom.py +++ b/tests/adapters/repositories/test_rom.py @@ -692,7 +692,14 @@ def _seed_children(uow: SqliteUnitOfWork, rom_id: int) -> None: steam_categories=(), ), ) - uow.playtime.save(rom_id, Playtime(total_seconds=3600, session_count=2)) + playtime = Playtime(total_seconds=3600, session_count=2) + playtime.enqueue_session( + device_id="device-1", + start_time="2026-01-01T10:00:00Z", + end_time="2026-01-01T11:00:00Z", + duration_ms=3_600_000, + ) + uow.playtime.save(rom_id, playtime) state = RomSaveSyncState(system="snes") state.adopt_baseline( "battery.srm", @@ -726,7 +733,9 @@ def test_children_survive_a_resave(self, uow: SqliteUnitOfWork): # (b) Every cascade child still exists. assert uow.rom_installs.get(rom_id) is not None assert uow.rom_metadata.get(rom_id) is not None - assert uow.playtime.get(rom_id) is not None + playtime = uow.playtime.get(rom_id) + assert playtime is not None + assert "2026-01-01T10:00:00Z" in playtime.pending_sessions save_state = uow.rom_save_sync_states.get(rom_id) assert save_state is not None assert "battery.srm" in save_state.files @@ -755,3 +764,8 @@ def test_genuine_delete_still_cascades(self, uow: SqliteUnitOfWork): (rom_id,), ).fetchone()[0] assert file_count == 0 + session_count = uow._conn.execute( + "SELECT COUNT(*) FROM rom_playtime_sessions WHERE rom_id = ?", + (rom_id,), + ).fetchone()[0] + assert session_count == 0 diff --git a/tests/adapters/romm/test_http.py b/tests/adapters/romm/test_http.py index 88ddb7a3..1cf8afa7 100644 --- a/tests/adapters/romm/test_http.py +++ b/tests/adapters/romm/test_http.py @@ -49,6 +49,8 @@ def plugin(): p.settings, decky.DECKY_PLUGIN_DIR, logging.getLogger("test"), "decky-romm-sync/9.9.9" ) p._romm_api = MagicMock() + p._prune_service = MagicMock() + p._prune_service.is_active.return_value = False steam_config = SteamConfigAdapter(user_home=decky.DECKY_USER_HOME, logger=decky.logger) p._steam_config = steam_config diff --git a/tests/adapters/test_descriptor_paths.py b/tests/adapters/test_descriptor_paths.py new file mode 100644 index 00000000..4fd138fc --- /dev/null +++ b/tests/adapters/test_descriptor_paths.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import os + +import pytest + +from adapters.descriptor_paths import ( + claim_source, + identity_for_stat, + remove_claimed, + remove_exact, + rename_claimed, + stat_beneath, +) + + +def test_inside_root_symlink_replacement_is_not_removed(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "source.srm" + source.write_bytes(b"sealed") + captured = stat_beneath(str(source), str(safe)) + assert captured is not None + identity = identity_for_stat(captured) + replacement = safe / "replacement.srm" + replacement.write_bytes(b"not sealed") + source.unlink() + source.symlink_to(replacement) + + with pytest.raises(RuntimeError, match="identity changed"): + remove_exact(str(source), str(safe), identity) + + assert source.is_symlink() + assert replacement.read_bytes() == b"not sealed" + + +def test_intermediate_symlink_never_authorizes_deletion_outside_root(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + target = outside / "target.srm" + target.write_bytes(b"keep") + (safe / "linked").symlink_to(outside, target_is_directory=True) + + with pytest.raises(OSError): + stat_beneath(str(safe / "linked" / "target.srm"), str(safe)) + + assert target.read_bytes() == b"keep" + + +def test_replacement_in_pre_rename_window_is_verified_and_rolled_back(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "source.srm" + source.write_bytes(b"sealed") + captured = stat_beneath(str(source), str(safe)) + assert captured is not None + identity = identity_for_stat(captured) + module = __import__("adapters.descriptor_paths", fromlist=["rename_noreplace_at"]) + original = module.rename_noreplace_at + replaced = False + + def replace_then_rename(source_fd, src, destination_fd, dst): + nonlocal replaced + if src == source.name and not replaced: + replaced = True + source.unlink() + source.write_bytes(b"replacement") + return original(source_fd, src, destination_fd, dst) + + monkeypatch.setattr(module, "rename_noreplace_at", replace_then_rename) + with pytest.raises(RuntimeError, match="while it was claimed"): + remove_exact(str(source), str(safe), identity) + + assert source.read_bytes() == b"replacement" + + +def test_claimed_directory_revalidates_descendants_and_restores_root_on_change(tmp_path): + safe = tmp_path / "safe" + source = safe / "game" + source.mkdir(parents=True) + child = source / "disc.bin" + child.write_bytes(b"sealed") + claim = claim_source(str(source), str(safe)) + child.write_bytes(b"replacement") + + with pytest.raises(RuntimeError, match="subtree changed"): + remove_claimed(str(source), str(safe), claim) + + assert source.is_dir() + assert child.read_bytes() == b"replacement" + + +def test_regular_root_hash_rejects_held_fd_write_after_rename(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"sealed") + claim = claim_source(str(source), str(safe)) + writer = os.open(source, os.O_WRONLY) + original = __import__("adapters.descriptor_paths", fromlist=["_require_claimed_identity"])._require_claimed_identity + changed = False + + def mutate_after_rename(path, current, expected): + nonlocal changed + original(path, current, expected) + if not changed: + changed = True + os.pwrite(writer, b"change", 0) + + monkeypatch.setattr("adapters.descriptor_paths._require_claimed_identity", mutate_after_rename) + try: + with pytest.raises(RuntimeError, match=r"active writer.*retained"): + remove_claimed(str(source), str(safe), claim) + finally: + os.close(writer) + + assert source.read_bytes() == b"change" + + +def test_directory_child_write_after_post_rename_inventory_is_restored(tmp_path, monkeypatch): + safe = tmp_path / "safe" + source = safe / "game" + source.mkdir(parents=True) + child = source / "disc.bin" + child.write_bytes(b"sealed") + claim = claim_source(str(source), str(safe)) + writer = os.open(child, os.O_WRONLY) + module = __import__("adapters.descriptor_paths", fromlist=["_inventory_directory"]) + original = module._inventory_directory + calls = 0 + + def mutate_after_inventory(*args, **kwargs): + nonlocal calls + result = original(*args, **kwargs) + calls += 1 + if calls == 1: + os.pwrite(writer, b"change", 0) + return result + + monkeypatch.setattr("adapters.descriptor_paths._inventory_directory", mutate_after_inventory) + try: + with pytest.raises(RuntimeError, match=r"active writer.*retained"): + remove_claimed(str(source), str(safe), claim) + finally: + os.close(writer) + + assert child.read_bytes() == b"change" + + +def test_claim_rejects_same_device_nested_mount_identity(tmp_path, monkeypatch): + safe = tmp_path / "safe" + mounted = safe / "game" / "mounted" + mounted.mkdir(parents=True) + (mounted / "outside.bin").write_bytes(b"keep") + module = __import__("adapters.descriptor_paths", fromlist=["_mount_id"]) + original = module._mount_id + + def fake_mount_id(fd): + target = os.readlink(f"/proc/self/fd/{fd}") + return original(fd) + (1 if "/mounted" in target else 0) + + monkeypatch.setattr("adapters.descriptor_paths._mount_id", fake_mount_id) + + with pytest.raises(ValueError, match="mount boundary"): + claim_source(str(safe / "game"), str(safe)) + + assert (mounted / "outside.bin").read_bytes() == b"keep" + + +def test_remove_reports_post_unlink_fsync_uncertainty(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"save") + claim = claim_source(str(source), str(safe)) + monkeypatch.setattr("adapters.descriptor_paths.os.fsync", lambda _fd: (_ for _ in ()).throw(OSError("EIO"))) + + outcome = remove_claimed(str(source), str(safe), claim) + + assert outcome == { + "success": False, + "changed": True, + "ambiguous": True, + "message": "Source was removed but directory durability is uncertain: EIO", + } + assert not source.exists() + + +def test_rename_reports_post_move_fsync_uncertainty(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + destination = safe / "backup.srm" + source.write_bytes(b"save") + claim = claim_source(str(source), str(safe)) + monkeypatch.setattr("adapters.descriptor_paths.os.fsync", lambda _fd: (_ for _ in ()).throw(OSError("EIO"))) + + outcome = rename_claimed(str(source), str(destination), str(safe), claim) + + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert destination.read_bytes() == b"save" + + +def test_rename_never_replaces_a_destination_created_after_the_absence_check(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + destination = safe / "backup.srm" + source.write_bytes(b"current-save") + claim = claim_source(str(source), str(safe)) + module = __import__("adapters.descriptor_paths", fromlist=["rename_noreplace_at"]) + original = module.rename_noreplace_at + + def race_destination(*args, **kwargs): + destination.write_bytes(b"concurrent-backup") + return original(*args, **kwargs) + + monkeypatch.setattr(module, "rename_noreplace_at", race_destination) + + with pytest.raises(FileExistsError, match="already exists"): + rename_claimed(str(source), str(destination), str(safe), claim) + + assert source.read_bytes() == b"current-save" + assert destination.read_bytes() == b"concurrent-backup" + + +def test_remove_reports_lease_release_failure_after_unlink_as_ambiguous(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"save") + claim = claim_source(str(source), str(safe)) + module = __import__("adapters.descriptor_paths", fromlist=["fcntl"]) + original = module.fcntl.fcntl + + def fail_unlock(fd, command, *args): + if command == module.fcntl.F_SETLEASE and args == (module.fcntl.F_UNLCK,): + raise OSError("injected lease release failure") + return original(fd, command, *args) + + monkeypatch.setattr(module.fcntl, "fcntl", fail_unlock) + + outcome = remove_claimed(str(source), str(safe), claim) + + assert outcome == { + "success": False, + "changed": True, + "ambiguous": True, + "message": "Source was removed but writer-exclusion teardown is uncertain: injected lease release failure", + } + assert not source.exists() diff --git a/tests/adapters/test_plugin_metadata.py b/tests/adapters/test_plugin_metadata.py index f6090f5d..364ffaa4 100644 --- a/tests/adapters/test_plugin_metadata.py +++ b/tests/adapters/test_plugin_metadata.py @@ -47,6 +47,35 @@ def test_read_version_missing_version_field_returns_fallback(self, tmp_path): adapter = PluginMetadataAdapter() assert adapter.read_version(str(plugin_dir)) == "0.0.0" + def test_read_name_returns_declared_name_and_has_safe_fallback(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / "package.json").write_text(json.dumps({"name": "custom-plugin"})) + adapter = PluginMetadataAdapter() + assert adapter.read_name(str(plugin_dir)) == "custom-plugin" + (plugin_dir / "package.json").write_text(json.dumps({"name": None})) + assert adapter.read_name(str(plugin_dir)) == "custom-plugin" + assert PluginMetadataAdapter().read_name(str(plugin_dir)) == "decky-plugin" + + def test_name_and_version_share_one_package_read(self, tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / "package.json").write_text(json.dumps({"name": "plugin", "version": "1.2.3"})) + adapter = PluginMetadataAdapter() + original = adapter._read + calls = 0 + + def counted(path): + nonlocal calls + calls += 1 + return original(path) + + monkeypatch.setattr(adapter, "_read", counted) + assert adapter.read_metadata(str(plugin_dir)) == ("plugin", "1.2.3") + assert adapter.read_name(str(plugin_dir)) == "plugin" + assert adapter.read_version(str(plugin_dir)) == "1.2.3" + assert calls == 1 + @pytest.mark.parametrize("empty_value", ["", None]) def test_read_version_empty_version_returned_as_is(self, tmp_path, empty_value): """The adapter does not validate the version string — empty/None pass through. diff --git a/tests/adapters/test_prune_artifacts.py b/tests/adapters/test_prune_artifacts.py new file mode 100644 index 00000000..dd20a7f8 --- /dev/null +++ b/tests/adapters/test_prune_artifacts.py @@ -0,0 +1,59 @@ +import os +from pathlib import Path + +from adapters.prune_artifacts import PruneArtifactAdapter + + +def test_discovers_and_removes_only_named_rom_cache_files(tmp_path): + covers = tmp_path / "covers" + artwork = tmp_path / "artwork" + covers.mkdir() + artwork.mkdir() + expected = [covers / "7.png", covers / "7.cover-meta.json", artwork / "7_grid.png"] + for path in expected: + path.write_bytes(b"x") + unrelated = artwork / "8_grid.png" + unrelated.write_bytes(b"keep") + adapter = PruneArtifactAdapter(runtime_dir=str(tmp_path)) + + artifacts = adapter.recovery_artifacts([7]) + sources = {Path(item["source_path"]) for item in artifacts} + assert set(expected) <= sources + assert artwork / "7_icon.png" in sources + assert adapter.remove([7])["success"] is True + assert all(not path.exists() for path in expected) + assert unrelated.exists() + + +def test_unsealed_cleanup_does_not_follow_symlinked_cache_directory(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + target = outside / "7.png" + target.write_bytes(b"keep") + (tmp_path / "covers").symlink_to(outside, target_is_directory=True) + (tmp_path / "artwork").mkdir() + adapter = PruneArtifactAdapter(runtime_dir=str(tmp_path)) + + result = adapter.remove([7]) + + assert result["success"] is False + assert target.read_bytes() == b"keep" + + +def test_preopened_cache_writer_prevents_artifact_deletion(tmp_path): + covers = tmp_path / "covers" + artwork = tmp_path / "artwork" + covers.mkdir() + artwork.mkdir() + target = covers / "7.png" + target.write_bytes(b"cached") + adapter = PruneArtifactAdapter(runtime_dir=str(tmp_path)) + writer = os.open(target, os.O_WRONLY) + try: + result = adapter.remove([7]) + finally: + os.close(writer) + + assert result["success"] is False + assert "active writer" in result["message"] + assert target.read_bytes() == b"cached" diff --git a/tests/adapters/test_recovery_bundle.py b/tests/adapters/test_recovery_bundle.py new file mode 100644 index 00000000..20b4348c --- /dev/null +++ b/tests/adapters/test_recovery_bundle.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import errno +import json +import os +import shutil +import stat +from pathlib import Path + +import pytest + +from adapters.recovery_bundle import RecoveryBundleAdapter + + +def _adapter(tmp_path) -> RecoveryBundleAdapter: + return RecoveryBundleAdapter(user_home=str(tmp_path), package_name="decky romm/sync", plugin_version="1.2.3") + + +def _snapshot() -> dict[str, object]: + return { + "roms": [{"rom_id": 7, "name": "Game"}], + "installs": [], + "metadata": [], + "save_sync": [], + "playtime": [], + "warnings": [], + } + + +def test_seals_verified_bundle_with_generated_destinations(tmp_path): + source_root = tmp_path / "sources" + source_root.mkdir() + nested = source_root / "server supplied name" + nested.mkdir() + (nested / "disc 1.bin").write_bytes(b"rom") + adapter = _adapter(tmp_path) + + sealed = Path( + adapter.seal_bundle( + "20260724T120000Z_7_abc-123", + _snapshot(), + [{"source_path": str(nested), "safe_root": str(source_root), "kind": "installed_rom", "rom_id": 7}], + "manual recovery\n", + "7 seconds\n", + ) + ) + + assert sealed.name == "20260724T120000Z_7_abc-123" + assert (sealed / "SEAL.json").exists() + manifest = json.loads((sealed / "manifest.json").read_text()) + assert manifest["plugin_version"] == "1.2.3" + assert manifest["artifacts"][0]["destination"] == "files/000001" + assert manifest["artifacts"][0]["source_path"].endswith("disc 1.bin") + assert (sealed / "files" / "000001").read_bytes() == b"rom" + assert (sealed / "roms" / "7" / "state.json").exists() + assert "files/000001" in (sealed / "checksums.sha256").read_text() + + +def test_rejects_path_escape_symlink_and_duplicate_identity(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + outside = tmp_path / "outside.bin" + outside.write_bytes(b"x") + link = safe / "link.bin" + link.symlink_to(outside) + adapter = _adapter(tmp_path) + with pytest.raises(ValueError, match=r"outside|symlink"): + adapter.measure_path(str(link), str(safe)) + with pytest.raises(ValueError, match="outside"): + adapter.measure_path(str(outside), str(safe)) + with pytest.raises(ValueError, match="unsafe recovery bundle id"): + adapter.seal_bundle("../escape", _snapshot(), [], "readme", "playtime") + + bundle_id = "20260724T120000Z_7_same" + adapter.seal_bundle(bundle_id, _snapshot(), [], "readme", "playtime") + with pytest.raises(FileExistsError): + adapter.seal_bundle(bundle_id, _snapshot(), [], "readme", "playtime") + + +def test_failed_copy_cleans_staging_without_touching_existing_bundle(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"save") + adapter = _adapter(tmp_path) + + def fail_copy(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr(adapter, "_copy_opened_source", fail_copy) + with pytest.raises(OSError, match="disk full"): + adapter.seal_bundle( + "20260724T120000Z_7_failure", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "current_save", "rom_id": 7}], + "readme", + "playtime", + ) + staging = Path(adapter.root()) / "staging" + assert list(staging.iterdir()) == [] + + +def test_sealed_bundle_revalidates_manifest_and_source_bytes(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"before") + adapter = _adapter(tmp_path) + sealed = adapter.seal_bundle( + "20260724T120000Z_7_validate", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "current_save", "rom_id": 7}], + "readme", + "playtime", + ) + + assert adapter.validate_sources(sealed) is True + source.write_bytes(b"after") + assert adapter.validate_sources(sealed) is False + + +def test_same_byte_source_replacement_fails_sealed_identity_validation(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"same bytes") + adapter = _adapter(tmp_path) + sealed = adapter.seal_bundle( + "20260724T120000Z_7_identity", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "current_save", "rom_id": 7}], + "readme", + "playtime", + ) + source.unlink() + source.write_bytes(b"same bytes") + + assert adapter.validate_sources(sealed) is False + source.write_bytes(b"before") + (Path(sealed) / "manifest.json").write_text("{}") + assert adapter.validate_sources(sealed) is False + + +def test_sealed_bundle_rejects_file_that_appears_in_an_initially_empty_source_set(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "late-grid.png" + adapter = _adapter(tmp_path) + sealed = adapter.seal_bundle( + "20260724T120000Z_7_late", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "steam_grid"}], + "readme", + "playtime", + ) + + assert adapter.validate_sources(sealed) is True + source.write_bytes(b"new") + assert adapter.validate_sources(sealed) is False + + +def test_source_replacement_with_symlink_fails_at_open_time(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"inside") + outside = tmp_path / "outside.srm" + outside.write_bytes(b"outside") + adapter = _adapter(tmp_path) + original = adapter._open_regular_beneath + replaced = False + + def replace_before_open(path: str, safe_root: str) -> int: + nonlocal replaced + if path == str(source) and not replaced: + replaced = True + source.unlink() + source.symlink_to(outside) + return original(path, safe_root) + + monkeypatch.setattr(adapter, "_open_regular_beneath", replace_before_open) + with pytest.raises((OSError, ValueError)): + adapter.seal_bundle( + "20260724T120000Z_7_replaced", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "current_save", "rom_id": 7}], + "readme", + "playtime", + ) + + +def test_real_directory_fsync_failure_aborts_sealing(tmp_path, monkeypatch): + adapter = _adapter(tmp_path) + original = os.fsync + + def fail_directory(fd: int) -> None: + if stat.S_ISDIR(os.fstat(fd).st_mode): + raise OSError(errno.EIO, "directory fsync failed") + original(fd) + + monkeypatch.setattr("adapters.recovery_bundle.os.fsync", fail_directory) + with pytest.raises(OSError, match="directory fsync failed"): + adapter.seal_bundle("20260724T120000Z_7_fsync", _snapshot(), [], "readme", "playtime") + + +def test_symlinked_recovery_root_is_rejected(tmp_path): + adapter = _adapter(tmp_path) + target = tmp_path / "outside" + target.mkdir() + Path(adapter.root()).symlink_to(target, target_is_directory=True) + + with pytest.raises(ValueError, match="trusted directory"): + adapter.free_bytes() + + +@pytest.mark.parametrize( + ("field", "value"), + [("sealed", False), ("bundle_id", "other"), ("file_count", 99)], +) +def test_seal_contract_fields_are_revalidated(tmp_path, field, value): + adapter = _adapter(tmp_path) + sealed = Path(adapter.seal_bundle("20260724T120000Z_7_seal-fields", _snapshot(), [], "readme", "playtime")) + seal_path = sealed / "SEAL.json" + seal = json.loads(seal_path.read_text()) + seal[field] = value + seal_path.write_text(json.dumps(seal)) + + assert adapter.validate_sources(str(sealed)) is False + + +def test_new_recovery_directories_fsync_each_parent(tmp_path, monkeypatch): + adapter = _adapter(tmp_path) + synced: list[int] = [] + original = os.fsync + + def track(fd: int) -> None: + if stat.S_ISDIR(os.fstat(fd).st_mode): + synced.append(os.fstat(fd).st_ino) + original(fd) + + monkeypatch.setattr("adapters.recovery_bundle.os.fsync", track) + + adapter.seal_bundle("20260724T120000Z_7_new-parents", _snapshot(), [], "readme", "playtime") + + root = Path(adapter.root()) + assert tmp_path.stat().st_ino in synced + assert root.stat().st_ino in synced + assert (root / "staging").stat().st_ino in synced + assert (root / "bundles").stat().st_ino in synced + + +def test_post_rename_fsync_failure_marks_bundle_durability_uncertain(tmp_path, monkeypatch): + adapter = _adapter(tmp_path) + adapter.free_bytes() + bundles = Path(adapter.root()) / "bundles" + bundles_inode = bundles.stat().st_ino + original = os.fsync + + def fail_final_sync(fd: int) -> None: + if os.fstat(fd).st_ino == bundles_inode: + raise OSError(errno.EIO, "directory fsync failed") + original(fd) + + monkeypatch.setattr("adapters.recovery_bundle.os.fsync", fail_final_sync) + bundle_id = "20260724T120000Z_7_uncertain" + with pytest.raises(OSError, match="durability is uncertain"): + adapter.seal_bundle(bundle_id, _snapshot(), [], "readme", "playtime") + + assert not (bundles / bundle_id).exists() + assert (bundles / f"{bundle_id}.durability-uncertain").is_dir() + + +def test_recovery_bundle_parent_replacement_is_not_reauthorized(tmp_path, monkeypatch): + adapter = _adapter(tmp_path) + adapter.free_bytes() + bundles = Path(adapter.root()) / "bundles" + detached = Path(adapter.root()) / "detached-bundles" + outside = tmp_path / "outside-bundles" + outside.mkdir() + original = adapter._copy_artifacts + + def swap_parent(staging, artifacts, free_bytes): + result = original(staging, artifacts, free_bytes) + bundles.rename(detached) + bundles.symlink_to(outside, target_is_directory=True) + return result + + monkeypatch.setattr(adapter, "_copy_artifacts", swap_parent) + + with pytest.raises(RuntimeError, match="unsafe staging was preserved"): + adapter.seal_bundle("20260724T120000Z_7_swapped", _snapshot(), [], "readme", "playtime") + + assert list(outside.iterdir()) == [] + + +def test_failed_seal_preserves_staging_instead_of_crossing_nested_mount(tmp_path, monkeypatch): + adapter = _adapter(tmp_path) + adapter.free_bytes() + module = __import__("adapters.descriptor_paths", fromlist=["_mount_id"]) + original_mount_id = module._mount_id + staging_path: Path | None = None + + def fake_mount_id(fd: int) -> int: + target = os.readlink(f"/proc/self/fd/{fd}") + return original_mount_id(fd) + (1 if target.endswith("/mounted") else 0) + + def add_mount_transition_then_fail(staging_fd: int, checksums) -> None: + nonlocal staging_path + del checksums + staging_path = Path(os.readlink(f"/proc/self/fd/{staging_fd}")) + mounted = staging_path / "mounted" + mounted.mkdir() + (mounted / "outside-marker").write_bytes(b"keep") + raise OSError("injected seal failure") + + monkeypatch.setattr("adapters.descriptor_paths._mount_id", fake_mount_id) + monkeypatch.setattr(adapter, "_verify_staging_checksums", add_mount_transition_then_fail) + + with pytest.raises(RuntimeError, match="unsafe staging was preserved") as caught: + adapter.seal_bundle("20260724T120000Z_7_mounted", _snapshot(), [], "readme", "playtime") + + assert staging_path is not None + assert str(staging_path) in str(caught.value) + assert (staging_path / "mounted" / "outside-marker").read_bytes() == b"keep" + + +def test_claim_digest_rejects_same_name_bundle_replacement(tmp_path): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"save") + adapter = _adapter(tmp_path) + sealed = Path( + adapter.seal_bundle( + "20260724T120000Z_7_bound", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "current_save", "rom_id": 7}], + "readme", + "playtime", + ) + ) + decoded = adapter.source_claims(str(sealed)) + original = sealed.with_name("original-bound") + sealed.rename(original) + shutil.copytree(original, sealed) + + assert adapter.validate_sources(str(sealed), decoded["bundle_digest"]) is False + assert decoded["claims"][str(source)]["sha256"] is not None + + +def test_destination_replacement_cannot_modify_outside_file(tmp_path, monkeypatch): + safe = tmp_path / "safe" + safe.mkdir() + source = safe / "save.srm" + source.write_bytes(b"save") + outside = tmp_path / "outside" + outside.write_bytes(b"outside") + outside.chmod(0o600) + adapter = _adapter(tmp_path) + original = adapter._copy_opened_source + + def replace_copied_destination(source_path, safe_root, parent_fd, name): + result = original(source_path, safe_root, parent_fd, name) + os.unlink(name, dir_fd=parent_fd) + os.symlink(outside, name, dir_fd=parent_fd) + return result + + monkeypatch.setattr(adapter, "_copy_opened_source", replace_copied_destination) + + with pytest.raises(RuntimeError, match="unsafe staging was preserved"): + adapter.seal_bundle( + "20260724T120000Z_7_destination", + _snapshot(), + [{"source_path": str(source), "safe_root": str(safe), "kind": "current_save", "rom_id": 7}], + "readme", + "playtime", + ) + + assert outside.read_bytes() == b"outside" + assert stat.S_IMODE(outside.stat().st_mode) == 0o600 diff --git a/tests/adapters/test_steam_recovery.py b/tests/adapters/test_steam_recovery.py new file mode 100644 index 00000000..5dfce7ae --- /dev/null +++ b/tests/adapters/test_steam_recovery.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import logging +import os +import shutil +from pathlib import Path + +import pytest +from _vendor import vdf + +from adapters.descriptor_paths import claim_source +from adapters.steam_recovery import SteamRecoveryAdapter + + +def _layout(tmp_path, app_id: int): + steam = tmp_path / ".local" / "share" / "Steam" + user = steam / "userdata" / "123" + config = user / "config" + grid = config / "grid" + grid.mkdir(parents=True) + localconfig = config / "localconfig.vdf" + with localconfig.open("w") as output: + vdf.dump({"UserLocalConfigStore": {"Apps": {str(app_id): {"UseSteamControllerConfig": "2"}}}}, output) + (grid / f"{app_id}p.png").write_bytes(b"cover") + first = config / "controller_configs" / "apps" / str(app_id) + first.mkdir(parents=True) + (first / "layout.vdf").write_text("one") + second = steam / "steamapps" / "common" / "Steam Controller Configs" / "123" / "config" / str(app_id) + second.mkdir(parents=True) + (second / "controller_neptune.vdf").write_text("two") + return first, second, grid + + +def _source_claims(snapshot): + claims = {} + for artifact in snapshot["artifacts"]: + source = artifact["source_path"] + claims[source] = claim_source(source, artifact["safe_root"]) + return claims + + +def test_snapshots_controller_setting_grid_and_both_input_roots(tmp_path): + app_id = 0x80000007 + first, second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + assert snapshot["controller_setting"] == "2" + sources = {Path(item["source_path"]) for item in snapshot["artifacts"]} + assert {grid / f"{app_id}p.png", first, second} <= sources + assert grid / f"{app_id}.png" in sources + + assert adapter.remove_state(app_id, snapshot, _source_claims(snapshot))["success"] is True + assert not (grid / f"{app_id}p.png").exists() + assert not first.exists() + assert not second.exists() + with (grid.parent / "localconfig.vdf").open() as source: + payload = vdf.load(source) + assert str(app_id) not in payload["UserLocalConfigStore"]["Apps"] + + +def test_preopened_steam_input_writer_retains_the_claimed_tree(tmp_path): + app_id = 0x80000007 + first, _second, _grid = _layout(tmp_path, app_id) + layout = first / "layout.vdf" + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + claims = _source_claims(snapshot) + writer = os.open(layout, os.O_WRONLY) + try: + outcome = adapter.remove_state(app_id, snapshot, claims) + finally: + os.close(writer) + + assert outcome["success"] is False + assert "active writer" in outcome["message"] + assert layout.read_text() == "one" + + +def test_missing_active_user_is_loud(tmp_path): + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + try: + adapter.snapshot(7) + except RuntimeError as exc: + assert "active Steam user" in str(exc) + else: + raise AssertionError("missing Steam user should not produce an empty recovery snapshot") + + +def test_cleanup_uses_captured_login_identity_when_active_user_changes(tmp_path): + app_id = 0x80000007 + first, _second, grid = _layout(tmp_path, app_id) + steam = tmp_path / ".local" / "share" / "Steam" + other_grid = steam / "userdata" / "456" / "config" / "grid" + other_grid.mkdir(parents=True) + (other_grid / f"{app_id}p.png").write_bytes(b"other-cover") + loginusers = steam / "config" / "loginusers.vdf" + loginusers.parent.mkdir() + with loginusers.open("w") as output: + vdf.dump( + { + "users": { + str(76561197960265728 + 123): {"MostRecent": "1"}, + str(76561197960265728 + 456): {"MostRecent": "0"}, + } + }, + output, + ) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + assert snapshot["user_id"] == "123" + + with loginusers.open("w") as output: + vdf.dump( + { + "users": { + str(76561197960265728 + 123): {"MostRecent": "0"}, + str(76561197960265728 + 456): {"MostRecent": "1"}, + } + }, + output, + ) + assert adapter.remove_state(app_id, snapshot, _source_claims(snapshot))["success"] is True + + assert not (grid / f"{app_id}p.png").exists() + assert not first.exists() + assert (other_grid / f"{app_id}p.png").read_bytes() == b"other-cover" + + +@pytest.mark.parametrize("subtree", ["grid", "user", "input"]) +def test_symlinked_steam_subtrees_are_rejected_without_touching_target(tmp_path, subtree): + app_id = 0x80000007 + first, _second, grid = _layout(tmp_path, app_id) + steam = tmp_path / ".local" / "share" / "Steam" + user = steam / "userdata" / "123" + outside = tmp_path / f"outside-{subtree}" + outside.mkdir() + marker = outside / "marker" + marker.write_text("keep") + + if subtree == "grid": + for child in grid.iterdir(): + child.unlink() + grid.rmdir() + grid.symlink_to(outside, target_is_directory=True) + elif subtree == "user": + shutil.rmtree(user) + user.symlink_to(outside, target_is_directory=True) + else: + shutil.rmtree(first) + first.symlink_to(outside, target_is_directory=True) + + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + with pytest.raises((OSError, ValueError)): + adapter.snapshot(app_id) + + assert marker.read_text() == "keep" + + +def test_controller_replacement_fsyncs_its_containing_directory(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + config_inode = grid.parent.stat().st_ino + synced_directory_inodes: list[int] = [] + original = os.fsync + + def track(fd: int) -> None: + current = os.fstat(fd) + if current.st_ino == config_inode: + synced_directory_inodes.append(current.st_ino) + original(fd) + + monkeypatch.setattr("adapters.steam_recovery.os.fsync", track) + assert adapter.remove_state(app_id, snapshot, _source_claims(snapshot))["success"] is True + + assert synced_directory_inodes == [config_inode] + + +def test_controller_writer_exclusion_release_failure_is_ambiguous(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + module = __import__("adapters.descriptor_paths", fromlist=["fcntl"]) + original = module.fcntl.fcntl + + def fail_unlock(fd, command, *args): + if command == module.fcntl.F_SETLEASE and args == (module.fcntl.F_UNLCK,): + raise OSError("injected controller lease release failure") + return original(fd, command, *args) + + monkeypatch.setattr(module.fcntl, "fcntl", fail_unlock) + + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert "injected controller lease release failure" in outcome["message"] + assert not list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + + +def test_controller_writer_exclusion_setup_failure_surfaces_retained_claim(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + module = __import__("adapters.descriptor_paths", fromlist=["fcntl"]) + original = module.fcntl.fcntl + + def fail_setup(fd, command, *args): + if command == module._F_SETOWN_EX: + raise OSError("injected controller lease setup failure") + return original(fd, command, *args) + + monkeypatch.setattr(module.fcntl, "fcntl", fail_setup) + + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + + claimed = list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert len(claimed) == 1 + assert str(claimed[0]) in outcome["message"] + assert "injected controller lease setup failure" in outcome["message"] + + +def test_controller_cleanup_retries_and_preserves_unrelated_concurrent_write(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + localconfig = grid.parent / "localconfig.vdf" + module = __import__("adapters.steam_recovery", fromlist=["rename_noreplace_at"]) + original = module.rename_noreplace_at + injected = False + + def write_then_rename(source_fd, src, destination_fd, dst): + nonlocal injected + if src == "localconfig.vdf" and not injected: + injected = True + with localconfig.open() as source: + payload = vdf.load(source) + payload["UserLocalConfigStore"]["Unrelated"] = {"Fresh": "value"} + with localconfig.open("w") as output: + vdf.dump(payload, output, pretty=True) + return original(source_fd, src, destination_fd, dst) + + monkeypatch.setattr(module, "rename_noreplace_at", write_then_rename) + + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + + assert outcome["success"] is True + with localconfig.open() as source: + payload = vdf.load(source) + assert payload["UserLocalConfigStore"]["Unrelated"] == {"Fresh": "value"} + assert str(app_id) not in payload["UserLocalConfigStore"]["Apps"] + + +def test_controller_cleanup_retains_write_through_held_fd_after_rename(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + localconfig = grid.parent / "localconfig.vdf" + with localconfig.open() as source: + fresh_payload = vdf.load(source) + fresh_payload["UserLocalConfigStore"]["Unrelated"] = {"Fresh": "value"} + held_fd = os.open(localconfig, os.O_RDWR) + original = os.link + injected = False + + def write_then_link(*args, **kwargs): + nonlocal injected + if not injected: + injected = True + with os.fdopen(os.dup(held_fd), "w") as output: + output.seek(0) + output.truncate() + vdf.dump(fresh_payload, output, pretty=True) + output.flush() + return original(*args, **kwargs) + + monkeypatch.setattr("adapters.steam_recovery.os.link", write_then_link) + try: + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + finally: + os.close(held_fd) + + claimed = list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert len(claimed) == 1 + assert str(claimed[0]) in outcome["message"] + with localconfig.open() as source: + payload = vdf.load(source) + assert str(app_id) not in payload["UserLocalConfigStore"]["Apps"] + with claimed[0].open() as source: + preserved = vdf.load(source) + assert preserved["UserLocalConfigStore"]["Unrelated"] == {"Fresh": "value"} + + +def test_controller_cleanup_retains_claim_when_preopened_writer_blocks_final_exclusion(tmp_path): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + localconfig = grid.parent / "localconfig.vdf" + writer = os.open(localconfig, os.O_WRONLY) + try: + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + finally: + os.close(writer) + + claimed = list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert len(claimed) == 1 + assert str(claimed[0]) in outcome["message"] + + +def test_controller_collision_retains_claim_when_preopened_writer_blocks_discard(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + localconfig = grid.parent / "localconfig.vdf" + replacement_payload = {"UserLocalConfigStore": {"Unrelated": {"NewPath": "value"}}} + writer = os.open(localconfig, os.O_WRONLY) + original_link = os.link + injected = False + + def collide_then_link(*args, **kwargs): + nonlocal injected + if not injected: + injected = True + with localconfig.open("w") as output: + vdf.dump(replacement_payload, output, pretty=True) + return original_link(*args, **kwargs) + + monkeypatch.setattr("adapters.steam_recovery.os.link", collide_then_link) + try: + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + finally: + os.close(writer) + + claimed = list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert len(claimed) == 1 + assert str(claimed[0]) in outcome["message"] + with localconfig.open() as source: + assert vdf.load(source) == replacement_payload + + +def test_controller_link_collision_preserves_newer_claimed_inode(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + localconfig = grid.parent / "localconfig.vdf" + with localconfig.open() as source: + fresh_payload = vdf.load(source) + fresh_payload["UserLocalConfigStore"]["Unrelated"] = {"HeldFresh": "value"} + replacement_payload = {"UserLocalConfigStore": {"Unrelated": {"NewPath": "value"}}} + held_fd = os.open(localconfig, os.O_RDWR) + original_link = os.link + injected = False + + def write_recreate_then_link(*args, **kwargs): + nonlocal injected + if not injected: + injected = True + with os.fdopen(os.dup(held_fd), "w") as output: + output.seek(0) + output.truncate() + vdf.dump(fresh_payload, output, pretty=True) + output.flush() + with localconfig.open("w") as output: + vdf.dump(replacement_payload, output, pretty=True) + return original_link(*args, **kwargs) + + monkeypatch.setattr("adapters.steam_recovery.os.link", write_recreate_then_link) + try: + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + finally: + os.close(held_fd) + + claimed = list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + assert outcome["success"] is False + assert outcome["changed"] is True + assert outcome["ambiguous"] is True + assert "newer source was preserved" in outcome["message"] + assert len(claimed) == 1 + assert str(claimed[0]) in outcome["message"] + with localconfig.open() as source: + assert vdf.load(source) == replacement_payload + with claimed[0].open() as source: + preserved = vdf.load(source) + assert preserved["UserLocalConfigStore"]["Unrelated"] == {"HeldFresh": "value"} + + +def test_controller_rollback_failure_preserves_claimed_newer_vdf(tmp_path, monkeypatch): + app_id = 0x80000007 + _first, _second, grid = _layout(tmp_path, app_id) + adapter = SteamRecoveryAdapter(user_home=str(tmp_path), logger=logging.getLogger("test")) + snapshot = adapter.snapshot(app_id) + localconfig = grid.parent / "localconfig.vdf" + with localconfig.open() as source: + fresh_payload = vdf.load(source) + fresh_payload["UserLocalConfigStore"]["Unrelated"] = {"Fresh": "value"} + held_fd = os.open(localconfig, os.O_RDWR) + original_link = os.link + original_unlink = os.unlink + injected = False + + def write_then_link(*args, **kwargs): + nonlocal injected + if not injected: + injected = True + with os.fdopen(os.dup(held_fd), "w") as output: + output.seek(0) + output.truncate() + vdf.dump(fresh_payload, output, pretty=True) + output.flush() + return original_link(*args, **kwargs) + + def fail_replacement_unlink(path, *args, **kwargs): + if path == "localconfig.vdf": + raise OSError("injected replacement unlink failure") + return original_unlink(path, *args, **kwargs) + + monkeypatch.setattr("adapters.steam_recovery.os.link", write_then_link) + monkeypatch.setattr("adapters.steam_recovery.os.unlink", fail_replacement_unlink) + try: + outcome = adapter.remove_state(app_id, snapshot, _source_claims(snapshot)) + finally: + os.close(held_fd) + + claimed = list(grid.parent.glob(".localconfig.vdf.prune-old-*")) + assert outcome["success"] is False + assert outcome["ambiguous"] is True + assert "newer source was preserved" in outcome["message"] + assert len(claimed) == 1 + with claimed[0].open() as source: + preserved = vdf.load(source) + assert preserved["UserLocalConfigStore"]["Unrelated"] == {"Fresh": "value"} diff --git a/tests/contract/_harness.py b/tests/contract/_harness.py index 63887fb3..01469090 100644 --- a/tests/contract/_harness.py +++ b/tests/contract/_harness.py @@ -81,6 +81,7 @@ "_core_service": "core_service", "_disc_service": "disc_service", "_version_switch_service": "version_switch_service", + "_prune_service": "prune_service", "_connection_service": "connection_service", "_startup_healing_service": "startup_healing_service", "_launch_gate_service": "launch_gate_service", diff --git a/tests/contract/_seed.py b/tests/contract/_seed.py index 27781f3a..3f314689 100644 --- a/tests/contract/_seed.py +++ b/tests/contract/_seed.py @@ -89,7 +89,7 @@ def seed_install( ) -> str: """Seed a ``RomInstall`` (seeds the ``Rom`` FK first). Returns the file path.""" seed_rom(harness, rom_id, platform_slug=platform_slug) - file_path = str(harness.tmp_path / "retrodeck" / "roms" / system / file_name) + file_path = os.path.join(harness.retrodeck_paths.roms_path(), system, file_name) with harness.uow_factory() as uow: uow.rom_installs.save( RomInstall.mark_installed( @@ -147,7 +147,7 @@ def seed_group_member( ) if not installed: return None - file_path = str(harness.tmp_path / "retrodeck" / "roms" / system / file_name) + file_path = os.path.join(harness.retrodeck_paths.roms_path(), system, file_name) with harness.uow_factory() as uow: uow.rom_installs.save( RomInstall.mark_installed( diff --git a/tests/contract/test_prune.py b/tests/contract/test_prune.py new file mode 100644 index 00000000..e50f38c5 --- /dev/null +++ b/tests/contract/test_prune.py @@ -0,0 +1,422 @@ +"""Wire-level contracts for explicit vanished-ROM cleanup.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from domain.platform_sync_state import PlatformSyncState +from domain.rom import Rom +from domain.rom_install import RomInstall +from domain.rom_save_sync_state import FileSyncState, RomSaveSyncState +from domain.sync_state import SyncState +from domain.version_metadata import VersionMetadata +from lib.errors import RommNotFoundError + + +def _seed_bulk_candidate(harness, rom_id: int = 41) -> None: + rom = Rom.synced( + rom_id=rom_id, + platform_slug="gba", + name="Removed Game", + fs_name="Removed Game.gba", + shortcut_app_id=None, + synced_at="2026-01-01T00:00:00", + ) + rom.record_fetch_generation("older-fetch") + with harness.uow_factory() as uow: + uow.roms.save(rom) + uow.platform_sync_state.save( + PlatformSyncState.stamp( + platform_slug="gba", + at="2026-01-02T00:00:00", + rom_count=1, + fetch_id="completed-fetch", + ) + ) + + +def _preview_request(preview_id=None): + return {"scope": "bulk", "rom_id": None, "preview_id": preview_id, "offset": 0, "limit": 50} + + +async def test_preview_is_local_paged_and_frontend_shaped(harness): + _seed_bulk_candidate(harness) + + result = await harness.plugin.get_prune_preview(_preview_request()) + + assert set(result) == { + "success", + "preview_id", + "scope", + "items", + "offset", + "limit", + "total", + "free_bytes", + "recovery_root", + } + assert result["success"] is True + assert result["scope"] == "bulk" + assert result["total"] == 1 + assert result["items"][0]["rom_id"] == 41 + assert result["items"][0]["installed"] is False + assert result["recovery_root"].endswith("-recovery") + assert harness.romm.call_log == [] + + +@pytest.mark.parametrize("state", [SyncState.RUNNING, SyncState.CANCELLING]) +async def test_preview_refuses_active_sync_with_canonical_shape(harness, state): + harness.plugin._sync_service._box.sync_state = state + + result = await harness.plugin.get_prune_preview(_preview_request()) + + assert set(result) == {"success", "reason", "message"} + assert result["success"] is False + assert result["reason"] == "sync_active" + assert result["message"] + + +async def test_unbound_exact_404_cleanup_deletes_real_aggregate_and_emits_completion(harness): + _seed_bulk_candidate(harness) + harness.romm.get_rom_once_side_effect_by_id[41] = RommNotFoundError("gone") + preview = await harness.plugin.get_prune_preview(_preview_request()) + + started = await harness.plugin.start_prune( + { + "preview_id": preview["preview_id"], + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": True, + "create_recovery_bundle": False, + "include_installed_rom_ids": [], + } + ) + + assert set(started) == {"success", "run_id", "status"} + assert started["success"] is True + assert started["status"] == "running" + task = harness.plugin._prune_service._task + assert task is not None + await task + + with harness.uow_factory() as uow: + assert uow.roms.get(41) is None + probes = [entry for entry in harness.romm.call_log if entry[0] == "get_rom_once"] + assert probes == [("get_rom_once", (41,), {})] * 3 + complete_calls = [item for item in harness.emit.await_args_list if item.args[0] == "prune_complete"] + assert len(complete_calls) == 1 + payload = complete_calls[0].args[1] + assert payload["success"] is True + assert payload["partial"] is False + assert payload["removed_rom_ids"] == [41] + assert payload["results"][0]["status"] == "removed" + + +async def test_action_report_rejects_stale_token_with_canonical_shape(harness): + result = await harness.plugin.report_prune_action( + { + "phase": "complete", + "run_id": "old-run", + "action_token": "old-token", + "success": True, + "message": "late", + } + ) + + assert result == { + "success": False, + "reason": "stale_action", + "message": "This cleanup action token is no longer active.", + } + + +@pytest.mark.parametrize( + ("method", "args"), + [ + ("start_sync", ()), + ("test_connection", ()), + ("connect_with_credentials", ("https://server.example", "user", "pass", None)), + ("connect_with_token", ("https://server.example", "token", None)), + ("connect_with_pairing_code", ("https://server.example", "code", None)), + ("sign_out", ()), + ("save_server_url", ("https://server.example", None)), + ("start_download", (41,)), + ("migrate_retrodeck_files", (None,)), + ("sync_rom_saves", (41,)), + ("switch_version", (0x80000001, 41, False)), + ("remove_rom", (41,)), + ("uninstall_all_roms", ()), + ("get_rom_relaunch_options", (41,)), + ("get_installed_relaunch_options", ()), + ("report_unit_results", ({}, "run", "unit", 0)), + ("report_removal_results", ([], None)), + ("reconcile_shortcuts", ([],)), + ("refresh_save_status", (41,)), + ("get_save_status", (41,)), + ("get_save_slots", (41,)), + ("record_session_start", (41,)), + ("reconcile_playtime", (41,)), + ("fetch_cover_base64", (41,)), + ("get_sgdb_artwork_base64", (41, 0)), + ("apply_sgdb_game_id", (41, 7)), + ("save_shortcut_icon", (0x80000001, "")), + ("clear_sync_cache", ()), + ("apply_steam_input_setting", ()), + ("set_system_core", ("n64", "")), + ("set_game_core", (41, "core")), + ("clear_game_core", (41,)), + ("select_disc", (41, None)), + ("evaluate_launch", (0x80000001,)), + ], +) +async def test_prune_claim_reciprocally_blocks_conflicting_callable_entries(harness, method, args): + harness.plugin._prune_service._starting = True + + result = await getattr(harness.plugin, method)(*args) + + assert result["success"] is False + assert result["reason"] == "prune_active" + assert result["message"] + + +@pytest.mark.parametrize("operation", ["save_status", "download"]) +async def test_detached_writer_lifetime_blocks_prune_admission(harness, monkeypatch, operation): + _seed_bulk_candidate(harness) + preview = await harness.plugin.get_prune_preview(_preview_request()) + release = asyncio.Event() + task = None + if operation == "save_status": + entered = asyncio.Event() + finished = asyncio.Event() + + async def delayed_status(_rom_id): + entered.set() + await release.wait() + finished.set() + + monkeypatch.setattr(harness.plugin._save_sync_service, "check_save_status_background", delayed_status) + assert (await harness.plugin.refresh_save_status(41))["success"] is True + await entered.wait() + else: + task = asyncio.create_task(release.wait()) + + async def start_download(_rom_id): + return {"success": True, "message": "started"} + + monkeypatch.setattr(harness.plugin._download_service, "start_download", start_download) + monkeypatch.setattr(harness.plugin._download_service, "task_for_rom", lambda _rom_id: task) + assert (await harness.plugin.start_download(41))["success"] is True + + blocked = await harness.plugin.start_prune( + { + "preview_id": preview["preview_id"], + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": True, + "create_recovery_bundle": False, + "installed_selection_id": None, + } + ) + assert blocked["reason"] == "operation_active" + + release.set() + if task is not None: + await task + else: + await finished.wait() + await asyncio.sleep(0) + await asyncio.sleep(0) + started = await harness.plugin.start_prune( + { + "preview_id": preview["preview_id"], + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": True, + "create_recovery_bundle": False, + "installed_selection_id": None, + } + ) + assert started["success"] is True + running = harness.plugin._prune_service._task + assert running is not None + await running + + +async def test_frontend_core_continuation_lease_blocks_prune_until_ack(harness, monkeypatch): + _seed_bulk_candidate(harness) + preview = await harness.plugin.get_prune_preview(_preview_request()) + + async def set_game_core(_rom_id, _label): + return {"success": True, "app_id": 0x80000001, "launch_options": "launch"} + + monkeypatch.setattr(harness.plugin._core_service, "set_game_core", set_game_core) + result = await harness.plugin.set_game_core(41, "core") + token = result["prune_lease_token"] + + blocked = await harness.plugin.start_prune( + { + "preview_id": preview["preview_id"], + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": True, + "create_recovery_bundle": False, + "installed_selection_id": None, + } + ) + assert blocked["reason"] == "operation_active" + + assert (await harness.plugin.release_prune_conflict_lease(token))["success"] is True + started = await harness.plugin.start_prune( + { + "preview_id": preview["preview_id"], + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": True, + "create_recovery_bundle": False, + "installed_selection_id": None, + } + ) + assert started["success"] is True + await harness.plugin._prune_service.shutdown() + + +async def _wait_for_prune_action(harness, action: str): + for _ in range(200): + for emitted in harness.emit.await_args_list: + if emitted.args[0] == "prune_action_required" and emitted.args[1]["action"] == action: + return emitted.args[1] + await asyncio.sleep(0.001) + raise AssertionError(f"Prune action {action} was not emitted") + + +async def test_recovery_on_repoint_uses_real_save_inventory_filesystem_and_sqlite(harness): + app_id = 0x80000041 + source = Rom.synced( + rom_id=41, + platform_slug="gba", + name="Removed Game", + fs_name="Removed Game.gba", + shortcut_app_id=app_id, + synced_at="2026-01-01T00:00:00", + version=VersionMetadata(sibling_group_key="group-41", regions=("USA",)), + ) + source.record_fetch_generation("older-fetch") + target = Rom.synced( + rom_id=42, + platform_slug="gba", + name="Live Game", + fs_name="Live Game.gba", + shortcut_app_id=None, + synced_at="2026-01-01T00:00:00", + version=VersionMetadata(sibling_group_key="group-41", regions=("USA",)), + ) + target.record_fetch_generation("completed-fetch") + rom_path = Path(harness.retrodeck_paths.roms_path()) / "gba" / source.fs_name + rom_path.parent.mkdir(parents=True) + rom_path.write_bytes(b"installed rom") + save_path = Path(harness.retrodeck_paths.saves_path()) / "gba" / "Removed Game.srm" + save_path.parent.mkdir(parents=True) + save_path.write_bytes(b"local save") + with harness.uow_factory() as uow: + uow.roms.save(source) + uow.roms.save(target) + uow.rom_installs.save( + RomInstall.mark_installed( + rom_id=41, + file_path=str(rom_path), + rom_dir=None, + platform_slug="gba", + system="gba", + installed_at="2026-01-01T00:00:00", + ) + ) + uow.rom_save_sync_states.save( + 41, + RomSaveSyncState(system="gba", files={"Removed Game.srm": FileSyncState(last_sync_hash="known")}), + ) + uow.platform_sync_state.save( + PlatformSyncState.stamp( + platform_slug="gba", + at="2026-01-02T00:00:00", + rom_count=2, + fetch_id="completed-fetch", + ) + ) + harness.romm.roms[42] = {"id": 42} + harness.romm.get_rom_once_side_effect_by_id[41] = RommNotFoundError("gone") + preview = await harness.plugin.get_prune_preview(_preview_request()) + staged = await harness.plugin.stage_prune_installed_selection( + { + "preview_id": preview["preview_id"], + "selection_id": None, + "rom_ids": [41], + "final": True, + } + ) + + started = await harness.plugin.start_prune( + { + "preview_id": preview["preview_id"], + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": False, + "create_recovery_bundle": True, + "installed_selection_id": staged["selection_id"], + } + ) + action = await _wait_for_prune_action(harness, "repoint_shortcut") + claim = await harness.plugin.report_prune_action( + { + "phase": "claim", + "run_id": action["run_id"], + "action_token": action["action_token"], + "action": action["action"], + "app_id": action["app_id"], + "target_rom_id": action["target_rom_id"], + } + ) + assert claim["success"] is True + assert ( + await harness.plugin.report_prune_action( + { + "phase": "complete", + "run_id": action["run_id"], + "action_token": action["action_token"], + "success": True, + "message": "Steam confirmed the launch command.", + } + ) + )["success"] is True + task = harness.plugin._prune_service._task + assert task is not None + await task + + with harness.uow_factory() as uow: + assert uow.roms.get(41) is None + bound = uow.roms.get(42) + assert bound is not None and bound.shortcut_app_id == app_id + assert uow.rom_installs.get(41) is None + assert uow.rom_save_sync_states.get(41) is None + assert started["success"] is True + assert not rom_path.exists() + assert not save_path.exists() + backups = list((save_path.parent / ".romm-backup").glob("Removed Game_*.srm")) + assert len(backups) == 1 and backups[0].read_bytes() == b"local save" + complete = [call.args[1] for call in harness.emit.await_args_list if call.args[0] == "prune_complete"][-1] + result = complete["results"][0] + assert result["status"] == "removed" + assert result["committed_action"] == "repoint_shortcut" + assert {"save_quarantine", "installed_rom_content"} <= set(result["mutations"]) + bundle = Path(result["bundle_path"]) + manifest = json.loads((bundle / "manifest.json").read_text()) + assert {item["kind"] for item in manifest["artifacts"]} >= {"current_save", "installed_rom"} diff --git a/tests/contract/test_rom_relaunch_options.py b/tests/contract/test_rom_relaunch_options.py index 06284e36..73293ccc 100644 --- a/tests/contract/test_rom_relaunch_options.py +++ b/tests/contract/test_rom_relaunch_options.py @@ -58,7 +58,9 @@ async def test_installed_bound_rom_returns_item(harness): item = await harness.plugin.get_rom_relaunch_options(42) assert item is not None - assert set(item.keys()) == {"app_id", "launch_options"} + assert set(item.keys()) == {"success", "app_id", "launch_options", "prune_lease_token"} + assert item["success"] is True + assert item["prune_lease_token"].startswith("launch_reconfirm:") assert item["app_id"] == 42 assert isinstance(item["launch_options"], str) assert item["launch_options"] # non-empty — the full launch command diff --git a/tests/contract/test_startup_reconcile.py b/tests/contract/test_startup_reconcile.py index 4e837ad1..3a9489db 100644 --- a/tests/contract/test_startup_reconcile.py +++ b/tests/contract/test_startup_reconcile.py @@ -18,16 +18,17 @@ async def test_no_installs_returns_empty_list(harness): """Nothing installed → empty list (the frontend no-ops on it).""" result = await harness.plugin.get_installed_relaunch_options() - assert result == [] - assert isinstance(result, list) + assert result == {"success": True, "items": [], "prune_lease_token": None} async def test_installed_bound_rom_shape(harness): """An installed+bound ROM → one {app_id, launch_options} item with a real command.""" seed_install(harness, 42, system="gba", platform_slug="gba", file_name="pokemon.gba") result = await harness.plugin.get_installed_relaunch_options() - assert len(result) == 1 - item = result[0] + assert result["success"] is True + assert result["prune_lease_token"].startswith("installed_reconcile:") + assert len(result["items"]) == 1 + item = result["items"][0] assert set(item.keys()) == {"app_id", "launch_options"} # seed_rom defaults shortcut_app_id to rom_id when not bound otherwise. assert item["app_id"] == 42 @@ -40,4 +41,4 @@ async def test_bound_rom_without_install_is_excluded(harness): """A ROM bound to a shortcut but not installed → no item (no install row).""" seed_rom(harness, 7, platform_slug="gba", shortcut_app_id=7) result = await harness.plugin.get_installed_relaunch_options() - assert result == [] + assert result == {"success": True, "items": [], "prune_lease_token": None} diff --git a/tests/contract/test_version_picker.py b/tests/contract/test_version_picker.py index d50733dd..9a9c9a8e 100644 --- a/tests/contract/test_version_picker.py +++ b/tests/contract/test_version_picker.py @@ -234,11 +234,11 @@ async def test_get_version_list_solo_group_not_multi(harness): _seed_rom(harness, rom_id=1, app_id=_APP_ID) harness.romm.roms[1] = {"id": 1, "sibling_roms": []} result = await harness.plugin.get_version_list(_APP_ID) - assert result == { - "multi_version": False, - "server_query_failed": False, - "bound_vanished": False, - } + assert result["multi_version"] is False + assert result["server_query_failed"] is False + assert result["bound_vanished"] is False + assert result["bound_version"]["rom_id"] == 1 + assert result["bound_version"]["active"] is True async def test_get_version_list_unknown_app_not_multi(harness): @@ -291,11 +291,14 @@ async def test_get_version_list_single_bound_404_preserves_non_multi_verdict(har result = await harness.plugin.get_version_list(_APP_ID) - assert result == { - "multi_version": False, - "server_query_failed": False, - "bound_vanished": True, - } + assert set(result) == {"multi_version", "server_query_failed", "bound_vanished", "bound_version"} + assert result["multi_version"] is False + assert result["server_query_failed"] is False + assert result["bound_vanished"] is True + assert result["bound_version"]["rom_id"] == 1 + assert result["bound_version"]["synced"] is True + assert result["bound_version"]["active"] is True + assert result["bound_version"]["vanished"] is True # ── switch_version ─────────────────────────────────────────────────────── @@ -325,6 +328,8 @@ async def test_switch_version_happy_moves_binding(harness): harness.romm.roms[1] = {"id": 1, "sibling_roms": [{"id": 2}]} result = await harness.plugin.switch_version(_APP_ID, 2, False) + lease_token = result.pop("prune_lease_token") + assert lease_token.startswith("version_switch:") assert result == { "success": True, "rom_id": 2, diff --git a/tests/domain/test_fetch_generation.py b/tests/domain/test_fetch_generation.py index 2d82fb68..28292447 100644 --- a/tests/domain/test_fetch_generation.py +++ b/tests/domain/test_fetch_generation.py @@ -2,7 +2,8 @@ from __future__ import annotations -from domain.fetch_generation import count_rows_for_skip +from domain.fetch_generation import count_rows_for_skip, prune_candidate_ids +from domain.platform_sync_state import PlatformSyncState from domain.rom import Rom @@ -62,3 +63,23 @@ def test_no_rows_counts_zero(self): def test_a_platform_with_no_superseded_rows_counts_all_of_them(self): rows = [_row(25135, "run-new"), _row(25136, "run-new")] assert count_rows_for_skip(rows, "run-new") == len(rows) + + +class TestPruneCandidateIds: + def test_mismatch_and_null_are_candidates(self): + stamp = PlatformSyncState.stamp(platform_slug="dc", at="now", rom_count=1, fetch_id="new") + assert prune_candidate_ids([_row(1, "new"), _row(2, "old"), _row(3, None)], stamp) == {2, 3} + + def test_missing_legacy_and_empty_completion_yield_none(self): + rows = [_row(1, "old")] + assert prune_candidate_ids(rows, None) == set() + assert ( + prune_candidate_ids(rows, PlatformSyncState.stamp(platform_slug="dc", at="now", rom_count=1, fetch_id=None)) + == set() + ) + assert ( + prune_candidate_ids( + rows, PlatformSyncState.stamp(platform_slug="dc", at="now", rom_count=0, fetch_id="new") + ) + == set() + ) diff --git a/tests/domain/test_prune.py b/tests/domain/test_prune.py new file mode 100644 index 00000000..169886f9 --- /dev/null +++ b/tests/domain/test_prune.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import pytest + +from domain.prune import group_rows, recovery_bundle_id, sanitize_package_name, selected_prune_ids +from domain.rom import Rom +from domain.version_metadata import VersionMetadata + + +def _rom(rom_id: int, group: str | None) -> Rom: + return Rom.synced( + rom_id=rom_id, + platform_slug="dc", + name=str(rom_id), + fs_name=f"{rom_id}.gdi", + shortcut_app_id=None, + synced_at="now", + version=VersionMetadata(sibling_group_key=group), + ) + + +def test_package_name_sanitization_and_fallback(): + assert sanitize_package_name("decky romm/../sync") == "decky-romm-..-sync" + assert sanitize_package_name("dëcky") == "d-cky" + assert sanitize_package_name("aéb") == "a-b" + assert sanitize_package_name("///") == "decky-plugin" + assert sanitize_package_name(None) == "decky-plugin" + + +def test_bundle_id_accepts_only_trusted_components(): + assert recovery_bundle_id("20260724T120000Z", 7, "abc-123") == "20260724T120000Z_7_abc-123" + with pytest.raises(ValueError): + recovery_bundle_id("now", 0, "abc") + with pytest.raises(ValueError): + recovery_bundle_id("now", 1, "../escape") + + +def test_null_group_keys_are_independent_singletons(): + groups = group_rows([_rom(3, None), _rom(2, "same"), _rom(1, None), _rom(4, "same")]) + assert [[row.rom_id for row in group] for group in groups] == [[1], [2, 4], [3]] + + +def test_selected_ids_truth_table(): + assert selected_prune_ids( + group_ids=[1, 2], + candidate_ids={1}, + vanished_ids={1}, + live_ids={2}, + remove_rows=True, + remove_fully_vanished=False, + ) == {1} + assert selected_prune_ids( + group_ids=[1, 2], + candidate_ids={1}, + vanished_ids={1, 2}, + live_ids=set(), + remove_rows=False, + remove_fully_vanished=True, + ) == {1, 2} + assert ( + selected_prune_ids( + group_ids=[1], + candidate_ids={1}, + vanished_ids={1}, + live_ids=set(), + remove_rows=True, + remove_fully_vanished=False, + ) + == set() + ) diff --git a/tests/fakes/fake_plugin_metadata_reader.py b/tests/fakes/fake_plugin_metadata_reader.py index 595503eb..02a8c175 100644 --- a/tests/fakes/fake_plugin_metadata_reader.py +++ b/tests/fakes/fake_plugin_metadata_reader.py @@ -13,12 +13,22 @@ class FakePluginMetadataReader: called with so tests can assert wiring. """ - def __init__(self, version: str = "0.0.0") -> None: + def __init__(self, version: str = "0.0.0", name: str = "decky-romm-sync") -> None: self.version = version + self.name = name self.last_plugin_dir: str | None = None self.read_count = 0 + def read_metadata(self, plugin_dir: str) -> tuple[str, str]: + self.last_plugin_dir = plugin_dir + self.read_count += 1 + return self.name, self.version + def read_version(self, plugin_dir: str) -> str: self.last_plugin_dir = plugin_dir self.read_count += 1 return self.version + + def read_name(self, plugin_dir: str) -> str: + self.last_plugin_dir = plugin_dir + return self.name diff --git a/tests/fakes/fake_rom_file_store.py b/tests/fakes/fake_rom_file_store.py index ce022580..e90177a5 100644 --- a/tests/fakes/fake_rom_file_store.py +++ b/tests/fakes/fake_rom_file_store.py @@ -2,6 +2,12 @@ from __future__ import annotations +import hashlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from models.prune import MutationOutcome, SourceClaim, SourceIdentity + class FakeRomFileStore: """In-memory ``RomFileStore`` for tests. @@ -65,3 +71,36 @@ def remove_tree(self, path: str) -> None: for d in list(self.dirs): if d.startswith(prefix): self.dirs.discard(d) + + def remove_exact(self, path: str, safe_root: str, identity: SourceIdentity) -> bool: + del safe_root, identity + if self.is_dir(path): + self.remove_tree(path) + return True + if path in self.files: + self.remove_file(path) + return True + return False + + def claim_source(self, path: str, safe_root: str) -> SourceClaim: + exists = self.exists(path) + return { + "source_path": path, + "safe_root": safe_root, + "source_identity": { + "exists": exists, + "mount_id": 1 if exists else 0, + "device": 1 if exists else 0, + "inode": 1 if exists else 0, + "mode": 1 if exists else 0, + "size": 0, + "mtime_ns": 0, + "ctime_ns": 0, + }, + "sha256": hashlib.sha256(self.files[path]).hexdigest() if path in self.files else None, + "entries": {}, + } + + def remove_claimed(self, path: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + changed = self.remove_exact(path, safe_root, claim["source_identity"]) + return {"success": True, "changed": changed, "ambiguous": False, "message": "Source removed"} diff --git a/tests/fakes/fake_save_file_store.py b/tests/fakes/fake_save_file_store.py index 0c4cc050..ce9c1619 100644 --- a/tests/fakes/fake_save_file_store.py +++ b/tests/fakes/fake_save_file_store.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from collections.abc import Iterator + from models.prune import MutationOutcome, SourceClaim, SourceIdentity + class FakeSaveFileStore: """In-memory ``SaveFileStore`` for tests. @@ -72,6 +74,16 @@ def is_dir(self, path: str) -> bool: prefix = path.rstrip("/") + "/" return any(stored.startswith(prefix) for stored in self.files) + def is_symlink(self, path: str) -> bool: + del path + return False + + def canonical_path(self, path: str) -> str: + return path + + def is_within(self, path: str, root: str) -> bool: + return path == root or path.startswith(root.rstrip("/") + "/") + def make_dirs(self, path: str) -> None: self.dirs.add(path) @@ -102,6 +114,42 @@ def rename(self, src: str, dst: str) -> None: else: self._ensure_mtime(dst) + def rename_exact(self, src: str, dst: str, safe_root: str, identity: SourceIdentity) -> bool: + del safe_root, identity + self.rename(src, dst) + return True + + def claim_source(self, path: str, safe_root: str) -> SourceClaim: + exists = path in self.files + return { + "source_path": path, + "safe_root": safe_root, + "source_identity": { + "exists": exists, + "mount_id": 1 if exists else 0, + "device": 1 if exists else 0, + "inode": 1 if exists else 0, + "mode": 1 if exists else 0, + "size": len(self.files.get(path, b"")), + "mtime_ns": 0, + "ctime_ns": 0, + }, + "sha256": hashlib.sha256(self.files[path]).hexdigest() if exists else None, + "entries": {}, + } + + def ensure_directory(self, path: str, safe_root: str) -> None: + del safe_root + self.make_dirs(path) + + def rename_claimed(self, src: str, dst: str, safe_root: str, claim: SourceClaim) -> MutationOutcome: + if not claim["source_identity"]["exists"]: + if src in self.files: + raise RuntimeError(f"Recovery source appeared after sealing: {src}") + return {"success": True, "changed": False, "ambiguous": False, "message": "Source was already absent"} + changed = self.rename_exact(src, dst, safe_root, claim["source_identity"]) + return {"success": True, "changed": changed, "ambiguous": False, "message": "Source renamed"} + def get_mtime(self, path: str) -> float: if path not in self.files: raise FileNotFoundError(path) diff --git a/tests/fakes/fake_unit_of_work.py b/tests/fakes/fake_unit_of_work.py index b2742b1f..d61fbb7d 100644 --- a/tests/fakes/fake_unit_of_work.py +++ b/tests/fakes/fake_unit_of_work.py @@ -12,9 +12,10 @@ per-rom child aggregate whose ``rom_id`` has no matching ``roms`` row raises ``sqlite3.IntegrityError``. The fake reproduces that at commit (see ``_PER_ROM_FK_CHILD_REPOS``) and, like the real failed COMMIT, rolls the -uncommitted writes back rather than leaving the orphan observable. ON DELETE -CASCADE is intentionally **not** modeled — no consumer deliberately purges a -``roms`` row yet (ADR-0007); only the orphan-child-on-commit check exists. +uncommitted writes back rather than leaving the orphan observable. A deliberate +parent-row deletion also models ``ON DELETE CASCADE`` across every per-ROM child +aggregate, including the playtime outbox and save-file rows represented inside +their parent fake repositories. The repositories persist across clean ``with`` blocks (the fake's storage is the fake's identity), so a service test can open the unit several times and see @@ -131,6 +132,7 @@ def __exit__( # Mirror PRAGMA foreign_keys=ON: a child row whose rom_id has no # matching roms row aborts the commit with sqlite3.IntegrityError. try: + self._cascade_deleted_roms(snapshot) self._enforce_rom_id_foreign_keys() except sqlite3.IntegrityError: # A failed COMMIT discards the transaction's uncommitted writes; @@ -178,6 +180,17 @@ def _enforce_rom_id_foreign_keys(self) -> None: f"FOREIGN KEY constraint failed: {repo_name} rom_id(s) {orphans} not in roms" ) + def _cascade_deleted_roms(self, snapshot: _Snapshot | None) -> None: + """Mirror the real schema's per-ROM ``ON DELETE CASCADE`` behavior.""" + if snapshot is None: + return + removed = set(snapshot.roms) - set(self.roms._snapshot()) + for rom_id in removed: + self.rom_installs.delete(rom_id) + self.rom_metadata.delete(rom_id) + self.playtime.delete(rom_id) + self.rom_save_sync_states.delete(rom_id) + class FakeUnitOfWorkFactory: """Call-shaped factory returning one shared :class:`FakeUnitOfWork`. diff --git a/tests/lib/test_prune_gate.py b/tests/lib/test_prune_gate.py new file mode 100644 index 00000000..ee2a1f79 --- /dev/null +++ b/tests/lib/test_prune_gate.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from lib.prune_gate import ( + acquire_prune_conflict_lease, + prune_active_blocked, + prune_exclusive_start, + release_prune_conflict_lease, + renew_prune_conflict_lease, + retain_prune_conflict, +) + + +class _PruneState: + def __init__(self, active: bool) -> None: + self.active = active + + def is_active(self) -> bool: + return self.active + + +class _Owner: + def __init__(self, active: bool) -> None: + self._prune_service = _PruneState(active) + self.called = False + + @prune_active_blocked + async def mutate(self): + self.called = True + return {"success": True} + + +@pytest.mark.asyncio +async def test_blocks_conflicting_operation_with_canonical_shape() -> None: + owner = _Owner(True) + result = await owner.mutate() + assert result["success"] is False + assert result["reason"] == "prune_active" + assert result["message"] + assert owner.called is False + + +@pytest.mark.asyncio +async def test_allows_operation_when_prune_is_idle() -> None: + owner = _Owner(False) + assert await owner.mutate() == {"success": True} + assert owner.called is True + + +@pytest.mark.asyncio +async def test_missing_prune_wiring_fails_loud() -> None: + class Unwired: + @prune_active_blocked + async def mutate(self): + return {"success": True} + + with pytest.raises(RuntimeError, match="_prune_service is unwired"): + await Unwired().mutate() + + +@pytest.mark.asyncio +async def test_prune_start_refuses_operation_that_entered_before_it() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + class Owner(_Owner): + @prune_active_blocked + async def slow_mutation(self): + entered.set() + await release.wait() + return {"success": True} + + @prune_exclusive_start + async def start_prune(self): + return {"success": True} + + owner = Owner(False) + mutation = asyncio.create_task(owner.slow_mutation()) + await entered.wait() + result = await owner.start_prune() + assert result["success"] is False + assert result["reason"] == "operation_active" + release.set() + await mutation + + +@pytest.mark.asyncio +async def test_detached_task_retains_conflict_claim_for_its_full_lifetime() -> None: + release = asyncio.Event() + + class Owner(_Owner): + @prune_exclusive_start + async def start_prune(self): + return {"success": True} + + owner = Owner(False) + task = asyncio.create_task(release.wait()) + await retain_prune_conflict(owner, task) + assert (await owner.start_prune())["reason"] == "operation_active" + + release.set() + await task + await asyncio.sleep(0) + assert await owner.start_prune() == {"success": True} + + +@pytest.mark.asyncio +async def test_concurrent_multicall_leases_are_reference_counted() -> None: + class Owner(_Owner): + @prune_exclusive_start + async def start_prune(self): + return {"success": True} + + owner = Owner(False) + first = await acquire_prune_conflict_lease(owner, "shortcut_removal") + second = await acquire_prune_conflict_lease(owner, "shortcut_removal") + await release_prune_conflict_lease(owner, first) + assert (await owner.start_prune())["reason"] == "operation_active" + + await release_prune_conflict_lease(owner, second) + assert await owner.start_prune() == {"success": True} + + +@pytest.mark.asyncio +async def test_abandoned_multicall_lease_expires_before_prune_admission(monkeypatch) -> None: + class Owner(_Owner): + @prune_exclusive_start + async def start_prune(self): + return {"success": True} + + owner = Owner(False) + monkeypatch.setattr("lib.prune_gate._LEASE_SECONDS", 0.0) + await acquire_prune_conflict_lease(owner, "shortcut_removal") + + assert await owner.start_prune() == {"success": True} + + +@pytest.mark.asyncio +async def test_renewed_frontend_lease_cannot_expire_while_heartbeats_continue(monkeypatch) -> None: + class Owner(_Owner): + @prune_exclusive_start + async def start_prune(self): + return {"success": True} + + owner = Owner(False) + monkeypatch.setattr("lib.prune_gate._LEASE_SECONDS", 0.05) + token = await acquire_prune_conflict_lease(owner, "long_rebake") + await asyncio.sleep(0.03) + + assert await renew_prune_conflict_lease(owner, token) is True + await asyncio.sleep(0.03) + assert (await owner.start_prune())["reason"] == "operation_active" + + await asyncio.sleep(0.03) + assert await owner.start_prune() == {"success": True} + assert await renew_prune_conflict_lease(owner, token) is False diff --git a/tests/services/library/test_reporter.py b/tests/services/library/test_reporter.py index 393bb269..0371d3e5 100644 --- a/tests/services/library/test_reporter.py +++ b/tests/services/library/test_reporter.py @@ -110,7 +110,7 @@ async def test_report_removal_unbinds_roms_so_stats_drop(self, plugin): _seed_rom(uow, 10, app_id=1001, platform_slug="n64", name="Game A") _seed_rom(uow, 20, app_id=1002, platform_slug="snes", name="Game B") - await plugin.report_removal_results([10, 20]) + await plugin.report_removal_results([10, 20], None) stats = await plugin.get_sync_stats() assert stats["roms"] == 0 diff --git a/tests/services/library/test_service.py b/tests/services/library/test_service.py index 2a93219b..d5ef9c85 100644 --- a/tests/services/library/test_service.py +++ b/tests/services/library/test_service.py @@ -921,7 +921,7 @@ async def test_unbinds_removed_roms_but_keeps_rows(self, plugin): _seed_rom(plugin._uow, 10, app_id=1001, platform_slug="n64", name="Game A") _seed_rom(plugin._uow, 20, app_id=1002, platform_slug="n64", name="Game B") - result = await plugin.report_removal_results([10, 20]) + result = await plugin.report_removal_results([10, 20], None) assert result["success"] is True with plugin._uow as uow: assert uow.roms.get(10).shortcut_app_id is None @@ -941,7 +941,7 @@ async def test_cleans_up_artwork_cover_path(self, plugin, tmp_path): uow.roms.save(rom) plugin._steam_config.grid_dir = lambda: str(tmp_path) - result = await plugin.report_removal_results([10]) + result = await plugin.report_removal_results([10], None) assert result["success"] is True assert not art_file.exists() @@ -954,7 +954,7 @@ async def test_cleans_up_artwork_app_id(self, plugin, tmp_path): _seed_rom(plugin._uow, 10, app_id=1001, platform_slug="n64", name="Game A") plugin._steam_config.grid_dir = lambda: str(grid_dir) - result = await plugin.report_removal_results([10]) + result = await plugin.report_removal_results([10], None) assert result["success"] is True assert not art_file.exists() @@ -963,7 +963,7 @@ async def test_partial_removal(self, plugin): _seed_rom(plugin._uow, 10, app_id=1001, platform_slug="n64", name="Game A") _seed_rom(plugin._uow, 20, app_id=1002, platform_slug="n64", name="Game B") - result = await plugin.report_removal_results([10]) + result = await plugin.report_removal_results([10], None) assert result["success"] is True with plugin._uow as uow: assert uow.roms.get(10).shortcut_app_id is None @@ -1029,7 +1029,7 @@ async def test_removes_app_id_artwork(self, plugin, tmp_path): _seed_rom(plugin._uow, 10, app_id=100001, platform_slug="n64", name="Game A") plugin._steam_config.grid_dir = lambda: str(grid_dir) - await plugin.report_removal_results([10]) + await plugin.report_removal_results([10], None) assert not art_file.exists() @pytest.mark.asyncio @@ -1041,7 +1041,7 @@ async def test_removes_staging_leftover(self, plugin, tmp_path): _seed_rom(plugin._uow, 10, app_id=100001, platform_slug="n64", name="Game A") plugin._steam_config.grid_dir = lambda: str(grid_dir) - await plugin.report_removal_results([10]) + await plugin.report_removal_results([10], None) assert not staging.exists() @@ -1055,7 +1055,7 @@ async def test_cleans_steam_input_on_removal(self, plugin, tmp_path): _seed_rom(plugin._uow, 10, app_id=1001, platform_slug="n64", name="Game A") _seed_rom(plugin._uow, 20, app_id=1002, platform_slug="n64", name="Game B") - await plugin.report_removal_results([10, 20]) + await plugin.report_removal_results([10, 20], None) plugin._steam_config.set_steam_input_config.assert_called_once_with([1001, 1002], mode="default") @pytest.mark.asyncio @@ -1064,7 +1064,7 @@ async def test_steam_input_error_doesnt_crash(self, plugin, tmp_path): plugin._steam_config.set_steam_input_config = MagicMock(side_effect=Exception("VDF error")) _seed_rom(plugin._uow, 10, app_id=1001, platform_slug="n64", name="Game A") - result = await plugin.report_removal_results([10]) + result = await plugin.report_removal_results([10], None) assert result["success"] is True # Should not crash with plugin._uow as uow: assert uow.roms.get(10).shortcut_app_id is None diff --git a/tests/services/prune/test_preview.py b/tests/services/prune/test_preview.py new file mode 100644 index 00000000..b895f529 --- /dev/null +++ b/tests/services/prune/test_preview.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +from services.prune._models import PrunePreview +from services.prune.preview import PreviewBuilder, PreviewBuilderConfig + + +class _Recovery: + def free_bytes(self) -> int: + return 1000 + + def root(self) -> str: + return "/recovery" + + +def test_preview_pages_stay_within_wire_budget_for_non_ascii_rows() -> None: + builder = PreviewBuilder( + config=PreviewBuilderConfig( + uow_factory=cast("Any", None), + recovery_store=cast("Any", _Recovery()), + retrodeck_paths=cast("Any", None), + settings={}, + ) + ) + entries = tuple( + { + "rom_id": index, + "name": "é" * 512, + "name_truncated": True, + "fs_name": "遊" * 512, + "fs_name_truncated": True, + "platform_slug": "platform", + "group_id": "é" * 512, + "group_id_truncated": True, + "group_size": 1, + "bound_count": 0, + "candidate": True, + "installed": True, + "installed_bytes": 1, + "warning": "遊" * 1024, + "warning_truncated": True, + } + for index in range(1, 51) + ) + preview = PrunePreview("preview", "bulk", None, frozenset(range(1, 51)), (), entries, 1000, "server|user") + offset = 0 + seen: list[int] = [] + + while offset < len(entries): + page = builder.page(preview, offset, 50) + assert len(json.dumps(page, ensure_ascii=True).encode("utf-8")) <= 48 * 1024 + ids = [item["rom_id"] for item in page["items"]] + assert ids + seen.extend(ids) + offset += len(ids) + + assert seen == list(range(1, 51)) diff --git a/tests/services/prune/test_recovery.py b/tests/services/prune/test_recovery.py new file mode 100644 index 00000000..2718c2ab --- /dev/null +++ b/tests/services/prune/test_recovery.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from typing import Any, cast + +from fakes.fake_unit_of_work import FakeUnitOfWork, FakeUnitOfWorkFactory +from fakes.system_time import FakeClock, FakeUuidGen + +from domain.playtime import Playtime +from domain.rom import Rom +from domain.rom_install import RomInstall +from domain.rom_metadata import RomMetadata +from domain.rom_save_sync_state import RomSaveSyncState +from services.prune.recovery import RecoveryCoordinator, RecoveryCoordinatorConfig + + +def _coordinator(uow: FakeUnitOfWork) -> RecoveryCoordinator: + unused = cast("Any", object()) + return RecoveryCoordinator( + config=RecoveryCoordinatorConfig( + uow_factory=FakeUnitOfWorkFactory(uow), + recovery_store=unused, + prune_artifacts=unused, + steam_recovery=unused, + retrodeck_paths=unused, + clock=FakeClock(), + uuid_gen=FakeUuidGen(), + ) + ) + + +def test_snapshot_is_lossless_for_every_per_rom_child_and_playtime_outbox() -> None: + uow = FakeUnitOfWork() + rom = Rom.synced( + rom_id=7, + platform_slug="gba", + name="Removed Game", + fs_name="Removed Game.gba", + shortcut_app_id=9001, + synced_at="2026-01-01T00:00:00Z", + ) + install = RomInstall.mark_installed( + rom_id=7, + file_path="/roms/gba/Removed Game.gba", + rom_dir=None, + platform_slug="gba", + system="gba", + installed_at="2026-01-01T00:00:00Z", + ) + metadata = RomMetadata.cached( + summary="summary", + genres=("Adventure",), + companies=("Studio",), + first_release_date=1, + average_rating=90.0, + game_modes=("Single player",), + player_count="1", + cached_at=123.0, + ) + save_state = RomSaveSyncState(system="gba") + save_state.adopt_baseline("Removed Game.srm", tracked_save_id=4, last_sync_hash="abc") + playtime = Playtime(total_seconds=3600, session_count=2, last_session_duration_sec=600) + playtime.begin_session("2026-01-02T10:00:00Z", monotonic=100.0) + playtime.enqueue_session( + device_id="device-1", + start_time="2026-01-01T10:00:00Z", + end_time="2026-01-01T11:00:00Z", + duration_ms=3_600_000, + ) + with uow: + uow.roms.save(rom) + uow.rom_installs.save(install) + uow.rom_metadata.save(7, metadata) + uow.rom_save_sync_states.save(7, save_state) + uow.playtime.save(7, playtime) + + snapshot = cast( + "dict[str, Any]", + _coordinator(uow).snapshot_state( + [7], + {"app_id": 9001, "minutes_playtime_forever": 90}, + ), + ) + + assert snapshot["roms"][0]["rom_id"] == 7 + assert snapshot["installs"][0]["file_path"] == "/roms/gba/Removed Game.gba" + assert snapshot["metadata"][0]["state"]["summary"] == "summary" + assert snapshot["save_sync"][0]["state"]["files"]["Removed Game.srm"]["last_sync_hash"] == "abc" + playtime_state = snapshot["playtime"][0]["state"] + assert playtime_state["last_session_start"] == "2026-01-02T10:00:00Z" + assert playtime_state["pending_sessions"]["2026-01-01T10:00:00Z"] == { + "device_id": "device-1", + "end_time": "2026-01-01T11:00:00Z", + "duration_ms": 3_600_000, + "attempts": 0, + } + assert snapshot["steam"] == {"app_id": 9001, "minutes_playtime_forever": 90} + + +def test_playtime_text_includes_open_and_pending_session_details() -> None: + text = RecoveryCoordinator._playtime_text( + { + "playtime": [ + { + "rom_id": 7, + "state": { + "total_seconds": 3600, + "session_count": 2, + "last_played": "2026-01-01T11:00:00Z", + "last_session_duration_sec": 600, + "last_session_start": "2026-01-02T10:00:00Z", + "last_session_start_monotonic": 100.0, + "pending_sessions": { + "2026-01-01T10:00:00Z": { + "device_id": "device-1", + "end_time": "2026-01-01T11:00:00Z", + "duration_ms": 3_600_000, + "attempts": 1, + } + }, + }, + } + ], + "steam": { + "app_id": 9001, + "minutes_playtime_forever": 90, + "minutes_playtime_last_two_weeks": 15, + }, + } + ) + + assert "open_session_start: 2026-01-02T10:00:00Z" in text + assert "open_session_monotonic: 100.0" in text + assert "start_time: 2026-01-01T10:00:00Z" in text + assert "device_id: device-1" in text + assert "duration_ms: 3600000" in text + assert "attempts: 1" in text + assert "Steam appId: 9001" in text diff --git a/tests/services/prune/test_registry.py b/tests/services/prune/test_registry.py new file mode 100644 index 00000000..2eb77bff --- /dev/null +++ b/tests/services/prune/test_registry.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from fakes.fake_unit_of_work import FakeUnitOfWork, FakeUnitOfWorkFactory + +from domain.collection_sync_state import CollectionSyncState +from domain.platform_sync_state import PlatformSyncState +from domain.rom import Rom +from domain.version_metadata import VersionMetadata +from services.prune.registry import PruneRegistry, PruneRegistryConfig + + +def _rom(rom_id: int, *, group: str | None = None, app_id: int | None = None) -> Rom: + return Rom.synced( + rom_id=rom_id, + platform_slug="gba", + name=f"Game {rom_id}", + fs_name=f"Game {rom_id}.gba", + shortcut_app_id=app_id, + synced_at="now", + version=VersionMetadata(sibling_group_key=group), + ) + + +def _registry(uow: FakeUnitOfWork) -> PruneRegistry: + return PruneRegistry(config=PruneRegistryConfig(uow_factory=FakeUnitOfWorkFactory(uow))) + + +def test_candidate_groups_keep_null_keys_as_singletons() -> None: + uow = FakeUnitOfWork() + with uow: + uow.roms.save(_rom(1)) + uow.roms.save(_rom(2)) + uow.roms.save(_rom(3, group="g")) + uow.roms.save(_rom(4, group="g")) + + groups = _registry(uow).groups_for_candidates({1, 3}) + + assert [[row.rom_id for row in group] for group in groups] == [[1], [3, 4]] + + +def test_repointed_group_delete_revalidates_and_invalidates_collection_only() -> None: + uow = FakeUnitOfWork() + app_id = 0x80000001 + old = _rom(1, group="g", app_id=app_id) + target = _rom(2, group="g") + with uow: + uow.roms.save(old) + uow.roms.save(target) + uow.platform_sync_state.save( + PlatformSyncState.stamp(platform_slug="gba", at="now", rom_count=2, fetch_id="fetch") + ) + uow.collection_sync_state.save( + CollectionSyncState.stamp( + collection_id="5", + collection_kind="standard", + updated_at="server", + completed_at="now", + rom_count=2, + member_rom_ids=(1, 2), + ) + ) + target.bind_shortcut(app_id) + with uow: + uow.roms.save(target) + + current = _registry(uow).reread_group(1) + deleted = _registry(uow).delete_rows(current, {1}, 2, app_id, False) + + assert deleted is True + assert uow.roms.get(1) is None + retained = uow.roms.get(2) + assert retained is not None and retained.shortcut_app_id == app_id + assert uow.collection_sync_state.get("5", "standard") is None + assert uow.platform_sync_state.get("gba") is not None + + +def test_new_sibling_or_changed_binding_blocks_pre_mutation_validation() -> None: + uow = FakeUnitOfWork() + app_id = 0x80000001 + expected = [_rom(1, group="g", app_id=app_id), _rom(2, group="g")] + with uow: + for row in expected: + uow.roms.save(row) + uow.roms.save(_rom(3, group="g")) + + assert _registry(uow).validate_deletion_state(expected, {1}, 2, app_id, False) is False diff --git a/tests/services/prune/test_requests.py b/tests/services/prune/test_requests.py new file mode 100644 index 00000000..c3b47137 --- /dev/null +++ b/tests/services/prune/test_requests.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from services.prune._models import PruneOptions +from services.prune.requests import parse_options, parse_preview_request, parse_selection_page, valid_snapshot + + +def _snapshot(app_id: int = 9001) -> dict[str, object]: + return { + "app_id": app_id, + "name": "Game", + "exe": "/plugin/bin/rom-launcher", + "start_dir": "/plugin", + "launch_options": "launch", + "minutes_playtime_forever": 10, + "minutes_playtime_last_two_weeks": None, + "last_played": 123, + "collections": [{"id": "favorites", "name": "Favorites"}], + } + + +def test_preview_request_defaults_and_rejects_bad_pages() -> None: + assert parse_preview_request({"scope": "bulk"}) == ("bulk", None, None, 0, 50) + assert parse_preview_request({"scope": "rom", "rom_id": 7}) == ("rom", 7, None, 0, 50) + invalid = parse_preview_request({"scope": "bulk", "offset": -1, "limit": 101}) + assert invalid == { + "success": False, + "reason": "invalid_page", + "message": "Offset must be non-negative and limit 0-100.", + } + + +def test_options_require_explicit_booleans_and_recovery_for_content_selection() -> None: + parsed = parse_options( + { + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": False, + "create_recovery_bundle": True, + }, + frozenset({7}), + ) + assert parsed == PruneOptions(True, True, False, True, frozenset({7})) + invalid = parse_options( + { + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": False, + "create_recovery_bundle": False, + }, + frozenset({7}), + ) + assert isinstance(invalid, dict) + assert invalid["reason"] == "invalid_options" + + +def test_installed_selection_pages_are_wire_bounded_without_total_selection_cap() -> None: + page = parse_selection_page( + {"preview_id": "preview", "selection_id": None, "rom_ids": list(range(1, 101)), "final": False} + ) + assert page == ("preview", None, list(range(1, 101)), False) + too_large = parse_selection_page( + {"preview_id": "preview", "selection_id": None, "rom_ids": list(range(1, 102)), "final": True} + ) + assert isinstance(too_large, dict) + assert too_large["reason"] == "invalid_selection" + + +def test_snapshot_requires_exact_app_complete_shape_and_no_base64() -> None: + assert valid_snapshot(_snapshot(), 9001) is True + assert valid_snapshot(_snapshot(9002), 9001) is False + missing = _snapshot() + missing.pop("launch_options") + assert valid_snapshot(missing, 9001) is False + encoded = _snapshot() + encoded["cover_base64"] = "AAAA" + assert valid_snapshot(encoded, 9001) is False + foreign = _snapshot() + foreign["exe"] = "/usr/bin/foreign-game" + assert valid_snapshot(foreign, 9001) is False + + +def test_snapshot_rejects_oversized_payload() -> None: + snapshot = _snapshot() + snapshot["collections"] = [{"id": str(index), "name": "x" * 4096} for index in range(100)] + assert valid_snapshot(snapshot, 9001) is False diff --git a/tests/services/prune/test_service.py b/tests/services/prune/test_service.py new file mode 100644 index 00000000..1a189064 --- /dev/null +++ b/tests/services/prune/test_service.py @@ -0,0 +1,1933 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import threading +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +import pytest +import pytest_asyncio +from fakes.fake_retrodeck_paths import FakeRetroDeckPaths +from fakes.fake_unit_of_work import FakeUnitOfWork, FakeUnitOfWorkFactory +from fakes.system_time import FakeClock, FakeUuidGen +from models.prune import SealedSourceClaims + +from domain.platform_sync_state import PlatformSyncState +from domain.rom import Rom +from domain.rom_install import RomInstall +from domain.version_metadata import VersionMetadata +from lib.errors import RommConnectionError, RommNotFoundError +from services.prune import PruneService, PruneServiceConfig +from services.prune._models import cancellation_state + +if TYPE_CHECKING: + from models.prune import MutationOutcome, RecoveryArtifact, SourceClaim, SteamRecoverySnapshot + + +class FakeRomm: + def __init__(self) -> None: + self.outcomes: dict[int, list[object]] = {} + self.calls: list[int] = [] + + def get_rom_once(self, rom_id: int) -> dict[str, Any]: + self.calls.append(rom_id) + values = self.outcomes.setdefault(rom_id, [{"id": rom_id}]) + value = values.pop(0) if len(values) > 1 else values[0] + if isinstance(value, Exception): + raise value + return value # type: ignore[return-value] + + +class FakeRecoveryStore: + def __init__(self) -> None: + self.sealed: list[dict[str, object]] = [] + self.failure: Exception | None = None + self.sources_valid = True + + def root(self) -> str: + return "/recovery" + + def free_bytes(self) -> int: + return 10_000 + + def measure_path(self, path: str, safe_root: str) -> int: + del path, safe_root + return 123 + + def validate_sources(self, bundle_path: str, bundle_digest: str | None = None) -> bool: + del bundle_path, bundle_digest + return self.sources_valid + + def source_claims(self, bundle_path: str) -> SealedSourceClaims: + del bundle_path + return SealedSourceClaims(claims={}, bundle_digest="sealed-digest") + + def seal_bundle(self, bundle_id, snapshot, artifacts, readme, playtime_text): + if self.failure is not None: + raise self.failure + self.sealed.append( + { + "bundle_id": bundle_id, + "snapshot": snapshot, + "artifacts": artifacts, + "readme": readme, + "playtime": playtime_text, + } + ) + return f"/recovery/bundles/{bundle_id}" + + +class FakePruneArtifacts: + def __init__(self) -> None: + self.removed: list[list[int]] = [] + + def recovery_artifacts(self, rom_ids: list[int]) -> list[RecoveryArtifact]: + del rom_ids + return [] + + def remove(self, rom_ids: list[int], claims: dict[str, SourceClaim] | None = None) -> MutationOutcome: + del claims + self.removed.append(rom_ids) + return { + "success": True, + "changed": bool(rom_ids), + "ambiguous": False, + "message": "removed", + } + + +class FakeSteamRecovery: + def __init__(self) -> None: + self.removed: list[int] = [] + + def snapshot(self, app_id: int) -> SteamRecoverySnapshot: + del app_id + return { + "user_id": "123", + "user_dir": "/steam/userdata/123", + "steam_root": "/steam", + "controller_setting": "2", + "artifacts": [], + } + + def validate_state(self, app_id: int, snapshot: SteamRecoverySnapshot) -> bool: + del app_id, snapshot + return True + + def remove_state( + self, + app_id: int, + snapshot: SteamRecoverySnapshot, + claims: dict[str, SourceClaim], + ) -> MutationOutcome: + del claims + assert snapshot["user_id"] == "123" + self.removed.append(app_id) + return {"success": True, "changed": True, "ambiguous": False, "message": "removed"} + + +class FakeSaveCoordinator: + def __init__(self) -> None: + self.locked: list[list[int]] = [] + self.quarantined: list[list[dict[str, str]]] = [] + self.inventory_failure_ids: set[int] = set() + self.lock_id_sequence: list[list[int]] = [] + self.warnings: list[str] = [] + self.absences_valid = True + self.absence_validations = 0 + + @contextlib.asynccontextmanager + async def lock_prune_roms(self, rom_ids: list[int]): + self.locked.append(rom_ids) + yield + + def inventory_prune_saves(self, purge_rom_ids: list[int]): + if self.inventory_failure_ids.intersection(purge_rom_ids): + raise OSError("save inventory failed") + lock_ids = self.lock_id_sequence.pop(0) if self.lock_id_sequence else purge_rom_ids + return { + "artifacts": [], + "exclusive": [], + "shared": [], + "warnings": list(self.warnings), + "lock_rom_ids": lock_ids, + "source_claims": {}, + } + + def quarantine_prune_saves( + self, files: list[dict[str, str]], claims: dict[str, SourceClaim] | None = None + ) -> dict[str, Any]: + del claims + self.quarantined.append(files) + return {"success": True, "moved": []} + + def validate_prune_absences(self, claims: dict[str, SourceClaim]) -> bool: + del claims + self.absence_validations += 1 + return self.absences_valid + + +class FakeInstalledFilesRemover: + def __init__(self) -> None: + self.installed_ids: set[int] = set() + self.block_ids: set[int] = set() + self.failure_ids: set[int] = set() + self.entered = threading.Event() + self.release = threading.Event() + + def __call__(self, rom_id: int, claims: dict[str, SourceClaim] | None = None) -> dict[str, Any]: + del claims + if rom_id in self.block_ids: + self.entered.set() + if not self.release.wait(timeout=5): + raise TimeoutError("test did not release installed-file removal") + if rom_id in self.installed_ids: + return {"success": True, "changed": True, "message": "removed"} + if rom_id in self.failure_ids: + return {"success": False, "reason": "unknown", "message": "delete failed"} + return {"success": False, "reason": "not_installed", "message": "not installed"} + + +class EventSink: + def __init__(self) -> None: + self.events: list[tuple[str, dict[str, Any]]] = [] + + async def __call__(self, event: str, /, *args: object) -> None: + assert len(args) == 1 and isinstance(args[0], dict) + payload = cast("dict[str, Any]", args[0]) + self.events.append((event, payload)) + + +@dataclass +class Harness: + service: PruneService + uow: FakeUnitOfWork + romm: FakeRomm + recovery: FakeRecoveryStore + artifacts: FakePruneArtifacts + steam_recovery: FakeSteamRecovery + saves: FakeSaveCoordinator + events: EventSink + active: set[int] + drifted: set[int] + installed_remover: FakeInstalledFilesRemover + clock: FakeClock + + +def _rom( + rom_id: int, + *, + fetch: str | None, + group: str | None = None, + app_id: int | None = None, + fs_name: str | None = None, +) -> Rom: + rom = Rom.synced( + rom_id=rom_id, + platform_slug="dc", + name=f"Game {rom_id}", + fs_name=fs_name or f"Game {rom_id}.gdi", + shortcut_app_id=app_id, + synced_at="now", + version=VersionMetadata(sibling_group_key=group, regions=("USA",)), + ) + if fetch is not None: + rom.record_fetch_generation(fetch) + return rom + + +def _seed(uow: FakeUnitOfWork, *rows: Rom, stamp_count: int = 1) -> None: + with uow: + for row in rows: + uow.roms.save(row) + uow.platform_sync_state.save( + PlatformSyncState.stamp(platform_slug="dc", at="now", rom_count=stamp_count, fetch_id="new") + ) + + +@pytest_asyncio.fixture +async def harness() -> Harness: + loop = asyncio.get_running_loop() + uow = FakeUnitOfWork() + romm = FakeRomm() + recovery = FakeRecoveryStore() + artifacts = FakePruneArtifacts() + steam_recovery = FakeSteamRecovery() + saves = FakeSaveCoordinator() + events = EventSink() + active: set[int] = set() + drifted: set[int] = set() + installed_remover = FakeInstalledFilesRemover() + + async def drift(rom_id: int) -> dict[str, Any]: + return {"drifted": rom_id in drifted} + + async def switch_version(app_id: int, target_rom_id: int, allow_stranded: bool) -> dict[str, Any]: + del allow_stranded + with uow: + target = uow.roms.get(target_rom_id) + assert target is not None + target.bind_shortcut(app_id) + target.record_applied_launch_options(f"launch-{target_rom_id}") + uow.roms.save(target) + return { + "success": True, + "app_id": app_id, + "rom_id": target_rom_id, + "target_installed": False, + "launch_options": f"launch-{target_rom_id}", + } + + clock = FakeClock() + service = PruneService( + config=PruneServiceConfig( + loop=loop, + logger=logging.getLogger("test-prune"), + clock=clock, + uuid_gen=FakeUuidGen( + [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000004", + "00000000-0000-4000-8000-000000000005", + ] + ), + emit=events, + uow_factory=FakeUnitOfWorkFactory(uow), + romm_api=cast("Any", romm), + recovery_store=recovery, + prune_artifacts=artifacts, + steam_recovery=steam_recovery, + retrodeck_paths=FakeRetroDeckPaths(saves="/saves", roms="/roms", bios="/bios", home="/retrodeck"), + save_coordinator=saves, + active_downloads=lambda: set(active), + drift_probe=drift, + remove_installed_files=installed_remover, + switch_version=switch_version, + settings={"preferred_region": "USA"}, + ) + ) + return Harness( + service, + uow, + romm, + recovery, + artifacts, + steam_recovery, + saves, + events, + active, + drifted, + installed_remover, + clock, + ) + + +async def _preview(harness: Harness, *, scope: str = "bulk", rom_id: int | None = None): + return await harness.service.get_prune_preview( + {"scope": scope, "rom_id": rom_id, "preview_id": None, "offset": 0, "limit": 50} + ) + + +async def _start(harness: Harness, preview_id: str, **overrides): + request = { + "preview_id": preview_id, + "confirmed": True, + "repoint_shortcuts": True, + "remove_rows": True, + "remove_fully_vanished": False, + "create_recovery_bundle": False, + "include_installed_rom_ids": [], + **overrides, + } + return await harness.service.start_prune(request) + + +def _steam_snapshot(app_id: int) -> dict[str, object]: + return { + "app_id": app_id, + "name": "Game", + "exe": "/plugin/bin/rom-launcher", + "start_dir": "/plugin", + "launch_options": "launch", + "minutes_playtime_forever": 10, + "minutes_playtime_last_two_weeks": 2, + "last_played": 123, + "collections": [], + } + + +async def _finish(harness: Harness) -> dict[str, Any]: + task = harness.service._task + assert task is not None + await task + return [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + + +def _active_run_task(harness: Harness) -> asyncio.Task[None]: + task = harness.service._task + assert task is not None + return task + + +async def _assert_task_cancelled(task: asyncio.Task[None]) -> None: + assert task.cancelled() + with pytest.raises(asyncio.CancelledError): + await task + + +async def _wait_action(harness: Harness, action: str) -> dict[str, Any]: + for _ in range(100): + for name, payload in harness.events.events: + if name == "prune_action_required" and payload["action"] == action: + return payload + await asyncio.sleep(0.001) + raise AssertionError(f"action {action} was not emitted") + + +async def _claim_action(harness: Harness, action: dict[str, Any]) -> dict[str, Any]: + return await harness.service.report_prune_action( + { + "phase": "claim", + "run_id": action["run_id"], + "action_token": action["action_token"], + "action": action["action"], + "app_id": action["app_id"], + "target_rom_id": action.get("target_rom_id"), + } + ) + + +async def _complete_action( + harness: Harness, + action: dict[str, Any], + *, + success: bool = True, + message: str = "confirmed", + snapshot: dict[str, object] | None = None, +) -> dict[str, Any]: + request: dict[str, Any] = { + "phase": "complete", + "run_id": action["run_id"], + "action_token": action["action_token"], + "success": success, + "message": message, + } + if snapshot is not None: + request["snapshot"] = snapshot + return await harness.service.report_prune_action(request) + + +@pytest.mark.asyncio +async def test_preview_is_generation_gated_paged_and_tokenized(harness): + _seed(harness.uow, _rom(1, fetch="old"), _rom(2, fetch="new")) + preview = await _preview(harness) + assert preview["success"] is True + assert preview["total"] == 1 + assert preview["items"][0]["rom_id"] == 1 + assert preview["recovery_root"] == "/recovery" + + count_only = await harness.service.get_prune_preview( + {"scope": "bulk", "rom_id": None, "preview_id": preview["preview_id"], "offset": 0, "limit": 0} + ) + assert count_only["items"] == [] + assert count_only["total"] == 1 + stale = await harness.service.get_prune_preview( + {"scope": "bulk", "rom_id": None, "preview_id": "wrong", "offset": 0, "limit": 50} + ) + assert stale["reason"] == "stale_preview" + + +@pytest.mark.asyncio +async def test_inline_preview_bypasses_missing_generation_for_exact_row(harness): + with harness.uow: + harness.uow.roms.save(_rom(9, fetch=None)) + bulk = await _preview(harness) + inline = await _preview(harness, scope="rom", rom_id=9) + assert bulk["total"] == 0 + assert inline["total"] == 1 + assert inline["items"][0]["rom_id"] == 9 + + +@pytest.mark.asyncio +async def test_stale_preview_refuses_start_after_local_change(harness): + _seed(harness.uow, _rom(1, fetch="old")) + preview = await _preview(harness) + with harness.uow: + harness.uow.rom_installs.save( + RomInstall.mark_installed( + rom_id=1, + file_path="/roms/dc/game.gdi", + rom_dir=None, + platform_slug="dc", + system="dc", + installed_at="now", + ) + ) + result = await _start(harness, preview["preview_id"]) + assert result["reason"] == "stale_preview" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "outcome,reason", + [ + ({"id": 1}, "options_excluded"), + ({"id": 99}, "liveness_uncertain"), + ({}, "liveness_uncertain"), + (RommConnectionError("offline"), "liveness_uncertain"), + ], +) +async def test_only_exact_404_can_authorize_removal(harness, outcome, reason): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [outcome] + preview = await _preview(harness) + await _start(harness, preview["preview_id"]) + complete = await _finish(harness) + assert harness.uow.roms.get(1) is not None + assert complete["results"][0]["reason"] == reason + + +@pytest.mark.asyncio +async def test_unbound_confirmed_404_row_is_deleted_after_final_reprobes(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert harness.uow.roms.get(1) is None + assert complete["removed_rom_ids"] == [1] + assert complete["results"][0]["mutations"] == ["plugin_artifacts", "database_rows"] + assert harness.romm.calls == [1, 1, 1] + assert harness.saves.locked == [[1]] + frames = [payload for name, payload in harness.events.events if name in {"prune_progress", "prune_complete"}] + assert frames + assert all(frame["preview_id"] == preview["preview_id"] for frame in frames) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("create_recovery_bundle", [False, True]) +async def test_save_appearing_after_artifact_cleanup_blocks_final_cascade(harness, monkeypatch, create_recovery_bundle): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + original = harness.artifacts.remove + + def appear_after_cleanup(*args): + outcome = original(*args) + harness.saves.absences_valid = False + return outcome + + monkeypatch.setattr(harness.artifacts, "remove", appear_after_cleanup) + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=create_recovery_bundle, + ) + complete = await _finish(harness) + + assert complete["results"][0]["reason"] == "save_state_changed" + assert complete["removed_rom_ids"] == [] + assert harness.saves.absence_validations == 1 + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_restore_race_after_initial_404_retains_everything(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone"), {"id": 1}] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert harness.uow.roms.get(1) is not None + assert complete["results"][0]["reason"] == "live" + + +@pytest.mark.asyncio +async def test_active_download_and_multiple_bindings_skip_before_mutation(harness): + _seed( + harness.uow, + _rom(1, fetch="old", group="g", app_id=0x80000001), + _rom(2, fetch="old", group="g", app_id=0x80000002), + ) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert complete["results"][0]["reason"] == "multiple_bindings" + assert harness.romm.calls == [] + + harness.active.add(1) + with harness.uow: + row = harness.uow.roms.get(2) + assert row is not None + row.unbind_shortcut() + harness.uow.roms.save(row) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert complete["results"][0]["reason"] == "download_in_progress" + + +@pytest.mark.asyncio +async def test_bound_live_group_repoints_then_deletes_old_row(harness): + app_id = 0x80000001 + _seed( + harness.uow, + _rom(1, fetch="old", group="g", app_id=app_id), + _rom(2, fetch="new", group="g"), + ) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.romm.outcomes[2] = [{"id": 2}] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"]) + action = await _wait_action(harness, "repoint_shortcut") + assert action["preview_id"] == preview["preview_id"] + assert action["target_rom_id"] == 2 + assert (await _claim_action(harness, action))["success"] is True + accepted = await _complete_action(harness, action) + assert accepted["success"] is True + duplicate = await harness.service.report_prune_action( + { + "phase": "complete", + "run_id": action["run_id"], + "action_token": action["action_token"], + "success": True, + "message": "late duplicate", + } + ) + assert duplicate["ignored"] is True + complete = await _finish(harness) + assert complete["publication_required"] is True + assert complete["removed_rom_ids"] == [1] + assert harness.uow.roms.get(1) is None + assert harness.uow.roms.get(2).shortcut_app_id == app_id + + +@pytest.mark.asyncio +async def test_fully_dead_shortcut_requires_confirmed_removal_action(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + stale = await harness.service.report_prune_action( + { + "phase": "claim", + "run_id": "wrong", + "action_token": action["action_token"], + } + ) + assert stale["reason"] == "stale_action" + await _claim_action(harness, action) + duplicate_claim = await _claim_action(harness, action) + assert duplicate_claim["success"] is True + assert duplicate_claim["ignored"] is True + await _complete_action(harness, action, message="removed") + complete = await _finish(harness) + assert complete["removed_rom_ids"] == [1] + assert harness.steam_recovery.removed == [] + + +@pytest.mark.asyncio +async def test_binding_change_after_shortcut_action_blocks_source_removal(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + await _claim_action(harness, action) + with harness.uow: + harness.uow.roms.save(_rom(1, fetch="old", app_id=0x80000002)) + await _complete_action(harness, action, message="removed") + + complete = await _finish(harness) + + assert complete["results"][0]["reason"] == "local_state_changed" + assert complete["affected_app_ids"] == [app_id] + assert harness.uow.roms.get(1) is not None + assert harness.artifacts.removed == [] + assert harness.steam_recovery.removed == [] + + +@pytest.mark.asyncio +async def test_recovery_failure_skips_group_and_success_records_bundle(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + harness.recovery.failure = OSError("disk full") + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + failed = await _finish(harness) + assert failed["results"][0]["reason"] == "recovery_failed" + assert harness.uow.roms.get(1) is not None + + harness.recovery.failure = None + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + complete = await _finish(harness) + assert complete["removed_rom_ids"] == [1] + assert harness.recovery.sealed[0]["snapshot"]["roms"][0]["rom_id"] == 1 + + +@pytest.mark.asyncio +async def test_recovery_snapshot_rejects_wire_base64_before_accepting_bounded_json(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + capture = await _wait_action(harness, "capture_shortcut_snapshot") + await _claim_action(harness, capture) + invalid = await harness.service.report_prune_action( + { + "phase": "complete", + "run_id": capture["run_id"], + "action_token": capture["action_token"], + "success": True, + "message": "bad", + "snapshot": {"cover_base64": "AAAA"}, + } + ) + assert invalid["reason"] == "invalid_snapshot" + wrong_app = await harness.service.report_prune_action( + { + "phase": "complete", + "run_id": capture["run_id"], + "action_token": capture["action_token"], + "success": True, + "message": "wrong app", + "snapshot": _steam_snapshot(app_id + 1), + } + ) + assert wrong_app["reason"] == "invalid_snapshot" + await _complete_action(harness, capture, message="captured", snapshot=_steam_snapshot(app_id)) + removal = await _wait_action(harness, "remove_shortcut") + await _claim_action(harness, removal) + await _complete_action(harness, removal, message="removed") + complete = await _finish(harness) + assert complete["removed_rom_ids"] == [1] + + +@pytest.mark.asyncio +async def test_concurrent_starts_atomically_consume_one_preview(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old")) + preview = await _preview(harness) + entered = threading.Event() + release = threading.Event() + original = harness.service._preview_builder.build + + def blocked_refresh(*args): + entered.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release preview refresh") + return original(*args) + + monkeypatch.setattr(harness.service._preview_builder, "build", blocked_refresh) + first = asyncio.create_task(_start(harness, preview["preview_id"])) + while not entered.is_set(): + await asyncio.sleep(0) + second = await _start(harness, preview["preview_id"]) + assert second["reason"] == "prune_active" + release.set() + assert (await first)["success"] is True + await _finish(harness) + complete_events = [event for event, _payload in harness.events.events if event == "prune_complete"] + assert complete_events == ["prune_complete"] + + +@pytest.mark.asyncio +async def test_preview_and_start_io_failures_use_canonical_shape(harness, monkeypatch): + monkeypatch.setattr( + harness.service._preview_builder, "build", lambda *_args: (_ for _ in ()).throw(OSError("disk")) + ) + preview = await _preview(harness) + assert preview == {"success": False, "reason": "unknown", "message": "disk"} + + _seed(harness.uow, _rom(1, fetch="old")) + monkeypatch.undo() + valid = await _preview(harness) + monkeypatch.setattr(harness.service._preview_builder, "build", lambda *_args: (_ for _ in ()).throw(OSError("db"))) + started = await _start(harness, valid["preview_id"]) + assert started == {"success": False, "reason": "unknown", "message": "db"} + assert harness.service.is_active() is False + + +@pytest.mark.asyncio +async def test_preview_discloses_non_candidate_group_members(harness): + _seed(harness.uow, _rom(1, fetch="old", group="g"), _rom(2, fetch="new", group="g")) + preview = await _preview(harness) + assert preview["total"] == 2 + assert {item["rom_id"]: item["candidate"] for item in preview["items"]} == {1: True, 2: False} + + +@pytest.mark.asyncio +async def test_repoint_option_is_independent_of_row_removal(harness): + app_id = 0x80000001 + _seed( + harness.uow, + _rom(1, fetch="old", group="g", app_id=app_id), + _rom(2, fetch="new", group="g"), + ) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + harness.romm.outcomes[2] = [{"id": 2}] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_rows=False) + action = await _wait_action(harness, "repoint_shortcut") + await _claim_action(harness, action) + await _complete_action(harness, action) + complete = await _finish(harness) + assert complete["results"][0]["status"] == "repointed" + assert harness.uow.roms.get(1) is not None + assert harness.uow.roms.get(2).shortcut_app_id == app_id + + +@pytest.mark.asyncio +async def test_repoint_uses_version_picker_filename_stem_ranking(harness): + app_id = 0x80000001 + _seed( + harness.uow, + _rom(9, fetch="old", group="g", app_id=app_id, fs_name="Gone.gdi"), + _rom(1, fetch="new", group="g", fs_name="Game (Europe).zip"), + _rom(2, fetch="new", group="g", fs_name="Game.iso"), + ) + harness.romm.outcomes[9] = [RommNotFoundError("gone")] + harness.romm.outcomes[1] = [{"id": 1}] + harness.romm.outcomes[2] = [{"id": 2}] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_rows=False) + action = await _wait_action(harness, "repoint_shortcut") + assert action["target_rom_id"] == 2 + await _claim_action(harness, action) + await _complete_action(harness, action) + await _finish(harness) + + +@pytest.mark.asyncio +async def test_fully_dead_bound_group_with_drift_requires_recovery(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + harness.drifted.add(1) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert complete["results"][0]["reason"] == "unsynced_saves" + assert not any(name == "prune_action_required" for name, _payload in harness.events.events) + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_action_claim_rejects_binding_drift_before_steam_mutation(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + with harness.uow: + harness.uow.roms.save(_rom(1, fetch="old", app_id=app_id + 1)) + claim = await _claim_action(harness, action) + assert claim["reason"] == "local_state_changed" + await harness.service.shutdown() + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_recovery_source_drift_aborts_before_mutation(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.recovery.sources_valid = False + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + complete = await _finish(harness) + assert complete["results"][0]["reason"] == "recovery_state_changed" + assert harness.uow.roms.get(1) is not None + assert harness.artifacts.removed == [] + + +@pytest.mark.asyncio +async def test_group_exception_is_reported_and_unrelated_group_continues(harness): + _seed(harness.uow, _rom(1, fetch="old"), _rom(2, fetch="old"), stamp_count=2) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.romm.outcomes[2] = [RommNotFoundError("gone")] * 3 + harness.saves.inventory_failure_ids.add(1) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert [result["status"] for result in complete["results"]] == ["failed", "removed"] + assert harness.uow.roms.get(1) is not None + assert harness.uow.roms.get(2) is None + assert complete["partial"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fault", [False, True]) +async def test_shielded_child_finishes_then_reraises_cancellation_with_fault_state(harness, fault): + entered = asyncio.Event() + release = asyncio.Event() + + async def child(): + entered.set() + await release.wait() + if fault: + raise OSError("child failed after cancellation") + return "finished" + + task = asyncio.create_task(harness.service._executor._shielded(child())) + await entered.wait() + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError) as caught: + await task + + state = cancellation_state(caught.value) + assert task.cancelled() + if fault: + assert isinstance(state.child_fault, OSError) + assert str(state.child_fault) == "child failed after cancellation" + else: + assert state.child_fault is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("resume_after_claim", [False, True]) +async def test_claimed_action_result_is_attached_before_request_task_reraises_cancellation(harness, resume_after_claim): + request_task = asyncio.create_task( + harness.service._request_action("run", "remove_shortcut", {"app_id": None}, None, None, {1}) + ) + action = await _wait_action(harness, "remove_shortcut") + assert (await _claim_action(harness, action))["success"] is True + if resume_after_claim: + await asyncio.sleep(0) + assert request_task.cancel() + await asyncio.sleep(0) + assert (await _complete_action(harness, action))["success"] is True + + with pytest.raises(asyncio.CancelledError) as caught: + await request_task + + state = cancellation_state(caught.value) + assert request_task.cancelled() + assert state.action_result is not None + assert state.action_result["success"] is True + assert state.action_result["claimed"] is True + + +@pytest.mark.asyncio +async def test_shutdown_waits_for_inflight_filesystem_finalization(harness): + row = _rom(1, fetch="old") + _seed(harness.uow, row) + with harness.uow: + harness.uow.rom_installs.save( + RomInstall.mark_installed( + rom_id=1, + file_path="/roms/dc/game.gdi", + rom_dir=None, + platform_slug="dc", + system="dc", + installed_at="now", + ) + ) + harness.installed_remover.installed_ids.add(1) + harness.installed_remover.block_ids.add(1) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + while not harness.installed_remover.entered.is_set(): + await asyncio.sleep(0) + + run_task = _active_run_task(harness) + shutdown = asyncio.create_task(harness.service.shutdown()) + await asyncio.sleep(0.01) + assert shutdown.done() is False + harness.installed_remover.release.set() + await shutdown + await _assert_task_cancelled(run_task) + + assert harness.uow.roms.get(1) is None + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + assert complete["partial"] is True + assert complete["removed_count"] == 1 + assert complete["results"][0]["status"] == "removed" + + +@pytest.mark.asyncio +async def test_shutdown_during_final_removed_progress_preserves_committed_ids(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + entered = asyncio.Event() + original = harness.service._executor._emit_progress + + async def block_final_progress(run_id, index, total, stage, rows, **kwargs): + if stage == "removed": + entered.set() + await asyncio.Future() + await original(run_id, index, total, stage, rows, **kwargs) + + monkeypatch.setattr(harness.service._executor, "_emit_progress", block_final_progress) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + await entered.wait() + assert harness.uow.roms.get(1) is None + + run_task = _active_run_task(harness) + await harness.service.shutdown() + await _assert_task_cancelled(run_task) + + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + assert complete["partial"] is True + assert complete["removed_count"] == 1 + assert complete["removed_rom_ids"] == [1] + assert complete["results"][0]["status"] == "removed" + + +@pytest.mark.asyncio +async def test_completion_results_are_emitted_in_bounded_chunks(harness): + rows = [_rom(rom_id, fetch="old") for rom_id in range(1, 27)] + _seed(harness.uow, *rows, stamp_count=26) + for row in rows: + harness.romm.outcomes[row.rom_id] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + await _finish(harness) + + completions = [payload for name, payload in harness.events.events if name == "prune_complete"] + assert sum(len(payload["results"]) for payload in completions) == 26 + assert completions[-1]["final"] is True + assert all(len(json.dumps(payload, ensure_ascii=True).encode("utf-8")) <= 48 * 1024 for payload in completions) + + +def test_one_large_group_result_is_explicitly_bounded(harness): + rows = [_rom(rom_id, fetch="old", group="g") for rom_id in range(1, 61)] + result = harness.service._executor._group_result( + rows, + "removed", + None, + "x" * 2000, + removed_rom_ids=list(range(1, 61)), + ) + assert len(result["rom_ids"]) == 50 + assert result["rom_count"] == 60 + assert result["rom_ids_truncated"] is True + assert len(result["removed_rom_ids"]) == 50 + assert result["removed_count"] == 60 + assert result["removed_rom_ids_truncated"] is True + assert len(result["message"]) == 512 + assert result["message_truncated"] is True + + +@pytest.mark.asyncio +async def test_save_owner_lock_set_retries_until_stable(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.saves.lock_id_sequence = [[1], [1, 2], [1, 2], [1, 2]] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + assert complete["results"][0]["status"] == "removed" + assert harness.saves.locked == [[1], [1, 2]] + + +@pytest.mark.asyncio +async def test_installed_file_failure_preserves_all_rows_and_reports_prior_mutation(harness): + _seed(harness.uow, _rom(1, fetch="old", group="g"), _rom(2, fetch="old", group="g")) + for rom_id in (1, 2): + harness.romm.outcomes[rom_id] = [RommNotFoundError("gone")] * 3 + with harness.uow: + harness.uow.rom_installs.save( + RomInstall.mark_installed( + rom_id=rom_id, + file_path=f"/roms/dc/{rom_id}.gdi", + rom_dir=None, + platform_slug="dc", + system="dc", + installed_at="now", + ) + ) + harness.installed_remover.installed_ids.add(1) + harness.installed_remover.failure_ids.add(2) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert result["reason"] == "rom_removal_failed" + assert result["mutations"] == ["installed_rom_content"] + assert harness.uow.roms.get(1) is not None and harness.uow.roms.get(2) is not None + assert harness.uow.rom_installs.get(1) is not None and harness.uow.rom_installs.get(2) is not None + + +@pytest.mark.asyncio +async def test_post_seal_database_drift_after_shortcut_removal_is_explicit_partial(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + capture = await _wait_action(harness, "capture_shortcut_snapshot") + await _claim_action(harness, capture) + await _complete_action(harness, capture, snapshot=_steam_snapshot(app_id)) + removal = await _wait_action(harness, "remove_shortcut") + with harness.uow: + row = harness.uow.roms.get(1) + assert row is not None + row.record_fetch_generation("changed-after-seal") + harness.uow.roms.save(row) + await _claim_action(harness, removal) + await _complete_action(harness, removal) + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert result["reason"] == "recovery_state_changed" + assert result["committed_action"] == "remove_shortcut" + assert harness.uow.roms.get(1).shortcut_app_id is None + assert harness.artifacts.removed == [] + + +@pytest.mark.asyncio +async def test_unclaimed_action_timeout_makes_late_claim_harmless(harness, monkeypatch): + monkeypatch.setattr("services.prune.service._ACTION_TIMEOUT_SECONDS", 0.01) + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + complete = await _finish(harness) + assert complete["results"][0]["reason"] == "steam_action_failed" + + late = await _claim_action(harness, action) + assert late["reason"] == "stale_action" + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_action_claim_expiring_during_validation_is_rejected(harness, monkeypatch): + app_id = 0x80000001 + entered = threading.Event() + release = threading.Event() + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + original = harness.service._registry.validate_action_state + + def delayed_validation(*args): + entered.set() + assert release.wait(timeout=5) + return original(*args) + + monkeypatch.setattr(harness.service._registry, "validate_action_state", delayed_validation) + claim_task = asyncio.create_task(_claim_action(harness, action)) + assert await asyncio.get_running_loop().run_in_executor(None, entered.wait, 5) + harness.clock.advance(61) + release.set() + + claim = await claim_task + assert claim["reason"] == "stale_action" + await harness.service.shutdown() + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_shutdown_after_snapshot_claim_does_not_begin_recovery(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + capture = await _wait_action(harness, "capture_shortcut_snapshot") + await _claim_action(harness, capture) + run_task = _active_run_task(harness) + shutdown = asyncio.create_task(harness.service.shutdown()) + while harness.service._task is not None and harness.service._task.cancelling() == 0: + await asyncio.sleep(0) + await _complete_action(harness, capture, snapshot=_steam_snapshot(app_id)) + await shutdown + await _assert_task_cancelled(run_task) + + assert harness.recovery.sealed == [] + assert harness.artifacts.removed == [] + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_server_switch_during_exact_404_never_authorizes_deletion(harness, monkeypatch): + harness.service._settings.update( + { + "romm_url": "https://server-a.example", + "romm_api_token_origin": "https://server-a.example", + "romm_user_id": 10, + } + ) + _seed(harness.uow, _rom(1, fetch="old")) + preview = await _preview(harness) + + def switched_namespace_404(_rom_id): + harness.service._settings.update( + { + "romm_url": "https://server-b.example", + "romm_api_token_origin": "https://server-b.example", + "romm_user_id": 20, + } + ) + raise RommNotFoundError("server B has no matching numeric id") + + monkeypatch.setattr(harness.romm, "get_rom_once", switched_namespace_404) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + complete = await _finish(harness) + + assert complete["results"][0]["reason"] == "server_namespace_changed" + assert harness.uow.roms.get(1) is not None + assert harness.artifacts.removed == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("phase", ["quarantine", "artifacts", "database"]) +async def test_shutdown_waits_for_each_executor_backed_finalization_phase(harness, monkeypatch, phase): + entered = threading.Event() + release = threading.Event() + if phase == "quarantine": + original = harness.saves.quarantine_prune_saves + + def blocked(*args): + entered.set() + assert release.wait(timeout=5) + return original(*args) + + monkeypatch.setattr(harness.saves, "quarantine_prune_saves", blocked) + elif phase == "artifacts": + original = harness.artifacts.remove + + def blocked(*args): + entered.set() + assert release.wait(timeout=5) + return original(*args) + + monkeypatch.setattr(harness.artifacts, "remove", blocked) + else: + original = harness.service._registry.delete_rows + + def blocked(*args): + entered.set() + assert release.wait(timeout=5) + return original(*args) + + monkeypatch.setattr(harness.service._registry, "delete_rows", blocked) + + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + while not entered.is_set(): + await asyncio.sleep(0) + run_task = _active_run_task(harness) + shutdown = asyncio.create_task(harness.service.shutdown()) + await asyncio.sleep(0.01) + assert shutdown.done() is False + release.set() + await shutdown + await _assert_task_cancelled(run_task) + + assert harness.uow.roms.get(1) is None + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + + +@pytest.mark.asyncio +async def test_shutdown_preserves_cancellation_when_shielded_finalization_faults(harness, monkeypatch): + entered = threading.Event() + release = threading.Event() + + def blocked_failure(*_args): + entered.set() + assert release.wait(timeout=5) + raise OSError("quarantine failed after cancellation") + + monkeypatch.setattr(harness.saves, "quarantine_prune_saves", blocked_failure) + _seed(harness.uow, _rom(1, fetch="old"), _rom(2, fetch="old"), stamp_count=2) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.romm.outcomes[2] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + assert await asyncio.get_running_loop().run_in_executor(None, entered.wait, 5) + + run_task = _active_run_task(harness) + shutdown = asyncio.create_task(harness.service.shutdown()) + await asyncio.sleep(0) + release.set() + await shutdown + await _assert_task_cancelled(run_task) + + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + assert complete["results"][0]["status"] == "failed" + assert "quarantine failed" in complete["results"][0]["message"] + assert harness.uow.roms.get(1) is not None + assert harness.uow.roms.get(2) is not None + assert 2 not in harness.romm.calls + + +@pytest.mark.asyncio +async def test_cancellation_during_final_liveness_guard_starts_no_local_mutation_or_later_group(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old"), _rom(2, fetch="old"), stamp_count=2) + for rom_id in (1, 2): + harness.romm.outcomes[rom_id] = [RommNotFoundError("gone")] * 3 + entered = threading.Event() + release = threading.Event() + calls = 0 + original = harness.romm.get_rom_once + + def block_final_probe(rom_id): + nonlocal calls + if rom_id == 1: + calls += 1 + if calls == 3: + entered.set() + assert release.wait(timeout=5) + return original(rom_id) + + monkeypatch.setattr(harness.romm, "get_rom_once", block_final_probe) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + assert await asyncio.get_running_loop().run_in_executor(None, entered.wait, 5) + + run_task = _active_run_task(harness) + shutdown = asyncio.create_task(harness.service.shutdown()) + await asyncio.sleep(0) + release.set() + await shutdown + await _assert_task_cancelled(run_task) + + assert harness.uow.roms.get(1) is not None + assert harness.uow.roms.get(2) is not None + assert harness.artifacts.removed == [] + assert 2 not in harness.romm.calls + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + + +@pytest.mark.asyncio +async def test_shutdown_cancels_admitted_preview_refresh_without_starting_run(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old")) + preview = await _preview(harness) + entered = threading.Event() + release = threading.Event() + original = harness.service._preview_builder.build + + def blocked_refresh(*args): + entered.set() + assert release.wait(timeout=5) + return original(*args) + + monkeypatch.setattr(harness.service._preview_builder, "build", blocked_refresh) + start = asyncio.create_task(_start(harness, preview["preview_id"])) + assert await asyncio.get_running_loop().run_in_executor(None, entered.wait, 5) + + await harness.service.shutdown() + assert harness.service.is_active() is False + assert harness.service._task is None + release.set() + with pytest.raises(asyncio.CancelledError): + await start + await asyncio.sleep(0.01) + assert not any(name == "prune_complete" for name, _payload in harness.events.events) + + +@pytest.mark.asyncio +async def test_shutdown_of_claimed_uncompleted_action_reports_ambiguity_and_halts_later_groups(harness, monkeypatch): + monkeypatch.setattr("services.prune.service._ACTION_TIMEOUT_SECONDS", 0.01) + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id), _rom(2, fetch="old"), stamp_count=2) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + harness.romm.outcomes[2] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + assert (await _claim_action(harness, action))["success"] is True + + run_task = _active_run_task(harness) + await harness.service.shutdown() + await _assert_task_cancelled(run_task) + + assert harness.uow.roms.get(1) is not None + assert harness.uow.roms.get(2) is not None + assert 2 not in harness.romm.calls + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + assert complete["results"][0]["reason"] == "action_ambiguous" + assert complete["results"][0]["action_ambiguous"] is True + + +@pytest.mark.asyncio +async def test_action_claim_binds_discriminant_app_target_and_single_group_binding(harness): + app_id = 0x80000001 + _seed( + harness.uow, + _rom(1, fetch="old", group="g", app_id=app_id), + _rom(2, fetch="new", group="g"), + ) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + harness.romm.outcomes[2] = [{"id": 2}] + preview = await _preview(harness) + await _start(harness, preview["preview_id"]) + action = await _wait_action(harness, "repoint_shortcut") + + for field, value in (("action", "remove_shortcut"), ("app_id", app_id + 1), ("target_rom_id", 99)): + request = { + "phase": "claim", + "run_id": action["run_id"], + "action_token": action["action_token"], + "action": action["action"], + "app_id": action["app_id"], + "target_rom_id": action["target_rom_id"], + } + request[field] = value + assert (await harness.service.report_prune_action(request))["reason"] == "action_mismatch" + + with harness.uow: + harness.uow.roms.save(_rom(3, fetch="new", group="g")) + assert (await _claim_action(harness, action))["reason"] == "local_state_changed" + + with harness.uow: + harness.uow.roms.delete(3) + other = harness.uow.roms.get(1) + assert other is not None + other.bind_shortcut(app_id + 1) + harness.uow.roms.save(other) + claim = await _claim_action(harness, action) + assert claim["reason"] == "local_state_changed" + await harness.service.shutdown() + + +@pytest.mark.asyncio +async def test_action_claim_adapter_exception_uses_canonical_failure(harness, monkeypatch): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + monkeypatch.setattr( + harness.service._registry, + "validate_action_state", + lambda *_args: (_ for _ in ()).throw(OSError("database unavailable")), + ) + + claim = await _claim_action(harness, action) + + assert claim == {"success": False, "reason": "unknown", "message": "database unavailable"} + await harness.service.shutdown() + + +@pytest.mark.asyncio +async def test_post_removal_reconciliation_exception_preserves_committed_action(harness, monkeypatch): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + await _claim_action(harness, action) + monkeypatch.setattr( + harness.service._registry, + "reconcile_removed_shortcut", + lambda *_args: (_ for _ in ()).throw(OSError("commit failed")), + ) + await _complete_action(harness, action) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert result["reason"] == "unknown" + assert result["committed_action"] == "remove_shortcut" + assert result["app_id"] == app_id + + +@pytest.mark.asyncio +async def test_post_filesystem_database_exception_preserves_actual_mutation_ledger(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old")) + with harness.uow: + harness.uow.rom_installs.save( + RomInstall.mark_installed( + rom_id=1, + file_path="/roms/dc/game.gdi", + rom_dir=None, + platform_slug="dc", + system="dc", + installed_at="now", + ) + ) + harness.installed_remover.installed_ids.add(1) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + monkeypatch.setattr( + harness.service._registry, + "delete_rows", + lambda *_args: (_ for _ in ()).throw(OSError("database commit failed")), + ) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert result["reason"] == "unknown" + assert result["mutations"] == ["installed_rom_content", "plugin_artifacts", "database_rows_ambiguous"] + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("boundary", ["save", "artifacts"]) +async def test_partial_adapter_outcomes_enter_actual_and_ambiguous_mutation_ledger(harness, monkeypatch, boundary): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + if boundary == "save": + monkeypatch.setattr( + harness.saves, + "quarantine_prune_saves", + lambda *_args: { + "success": False, + "moved": ["/saves/game.srm"], + "ambiguous": True, + "message": "save parent fsync failed", + }, + ) + category = "save_quarantine" + else: + monkeypatch.setattr( + harness.artifacts, + "remove", + lambda *_args: { + "success": False, + "changed": True, + "ambiguous": True, + "message": "artifact parent fsync failed", + }, + ) + category = "plugin_artifacts" + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert category in result["mutations"] + assert category in result["ambiguous_mutations"] + + +@pytest.mark.asyncio +async def test_recovery_warnings_are_bounded_and_visible_without_a_bundle(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.saves.warnings = [f"warning {index}: {'x' * 300}" for index in range(7)] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["warning_count"] == 7 + assert len(result["warnings"]) == 5 + assert all(len(value) <= 256 for value in result["warnings"]) + assert result["warnings_truncated"] is True + assert result["warnings_omitted"] is True + + +@pytest.mark.asyncio +async def test_omitted_short_warnings_are_not_marked_as_display_truncated(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.saves.warnings = [f"warning {index}" for index in range(6)] + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["warning_count"] == 6 + assert len(result["warnings"]) == 5 + assert result["warnings_omitted"] is True + assert result["warnings_truncated"] is False + + +@pytest.mark.asyncio +async def test_installed_selection_stages_more_than_256_disclosed_rows(harness): + rows = [_rom(rom_id, fetch="old") for rom_id in range(1, 301)] + _seed(harness.uow, *rows, stamp_count=len(rows)) + with harness.uow: + for row in rows: + harness.uow.rom_installs.save( + RomInstall.mark_installed( + rom_id=row.rom_id, + file_path=f"/roms/dc/{row.rom_id}.gdi", + rom_dir=None, + platform_slug="dc", + system="dc", + installed_at="now", + ) + ) + preview = await _preview(harness) + selection_id = None + for offset in range(0, 300, 100): + staged = await harness.service.stage_prune_installed_selection( + { + "preview_id": preview["preview_id"], + "selection_id": selection_id, + "rom_ids": list(range(offset + 1, offset + 101)), + "final": offset == 200, + } + ) + assert staged["success"] is True + selection_id = staged["selection_id"] + + assert staged["selected_count"] == 300 + assert staged["finalized"] is True + assert harness.service._selection is not None + assert len(harness.service._selection.rom_ids) == 300 + + +@pytest.mark.asyncio +async def test_shutdown_waits_for_recovery_copy_worker_and_starts_no_mutation(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 2 + entered = threading.Event() + release = threading.Event() + original = harness.recovery.seal_bundle + + def blocked_seal(*args): + entered.set() + assert release.wait(timeout=5) + return original(*args) + + monkeypatch.setattr(harness.recovery, "seal_bundle", blocked_seal) + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + assert await asyncio.get_running_loop().run_in_executor(None, entered.wait, 5) + + run_task = _active_run_task(harness) + shutdown = asyncio.create_task(harness.service.shutdown()) + await asyncio.sleep(0.01) + assert shutdown.done() is False + release.set() + await shutdown + await _assert_task_cancelled(run_task) + + assert harness.recovery.sealed + assert harness.uow.roms.get(1) is not None + assert harness.artifacts.removed == [] + complete = [payload for name, payload in harness.events.events if name == "prune_complete"][-1] + assert complete["reason"] == "cancelled" + + +@pytest.mark.asyncio +async def test_post_seal_aggregate_drift_aborts_before_shortcut_removal(harness, monkeypatch): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 2 + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + capture = await _wait_action(harness, "capture_shortcut_snapshot") + await _claim_action(harness, capture) + + def mutate_after_seal(_bundle_path): + with harness.uow: + row = harness.uow.roms.get(1) + assert row is not None + row.record_fetch_generation("changed-after-seal") + harness.uow.roms.save(row) + return {"claims": {}, "bundle_digest": "sealed-digest"} + + monkeypatch.setattr(harness.recovery, "source_claims", mutate_after_seal) + await _complete_action(harness, capture, snapshot=_steam_snapshot(app_id)) + complete = await _finish(harness) + + assert complete["results"][0]["reason"] == "recovery_state_changed" + assert not any( + name == "prune_action_required" and payload["action"] == "remove_shortcut" + for name, payload in harness.events.events + ) + assert harness.uow.roms.get(1).shortcut_app_id == app_id + + +@pytest.mark.asyncio +async def test_controller_state_drift_aborts_before_shortcut_removal(harness, monkeypatch): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 2 + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_fully_vanished=True, + create_recovery_bundle=True, + ) + capture = await _wait_action(harness, "capture_shortcut_snapshot") + await _claim_action(harness, capture) + monkeypatch.setattr(harness.steam_recovery, "validate_state", lambda *_args: False) + await _complete_action(harness, capture, snapshot=_steam_snapshot(app_id)) + + complete = await _finish(harness) + + assert complete["results"][0]["reason"] == "recovery_state_changed" + assert not any( + name == "prune_action_required" and payload["action"] == "remove_shortcut" + for name, payload in harness.events.events + ) + assert harness.uow.roms.get(1).shortcut_app_id == app_id + + +@pytest.mark.asyncio +async def test_claimed_removal_timeout_is_ambiguous_and_absent_retry_reconciles(harness, monkeypatch): + monkeypatch.setattr("services.prune.service._ACTION_TIMEOUT_SECONDS", 0.01) + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 6 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + first = await _wait_action(harness, "remove_shortcut") + await _claim_action(harness, first) + ambiguous = await _finish(harness) + + assert ambiguous["results"][0]["reason"] == "action_ambiguous" + assert ambiguous["results"][0]["action_ambiguous"] is True + assert harness.uow.roms.get(1).shortcut_app_id == app_id + + previous_events = len(harness.events.events) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + for _ in range(100): + actions = [ + payload + for name, payload in harness.events.events[previous_events:] + if name == "prune_action_required" and payload["action"] == "remove_shortcut" + ] + if actions: + break + await asyncio.sleep(0.001) + assert actions + second = actions[-1] + await _claim_action(harness, second) + accepted = await harness.service.report_prune_action( + { + "phase": "complete", + "run_id": second["run_id"], + "action_token": second["action_token"], + "success": True, + "message": "Steam confirmed the shortcut is already absent.", + "shortcut_absent": True, + } + ) + assert accepted["success"] is True + complete = await _finish(harness) + assert complete["removed_rom_ids"] == [1] + assert harness.uow.roms.get(1) is None + + +@pytest.mark.asyncio +async def test_claimed_repoint_timeout_is_an_explicit_ambiguous_partial(harness, monkeypatch): + monkeypatch.setattr("services.prune.service._ACTION_TIMEOUT_SECONDS", 0.01) + app_id = 0x80000001 + _seed( + harness.uow, + _rom(1, fetch="old", group="g", app_id=app_id), + _rom(2, fetch="new", group="g"), + ) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + harness.romm.outcomes[2] = [{"id": 2}] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"]) + action = await _wait_action(harness, "repoint_shortcut") + await _claim_action(harness, action) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["reason"] == "action_ambiguous" + assert result["action_ambiguous"] is True + assert result["committed_action"] == "repoint_shortcut" + assert result["target_rom_id"] == 2 + + +@pytest.mark.asyncio +async def test_attempted_unconfirmed_removal_is_an_explicit_ambiguous_partial(harness): + app_id = 0x80000001 + _seed(harness.uow, _rom(1, fetch="old", app_id=app_id)) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + action = await _wait_action(harness, "remove_shortcut") + await _claim_action(harness, action) + await harness.service.report_prune_action( + { + "phase": "complete", + "run_id": action["run_id"], + "action_token": action["action_token"], + "success": False, + "reason": "steam_action_failed", + "message": "Steam removal was attempted but could not be confirmed.", + "mutation_attempted": True, + } + ) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["reason"] == "action_ambiguous" + assert result["action_ambiguous"] is True + assert harness.uow.roms.get(1).shortcut_app_id == app_id + + +@pytest.mark.asyncio +async def test_repoint_only_reprobes_vanished_source_after_recovery_and_action(harness): + app_id = 0x80000001 + _seed( + harness.uow, + _rom(1, fetch="old", group="g", app_id=app_id), + _rom(2, fetch="new", group="g"), + ) + harness.romm.outcomes[1] = [RommNotFoundError("gone"), RommNotFoundError("gone"), {"id": 1}] + harness.romm.outcomes[2] = [{"id": 2}] * 3 + preview = await _preview(harness) + await _start( + harness, + preview["preview_id"], + remove_rows=False, + create_recovery_bundle=True, + ) + action = await _wait_action(harness, "repoint_shortcut") + await _claim_action(harness, action) + await _complete_action(harness, action) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert result["reason"] == "live" + assert result["committed_action"] == "repoint_shortcut" + assert result["target_rom_id"] == 2 + assert harness.uow.roms.get(1) is not None + + +@pytest.mark.asyncio +async def test_mixed_repoint_reprobes_non_candidate_vanished_source_after_action(harness): + app_id = 0x80000001 + _seed( + harness.uow, + _rom(1, fetch="new", group="g", app_id=app_id), + _rom(2, fetch="new", group="g"), + _rom(3, fetch="old", group="g"), + ) + harness.romm.outcomes[1] = [RommNotFoundError("gone"), RommNotFoundError("gone"), {"id": 1}] + harness.romm.outcomes[2] = [{"id": 2}] * 3 + harness.romm.outcomes[3] = [RommNotFoundError("gone")] * 3 + preview = await _preview(harness) + await _start(harness, preview["preview_id"]) + action = await _wait_action(harness, "repoint_shortcut") + await _claim_action(harness, action) + await _complete_action(harness, action) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "partial" + assert result["reason"] == "live" + assert result["committed_action"] == "repoint_shortcut" + assert harness.romm.calls.count(1) == 3 + assert harness.uow.roms.get(3) is not None + + +@pytest.mark.asyncio +async def test_zero_change_final_guard_reports_no_mutation_categories(harness, monkeypatch): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + monkeypatch.setattr( + harness.artifacts, + "remove", + lambda *_args: {"success": True, "changed": False, "ambiguous": False, "message": "none"}, + ) + monkeypatch.setattr(harness.service._registry, "delete_rows", lambda *_args: False) + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + + complete = await _finish(harness) + + result = complete["results"][0] + assert result["status"] == "skipped" + assert result["reason"] == "local_state_changed" + assert "mutations" not in result + + +@pytest.mark.asyncio +async def test_post_delete_progress_failure_keeps_removed_terminal_result(harness): + _seed(harness.uow, _rom(1, fetch="old")) + harness.romm.outcomes[1] = [RommNotFoundError("gone")] * 3 + + async def fail_removed_progress(event: str, payload: dict[str, Any]) -> None: + if event == "prune_progress" and payload.get("stage") == "removed": + raise OSError("bridge reset") + await harness.events(event, payload) + + harness.service._executor._emit = fail_removed_progress + preview = await _preview(harness) + await _start(harness, preview["preview_id"], remove_fully_vanished=True) + + complete = await _finish(harness) + + assert complete["removed_rom_ids"] == [1] + assert complete["results"][0]["status"] == "removed" + assert harness.uow.roms.get(1) is None diff --git a/tests/services/saves/sync_engine/test_matrix.py b/tests/services/saves/sync_engine/test_matrix.py index 94e95ff8..5bf863c2 100644 --- a/tests/services/saves/sync_engine/test_matrix.py +++ b/tests/services/saves/sync_engine/test_matrix.py @@ -2714,3 +2714,20 @@ def test_returns_false_when_nothing_to_back_up(self, tmp_path): saves_dir.mkdir(parents=True, exist_ok=True) assert matrix.quarantine_local_file(str(saves_dir), "absent.srm") is False + + def test_symlinked_backup_directory_fails_closed(self, tmp_path): + svc, _ = make_service(tmp_path) + matrix = svc._sync_engine._matrix + saves_dir = tmp_path / "saves" / "gba" + outside = tmp_path / "outside" + saves_dir.mkdir(parents=True, exist_ok=True) + outside.mkdir() + save = saves_dir / "game.srm" + save.write_bytes(b"keep") + (saves_dir / ".romm-backup").symlink_to(outside, target_is_directory=True) + + with pytest.raises(ValueError, match="Unsafe save backup directory"): + matrix.quarantine_local_file(str(saves_dir), "game.srm", preserve_history=True) + + assert save.read_bytes() == b"keep" + assert list(outside.iterdir()) == [] diff --git a/tests/services/saves/test_service.py b/tests/services/saves/test_service.py index 874f3082..5fb65de4 100644 --- a/tests/services/saves/test_service.py +++ b/tests/services/saves/test_service.py @@ -5,6 +5,7 @@ import logging import os import time +from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -1467,3 +1468,91 @@ def test_rom_id_scope_excludes_unconfirmed_target(self, tmp_path): _seed_save_state(svc, 42, state) assert svc.build_save_inventory(rom_id=42) == [] + + +class TestPruneSaveInventory: + def test_persisted_known_filename_and_matching_history_are_recovery_artifacts(self, tmp_path): + svc, _ = make_service(tmp_path) + _install_rom(svc, tmp_path, rom_id=42, system="gba", file_name="renamed.gba") + saves_dir = tmp_path / "saves" / "gba" + saves_dir.mkdir(parents=True) + historical = saves_dir / "old-name.srm" + historical.write_bytes(b"save") + backup_dir = saves_dir / ".romm-backup" + backup_dir.mkdir() + matching_backup = backup_dir / "old-name_20260101_120000.srm" + matching_backup.write_bytes(b"history") + (backup_dir / "other_20260101_120000.srm").write_bytes(b"other") + state = RomSaveSyncState(system="gba", files={"old-name.srm": FileSyncState(last_sync_hash="known")}) + with svc._uow_factory() as uow: + uow.rom_save_sync_states.save(42, state) + + inventory = svc.inventory_prune_saves([42]) + + artifact_paths = {item["source_path"] for item in inventory["artifacts"]} + expected_current = {str(saves_dir / f"renamed.{extension}") for extension in ("srm", "rtc", "sav")} + assert artifact_paths == {str(historical), str(matching_backup), *expected_current} + assert {item["path"] for item in inventory["exclusive"]} == {str(historical), *expected_current} + + def test_shared_save_expands_lock_owners_and_is_never_quarantined(self, tmp_path): + svc, _ = make_service(tmp_path) + _install_rom(svc, tmp_path, rom_id=1, system="gba", file_name="shared.gba") + _install_rom(svc, tmp_path, rom_id=2, system="gba", file_name="shared.gba") + shared = _create_save(tmp_path, system="gba", rom_name="shared", content=b"shared") + + inventory = svc.inventory_prune_saves([1]) + + assert inventory["exclusive"] == [] + assert inventory["shared"] == [str(shared)] + assert inventory["lock_rom_ids"] == [1, 2] + + def test_missing_exclusive_save_is_claimed_absent_and_late_creation_is_retained(self, tmp_path): + svc, _ = make_service(tmp_path) + _install_rom(svc, tmp_path, rom_id=42, system="gba", file_name="late.gba") + expected = str(tmp_path / "saves" / "gba" / "late.srm") + + inventory = svc.inventory_prune_saves([42]) + + expected_paths = {str(tmp_path / "saves" / "gba" / f"late.{extension}") for extension in ("srm", "rtc", "sav")} + assert {item["path"] for item in inventory["exclusive"]} == expected_paths + assert {item["source_path"] for item in inventory["artifacts"]} == expected_paths + assert set(inventory["source_claims"]) == expected_paths + assert inventory["source_claims"][expected]["source_identity"]["exists"] is False + + Path(expected).parent.mkdir(parents=True, exist_ok=True) + Path(expected).write_bytes(b"created by emulator") + result = svc.quarantine_prune_saves(inventory["exclusive"], inventory["source_claims"]) + + assert result["success"] is False + assert "appeared after sealing" in result["message"] + assert Path(expected).read_bytes() == b"created by emulator" + + def test_expected_absence_is_rechecked_after_quarantine_before_cascade(self, tmp_path): + svc, _ = make_service(tmp_path) + _install_rom(svc, tmp_path, rom_id=42, system="gba", file_name="late.gba") + expected = Path(tmp_path / "saves" / "gba" / "late.srm") + expected.parent.mkdir(parents=True) + inventory = svc.inventory_prune_saves([42]) + + result = svc.quarantine_prune_saves(inventory["exclusive"], inventory["source_claims"]) + assert result["success"] is True + + expected.parent.mkdir(parents=True, exist_ok=True) + expected.write_bytes(b"created after absence was consumed") + + assert svc.validate_prune_absences(inventory["source_claims"]) is False + assert expected.read_bytes() == b"created after absence was consumed" + + def test_quarantined_present_save_is_rechecked_for_recreation_before_cascade(self, tmp_path): + svc, _ = make_service(tmp_path) + _install_rom(svc, tmp_path, rom_id=42, system="gba", file_name="present.gba") + expected = _create_save(tmp_path, system="gba", rom_name="present", content=b"sealed-current") + inventory = svc.inventory_prune_saves([42]) + assert inventory["source_claims"][str(expected)]["source_identity"]["exists"] is True + + result = svc.quarantine_prune_saves(inventory["exclusive"], inventory["source_claims"]) + assert result["success"] is True + expected.write_bytes(b"emulator-recreated") + + assert svc.validate_prune_absences(inventory["source_claims"]) is False + assert expected.read_bytes() == b"emulator-recreated" diff --git a/tests/services/test_downloads.py b/tests/services/test_downloads.py index be374bd5..ca1559f1 100644 --- a/tests/services/test_downloads.py +++ b/tests/services/test_downloads.py @@ -2774,12 +2774,13 @@ async def test_rejects_rom_dir_outside_roms_base(self, plugin, tmp_path): rom_dir=str(evil_dir), ) - await plugin.remove_rom(99) + result = await plugin.remove_rom(99) # The evil dir/file should NOT be deleted assert evil_dir.exists() assert evil_file.exists() - # The install record is still cleaned up (the rejection is silent) - assert plugin._uow.rom_installs.get(99) is None + assert result["success"] is False + # Unsafe paths are failures: retain the record so the user can repair it. + assert plugin._uow.rom_installs.get(99) is not None @pytest.mark.asyncio async def test_rejects_file_path_outside_roms_base(self, plugin, tmp_path): @@ -2805,9 +2806,10 @@ async def test_rejects_file_path_outside_roms_base(self, plugin, tmp_path): rom_dir=None, ) - await plugin.remove_rom(99) + result = await plugin.remove_rom(99) assert evil_file.exists() - assert plugin._uow.rom_installs.get(99) is None + assert result["success"] is False + assert plugin._uow.rom_installs.get(99) is not None class TestPathTraversalFsName: @@ -3569,8 +3571,7 @@ async def test_mixed_success_and_failure(self, plugin, tmp_path): good_file = roms_dir / "game_a.z64" good_file.write_text("data") - # Create another file but make deletion fail by using a non-safe path - # (outside roms dir, which _delete_rom_files should reject silently) + # Create another file but make deletion fail by using a non-safe path. bad_file = tmp_path / "outside" / "game_b.z64" bad_file.parent.mkdir(parents=True) bad_file.write_text("data") @@ -3579,14 +3580,16 @@ async def test_mixed_success_and_failure(self, plugin, tmp_path): _seed_install(plugin._uow, 2, file_path=str(bad_file), rom_dir=None, system="snes") result = await plugin.uninstall_all_roms() - assert result["success"] is True + assert result["success"] is False # good_file should be deleted assert not good_file.exists() # bad_file should still exist (outside roms dir) assert bad_file.exists() - # The path rejection is silent (no exception), so both records are cleared. - assert result["removed_count"] == 2 - assert list(plugin._uow.rom_installs.iter_all()) == [] + assert result["removed_count"] == 1 + assert len(result["errors"]) == 1 + assert result["errors"][0]["rom_id"] == "2" + assert plugin._uow.rom_installs.get(1) is None + assert plugin._uow.rom_installs.get(2) is not None class TestRemoveRomFileAlreadyGone: diff --git a/tests/services/test_rom_removal.py b/tests/services/test_rom_removal.py index bb2d6398..8671824c 100644 --- a/tests/services/test_rom_removal.py +++ b/tests/services/test_rom_removal.py @@ -3,6 +3,7 @@ import asyncio import logging import os +import shutil import sys import pytest @@ -15,6 +16,8 @@ sys.path.insert(0, os.path.dirname(__file__)) # conftest.py patches decky before this import +from adapters.recovery_bundle import RecoveryBundleAdapter +from adapters.rom_files import RomFileAdapter from domain.rom import Rom from domain.rom_install import RomInstall from services.rom_removal import RomRemovalService, RomRemovalServiceConfig @@ -141,11 +144,34 @@ def test_single_file_owns_no_dir_so_system_dir_not_removed(self, service, rom_fi assert system_dir in rom_files.dirs # the system dir itself survives assert rom_files.remove_tree_calls == [] + def test_single_file_record_pointing_at_nested_directory_fails_closed(self, service, rom_files): + nested = f"{_ROMS_BASE}/n64/shared-content" + rom_files.dirs.add(nested) + rom_files.files[f"{nested}/other.z64"] = b"keep" + + with pytest.raises(ValueError, match="Expected installed ROM file"): + service._delete_rom_files(_make_install(1, file_path=nested, rom_dir=None)) + + assert f"{nested}/other.z64" in rom_files.files + assert rom_files.remove_tree_calls == [] + + def test_filesystem_only_removal_leaves_install_and_rom_rows(self, service, uow, rom_files): + rom_path = f"{_ROMS_BASE}/n64/game.z64" + rom_files.files[rom_path] = b"rom" + _seed_install(uow, _make_install(7, file_path=rom_path)) + + result = service.delete_rom_files(7) + + assert result["success"] is True + assert uow.roms.get(7) is not None + assert uow.rom_installs.get(7) is not None + def test_refuses_file_outside_roms_dir(self, service, rom_files): evil = "/evil/important.txt" rom_files.files[evil] = b"do not delete" - service._delete_rom_files(_make_install(1, file_path=evil, rom_dir=None)) + with pytest.raises(ValueError, match="outside roms directory"): + service._delete_rom_files(_make_install(1, file_path=evil, rom_dir=None)) assert evil in rom_files.files assert rom_files.remove_file_calls == [] @@ -155,7 +181,8 @@ def test_refuses_rom_dir_outside_roms_dir(self, service, rom_files): evil_dir = "/evil/dir" rom_files.files[f"{evil_dir}/file.txt"] = b"important" - service._delete_rom_files(_make_install(1, file_path="", rom_dir=evil_dir)) + with pytest.raises(ValueError, match="outside roms directory"): + service._delete_rom_files(_make_install(1, file_path="", rom_dir=evil_dir)) assert f"{evil_dir}/file.txt" in rom_files.files assert rom_files.remove_tree_calls == [] @@ -168,6 +195,147 @@ def test_empty_paths_no_crash(self, service): # No file_path, no rom_dir service._delete_rom_files(_make_install(1, file_path="", rom_dir=None)) + def test_sealed_file_replacement_is_retained_at_mutation_time(self, tmp_path, logger): + roms = tmp_path / "roms" + rom_path = roms / "n64" / "game.z64" + rom_path.parent.mkdir(parents=True) + rom_path.write_bytes(b"sealed") + recovery = RecoveryBundleAdapter( + user_home=str(tmp_path), + package_name="decky-romm-sync", + plugin_version="test", + ) + bundle = recovery.seal_bundle( + "20260724T120000Z_1_rom", + {"roms": [{"rom_id": 1}]}, + [{"source_path": str(rom_path), "safe_root": str(roms), "kind": "installed_rom", "rom_id": 1}], + "readme", + "playtime", + ) + claims = recovery.source_claims(bundle)["claims"] + rom_path.unlink() + rom_path.write_bytes(b"replacement") + uow = FakeUnitOfWork() + _seed_install(uow, _make_install(1, file_path=str(rom_path))) + real_service = RomRemovalService( + config=RomRemovalServiceConfig( + logger=logger, + loop=asyncio.new_event_loop(), + rom_file_store=RomFileAdapter(), + retrodeck_paths=FakeRetroDeckPaths(roms=str(roms)), + download_queue_cleanup=None, + uow_factory=FakeUnitOfWorkFactory(uow), + ) + ) + + result = real_service.delete_rom_files(1, claims) + + assert result["success"] is False + assert "identity changed" in result["message"] + assert rom_path.read_bytes() == b"replacement" + + def test_preopened_rom_writer_prevents_installed_file_deletion(self, tmp_path, logger): + roms = tmp_path / "roms" + rom_path = roms / "n64" / "game.z64" + rom_path.parent.mkdir(parents=True) + rom_path.write_bytes(b"installed") + uow = FakeUnitOfWork() + _seed_install(uow, _make_install(1, file_path=str(rom_path))) + real_service = RomRemovalService( + config=RomRemovalServiceConfig( + logger=logger, + loop=asyncio.new_event_loop(), + rom_file_store=RomFileAdapter(), + retrodeck_paths=FakeRetroDeckPaths(roms=str(roms)), + download_queue_cleanup=None, + uow_factory=FakeUnitOfWorkFactory(uow), + ) + ) + writer = os.open(rom_path, os.O_WRONLY) + try: + result = real_service.delete_rom_files(1) + finally: + os.close(writer) + + assert result["success"] is False + assert "active writer" in result["message"] + assert rom_path.read_bytes() == b"installed" + + def test_selected_directory_child_change_is_retained_at_mutation_time(self, tmp_path, logger): + roms = tmp_path / "roms" + rom_dir = roms / "psx" / "Game" + rom_dir.mkdir(parents=True) + child = rom_dir / "disc.bin" + child.write_bytes(b"sealed") + recovery = RecoveryBundleAdapter(user_home=str(tmp_path), package_name="decky-romm-sync", plugin_version="test") + bundle = recovery.seal_bundle( + "20260724T120000Z_1_directory", + {"roms": [{"rom_id": 1}]}, + [{"source_path": str(rom_dir), "safe_root": str(roms), "kind": "installed_rom", "rom_id": 1}], + "readme", + "playtime", + ) + claims = recovery.source_claims(bundle)["claims"] + child.write_bytes(b"replacement") + uow = FakeUnitOfWork() + _seed_install(uow, _make_install(1, file_path=str(child), rom_dir=str(rom_dir), system="psx")) + real_service = RomRemovalService( + config=RomRemovalServiceConfig( + logger=logger, + loop=asyncio.new_event_loop(), + rom_file_store=RomFileAdapter(), + retrodeck_paths=FakeRetroDeckPaths(roms=str(roms)), + download_queue_cleanup=None, + uow_factory=FakeUnitOfWorkFactory(uow), + ) + ) + + result = real_service.delete_rom_files(1, claims) + + assert result["success"] is False + assert "subtree changed" in result["message"] + assert child.read_bytes() == b"replacement" + + def test_unselected_directory_replacement_after_final_claim_is_retained(self, tmp_path, logger, monkeypatch): + roms = tmp_path / "roms" + rom_dir = roms / "psx" / "Game" + rom_dir.mkdir(parents=True) + (rom_dir / "disc.bin").write_bytes(b"original") + replacement = roms / "psx" / "Replacement" + replacement.mkdir() + (replacement / "disc.bin").write_bytes(b"replacement") + store = RomFileAdapter() + original_claim = store.claim_source + + def claim_then_replace(path: str, safe_root: str): + claim = original_claim(path, safe_root) + shutil.rmtree(path) + replacement.rename(path) + return claim + + monkeypatch.setattr(store, "claim_source", claim_then_replace) + uow = FakeUnitOfWork() + _seed_install( + uow, + _make_install(1, file_path=str(rom_dir / "disc.bin"), rom_dir=str(rom_dir), system="psx"), + ) + real_service = RomRemovalService( + config=RomRemovalServiceConfig( + logger=logger, + loop=asyncio.new_event_loop(), + rom_file_store=store, + retrodeck_paths=FakeRetroDeckPaths(roms=str(roms)), + download_queue_cleanup=None, + uow_factory=FakeUnitOfWorkFactory(uow), + ) + ) + + result = real_service.delete_rom_files(1) + + assert result["success"] is False + assert "identity changed" in result["message"] + assert (rom_dir / "disc.bin").read_bytes() == b"replacement" + class TestRemoveRom: @pytest.mark.asyncio @@ -305,16 +473,20 @@ async def test_removes_rom_dir(self, service, uow, rom_files): assert f"{_ROMS_BASE}/psx" in rom_files.dirs @pytest.mark.asyncio - async def test_path_traversal_rejected_record_still_deleted(self, service, uow, rom_files): + async def test_path_traversal_rejected_preserves_install_record(self, service, uow, rom_files): evil = "/etc/passwd" rom_files.files[evil] = b"root:x:0:0" _seed_install(uow, _make_install(99, file_path=evil, rom_dir=None)) result = await service.remove_rom(99) - assert result["success"] is True + assert result == { + "success": False, + "reason": "unknown", + "message": "Failed to delete ROM files", + } assert evil in rom_files.files # not deleted (outside roms dir) - assert uow.rom_installs.get(99) is None + assert uow.rom_installs.get(99) is not None @pytest.mark.asyncio async def test_removes_nested_single_file_entry(self, service, uow, rom_files): @@ -447,7 +619,7 @@ async def test_deletes_rom_directories(self, service, uow, rom_files): assert all(not p.startswith(rom_dir + "/") for p in rom_files.files) @pytest.mark.asyncio - async def test_outside_roms_dir_skipped_record_still_cleared(self, service, uow, rom_files): + async def test_outside_roms_dir_is_partial_failure_and_preserves_record(self, service, uow, rom_files): good_file = f"{_ROMS_BASE}/n64/game_a.z64" rom_files.files[good_file] = b"\x00" * 100 bad_file = "/outside/game_b.z64" @@ -459,12 +631,12 @@ async def test_outside_roms_dir_skipped_record_still_cleared(self, service, uow, uow.rom_installs.save(_make_install(2, file_path=bad_file, rom_dir=None, system="snes")) result = await service.uninstall_all_roms() - assert result["success"] is True + assert result["success"] is False + assert result["removed_count"] == 1 + assert len(result["errors"]) == 1 assert good_file not in rom_files.files assert bad_file in rom_files.files # not deleted (outside roms dir) - # Install records for the path-rejected (no exception) ROMs are still cleared: - # the safety guard returns silently, so the deletion is treated as "succeeded". - assert list(uow.rom_installs.iter_all()) == [] + assert [install.rom_id for install in uow.rom_installs.iter_all()] == [2] @pytest.mark.asyncio async def test_partial_failure_reports_errors_and_not_success(self, service, uow, rom_files): diff --git a/tests/services/test_version_switch.py b/tests/services/test_version_switch.py index a0294290..986850be 100644 --- a/tests/services/test_version_switch.py +++ b/tests/services/test_version_switch.py @@ -214,11 +214,14 @@ def test_unknown_app_id_not_multi(self, event_loop, service): def test_solo_group_not_multi(self, event_loop, service, uow, romm): _seed_rom(uow, rom_id=1, app_id=_APP_ID) romm.roms[1] = {"id": 1, "sibling_roms": []} - assert _run(event_loop, service.get_version_list(_APP_ID)) == { - "multi_version": False, - "server_query_failed": False, - "bound_vanished": False, - } + result = _run(event_loop, service.get_version_list(_APP_ID)) + + assert result["multi_version"] is False + assert result["server_query_failed"] is False + assert result["bound_vanished"] is False + assert result["bound_version"]["rom_id"] == 1 + assert result["bound_version"]["active"] is True + assert result["bound_version"]["is_default"] is True def test_local_members_listed_with_markers(self, event_loop, service, uow, romm): _seed_rom(uow, rom_id=1, app_id=_APP_ID, regions=("USA",)) @@ -499,11 +502,15 @@ def test_single_bound_404_preserves_bound_verdict_in_non_multi_shape(self, event _seed_rom(uow, rom_id=1, app_id=_APP_ID) romm.get_rom_side_effect = RommNotFoundError("bound gone") - assert _run(event_loop, service.get_version_list(_APP_ID)) == { - "multi_version": False, - "server_query_failed": False, - "bound_vanished": True, - } + result = _run(event_loop, service.get_version_list(_APP_ID)) + + assert result["multi_version"] is False + assert result["server_query_failed"] is False + assert result["bound_vanished"] is True + assert result["bound_version"]["rom_id"] == 1 + assert result["bound_version"]["active"] is True + assert result["bound_version"]["vanished"] is True + assert result["bound_version"]["is_default"] is False def test_vanished_stale_main_sibling_never_receives_default_badge(self, event_loop, service, uow, romm): _seed_rom(uow, rom_id=1, app_id=None, is_main_sibling=True) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d6c98862..c3f23e25 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -48,6 +48,7 @@ from services.library import LibraryService from services.metadata import MetadataService from services.playtime import PlaytimeService +from services.prune import PruneService from services.saves import SaveService from services.steamgrid import SteamGridService from services.version_switch import VersionSwitchService @@ -247,6 +248,9 @@ def _make_deps(self, tmp_path): "renderer_rss": FakeRendererRss(), "renderer_gc": FakeRendererGc(), "resolve_upload_conflict": resolve_upload_conflict, + "recovery_store": MagicMock(), + "prune_artifacts": MagicMock(), + "steam_recovery": MagicMock(), "settings": settings, "loop": asyncio.new_event_loop(), "logger": logger, @@ -299,6 +303,9 @@ def _make_config(deps: dict[str, Any]) -> WiringConfig: renderer_rss=deps["renderer_rss"], renderer_gc=deps["renderer_gc"], resolve_upload_conflict=deps["resolve_upload_conflict"], + recovery_store=deps["recovery_store"], + prune_artifacts=deps["prune_artifacts"], + steam_recovery=deps["steam_recovery"], ), stores=StateBundle( settings=deps["settings"], @@ -382,7 +389,7 @@ def test_services_share_settings_reference(self, tmp_path): def test_returns_expected_services(self, tmp_path): deps = self._make_deps(tmp_path) result = wire_services(self._make_config(deps)) - assert len(result) == 22 + assert len(result) == 23 assert "migration_service" in result assert "game_detail_service" in result assert "rom_removal_service" in result @@ -393,6 +400,7 @@ def test_returns_expected_services(self, tmp_path): assert isinstance(result["disc_service"], DiscService) assert "version_switch_service" in result assert isinstance(result["version_switch_service"], VersionSwitchService) + assert isinstance(result["prune_service"], PruneService) assert "connection_service" in result assert "startup_healing_service" in result assert "launch_gate_service" in result diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 71da6f12..ebf9f348 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -31,6 +31,78 @@ from services.steamgrid import SteamGridService, SteamGridServiceConfig +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("event", "payload"), + [ + ("sync_complete", {"total_games": 1}), + ("sync_stale", {"remove": [{"rom_id": 1, "app_id": 42}]}), + ("download_complete", {"app_id": 42, "launch_options": "launch"}), + ("migration_relaunch_options", {"items": [{"app_id": 42, "launch_options": "launch"}]}), + ], +) +async def test_continuation_events_hold_a_renewable_prune_lease(plugin, event, payload): + import decky + + decky.emit.reset_mock() + await plugin._emit_with_prune_continuation(event, payload) + + emitted = decky.emit.await_args.args[1] + token = emitted["prune_lease_token"] + assert token.startswith(f"{event}:") + assert "prune_lease_token" not in payload + assert plugin._prune_admission_gate.conflicting_operations == 1 + assert (await plugin.renew_prune_conflict_lease(token))["success"] is True + assert (await plugin.release_prune_conflict_lease(token))["success"] is True + assert plugin._prune_admission_gate.conflicting_operations == 0 + + +@pytest.mark.asyncio +async def test_rejected_continuation_event_releases_its_unreachable_lease(plugin): + import decky + + decky.emit.reset_mock() + decky.emit.side_effect = RuntimeError("bridge rejected event") + + with pytest.raises(RuntimeError, match="bridge rejected event"): + await plugin._emit_with_prune_continuation("download_complete", {"app_id": 42}) + + assert plugin._prune_admission_gate.conflicting_operations == 0 + assert plugin._prune_admission_gate.leases == {} + decky.emit.side_effect = None + + +@pytest.mark.asyncio +async def test_download_without_a_bound_shortcut_emits_no_continuation_lease(plugin): + import decky + + decky.emit.reset_mock() + await plugin._emit_with_prune_continuation("download_complete", {"app_id": None}) + + assert "prune_lease_token" not in decky.emit.await_args.args[1] + assert not hasattr(plugin, "_prune_admission_gate") + + +@pytest.mark.asyncio +async def test_terminal_prune_completion_holds_publication_lease_before_release_wait(plugin): + import decky + + decky.emit.reset_mock() + plugin._prune_service.start_prune = AsyncMock(return_value={"success": True, "run_id": "next"}) + + await plugin._emit_with_prune_continuation( + "prune_complete", + {"run_id": "run-1", "final": True, "publication_required": True}, + ) + + token = decky.emit.await_args.args[1]["prune_lease_token"] + assert token.startswith("prune_complete:") + blocked = await plugin.start_prune({"confirmed": True}) + assert blocked["reason"] == "operation_active" + assert (await plugin.release_prune_conflict_lease(token))["success"] is True + assert await plugin.start_prune({"confirmed": True}) == {"success": True, "run_id": "next"} + + @pytest.fixture def plugin(): p = Plugin() @@ -45,6 +117,8 @@ def plugin(): # to exercise the @migration_blocked gate override this. p._migration_service = MagicMock() p._migration_service.is_retrodeck_migration_pending.return_value = False + p._prune_service = MagicMock() + p._prune_service.is_active.return_value = False import decky @@ -775,6 +849,14 @@ async def test_propagates_exceptions(self, plugin): "report_unit_results", "get_registry_platforms", "report_removal_results", + "stage_prune_installed_selection", + "release_prune_conflict_lease", + "renew_prune_conflict_lease", + "wait_for_prune_release", + # Ack for an already-started cleanup run must remain available while the + # run waits on its exact Steam action token; gating it would deadlock the + # recovery-backed operation if migration state changed mid-run. + "report_prune_action", # Sync-start reconcile of Steam-UI-deleted shortcut bindings (#1046) — clears # only the SQLite ``shortcut_app_id`` link (never a RetroDECK path), so it is # not gated by a pending migration, matching report_removal_results above. @@ -990,6 +1072,7 @@ async def test_main_calls_detect_path_change_before_prune(self): "core_service": MagicMock(), "disc_service": MagicMock(), "version_switch_service": MagicMock(), + "prune_service": MagicMock(shutdown=AsyncMock()), "connection_service": connection_service, "startup_healing_service": startup_healing_service, "launch_gate_service": MagicMock(), @@ -1015,6 +1098,9 @@ async def test_main_calls_detect_path_change_before_prune(self): renderer_rss=FakeRendererRss(), renderer_gc=FakeRendererGc(), resolve_upload_conflict=MagicMock(), + recovery_store=MagicMock(), + prune_artifacts=MagicMock(), + steam_recovery=MagicMock(), ), stores=StateBundle( settings={}, @@ -1132,6 +1218,8 @@ def _plugin_with_playtime(self): p.loop = asyncio.get_event_loop() p._playtime_service = MagicMock() p._playtime_service.record_session_start.return_value = {"success": True} + p._prune_service = MagicMock() + p._prune_service.is_active.return_value = False return p @pytest.mark.asyncio diff --git a/tests/test_plugin_callable_delegation.py b/tests/test_plugin_callable_delegation.py index 4e6b3e8e..b310b4c0 100644 --- a/tests/test_plugin_callable_delegation.py +++ b/tests/test_plugin_callable_delegation.py @@ -53,6 +53,8 @@ def plugin(): p._playtime_service = MagicMock() p._launch_gate_service = MagicMock() p._session_lifecycle_service = MagicMock() + p._prune_service = MagicMock() + p._prune_service.is_active.return_value = False return p @@ -224,7 +226,12 @@ async def test_set_game_core_delegates(self, plugin): ) result = await plugin.set_game_core(42, "Snes9x") plugin._core_service.set_game_core.assert_awaited_once_with(42, "Snes9x") - assert result == {"success": True, "launch_options": "flatpak run …", "app_id": 99} + assert result == { + "success": True, + "launch_options": "flatpak run …", + "app_id": 99, + "prune_lease_token": "game_core:1", + } @pytest.mark.asyncio async def test_clear_game_core_delegates(self, plugin): @@ -235,7 +242,12 @@ async def test_clear_game_core_delegates(self, plugin): ) result = await plugin.clear_game_core(42) plugin._core_service.clear_game_core.assert_awaited_once_with(42) - assert result == {"success": True, "launch_options": "flatpak run …", "app_id": 99} + assert result == { + "success": True, + "launch_options": "flatpak run …", + "app_id": 99, + "prune_lease_token": "game_core:1", + } @pytest.mark.asyncio async def test_get_platform_core_info_delegates(self, plugin): @@ -431,7 +443,7 @@ async def test_remove_all_shortcuts_delegates(self, plugin): @pytest.mark.asyncio async def test_report_removal_results_delegates(self, plugin): plugin._shortcut_removal_service.report_removal_results = AsyncMock(return_value={"ok": True}) - result = await plugin.report_removal_results([1, 2]) + result = await plugin.report_removal_results([1, 2], None) plugin._shortcut_removal_service.report_removal_results.assert_awaited_once_with([1, 2]) assert result == {"ok": True} @@ -538,17 +550,20 @@ async def test_get_installed_rom_delegates(self, plugin): class TestRomRemovalCallableDelegation: @pytest.mark.asyncio async def test_remove_rom_delegates(self, plugin): - plugin._rom_removal_service.remove_rom = AsyncMock(return_value={"removed": True}) + plugin._rom_removal_service.remove_rom = AsyncMock(return_value={"success": True}) result = await plugin.remove_rom(42) plugin._rom_removal_service.remove_rom.assert_awaited_once_with(42) - assert result == {"removed": True} + assert result["success"] is True + assert result["prune_lease_token"].startswith("rom_uninstall:") @pytest.mark.asyncio async def test_uninstall_all_roms_delegates(self, plugin): - plugin._rom_removal_service.uninstall_all_roms = AsyncMock(return_value={"removed": 3}) + plugin._rom_removal_service.uninstall_all_roms = AsyncMock(return_value={"success": True, "app_ids": [42]}) result = await plugin.uninstall_all_roms() plugin._rom_removal_service.uninstall_all_roms.assert_awaited_once_with() - assert result == {"removed": 3} + assert result["success"] is True + assert result["app_ids"] == [42] + assert result["prune_lease_token"].startswith("bulk_uninstall:") # ── Saves callables ─────────────────────────────────────────────────── @@ -647,7 +662,19 @@ async def test_get_sgdb_artwork_base64_delegates(self, plugin): plugin._sgdb_service.get_sgdb_artwork_base64 = AsyncMock(return_value={"base64": "data"}) result = await plugin.get_sgdb_artwork_base64(42, 1) plugin._sgdb_service.get_sgdb_artwork_base64.assert_awaited_once_with(42, 1) + token = result.pop("prune_lease_token") + assert token.startswith("sgdb_artwork:") assert result == {"base64": "data"} + assert (await plugin.release_prune_conflict_lease(token))["success"] is True + + @pytest.mark.asyncio + async def test_get_sgdb_artwork_without_image_creates_no_continuation_lease(self, plugin): + plugin._sgdb_service.get_sgdb_artwork_base64 = AsyncMock(return_value={"base64": None}) + + result = await plugin.get_sgdb_artwork_base64(42, 1) + + assert result == {"base64": None} + assert plugin._prune_admission_gate.conflicting_operations == 0 @pytest.mark.asyncio async def test_verify_sgdb_api_key_delegates(self, plugin): @@ -824,8 +851,10 @@ async def test_unload_calls_shutdown_on_sync_and_download(self, plugin): plugin._download_service.shutdown = AsyncMock() plugin._migration_service.shutdown = AsyncMock() plugin._session_lifecycle_service.shutdown = AsyncMock() + plugin._prune_service.shutdown = AsyncMock() await plugin._unload() plugin._sync_service.shutdown.assert_called_once_with() plugin._download_service.shutdown.assert_awaited_once_with() plugin._migration_service.shutdown.assert_awaited_once_with() plugin._session_lifecycle_service.shutdown.assert_awaited_once_with() + plugin._prune_service.shutdown.assert_awaited_once_with()