fix(peer): refresh settled install state after operations

The follow-up review found a few stale lifecycle edges around local game
transactions. Recovery could sweep active roots, post-operation refreshes
still re-ran full startup recovery, and the UI kept inferring local-only state
from downloaded and installed flags instead of the backend availability.

This updates the peer lifecycle so startup recovery skips active operations,
install/update/uninstall refresh only the affected game after the operation
guard is dropped, and path-changing game-directory updates are rejected while
operations are active. It also removes the dead UpdateGame command, drops the
unused manifest_hash write field while preserving old JSON reads, renames the
internal install-finished event, and carries availability through the DB,
peer summaries, Tauri refreshes, and the React model.

The included follow-up documents record the review source, implementation
decisions, and the remaining FOLLOW_UP_2.md work so later commits can stay
small instead of reopening the completed plan items.

Test Plan:
- git diff --check
- just fmt
- just clippy
- just test

Follow-up-Plan: FOLLOW_UP_PLAN.md
This commit is contained in:
2026-05-16 08:50:51 +02:00
parent fce34c7bd2
commit b5d20c1e72
22 changed files with 1389 additions and 131 deletions
+12 -13
View File
@@ -65,10 +65,10 @@ installed = local/ is a directory
The two predicates are independent.
- `installed && downloaded` → Ready, fully shareable.
- `installed && !downloaded` → installed locally; peer announces in deltas but `availability = LocalOnly` (refuses to serve archives).
- `!installed && downloaded` → ready to install; UI shows pending; `eti_version` is surfaced from `version.ini`.
- `!installed && !downloaded` → blank slate.
- `installed && downloaded` → Ready, fully shareable.
- `installed && !downloaded` → installed locally; peer announces in deltas but `availability = LocalOnly` (refuses to serve archives).
- `!installed && downloaded` → ready to install; UI shows pending; `eti_version` is surfaced from `version.ini`.
- `!installed && !downloaded` → blank slate.
`is_committed_install` (the predicate) requires `local/` to be a **directory**, not merely "a path that exists." This handles symlinks, leftover regular files named `local`, etc.
@@ -82,8 +82,7 @@ Per-game JSON at `<game_root>/.lanspread.json`:
"id": "...",
"recorded_at": <unix_ts>,
"state": "None" | "Installing" | "Updating" | "Uninstalling",
"eti_version": "20251028"?,
"manifest_hash": <u64>?
"eti_version": "20251028"?
}
```
@@ -98,7 +97,7 @@ Per-game JSON at `<game_root>/.lanspread.json`:
### Intent transitions
- **Begin operation**: write intent with new state *before* any FS mutation.
- **Begin operation**: write intent with new state _before_ any FS mutation.
- **Commit operation**: write intent with `None` immediately after the commit rename, before emitting events or starting cleanup.
- **Cleanup** (e.g., deleting `.local.backup` after a successful update): best-effort, after intent flush and event emit. Failure logs but doesn't fail the operation.
@@ -173,7 +172,7 @@ A failed download is destructive to the previous downloaded state but leaves `lo
1. Write intent `Installing`.
2. Create `.local.installing/`, drop `.lanspread_owned` marker.
3. Extract archives into `.local.installing/`. Non-zero unrar exit ⇒ error ⇒ rollback.
4. `rename(.local.installing, local)`. ← commit point.
4. `rename(.local.installing, local)`. ← commit point.
5. Write intent `None`.
6. Emit success event; trigger rescan.
@@ -185,7 +184,7 @@ A failed download is destructive to the previous downloaded state but leaves `lo
2. `rename(local, .local.backup)`; drop `.lanspread_owned` marker inside.
3. Create `.local.installing/`, drop marker.
4. Extract new archive into `.local.installing/`. Non-zero unrar exit ⇒ error ⇒ rollback.
5. `rename(.local.installing, local)`. ← commit point.
5. `rename(.local.installing, local)`. ← commit point.
6. Write intent `None`.
7. Emit success event; trigger rescan.
8. Best-effort `rm -rf .local.backup`; failure logged, not fatal.
@@ -253,7 +252,7 @@ Catalog enforcement points inside the peer:
- `scan_local_library` and the optimized rescan: a game root whose ID is not in the catalog is skipped — no `GameSummary` emitted, no entry in the library index, no contribution to `library_rev`. The intent log and recovery still operate on it (so we don't corrupt on-disk state for an unknown ID), but it is invisible to peers and the UI.
- `local_download_available` and the file-stream handlers (see "Operation table" below): refuse all serve requests for non-catalog IDs.
- `InstallGame` / `UpdateGame` / `UninstallGame` commands for non-catalog IDs are rejected with an error; the peer logs and emits no events.
- `InstallGame` / `UninstallGame` commands for non-catalog IDs are rejected with an error; the peer logs and emits no events.
This is also why the watcher reacting to a new game root cannot announce it without consulting the catalog — events for non-catalog IDs flow through gating but never become deltas.
@@ -269,7 +268,7 @@ This is also why the watcher reacting to a new game root cannot announce it with
- Sweep `.version.ini.discarded`.
- Remove `Downloading` from the operation table.
- `download_chunk` for version.ini paths: writes to memory rather than disk.
- New `PeerCommand` variant: `InstallGame { id }` — runs the install transaction over an already-downloaded game (auto-fired by the peer when a download commits; can also be fired by Tauri for explicit re-install). Plus `UpdateGame { id }` and `UninstallGame { id }`.
- New `PeerCommand` variant: `InstallGame { id }` — runs the install transaction over an already-downloaded game (auto-fired by the peer when a download commits; can also be fired by Tauri for explicit re-install). Plus `UninstallGame { id }`.
### Operation table
@@ -369,8 +368,8 @@ Non-fatal. Log a warning; the fallback scan is sufficient on its own.
- `game-install-failed` — extraction or commit-rename failed, rollback complete.
- `game-uninstall-begin`, `game-uninstall-finished`, `game-uninstall-failed`.
- Existing `game-download-finished` fires when the `version.ini` rename commits (which happens inside `download_game_files`, before the auto-fired `InstallGame`).
- Existing `game-unpack-finished` fires only on true transactional install success (peer-driven). It does NOT fire on extract failure or commit-rename failure — those route to `game-install-failed`.
- **Uninstall affordance**: secondary action (icon button or row-context menu), *not* a mode of the main button. Main button stays Install / Open.
- `game-install-finished` fires only on true transactional install success (peer-driven). It does NOT fire on extract failure or commit-rename failure — those route to `game-install-failed`.
- **Uninstall affordance**: secondary action (icon button or row-context menu), _not_ a mode of the main button. Main button stays Install / Open.
- Surface `eti_version` for games that are downloaded but not installed.
- Render the `LocalOnly` availability badge for installed-but-not-downloaded games: the user can play them locally but the row should communicate that this peer can't serve archives.