Skip to content

Commit 3d56e72

Browse files
committed
fix(ui): finish the 404-vs-unreachable sweep across the saves surfaces
Review caught three consumers that still spoke of an unreachable server on an answered 404, and three smaller defects: - computeSyncSummary (the Saves-tab slot header, #1560's own surface) printed a literal "Server unreachable" off the bare server_query_failed flag. Gate it on server_query_reason: only server_unreachable says so, otherwise "Save status unavailable". - CustomPlayButton's resolve-conflict toast body was left ungated inside the branch whose store feed was already gated, so it asserted reachability on a 404. Gate the copy on the same slug. - VersionHistoryPanel's load and restore copy claimed the game's saves are gone. The 404 can be the device registration, not the ROM (the backend says so in its own comment), so it now says RomM couldn't find the save data — matching the wizard's wording. Each of these three strings now carries a test asserting it never claims reachability nor that the saves are absent; the two that had unpinned copy were how the drift got in. Also: the 404 gate's nested-try handling was the reverse of its docstring — a nested peel leaked into the outer handler's scan and would false-positive. Scope the dict walk to skip nested try statements (each is scanned as its own statement) and pin both directions with tests. Move a misplaced union comment onto the member it describes, and drop a dead duplicate mock setup.
1 parent 6518b81 commit 3d56e72

10 files changed

Lines changed: 140 additions & 34 deletions

scripts/check_404_not_unreachable.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@
6262
The AST heuristic is intentionally conservative, and a guardrail rather than a
6363
prover: it reads one ``try`` statement at a time and does not follow a verdict
6464
returned by a helper the handler calls, nor one assembled across statements
65-
(``resp = {...}; resp["reason"] = ...``). A ``try`` nested *inside* a catch-all
66-
handler contributes its own dict literals to that handler's scan, so a nested
67-
``except RommNotFoundError`` peel can clear the outer handler too.
65+
(``resp = {...}; resp["reason"] = ...``). A ``try`` nested *inside* a handler is
66+
scanned as its own statement, with its own sibling-peel check, and its dict
67+
literals are NOT attributed to the enclosing handler — otherwise a nested peel
68+
would read as an unpeeled verdict on the outer one and false-positive.
6869
"""
6970

7071
from __future__ import annotations
@@ -73,6 +74,10 @@
7374
import sys
7475
from dataclasses import dataclass
7576
from pathlib import Path
77+
from typing import TYPE_CHECKING
78+
79+
if TYPE_CHECKING:
80+
from collections.abc import Iterator
7681

7782
REPO_ROOT = Path(__file__).resolve().parent.parent
7883
SERVICES_DIR = REPO_ROOT / "py_modules" / "services"
@@ -154,14 +159,30 @@ def _hardcoded_verdict_spelling(value: ast.expr) -> str | None:
154159
return None
155160

156161

162+
def _walk_outside_nested_try(node: ast.AST) -> Iterator[ast.AST]:
163+
"""Yield *node*'s descendants, never descending into a nested ``ast.Try``.
164+
165+
The caller's top-level walk visits every ``ast.Try`` in the module, so a
166+
nested one is scanned on its own terms — with its own sibling-peel check.
167+
Attributing its dict literals to the enclosing handler as well would make a
168+
nested ``except RommNotFoundError`` peel look like an unpeeled verdict on
169+
the outer handler, which is a false positive.
170+
"""
171+
for child in ast.iter_child_nodes(node):
172+
if isinstance(child, ast.Try):
173+
continue
174+
yield child
175+
yield from _walk_outside_nested_try(child)
176+
177+
157178
def _unreachable_spelling(handler: ast.ExceptHandler) -> str | None:
158179
"""Return how *handler* hardcodes the unreachable verdict, or None.
159180
160181
Only a verdict *key* counts (see :data:`VERDICT_KEYS`) — mentioning the slug
161182
anywhere else in the handler (a log line, a comparison against a classified
162183
reason) is not a hardcoded verdict.
163184
"""
164-
for node in ast.walk(handler):
185+
for node in _walk_outside_nested_try(handler):
165186
if not isinstance(node, ast.Dict):
166187
continue
167188
for key, value in zip(node.keys, node.values, strict=True):

src/components/CustomPlayButton.test.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,7 +1245,9 @@ describe("CustomPlayButton — resolve conflict reads the known conflict (#1276)
12451245
});
12461246

12471247
it("server_query_failed keeps the button in conflict and toasts (does NOT silently proceed)", async () => {
1248-
vi.mocked(backend.getSaveStatus).mockResolvedValue(saveStatus({ server_query_failed: true, conflicts: [] }));
1248+
vi.mocked(backend.getSaveStatus).mockResolvedValue(
1249+
saveStatus({ server_query_failed: true, server_query_reason: "server_unreachable", conflicts: [] }),
1250+
);
12491251
const dataChanged = vi.fn();
12501252
globalThis.addEventListener("romm_data_changed", dataChanged);
12511253

@@ -1274,12 +1276,16 @@ describe("CustomPlayButton — resolve conflict reads the known conflict (#1276)
12741276
const utils = await renderInConflict();
12751277
await clickResolve(utils);
12761278

1277-
// Post-catch state: the global store actually flipped.
1279+
// Post-catch state: the global store actually flipped, and the copy still
1280+
// names the connection — that IS the cause on this branch.
12781281
expect(getRommConnectionState()).toBe("offline");
1282+
expect(vi.mocked(toaster.toast)).toHaveBeenCalledWith(
1283+
expect.objectContaining({ body: expect.stringContaining("Couldn't reach server") }),
1284+
);
12791285
await utils.findByText("Resolve Conflict");
12801286
});
12811287

1282-
it("server_query_failed with a not_found reason leaves the store ALONE", async () => {
1288+
it("server_query_failed with a not_found reason leaves the store ALONE and does not blame the connection", async () => {
12831289
setRommConnectionState("connected");
12841290
vi.mocked(backend.getSaveStatus).mockResolvedValue(
12851291
saveStatus({ server_query_failed: true, server_query_reason: "not_found", conflicts: [] }),
@@ -1289,9 +1295,15 @@ describe("CustomPlayButton — resolve conflict reads the known conflict (#1276)
12891295
await clickResolve(utils);
12901296

12911297
// The #1570 defect: a definitive 404 is the server ANSWERING, so feeding
1292-
// the global store off the bare flag blacked out the whole UI. The button
1293-
// must still refuse to claim the conflict was resolved.
1298+
// the global store off the bare flag blacked out the whole UI. The toast
1299+
// must not assert reachability either, nor claim the saves are gone — the
1300+
// 404 can be the device registration rather than the ROM.
12941301
expect(getRommConnectionState()).toBe("connected");
1302+
const toastCalls = vi.mocked(toaster.toast).mock.calls;
1303+
const body = toastCalls[toastCalls.length - 1]?.[0].body ?? "";
1304+
expect(body).not.toMatch(/couldn't reach|unreachable|not reachable|offline/i);
1305+
expect(body).not.toMatch(/no saves|has no save|without saves/i);
1306+
// Still refuses to claim the conflict was resolved.
12951307
expect(vi.mocked(handleConflicts)).not.toHaveBeenCalled();
12961308
await utils.findByText("Resolve Conflict");
12971309
});

src/components/CustomPlayButton.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -735,14 +735,20 @@ export const CustomPlayButton: FC<CustomPlayButtonProps> = ({ appId }) => { // N
735735
// believing the conflict was cleared. Surface it and stay in conflict,
736736
// exactly like the network-throw catch below (#1276).
737737
if (result.server_query_failed) {
738-
// Only an explicit unreachable verdict is a connectivity signal:
739-
// feeding the store off the bare flag blacked out the whole UI over a
740-
// ROM the server merely no longer has (#1570).
741-
if (result.server_query_reason === "server_unreachable") {
738+
// Only an explicit unreachable verdict is a connectivity signal — for
739+
// the store AND the copy. Off the bare flag both blamed the connection
740+
// for a ROM the server merely no longer has (#1570).
741+
const unreachable = result.server_query_reason === "server_unreachable";
742+
if (unreachable) {
742743
reportServerReachable(false);
743744
}
744745
detach(debugLog(`CustomPlayButton: resolve conflict — server query failed for rom ${romId}`));
745-
toaster.toast({ title: "RomM Sync", body: "Couldn't reach server to resolve conflict" });
746+
toaster.toast({
747+
title: "RomM Sync",
748+
body: unreachable
749+
? "Couldn't reach server to resolve conflict"
750+
: "RomM couldn't find this game's save data — conflict left unresolved",
751+
});
746752
setState("conflict");
747753
return;
748754
}

src/components/SlotSetupWizard.test.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,9 +1120,6 @@ describe("SlotSetupWizard", () => {
11201120
// #1560's path: the 404 means the server ANSWERED. Reporting offline
11211121
// here blacked out the whole UI while RomM was working fine (#1570).
11221122
setRommConnectionState("checking");
1123-
vi.mocked(backend.getSaveSetupInfo).mockResolvedValue(
1124-
makeSetupInfo({ recommended_action: "not_found", server_query_failed: true }),
1125-
);
11261123
const result = makeSetupInfo({ recommended_action: "not_found", server_query_failed: true });
11271124
vi.mocked(backend.getSaveSetupInfo).mockResolvedValue(result);
11281125
render(<SlotSetupWizard {...defaultProps()} />);

src/components/saves/VersionHistoryPanel.test.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,12 @@ describe("VersionHistoryPanel", () => {
155155
const { getByText, container } = render(<VersionHistoryPanel {...defaultProps()} />);
156156
fireEvent.click(getByText("Previous Versions"));
157157
await flushAsync();
158-
expect(container.textContent).toContain("RomM no longer has this game's saves.");
159-
// The server answered — blaming the connection is the #1570 defect.
160-
expect(container.textContent).not.toContain("Couldn't reach RomM");
158+
expect(container.textContent).toContain("RomM couldn't find this game's save data.");
159+
// The server answered, so the copy must not blame the connection — and it
160+
// must not claim the saves are gone either: the 404 can be the device
161+
// registration rather than the ROM (#1570).
162+
expect(container.textContent).not.toMatch(/couldn't reach|unreachable|not reachable|offline/i);
163+
expect(container.textContent).not.toMatch(/no saves|has no save|without saves/i);
161164
});
162165

163166
it("Retry button retriggers loadVersions", async () => {
@@ -390,9 +393,13 @@ describe("VersionHistoryPanel", () => {
390393
fireEvent.click(getByText("Restore"));
391394
await flushAsync();
392395
await flushAsync();
393-
expect(vi.mocked(toaster.toast)).toHaveBeenCalledWith(
394-
expect.objectContaining({ body: "RomM no longer has this game's saves — nothing was restored." }),
395-
);
396+
const toastCalls = vi.mocked(toaster.toast).mock.calls;
397+
const body = toastCalls[toastCalls.length - 1]?.[0].body ?? "";
398+
expect(body).toBe("RomM couldn't find this game's save data — nothing was restored.");
399+
// Same rule as the wizard copy: no reachability claim, no claim that the
400+
// game's saves are gone (the 404 can be the device id) — see #1570.
401+
expect(body).not.toMatch(/couldn't reach|unreachable|not reachable|offline/i);
402+
expect(body).not.toMatch(/no saves|has no save|without saves/i);
396403
expect(onRestored).not.toHaveBeenCalled();
397404
});
398405

src/components/saves/VersionHistoryPanel.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const VersionHistoryPanel: FC<VersionHistoryPanelProps> = ({
5454
// The server answered — blaming the connection here would be a lie (#1570).
5555
detach(debugLog(`VersionHistoryPanel: server has no such entry for ${filename}: ${result.message}`));
5656
setVersions(null);
57-
setLoadError("RomM no longer has this game's saves.");
57+
setLoadError("RomM couldn't find this game's save data.");
5858
} else {
5959
detach(debugLog(`VersionHistoryPanel: server unreachable for ${filename}: ${result.message}`));
6060
setVersions(null);
@@ -154,9 +154,8 @@ export const VersionHistoryPanel: FC<VersionHistoryPanelProps> = ({
154154
// instead of telling the user the version is gone.
155155
toaster.toast({ title: "RomM Sync", body: "Couldn't reach RomM. Check your connection and try again." });
156156
} else if (result.status === "not_found") {
157-
// Mirror of the branch above: RomM answered, so a retry cannot help
158-
// and the toast must not send the user to check their connection.
159-
toaster.toast({ title: "RomM Sync", body: "RomM no longer has this game's saves — nothing was restored." });
157+
// Mirror of the branch above: RomM answered, so a retry cannot help.
158+
toaster.toast({ title: "RomM Sync", body: "RomM couldn't find this game's save data — nothing was restored." });
160159
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive final branch of the 9-member RollbackStatus union; an explicit check (vs. plain `else`) keeps the per-status symmetry and leaves any future-added status unhandled instead of silently routing it to the "unsupported" toast
161160
} else if (result.status === "unsupported") {
162161
toaster.toast({ title: "RomM Sync", body: "Version history requires RomM 4.7+" });

src/components/saves/helpers.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,10 @@ describe("computeSyncSummary", () => {
291291
});
292292
});
293293

294-
it("short-circuits to 'Server unreachable' when server_query_failed is true", () => {
294+
it("short-circuits to 'Server unreachable' when the reason is server_unreachable", () => {
295295
const status = makeStatus({
296296
server_query_failed: true,
297+
server_query_reason: "server_unreachable",
297298
files: [
298299
{
299300
filename: "save.srm",
@@ -317,8 +318,31 @@ describe("computeSyncSummary", () => {
317318
});
318319
});
319320

321+
it("does NOT claim the server is unreachable on a definitive 404", () => {
322+
// The Saves tab is #1560's own surface: saying "Server unreachable" for a
323+
// ROM the server merely answered 404 for is the exact lie #1570 removes.
324+
// It still must not run the matrix against the empty server list.
325+
const status = makeStatus({
326+
server_query_failed: true,
327+
server_query_reason: "not_found",
328+
files: [],
329+
});
330+
const { syncSummaryText } = computeSyncSummary(true, status, []);
331+
expect(syncSummaryText).not.toMatch(/unreachable|not reachable|offline/i);
332+
expect(syncSummaryText).not.toMatch(/no saves|has no save|without saves/i);
333+
expect(syncSummaryText).toBe("Save status unavailable");
334+
});
335+
336+
it("does not claim unreachable when the query failed without a reason slug", () => {
337+
// Defensive: an older backend (or an unclassified failure) sends the flag
338+
// with no reason. Silence about the cause beats asserting the wrong one.
339+
const status = makeStatus({ server_query_failed: true, files: [] });
340+
const { syncSummaryText } = computeSyncSummary(true, status, []);
341+
expect(syncSummaryText).not.toMatch(/unreachable|not reachable|offline/i);
342+
});
343+
320344
it("server_query_failed wins over conflicts", () => {
321-
const status = makeStatus({ server_query_failed: true });
345+
const status = makeStatus({ server_query_failed: true, server_query_reason: "server_unreachable" });
322346
const conflicts: SyncConflict[] = [
323347
{
324348
type: "sync_conflict",

src/components/saves/helpers.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ export function statusLabel(status: string, lastSyncAt: string | null): { color:
123123
* Build the active slot's sync-summary header line.
124124
*
125125
* Returns the empty (null) state for inactive slots or missing status. When
126-
* the backend signals `server_query_failed`, short-circuits with a neutral
127-
* "Server unreachable" instead of running the matrix-derived classification
128-
* (which would otherwise read an empty server list as "ready to upload").
126+
* the backend signals `server_query_failed`, short-circuits instead of running
127+
* the matrix-derived classification (which would read an empty server list as
128+
* "ready to upload"). Only an explicit `server_query_reason` of
129+
* `server_unreachable` may say so in the text — on an answered 404 that would
130+
* be false (#1570).
129131
*
130132
* The summary is derived from the per-file matrix statuses so it can never
131133
* disagree with the per-file badges (`statusLabel`): any file pending upload
@@ -142,7 +144,9 @@ export function computeSyncSummary(
142144
if (!isActive || !saveStatus) return { syncSummaryText: null, syncSummaryColor: MUTED_COLOR };
143145

144146
if (saveStatus.server_query_failed) {
145-
return { syncSummaryText: "Server unreachable", syncSummaryColor: MUTED_COLOR };
147+
const text =
148+
saveStatus.server_query_reason === "server_unreachable" ? "Server unreachable" : "Save status unavailable";
149+
return { syncSummaryText: text, syncSummaryColor: MUTED_COLOR };
146150
}
147151

148152
const hasConflict = conflicts.length > 0;

src/types/saves.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,8 @@ export type RollbackStatus =
244244
export type ListFileVersionsResult =
245245
| { status: "ok"; versions: SaveVersionEntry[] }
246246
| { status: "multi_file_unsupported"; versions: SaveVersionEntry[] }
247-
// See RollbackStatus — the server answered, it just has no such entry.
248247
| { status: "server_unreachable"; message: string }
248+
// See RollbackStatus — the server answered, it just has no such entry.
249249
| { status: "not_found"; message: string };
250250

251251
/** Discriminated-status result of `copySaveToSlot` — copies one server save into

tests/scripts/test_check_404_not_unreachable.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,42 @@ def f():
248248
""")
249249
assert findings == []
250250

