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
10 KiB
Review Step 2 — On-the-fly Decisions (IMPL_DECISIONS.md)
The implementor recorded six decisions made during implementation. Each is evaluated below.
1. Added just test recipe
Added a
just testrecipe so unit tests can be run through the repository's requiredjust ...command surface instead of invokingcargo testdirectly.
Verdict: good.
CLAUDE.md explicitly says "Never use normal cargo ... commands, use the just ... commands instead." A just test recipe was a missing primitive, and adding it (justfile:22-23: cargo test --workspace) closes that gap cleanly. The implementation is the smallest possible — a one-line wrapper that runs the workspace tests. Nothing to argue with.
Tiny nit, not blocking: it doesn't take any args. If unit tests grow, you'll want just test [args] so the user can target a single test. That's a future polish.
2. Kept game-unpack-finished event name for compatibility
Kept the existing frontend
game-unpack-finishedevent name for successful transactional installs. The peer now emits install lifecycle events, but the compatibility event still lets the UI reuse its existing "install complete" path.
Verdict: defensible, but worth a follow-up.
The plan said 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." Implementation matches this: PeerEvent::InstallGameFinished is bridged to game-unpack-finished in src-tauri/src/lib.rs:880-894, and InstallGameFailed is bridged to game-install-failed. So the contract is correct.
What's questionable is the name. There is no longer an "unpack" stage that's distinct from "install" — they're one transaction. A name like game-install-finished would be more honest. Keeping the old name makes the React side's setupUnpackListener (src/App.tsx:293-320) look stale: it's now an install-finished listener wearing an unpack-finished label.
The compatibility argument is thin because there is no external consumer of this event — it's an internal name shared between Rust and TS in this repo. The TS side could be renamed in the same change.
That said: this is a cosmetic decision and the implementor correctly noted it. Not a blocker. Plan said the event "fires on transactional install success" without prescribing a name. Recommend renaming in a follow-up, but the decision is acceptable as written.
3. Reused .lanspread/library_index.json for per-ID rescan
Implemented watcher rescans by reusing the existing
.lanspread/library_index.jsoncache and updating a single game entry in that index. This satisfies the per-ID optimized rescan requirement without adding a second cache format.
Verdict: good call.
The plan said:
Optimized rescan (single ID)
- Stat
version.ini(existence + mtime), checklocal/is-a-directory, stat any*.etifiles at root level.- Compare to cached fingerprint.
- Recursive walk only on fingerprint change.
It didn't prescribe a storage format — only behavior. Reusing the existing index file (local_games.rs:111-148) is the right call: one cache, one schema, one path to keep coherent. update_index_for_game (local_games.rs:394-449) stats the fingerprint, short-circuits on equality, and rebuilds only when the fingerprint differs. rescan_local_game (local_games.rs:568-589) is the single-ID entry point.
Two minor concerns:
save_library_index(local_games.rs:141-148) does not use the atomic temp+rename pattern. The plan reserves atomic writes for the intent log, so this is consistent — but the index file is at risk of corruption on a crash mid-write. The blast radius is small: corrupt index → rescan rebuilds from scratch on next load. So this is acceptable, just worth knowing.- The index file's
revisionis incremented inside bothscan_local_libraryandrescan_local_game. If both paths race (fallback scan running while a watcher-triggered rescan finishes), the in-memory views could diverge briefly. They're each protected by reading and writing the same path with no global lock. In practice the gate inlocal_monitor.rs:32-36prevents concurrent rescans for the same ID but not across IDs vs. fallback. Worth a thought; not a correctness bug because the next scan reconciles.
These caveats don't change the verdict.
4. Separate active_downloads cancellation-token map
Kept a separate
active_downloadscancellation-token map next to the singleactive_operationstable. The operation table is the authoritative state for gates; the token map is only cancellation plumbing for in-flight downloads.
Verdict: pragmatic.
The plan called for a single per-peer operation table. The implementation has it (active_operations) and additionally keeps active_downloads: HashMap<String, CancellationToken> for cancellation tokens (context.rs:38).
This is justifiable: CancellationToken doesn't fit naturally inside HashMap<String, OperationKind> because OperationKind is Copy. Storing the token alongside as a separate map keeps OperationKind simple and lets the gate-path (which only needs presence/kind) stay cheap. The download operation guard (OperationGuard::download, context.rs:142-153) clears both maps on drop.
Alternative: store OperationState { kind: OperationKind, cancel: Option<CancellationToken> }. Cleaner conceptually but invasive. The current shape is fine.
One thing to verify: the gate-path readers (local_download_available, watcher event handler, stream handlers) all check only active_operations. They don't need the token map. So the split doesn't leak. Good separation.
5. Downloaded-but-not-installed routes directly to InstallGame
Treated a downloaded-but-not-installed game as immediately installable from Tauri by sending
PeerCommand::InstallGamedirectly. A not-downloaded game still usesGetGame, and the peer auto-installs after the sentinel commit.
Verdict: matches the plan.
This decision is actually prescribed by PLAN.md — see the install_game(id) routing rules:
version.iniabsent → sendPeerCommand::GetGame { id }(fetch first; peer auto-firesInstallGameon download commit).version.inipresent,local/absent → sendPeerCommand::InstallGame { id }directly (no re-download).version.inipresent,local/present → no-op.
Looking at src-tauri/src/lib.rs:88-128, the implementation matches the plan exactly. So this isn't really a "decision" — it's the plan. The implementor may have flagged it because they wanted to be explicit that they didn't add an intermediate download step. Either way, no concern.
6. LocalOnly badge derived from installed && !downloaded
Derived the UI's
LocalOnlybadge frominstalled && !downloadedbecause the UI-facingGametype does not carry the protocol-levelAvailabilityenum.
Verdict: acceptable, but a smell worth noting.
The protocol-level Availability is computed in build_game_summary (local_games.rs:352-356), then thrown away when game_from_summary (local_games.rs:370-387) converts to Game. The TS side has no availability field, so the badge is recomputed from installed && !downloaded in src/App.tsx:742-744.
This works because:
Ready⇔downloaded == trueLocalOnly⇔installed && !downloaded(because by construction we only emitLocalOnlywhen not downloaded)- The third value
Availability::Downloadingis never emitted bybuild_game_summary.
So the predicate installed && !downloaded covers every case build_game_summary will produce. It's correct today.
The smell: if Availability::Downloading ever starts being emitted (or if a future state is added — e.g., Quarantined), the UI would silently miss it because the TS side reverse-engineers availability from two booleans. The single source of truth should flow through. A small, future-proofing follow-up is to add availability: string to the TS Game interface and serialize the protocol value end-to-end.
Not a blocker for this change.
Other implementation decisions NOT recorded in IMPL_DECISIONS.md but observed
These were made silently. The implementor may want to add them to the doc.
a. partition_download_descriptions keeps version.ini in transfer_descs
Plan says "partition" into two lists; implementation keeps both in one list and uses an in-memory version_buffer to route chunks. The end behavior is correct; the naming is misleading. See REVIEW_STEP_1 §1.
b. recover_on_startup re-runs on every load_local_library
Re-runs on SetGameDir and after every install/uninstall. Plan describes it as a one-shot startup. Likely intentional ("recovery is also a safety net for SetGameDir"), but never written down. See REVIEW_STEP_1 §3.
c. PeerCommand::UpdateGame is dead
The variant exists but has no sender path. The auto-install path uses RequestedInstallOperation::Auto and the Tauri update_game command sends GetGame. Either remove the variant or wire update_game to send it.
d. Auto/Install collapse in run_install_operation
Auto and Install both pick Updating when local/ exists and Installing otherwise (handlers.rs:356-363). That means an explicit PeerCommand::InstallGame against an already-installed game becomes an update. The user-facing semantics are surprising (calling "Install" produces an update) but the install_game Tauri command shields the UI from this by checking local/ first. Worth recording.
Summary
The six recorded decisions are all defensible. Two have follow-up smells worth fixing later (the game-unpack-finished legacy name and the UI's reverse-engineered LocalOnly predicate). The other four are either pragmatic minor refinements (just test, active_downloads map split) or directly implement the plan (InstallGame routing).
There are at least four additional decisions the implementor made but did not record. Recommend adding them to IMPL_DECISIONS.md so the rationale is preserved.