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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user