From fce34c7bd2d4490e062f9284c26c16d53aa6ba8a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 15 May 2026 18:21:09 +0200 Subject: [PATCH] docs(peer): document transactional install model Update the peer README and architecture notes to match the landed runtime: version.ini is the download sentinel, local/ is the install predicate, install state is recovered through .lanspread.json intents, and watcher rescans are operation-gated rather than time-debounced. Add IMPL_DECISIONS.md with the implementation-time choices that were not already prescribed by PLAN.md, including the just test recipe, the UI event compatibility bridge, reuse of the existing library index for per-ID rescans, and the split between active operation state and download cancellation tokens. Test Plan: - just fmt - just clippy - just test - just build Refs: PLAN.md --- IMPL_DECISIONS.md | 20 ++++++++ crates/lanspread-peer/ARCHITECTURE.md | 50 ++++++++++++++++--- crates/lanspread-peer/README.md | 72 ++++++++++++++++----------- 3 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 IMPL_DECISIONS.md diff --git a/IMPL_DECISIONS.md b/IMPL_DECISIONS.md new file mode 100644 index 0000000..8696cce --- /dev/null +++ b/IMPL_DECISIONS.md @@ -0,0 +1,20 @@ +# Implementation Decisions + +- Added a `just test` recipe so unit tests can be run through the repository's + required `just ...` command surface instead of invoking `cargo test` + directly. +- Kept the existing frontend `game-unpack-finished` event 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. +- Implemented watcher rescans by reusing the existing `.lanspread/library_index.json` + cache and updating a single game entry in that index. This satisfies the + per-ID optimized rescan requirement without adding a second cache format. +- Kept a separate `active_downloads` cancellation-token map next to the single + `active_operations` table. The operation table is the authoritative state for + gates; the token map is only cancellation plumbing for in-flight downloads. +- Treated a downloaded-but-not-installed game as immediately installable from + Tauri by sending `PeerCommand::InstallGame` directly. A not-downloaded game + still uses `GetGame`, and the peer auto-installs after the sentinel commit. +- Derived the UI's `LocalOnly` badge from `installed && !downloaded` because the + UI-facing `Game` type does not carry the protocol-level `Availability` enum. diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index dca0cb5..cda26cb 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -69,7 +69,10 @@ When a peer is discovered: ## When peers broadcast their game list - Only on changes, not on a timer. -- Use a short debounce (1-2 seconds) to coalesce bursts of filesystem events. +- Filesystem events are gated per game ID instead of time-debounced: + - an active operation lock drops events for that game; + - a rescan already running for the ID sets a rescan-pending flag; + - the running rescan loops once more when that flag was set. - Send `LibraryDelta` to known peers; send `LibrarySummary` on new connections. ## Local game scanning: fast and low cost @@ -78,21 +81,50 @@ When a peer is discovered: 1. Maintain a persistent on-disk index (per game): - `manifest_hash`, total size, file list (optional), and a fingerprint - (version.ini mtime, .eti mtime/size, local install dir presence). + (root-level `version.ini` mtime, root-level `.eti` mtime/size, and + `local/` directory presence). 2. Use filesystem watchers to update only changed games. -3. Keep a fallback periodic scan with a long interval (minutes) to recover from - missed events. +3. Keep a 300-second fallback scan to recover from missed events. ### Fast-path scanning - On startup, list only top-level game directories. - For each game, read a cheap fingerprint: - - `.eti` size + mtime - - `version.ini` mtime (if installed) - - presence of `local/` content + - root-level `.eti` file names, sizes, and mtimes + - root-level `version.ini` mtime + - presence of `local/` as a directory - If fingerprint unchanged, reuse cached size and manifest hash. - Only run a recursive scan for new or changed games. +## Local State and Recovery + +Downloaded and installed are independent predicates: + +- `downloaded` is true only when `/version.ini` exists as a regular + file. The sentinel is written last through `.version.ini.tmp` and atomic + rename. An interrupted replacement leaves no restored old sentinel because + archive bytes may already have changed. +- `installed` is true when `/local/` is a directory. The contents of + `local/` are user-owned and are skipped by manifests, fingerprints, and file + serving. + +Reserved per-game paths: + +- `.version.ini.tmp` and `.version.ini.discarded` are download transaction + scratch files and are swept during startup recovery. +- `.local.installing/` is extraction staging. +- `.local.backup/` holds the previous install while an update or uninstall is in + flight. +- `.lanspread.json` is the atomic per-game intent log. +- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership + when the current intent is `None`. + +Recovery reads `.lanspread.json` and combines the recorded intent with the +observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent +states `Installing`, `Updating`, and `Uninstalling` prove ownership of the +corresponding reserved directories even if the marker was not flushed before a +crash. With intent `None`, markerless `.local.*` directories are left untouched. + ### Result Most scans become O(number of game dirs), with full recursion only when needed. @@ -102,6 +134,10 @@ Most scans become O(number of game dirs), with full recursion only when needed. - Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated calls can be skipped when unchanged. - Downloads remain chunked QUIC streams with the existing integrity checks. +- A game is transferable only when its ID is in the catalog, no operation is + active for that ID, and the root-level `version.ini` sentinel exists. +- `local/` paths are never served, even if a stale or malicious manifest request + asks for them. ## Fault tolerance rules diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index 0bd2188..756ad0d 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -7,16 +7,17 @@ It is designed to run headless – other crates (most notably ## Runtime Overview -- `start_peer(game_dir, tx_events, peer_game_db)` boots the asynchronous runtime in the - background and returns an `UnboundedSender` that the caller uses - for control. The initial game directory is installed directly into the peer - context, the local library scan is attempted before discovery starts, and the - provided `PeerGameDB` remains shared so the UI layer can observe live peer - metadata. +- `start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)` boots the + asynchronous runtime in the background and returns a `PeerRuntimeHandle` whose + sender controls the peer. The injected `Unpacker` keeps archive extraction out + of the peer crate's platform layer, and the catalog set gates which local game + roots are announced or served. - `PeerCommand` represents the small control surface exposed to the UI layer: - `ListGames`, `GetGame`, `DownloadGameFiles`, and `SetGameDir`. + `ListGames`, `GetGame`, `DownloadGameFiles`, `InstallGame`, `UpdateGame`, + `UninstallGame`, and `SetGameDir`. - `PeerEvent` enumerates everything the peer runtime reports back to the UI: - library snapshots, download lifecycle updates, and peer membership changes. + library snapshots, download/install/uninstall lifecycle updates, runtime + failures, and peer membership changes. - `PeerGameDB` collects remote peer metadata. It aggregates discovered peers’ `Game` definitions, tracks the latest ETI version per title, and keeps the last seen list of `GameFileDescription` entries for each peer. @@ -34,12 +35,13 @@ lifetime of the process: remains responsive. 3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping requests to keep peer liveness up to date and prunes stale entries from `PeerGameDB`. -4. **Local game monitor** (`run_local_game_monitor`) – periodically rescans the - configured game directory and announces local library deltas to known peers. +4. **Local game monitor** (`run_local_game_monitor`) – watches the configured + game directory and each game root non-recursively, gates per-ID rescans while + operations are active, and runs a 300-second fallback scan for missed events. `scan_local_library` maintains a lightweight on-disk index and produces both a -`GameDB` and protocol summaries. The resulting database is used to respond to -incoming metadata requests (`Request::ListGames` / `Request::GetGame`). +`GameDB` and protocol summaries. A game is downloaded only when its root-level +`version.ini` sentinel exists; `local/` being a directory is the install signal. ## Networking and File Transfer @@ -60,19 +62,32 @@ When the UI asks to download a game: manifests are merged inside `PeerGameDB`. 2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file list back with `PeerCommand::DownloadGameFiles`. -3. `download_game_files` prepares the filesystem (creating directories and - pre-sizing files where possible), emits `PeerEvent::DownloadGameFilesBegin`, - and builds a per-peer plan (`build_peer_plans`) that round-robins file chunks - across the available peers that advertise the latest version. +3. `download_game_files` starts a version-sentinel transaction, parks any old + `version.ini` as `.version.ini.discarded`, prepares non-sentinel files, emits + `PeerEvent::DownloadGameFilesBegin`, and builds a per-peer plan + (`build_peer_plans`) that round-robins file chunks across the available peers + that advertise the latest version. 4. Each plan is executed in its own task (`download_from_peer`). Chunk requests use per-chunk QUIC streams and write into pre-created files. The chunk writer keeps existing data intact and only truncates when we intentionally fall back to a full file transfer, which prevents corruption when multiple peers fill different regions of the same file. -5. Failures are accumulated and retried (up to `MAX_RETRY_COUNT`) via - `retry_failed_chunks`. If everything succeeds, - `PeerEvent::DownloadGameFilesFinished` is emitted; otherwise the UI receives - `PeerEvent::DownloadGameFilesFailed`. +5. `version.ini` chunks are buffered in memory and committed last via + `.version.ini.tmp` followed by an atomic rename. Failures are accumulated and + retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed or + cancelled downloads sweep `.version.ini.tmp` and `.version.ini.discarded` + without restoring the previous sentinel. +6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` + is emitted and the peer auto-runs the install transaction. + +### Install Transactions + +Install, update, uninstall, and startup recovery live under `src/install/`. +Each game root has an atomic `.lanspread.json` intent log for install-side +operations and uses Lanspread-owned `.local.installing/` and `.local.backup/` +directories marked by `.lanspread_owned`. Startup recovery combines the recorded +intent with the observed filesystem state and only deletes reserved directories +when intent or marker ownership proves they belong to Lanspread. ## Integration with `lanspread-tauri-deno-ts` @@ -80,7 +95,8 @@ The Tauri application embeds this crate in `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`: - `LanSpreadState` holds onto the peer control channel, the latest aggregated - `GameDB`, per-game download state, and the user-selected game directory. + `GameDB`, per-game operation state, the catalog set, and the user-selected + game directory. - The Tauri commands (`request_games`, `install_game`, `update_game`, and `update_game_directory`) translate UI actions into `PeerCommand`s. In particular, `update_game_directory` validates the filesystem path before @@ -89,11 +105,9 @@ The Tauri application embeds this crate in database. - A background task consumes `PeerEvent`s and fans them out to the front-end via Tauri publish/subscribe events (`games-list-updated`, `game-download-*`, - `peer-*`). Successful downloads trigger an `unrar` sidecar to unpack ETI - archives and clean up the temporary backup folders that are created when - updates begin. -- When downloads fail the Tauri layer restores the on-disk backup, keeping the - previous installation consistent even after partial transfers. + `game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only + provides the unrar sidecar through the injected `Unpacker`; rollback and + cleanup live in the peer transaction code. ## Security & Operational Notes @@ -102,9 +116,9 @@ The Tauri application embeds this crate in - Peer discovery is restricted to the local link via mDNS. - Long-running blocking mDNS calls are isolated on dedicated threads which keeps the async runtime responsive even when discovery takes a long time. -- File writes are chunk-safe: partial chunk downloads now open files without - truncating existing data, avoiding the corruption that occurred previously - when multiple peers collectively filled a file. +- File writes are chunk-safe: partial chunk downloads open files without + truncating existing data, and root-level `version.ini` is written only after + the rest of the download has succeeded. ## Known Limitations