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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ jobs:
- name: Check failure-shape dialect gate
run: python scripts/check_failure_shape.py --check

- name: Check 404-vs-unreachable classification gate
run: python scripts/check_404_not_unreachable.py --check

- name: Check no bare ignores
run: bash scripts/check_no_bare_ignores.sh

Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ Format: **invariant** — tier — enforced by.

- **Callable failures use `{success, reason, message}` (never `error` / `error_code`)** — check —
`scripts/check_failure_shape.py --check`
- **A definitive 404 is `not_found`, never `server_unreachable` — a catch-all `except Exception` in `services/` may not
bind a verdict key (`reason` / `status` / `recommended_action`) to a hardcoded `SERVER_UNREACHABLE`; route the
exception through `classify_error`, or peel the 404 off with a sibling `except RommNotFoundError` where the verdict is
a partial-success flag** — check — `scripts/check_404_not_unreachable.py --check`
- **Frontend↔backend callable parity (names + arity)** — check — `scripts/check_callable_manifest.py`
- **Every backend `emit` event name has a frontend listener, and vice versa** — check — `scripts/check_event_parity.py`
- **`settings.json` is written only by its owner (`adapters/persistence.py`)** — check —
Expand Down
12 changes: 8 additions & 4 deletions docs/user-guide/save-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,14 @@ If the RomM server is unreachable when a sync is attempted:
"Server offline" is reported **only** for a genuine reachability failure (the server can't be reached or times out). A
sync that fails for another reason — for example an expired or revoked login token, or an SSL certificate problem —
shows that specific reason instead (such as "Authentication failed — check your username and password"), so a working
server is never mislabelled as offline. This applies to both surfaces: the warning shown before launch and the "after
exit" toast both name the actual cause rather than a generic "failed to sync" message. When more than one save file
fails in the same sync, the toast shows the first file's reason followed by a "(+N more)" count so the message stays
short. If you see an authentication message, re-enter your server URL and sign in again in the plugin settings.
server is never mislabelled as offline. That includes the case where RomM answers that it no longer _has_ something the
plugin asked about: if the game was deleted and re-added on the server (which gives it a new id), or the RomM database
was wiped and this device's registration went with it, the server is answering perfectly well. You'll see a message
naming what's missing rather than "RomM is offline", and the offline badge stays clear. This applies to both surfaces:
the warning shown before launch and the "after exit" toast both name the actual cause rather than a generic "failed to
sync" message. When more than one save file fails in the same sync, the toast shows the first file's reason followed by
a "(+N more)" count so the message stays short. If you see an authentication message, re-enter your server URL and sign
in again in the plugin settings.

After a failed sync the game-detail save panel reflects the honest state right away: a file whose upload failed shows a
yellow **Local changes** badge (not a green "synced"), and its "Last synced" line keeps the time of the last
Expand Down
13 changes: 13 additions & 0 deletions docs/user-guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ directions. If RomM goes away, the **RomM offline** badge appears on its own wit
reachable again the badge clears, Download and slot switching re-enable, and the saves list, achievements, and setup
screen reload themselves on the spot — no need to leave and re-open the page.

Only a genuine connection failure marks the plugin offline. If RomM replies that it no longer has the thing being asked
about — most often because the game was deleted and re-added on the server, which gives it a brand-new id, or because
the RomM database was reset and this device's registration disappeared with it — that is the server _answering_, so the
**RomM offline** badge stays clear and the surface tells you what's missing instead. If you see such a message while the
badge is clear, the fix is on the RomM side (re-sync the library, or re-check the game), not with your network.

The first-time save-slot setup screen is the clearest example. If RomM can't find the save data that setup needs, it
pauses with "RomM couldn't find the save data for this setup" rather than the "server is not reachable" message, and the
offline badge stays clear. Setup deliberately stops there instead of picking a slot for you: the plugin has no
trustworthy view of what's on the server, and choosing a slot on that basis could overwrite real saves on the first
sync. Re-check the game in RomM (or, after a server database reset, reconnect in the plugin's Connection settings so
this device is registered again), then tap **Retry**.

### Save file not found

**Symptom**: The game detail page shows save status but no save file is being synced.
Expand Down
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ run = [
"python scripts/check_aggregate_field_assignment.py",
"python scripts/check_uow_seam_nesting.py",
"python scripts/check_failure_shape.py --check",
"python scripts/check_404_not_unreachable.py --check",
"bash scripts/check_no_bare_ignores.sh",
"python scripts/check_event_parity.py",
"python scripts/check_settings_owner.py",
Expand Down
13 changes: 11 additions & 2 deletions py_modules/lib/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""RomM API error types for structured error handling."""

import socket
from typing import Any

from lib.list_result import ErrorCode
Expand Down Expand Up @@ -170,8 +171,8 @@ def classify_error(exc):

``reason`` is a canonical :class:`lib.list_result.ErrorCode` slug
(returned as its string value). Several exception types fold onto one
slug \u2014 connection/timeout/SSL/5xx/generic-API all map to
``server_unreachable``; 401 and 403 both map to ``auth_failed`` \u2014 but
slug \u2014 connection/timeout/SSL/5xx/generic-API plus raw socket errors
all map to ``server_unreachable``; 401/403 map to ``auth_failed`` \u2014 but
each branch keeps a distinct human ``message``. In particular the 403
branch stays distinguishable from the 401 branch: a Cloudflare
bot-fight 403 at the tunnel edge is not a wrong-credentials failure, so
Expand Down Expand Up @@ -212,6 +213,14 @@ def classify_error(exc):
)
if isinstance(exc, RommApiError):
return ErrorCode.SERVER_UNREACHABLE.value, str(exc)
if isinstance(exc, (ConnectionError, TimeoutError, socket.gaierror)):
# A socket-level failure that never passed through the adapter's
# ``translate_http_error`` (a caller reaching the network outside the
# RomM client). Same reachability verdict as ``RommConnectionError``.
# Deliberately NOT bare ``OSError``: that also covers local disk I/O,
# so a failed settings write would report the server as unreachable —
# the same class of lie as reporting a 404 that way.
return ErrorCode.SERVER_UNREACHABLE.value, f"Server unreachable — {exc}"
return ErrorCode.UNKNOWN.value, str(exc)


Expand Down
8 changes: 5 additions & 3 deletions py_modules/services/achievements.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from typing import TYPE_CHECKING, Any

from domain.achievements import extract_achievements_from_rom, extract_game_progress
from lib.list_result import ErrorCode
from lib.errors import classify_error

if TYPE_CHECKING:
import asyncio
Expand Down Expand Up @@ -159,9 +159,10 @@ async def get_achievements(self, rom_id):
"total": len(stale["achievements"]),
"stale": True,
}
reason, _message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE.value,
"reason": reason,
"message": str(e),
"achievements": [],
"total": 0,
Expand Down Expand Up @@ -219,9 +220,10 @@ async def get_achievement_progress(self, rom_id):
stale_progress = self._achievements_cache.get(rom_id_str, {}).get("user_progress")
if stale_progress:
return {**self._progress_data_response(stale_progress), "stale": True}
reason, _message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE.value,
"reason": reason,
"message": str(e),
"earned": 0,
"total": 0,
Expand Down
8 changes: 6 additions & 2 deletions py_modules/services/artwork.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
)
from domain.cover_refresh import cover_ts_only_change, scan_cover_refresh_candidates
from domain.sync_stage import SyncStage
from lib.errors import RommNotFoundError
from lib.errors import RommNotFoundError, classify_error
from lib.list_result import ErrorCode


Expand Down Expand Up @@ -823,9 +823,13 @@ async def refresh_cover(self, rom_id: int) -> dict[str, Any]:
rom = await self._loop.run_in_executor(None, self._romm_api.get_rom, rom_id)
except Exception as e:
self._logger.warning(f"refresh_cover: failed to fetch rom {rom_id}: {e}")
reason, _message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE.value,
"reason": reason,
# Already true for both branches (the ROM is gone / the server
# is down), and more specific than the classifier's generic
# string — only the routing slug was ever wrong here.
"message": "Could not fetch ROM from server",
}
if not rom:
Expand Down
18 changes: 15 additions & 3 deletions py_modules/services/saves/copies.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from domain.save_layout import SAVE_SYNC_CONTENT_DIR_REASON
from domain.save_slot import save_in_slot
from domain.save_status import compute_multi_file_slot
from lib.errors import RommConflictError
from lib.errors import RommConflictError, RommNotFoundError
from services.saves._helpers import local_save_target
from services.saves._settings import resolve_default_slot

Expand Down Expand Up @@ -256,7 +256,10 @@ async def copy_save_to_slot(self, rom_id: int, save_id: int, target_slot: str) -
unconditionally, to protect its dirty local before the copy overwrites
it — ``conflict_blocked`` / ``preflight_failed`` on a bad pre-flight.
7. ``list_saves`` with no slot filter (the source may be in any slot);
failure is ``server_unreachable``.
a transport failure is ``server_unreachable``, a definitive 404 (the
server answered that it has no such ROM or device id) is ``not_found``
— distinct so the copy surface never reports an answering server
offline (#1570).
8. The copy proper runs in :meth:`_copy_save_to_slot_io`.

A dedup pre-check between the ``list_saves`` and the copy short-circuits
Expand All @@ -267,7 +270,7 @@ async def copy_save_to_slot(self, rom_id: int, save_id: int, target_slot: str) -
Returns a discriminated-status dict (mirrors ``RollbackStatus``):
``ok | already_present{existing_id} | not_configured | invalid_slot_name |
rom_not_installed | version_deleted | unsupported{reason?} |
server_unreachable{message} | conflict_blocked{conflicts} |
server_unreachable{message} | not_found{message} | conflict_blocked{conflicts} |
preflight_failed{errors} | target_slot_busy{message} | copy_failed{message}``.
"""
rom_id = int(rom_id)
Expand Down Expand Up @@ -332,6 +335,15 @@ async def copy_save_to_slot(self, rom_id: int, save_id: int, target_slot: str) -
None,
lambda: self._retry.with_retry(lambda: self._romm_api.list_saves(rom_id, device_id=device_id)),
)
except RommNotFoundError as e:
# The server ANSWERED: it has no such ROM (or no such device id).
# ``list_saves`` is rom- AND device-scoped, so this is the #1560
# family. Not a connectivity verdict — the copy surface must not
# report the server offline (#1570).
self._log_debug(f"copy_save_to_slot: server has no such entity: {e}")
# Persist whatever the pre-flight mutated before bailing.
await self._loop.run_in_executor(None, self._write_save_state, rom_id, save_state)
return {"status": "not_found", "message": str(e)}
except Exception as e:
self._log_debug(f"copy_save_to_slot: failed to list saves: {e}")
# Persist whatever the pre-flight mutated before bailing.
Expand Down
10 changes: 6 additions & 4 deletions py_modules/services/saves/slots/deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from typing import TYPE_CHECKING, Any

from domain.save_slot import save_in_slot, slot_query_param
from lib.list_result import ErrorCode
from lib.errors import classify_error
from services.saves._settings import save_sync_enabled

if TYPE_CHECKING:
Expand Down Expand Up @@ -128,10 +128,11 @@ async def get_slot_delete_info(self, rom_id: int, slot: str) -> dict[str, Any]:
self._logger.warning(
f"get_slot_delete_info: failed to list saves for slot '{slot}': {e}",
)
reason, message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE.value,
"message": "Cannot inspect slot — server unreachable",
"reason": reason,
"message": f"Cannot inspect slot — {message}",
}

