Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/architecture/backend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,26 @@ excluded from default ranking. A bound-id 404 is a successful entity verdict (`b
`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.

#### Version-switch target liveness

`VersionSwitchService.switch_version` obtains a fresh exact-id verdict immediately before every actual binding move onto
an already-local target. The save-stranding guard runs first, so its initial soft block performs no target request; both
follow-up choices (`Sync now` retry and `Switch anyway`) re-enter the service and receive the same liveness guard.
`allow_stranded` bypasses only save stranding. The active-target no-op, invalid local context, active download,
non-member target, and target bound to another shortcut all stop before the request because none can enter the binding
write.

The local-target request uses `RommRomReader.get_rom_once` on the worker executor: one attempt, three-second timeout, no
retry-progress event, and no open UoW. Only `RommNotFoundError` returns the canonical `version_vanished` refusal.
Timeout, transport/DNS/SSL, authentication, server errors, and malformed or empty successful payloads are logged and
fail open, preserving the fast offline switch path. The existing short write UoW still rechecks membership and
bound-elsewhere after the request; the request narrows the race but cannot form a transaction across RomM and SQLite.

A server-only target still needs its full detail before it can be validated and persisted. That mandatory fetch keeps
the normal retry/classification policy and is not preceded by a redundant exact-id probe; only its typed 404 is peeled
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.

#### LibraryService decomposition (`services/library/`)

The library sync subsystem is a façade over three sub-services that coordinate through a shared `LibrarySyncStateBox`:
Expand Down
16 changes: 16 additions & 0 deletions docs/architecture/steam-non-steam-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,22 @@ a shortcut still bound to a vanished id show the retained context while the user
tab likewise skips positively vanished inactive installs before checking local drift, then continues through later live
candidates.

The list verdict is advisory UI state, not authority for a later write. Immediately before `switch_version` moves the
binding onto an already-local target, it checks that exact target id again through the same three-second, single-attempt
`get_rom_once` path. The request runs on the worker executor outside the write UoW and after the save-stranding guard
permits the attempt. Consequently an initial unsynced-save warning makes no target request, while both `Sync now` and
`Switch anyway` retries are protected; `allow_stranded` never bypasses liveness. A typed target 404 returns
`version_vanished` without changing the binding or any recorded launch state. Every other optional-probe outcome fails
open, so a local switch remains fast when RomM is uncertain or offline. The active-target no-op does not probe because
it moves no binding.

A server-only target already requires its full RomM detail for membership validation and row construction. That fetch
keeps its normal retry policy, doubles as the liveness verdict, and receives no second probe. Its typed 404 produces the
same `version_vanished` refusal; other failures retain their ordinary classified reason. Network I/O remains outside the
short write UoW, whose fresh membership and bound-elsewhere checks still decide SQLite races. This leaves an unavoidable
cross-system interval after a successful response: the liveness check reduces stale-list risk but is not a transaction
with RomM.

## 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
Expand Down
8 changes: 8 additions & 0 deletions docs/user-guide/managing-games.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ title and Region/Languages rows update to reflect it, and its cover refreshes to
keeps its name, its place in your collections, and its playtime — all tied to the shortcut, which never changes. A
single-version game shows no Version control.

The plugin checks the selected version again immediately before moving the shortcut. If RomM now answers that the exact
version no longer exists, the switch is refused with **Could not switch version**, nothing about the shortcut changes,
and the picker refreshes directly so the row becomes unavailable without starting a library sync. This protection also
applies after **Sync now & switch** and **Switch anyway**; the latter bypasses only the unsynced-save warning. If RomM
cannot provide a definitive answer because of a timeout, connection/sign-in/server error, or malformed response, the
local switch is allowed to continue. In particular, the offline **Switch anyway** path does not wait through the normal
retry sequence. A target-specific refusal does not by itself change the plugin's global online/offline status.

Switching **never deletes anything.** ROM files already on disk stay put, and save files are never moved or deleted by a
switch. What happens depends on whether the version you're leaving is downloaded and whether its saves are synced:

Expand Down
87 changes: 64 additions & 23 deletions py_modules/services/version_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
markers the picker paints. Retained local rows are checked for ephemeral liveness
on each read; only an exact-ROM 404 marks one vanished. ``switch_version`` moves
the group's Steam-shortcut binding to a chosen sibling (the active version),
rechecking a local target's exact-id liveness immediately before the write and
persisting a server-only target first when needed.

A version switch is a pure binding move: the shortcut's name and appId stay
Expand All @@ -21,7 +22,8 @@
resolved by ``sibling_group_key`` over the migration-010 index; the server view
comes from ``get_rom(bound).sibling_roms``. Server I/O and the relaunch resolver
run outside the Unit of Work (ADR-0006) — read/close, then fetch/resolve, then a
short write UoW.
short write UoW. The liveness request narrows the race but cannot transact with
RomM and SQLite as one unit.
"""

from __future__ import annotations
Expand Down Expand Up @@ -540,8 +542,10 @@ async def switch_version(self, app_id: int, target_rom_id: int, allow_stranded:
first — the refusal carries ``server_reachable`` (from the reachability
probe), ``unsynced_rom_id``, and ``unsynced_version_name`` so the frontend
modal can offer "Sync now & switch" / "Switch anyway" / "Cancel".
``allow_stranded`` is the "Switch anyway" override — honoured even offline
(the switch is purely local). Saves are never deleted or transferred.
``allow_stranded`` is the "Switch anyway" override for the save-stranding
gate only. The local target still receives a short, single-attempt exact-id
liveness probe; only a typed 404 refuses it, while uncertainty fails open so
an offline switch stays fast. Saves are never deleted or transferred.

On success returns
``{success: True, rom_id, target_installed, launch_options, app_id}``:
Expand All @@ -553,10 +557,10 @@ async def switch_version(self, app_id: int, target_rom_id: int, allow_stranded:
target outside the group → ``not_in_group``; target bound to a *different*
shortcut (grandfathered duplicate, ADR-0021 §5) → ``bound_elsewhere``; a
server-only target whose detail the aggregate rejects → ``invalid_target``;
a failed server-only target fetchthe :func:`classify_error` slug for the
exception, so a server that answered 404 for a dropped id reads
``not_found`` and only a genuine transport failure reads
``server_unreachable`` (#1570).
a definitive 404 for either target shape``version_vanished``; any other
failed mandatory server-only target fetch → the :func:`classify_error` slug
for the exception. The optional local probe instead fails open on every
non-404 outcome (#1570).
"""
app_id = int(app_id)
target_rom_id = int(target_rom_id)
Expand Down Expand Up @@ -588,21 +592,7 @@ async def switch_version(self, app_id: int, target_rom_id: int, allow_stranded:
return self._switch_success(ctx.bound_rom_id, ctx.bound_installed, launch_options, app_id)

if ctx.target_is_local:
# Fast reject an invalid local target before the drift/reachability
# probes; the write UoW re-checks these same facts (TOCTOU).
if not target_in_sibling_group(
bound_group_key=ctx.group_key,
target_group_key=ctx.target_group_key,
target_is_local=True,
target_is_server_sibling=False,
):
return self._not_in_group()
if ctx.target_app_id is not None and ctx.target_app_id != app_id:
return self._bound_elsewhere(target_rom_id)
block = await self._save_stranding_block(ctx, allow_stranded)
if block is not None:
return block
return await self._switch_local(app_id, target_rom_id, ctx.group_key)
return await self._switch_to_local_target(app_id, target_rom_id, allow_stranded, ctx)

# Target not persisted locally — the save-stranding gate on the bound
# version applies first (allow_stranded skips it), then fetch the detail,
Expand All @@ -612,7 +602,10 @@ async def switch_version(self, app_id: int, target_rom_id: int, allow_stranded:
return block
try:
target_dict = await self._loop.run_in_executor(None, self._romm_api.get_rom, target_rom_id)
except Exception as e: # classified: a 404 on the target is not an offline server
except RommNotFoundError:
self._logger.warning(f"Version switch: target rom {target_rom_id} is gone from the server")
return self._version_vanished()
except Exception as e: # mandatory fetch: preserve every non-404 classified failure
self._logger.warning(f"Version switch: target fetch failed for rom {target_rom_id}: {e}")
reason, message = classify_error(e)
return {
Expand Down Expand Up @@ -640,6 +633,46 @@ async def switch_version(self, app_id: int, target_rom_id: int, allow_stranded:
None, self._persist_and_bind, target_dict, app_id, ctx.platform_slug, ctx.group_key
)

async def _switch_to_local_target(
self, app_id: int, target_rom_id: int, allow_stranded: bool, ctx: _SwitchContext
) -> dict[str, Any]:
"""Validate a local target, guard its liveness, then enter the binding write."""
# Fast reject invalid local state before drift/reachability probes; the
# write UoW re-checks these same membership/collision facts (TOCTOU).
if not target_in_sibling_group(
bound_group_key=ctx.group_key,
target_group_key=ctx.target_group_key,
target_is_local=True,
target_is_server_sibling=False,
):
return self._not_in_group()
if ctx.target_app_id is not None and ctx.target_app_id != app_id:
return self._bound_elsewhere(target_rom_id)
block = await self._save_stranding_block(ctx, allow_stranded)
if block is not None:
return block
target_vanished = await self._loop.run_in_executor(None, self._probe_switch_target_vanished, target_rom_id)
if target_vanished:
return self._version_vanished()
return await self._switch_local(app_id, target_rom_id, ctx.group_key)

def _probe_switch_target_vanished(self, rom_id: int) -> bool:
"""Probe one local switch target once; only a typed 404 refuses it."""
try:
response: Any = self._romm_api.get_rom_once(rom_id)
except RommNotFoundError:
return True
except Exception as e:
self._logger.warning(f"Version switch: target liveness probe failed open for rom {rom_id}: {e}")
return False
payload_id = response.get("id") if isinstance(response, dict) else None
if type(payload_id) is not int or payload_id != rom_id:
self._logger.warning(
f"Version switch: target liveness probe returned an untrustworthy payload for rom {rom_id}; "
"failing open"
)
return False

async def _save_stranding_block(self, ctx: _SwitchContext, allow_stranded: bool) -> dict[str, Any] | None:
"""Soft-block the switch when the bound version has un-uploaded save drift.

Expand Down Expand Up @@ -874,3 +907,11 @@ def _bound_elsewhere(target_rom_id: int) -> dict[str, Any]:
"reason": "bound_elsewhere",
"message": f"Version {target_rom_id} is already used by another shortcut.",
}

@staticmethod
def _version_vanished() -> dict[str, Any]:
return {
"success": False,
"reason": "version_vanished",
"message": "This version is no longer available on RomM.",
}
9 changes: 8 additions & 1 deletion src/api/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,14 +505,21 @@ export interface SwitchVersionUnsyncedSaves {
* `bound_elsewhere` (a grandfathered duplicate),
* `invalid_target` (a server-only target the aggregate rejects),
* `download_in_progress` (a group download is running — cancel it first), or
* `version_vanished` (RomM definitively no longer has the target), or
* `server_unreachable`. All surface via the picker's toast fallback
* (`result.message`). The literal reason union excludes `unsynced_saves` so the
* soft-block variant narrows cleanly.
*/
export interface SwitchVersionFailure {
success: false;
reason:
"not_found" | "not_in_group" | "bound_elsewhere" | "invalid_target" | "download_in_progress" | "server_unreachable";
| "not_found"
| "not_in_group"
| "bound_elsewhere"
| "invalid_target"
| "download_in_progress"
| "version_vanished"
| "server_unreachable";
message: string;
}

Expand Down
Loading
Loading