Skip to content

Commit a10e2ab

Browse files
authored
fix(version-picker): disable versions missing from RomM (#1575)
## What this is PR 3 of #1570. Retained local ROM rows no longer masquerade as live, selectable versions after RomM drops their ids. Availability is recomputed whenever the Game Page lazily loads the version list; it is never persisted. ## What changed - The bound ROM detail request establishes whether the active id is still available and supplies RomM's direct sibling view. - Local members absent from that direct view are confirmed concurrently through exact-id, single-attempt `get_rom` probes. If the bound id itself returns 404, every other local member is probed individually. - Only `RommNotFoundError` marks a version `vanished`; every non-404 failure fails open. - Vanished rows remain visible with their local state, but are dimmed, labelled `No longer available on RomM`, disabled, and excluded from Default ranking. - `vanished` remains separate from `switchable`, preserving sibling-group membership semantics. - A bound 404 returns `bound_vanished: true` without reporting RomM offline or updating the global connection state. - `SavesTab` excludes vanished versions from its stranded-save advice while continuing to inspect live inactive installs. - The deferred artwork cleanup now reports a missing/falsy ROM as `not_found` instead of `server_unreachable`; artwork remains data-only and is not used as liveness evidence. The version-switch commit guard and deletion/purge behavior remain in PRs 4 and 5. ## Safety Liveness probes use the existing three-second `request_once` path on the executor fan-out, so they neither retry nor block the event loop. Direct sibling presence is a positive server statement; absence alone never proves disappearance because sibling groups can be transitive. A restored database or visibility change can make the same id available again. The next list load recomputes the result and removes `vanished` automatically. ## Docs Updated the backend and Steam-shortcut architecture pages plus the managing-games and save-sync user guides. Frozen ADR history remains unchanged. ## Verification `mise run gate` passed on the final code state: - 6334 backend tests - 2244 frontend tests - Ruff and basedpyright clean - TypeScript, ESLint, Prettier, Deno, build, size limit, import contracts, and repository invariant checks passed The subsequent documentation wording correction passed focused Markdown formatting, link, whitespace, backend, frontend, and 404-classification checks. Final review found no remaining issues. ## On-device verification Open a Game Page whose shortcut is bound to a RomM id that was removed while a replacement version remains in the local sibling group. The page should render without waiting for liveness probes; the removed version should remain visible but disabled, the live replacement should remain selectable, and RomM must not be reported offline. Closes part of #1570.
1 parent d5b8b56 commit a10e2ab

23 files changed

Lines changed: 662 additions & 75 deletions

docs/architecture/backend-architecture.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ the rest are single modules. A service over ~700 LOC is the decomposition signal
127127
| `connection.py` | ConnectionService — connection test + RomM minimum-version gate + Client API Token lifecycle (mint/establish via credentials, validate/store a user-pasted token, or exchange a short-lived pairing code for a token — the latter two for OIDC accounts; host-bound to the minting origin; see [ConnectionService notes](#connectionservice-notes)) |
128128
| `protocols/` | Protocol interfaces grouped by concern (see [Protocol Interfaces](#protocol-interfaces)) |
129129

130+
#### Version-list liveness
131+
132+
`VersionSwitchService.get_version_list` separates retained local membership from current RomM availability. On each lazy
133+
Game Page load, the bound detail request proves the bound id live and supplies the direct sibling ids; only non-bound
134+
local members absent from that direct sibling view are exact-id probed through `RommRomReader.get_rom_once`, the
135+
adapter's single-attempt short-timeout `request_once` path, concurrently on the existing executor fan-out. Only
136+
`RommNotFoundError` produces `vanished: true`. All other probe failures and malformed/falsy responses fail open. No
137+
result is persisted.
138+
139+
The response carries `vanished` on every version and `bound_vanished` even when `multi_version` is false. Retained
140+
vanished rows remain in the payload and keep the independently-computed `switchable` membership verdict, but are
141+
excluded from default ranking. A bound-id 404 is a successful entity verdict (`bound_vanished: true`,
142+
`server_query_failed: false`), not a global reachability signal. Artwork and save reads do not contribute availability
143+
evidence; `fetch_cover_base64` remains a nullable data callable.
144+
130145
#### LibraryService decomposition (`services/library/`)
131146

132147
The library sync subsystem is a façade over three sub-services that coordinate through a shared `LibrarySyncStateBox`:

docs/architecture/steam-non-steam-shortcuts.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,32 @@ Two guards keep a re-import from wiping a freshly-bound shortcut (`#1036`):
125125
the run just re-bound onto the new `rom_id` is never emitted for removal. The `get_by_app_id` reverse lookup orders
126126
`rom_id DESC LIMIT 1` so it resolves the live (newest) binding for any pre-migration edge state.
127127

128+
### Retained-row availability in the version picker
129+
130+
ADR-0007 keeps a `roms` row after RomM stops returning that id because the row can anchor local-only saves, playtime,
131+
and an installed ROM. Retention does not imply that RomM still offers the row as a playable version. The Game Page's
132+
lazy `get_version_list` load therefore recomputes availability every time; there is no liveness column, migration,
133+
cache, or persisted verdict.
134+
135+
The existing detail request for the bound id supplies its own answer and RomM's current direct `sibling_roms` view. The
136+
bound id and every local id positively present in that view are live. A local group member absent from the direct view
137+
is only a suspect because RomM sibling membership can be transitive, so each suspect is checked by an exact-id
138+
`get_rom_once` request. These checks fan out concurrently on the worker executor and use the short timeout with no
139+
retry, keeping them off the event loop and out of the initial Game Page render.
140+
141+
Only a typed `RommNotFoundError` from an exact id marks that entry `vanished`. A successful response is live; timeout,
142+
transport, authentication, server errors, and malformed or empty data all fail open and leave it available. If the bound
143+
detail itself 404s, `bound_vanished` is true, `server_query_failed` stays false, and every other local member is checked
144+
individually. That entity-specific 404 is not fed into the global connection store; a genuine explicit
145+
server-unreachable result still is. Cover and save endpoints are not liveness authorities.
146+
147+
Vanished rows stay visible with their active and downloaded markers, but are disabled and excluded before the existing
148+
default-resolution kernel runs. `vanished` does not change `switchable` or
149+
`domain.sibling_group.target_in_sibling_group`: availability and sibling membership remain separate verdicts. This lets
150+
a shortcut still bound to a vanished id show the retained context while the user selects a live alternative. The Saves
151+
tab likewise skips positively vanished inactive installs before checking local drift, then continues through later live
152+
candidates.
153+
128154
## Sync-start reconcile of Steam-UI-deleted shortcuts
129155

130156
A user can delete a RomM shortcut through **Steam's own UI** (remove from library), which the plugin never observes. The

docs/user-guide/managing-games.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ a not-yet-synced version whose match conflicts, are both shown (so you know they
5454
either would move the shortcut onto a different game. To make one selectable, fix its metadata match in RomM so it
5555
agrees with this game's, then re-sync.
5656

57+
A retained local version that RomM has positively confirmed it no longer has is also shown dimmed and disabled, with the
58+
separate label _"No longer available on RomM"_. Its **active** and **Downloaded** markers remain visible so it is clear
59+
what the shortcut and local files still refer to, but it never receives the **Default** badge. If the shortcut is still
60+
active on that unavailable id, choose any live alternative in the same list to recover the game.
61+
62+
This availability check is fresh each time the version list loads and is not saved in the plugin database. Only a 404
63+
for that exact ROM id establishes that it is gone. Timeouts, sign-in/permission errors, server errors, and malformed
64+
responses fail open: the plugin does not disable a version merely because it could not get a trustworthy answer. A
65+
ROM-specific 404 also does not make the rest of the plugin report that RomM is offline.
66+
5767
Selecting a different version **rebinds** the game to it: the Download button now fetches that version, the panel's
5868
title and Region/Languages rows update to reflect it, and its cover refreshes to the new version. The Steam shortcut
5969
keeps its name, its place in your collections, and its playtime — all tied to the shortcut, which never changes. A
@@ -71,7 +81,8 @@ switch. What happens depends on whether the version you're leaving is downloaded
7181
drops the sync option and offers only **Switch anyway** and **Cancel**, explaining why.
7282

7383
When you leave a downloaded version with unsynced saves behind, the **Saves** tab shows a reminder banner ("switch back
74-
to sync them") so those saves aren't forgotten.
84+
to sync them") so those saves aren't forgotten. It does not recommend switching back to a version positively confirmed
85+
as no longer available on RomM; it skips that retained row and continues checking any later live versions.
7586

7687
While a download of the game is running, switching is blocked with a short message — cancel the running download first.
7788

docs/user-guide/save-sync.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ plugin warns you before you leave such a version and shows a reminder banner on
4040
forgotten (see [Managing Games → Versions](managing-games.md#versions)). Nothing is lost — the saves just don't sync
4141
while their version is inactive.
4242

43+
The reminder never tells you to switch back to a retained version that the current version-list check positively
44+
confirmed is [no longer available on RomM](managing-games.md#the-switch-version-control). Such a row may still keep its
45+
local files and saves, but it cannot be selected; the Saves tab skips it and continues looking for drift on later live
46+
inactive versions.
47+
4348
## Important: Use Your Own RomM Account
4449

4550
Save files in RomM are tied to the authenticated user account. If multiple people share the same RomM account, their

py_modules/adapters/romm/http.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,9 @@ def request_once(self, path: str, *, timeout: int):
348348
"""GET a JSON resource in a SINGLE attempt with a SHORT *timeout*.
349349
350350
Deliberately bypasses :meth:`with_retry` and the 30s urlopen timeout used
351-
by :meth:`request`. The launch-gate reachability probe needs a fast
352-
offline verdict (~3s, one shot) instead of waiting through 3 retry
353-
attempts and up to ~90s of accumulated remote timeouts. The real sync
354-
paths keep the retrying :meth:`request`.
351+
by :meth:`request`. Point probes need a fast verdict (~3s, one shot)
352+
instead of waiting through 3 retry attempts and up to ~90s of accumulated
353+
remote timeouts. The real sync paths keep the retrying :meth:`request`.
355354
"""
356355
return self._build_get(path, timeout=timeout)()
357356

py_modules/adapters/romm/romm_api.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,8 @@ def get_version(self) -> str | None:
101101

102102
# ── Server / Auth ─────────────────────────────────────────────────
103103

104-
# Fast-fail reachability probe: a single ~3s attempt, no retry. Keeps the
105-
# launch gate's "offline" verdict snappy instead of waiting through the
106-
# retrying heartbeat (3 attempts + up to ~90s of accumulated timeouts).
104+
# Fast-fail point probes: a single ~3s attempt, no retry. Keeps verdicts
105+
# snappy instead of waiting through 3 attempts and accumulated timeouts.
107106
_PROBE_TIMEOUT_SECONDS = 3
108107

109108
def heartbeat(self) -> dict[str, Any]:
@@ -130,6 +129,10 @@ def get_current_user(self) -> dict[str, Any]:
130129
def get_rom(self, rom_id: int) -> dict[str, Any]:
131130
return self._client.request(f"/api/roms/{rom_id}")
132131

132+
def get_rom_once(self, rom_id: int) -> dict[str, Any]:
133+
"""Single-attempt, short-timeout exact-ROM read for liveness probes."""
134+
return self._client.request_once(f"/api/roms/{rom_id}", timeout=self._PROBE_TIMEOUT_SECONDS)
135+
133136
def list_roms(self, platform_id: int, limit: int = LIST_PAGE_SIZE, offset: int = 0) -> dict[str, Any]:
134137
return self._client.request(
135138
f"/api/roms?platform_ids={platform_id}&limit={limit}&offset={offset}{_LIST_AGGREGATIONS_DISABLED}"

py_modules/services/artwork.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,7 @@ async def refresh_cover(self, rom_id: int) -> dict[str, Any]:
835835
if not rom:
836836
return {
837837
"success": False,
838-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
838+
"reason": ErrorCode.NOT_FOUND.value,
839839
"message": "Could not fetch ROM from server",
840840
}
841841

py_modules/services/protocols/transport.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ def get_rom(self, rom_id: int) -> dict[str, Any]:
145145
"""
146146
...
147147

148+
def get_rom_once(self, rom_id: int) -> dict[str, Any]:
149+
"""Fetch a single ROM by ID once with the short probe timeout."""
150+
...
151+
148152
def list_roms(self, platform_id: int, limit: int = LIST_PAGE_SIZE, offset: int = 0) -> dict[str, Any]:
149153
"""List ROMs for a platform with pagination.
150154

0 commit comments

Comments
 (0)