# Local tracked files pointing to server saves in this slot
Expand Down Expand Up @@ -178,9 +179,10 @@ async def _delete_server_slot_saves(self, rom_id: int, slot: str) -> dict[str, A
return {"success": True, "count": len(save_ids), "ids": set(save_ids)}
except Exception as e:
self._logger.warning(f"delete_slot: server delete failed for slot '{slot}': {e}")
reason, _message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE.value,
"reason": reason,
"message": f"Failed to delete server saves: {e}",
}

Expand Down
10 changes: 7 additions & 3 deletions py_modules/services/saves/slots/listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from domain.rom_save_sync_state import RomSaveSyncState
from domain.save_slot import save_in_slot, slot_query_param
from lib.list_result import ErrorCode
from lib.errors import classify_error
from services.saves._messages import SAVE_SYNC_DISABLED
from services.saves._settings import resolve_default_slot, save_sync_enabled

Expand Down Expand Up @@ -98,9 +98,12 @@ async def get_save_slots(self, rom_id: int) -> dict[str, Any]:
)
except Exception as e:
self._log_debug(f"Failed to fetch save slots for rom {rom_id}: {e}")
reason, _message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE,
"reason": reason,
# The raw exception text is neutral and carries the concrete
# detail; only the routing slug was ever wrong here.
"message": str(e),
"slots": [],
"active_slot": active_slot,
Expand Down Expand Up @@ -212,9 +215,10 @@ async def get_slot_saves(self, rom_id: int, slot: str) -> dict[str, Any]:
]
return {"success": True, "slot": slot, "saves": saves}
except Exception as e:
reason, _message = classify_error(e)
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE,
"reason": reason,
"message": str(e),
"slot": slot,
"saves": [],
Expand Down
Loading
Loading