251+
def test_nested_try_peeling_the_404_does_not_flag_the_outer_handler(self):
252+
"""A nested ``try`` is scanned on its own terms, not the enclosing one.
253+
254+
Attributing the inner dict literals to the outer handler made a nested
255+
peel read as an unpeeled verdict — a false positive, which would
256+
wrongly block a future PR.
257+
"""
258+
findings = _scan("""
259+
def f():
260+
try:
261+
outer()
262+
except Exception:
263+
try:
264+
fetch()
265+
except RommNotFoundError:
266+
return {"recommended_action": "not_found", "server_query_failed": True}
267+
except Exception:
268+
return {"recommended_action": "server_unreachable", "server_query_failed": True}
269+
""")
270+
assert findings == []
271+
272+
def test_nested_try_does_not_mask_the_outer_handlers_own_verdict(self):
273+
"""The exclusion is scoped: a verdict in the OUTER body is still found."""
274+
findings = _scan("""
275+
def f():
276+
try:
277+
outer()
278+
except Exception:
279+
try:
280+
cleanup()
281+
except Exception:
282+
pass
283+
return {"success": False, "reason": ErrorCode.SERVER_UNREACHABLE.value, "message": "m"}
284+
""")
285+
assert len(findings) == 1
286+
251287
def test_handler_with_no_return_dict(self):
252288
findings = _scan("""
253289
def f():

0 commit comments

Comments
 (0)