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
This commit is contained in:
2026-05-15 18:21:09 +02:00
parent c5dfbf99a0
commit fce34c7bd2
3 changed files with 106 additions and 36 deletions
+20
View File
@@ -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.
+43 -7
View File
@@ -69,7 +69,10 @@ When a peer is discovered:
## When peers broadcast their game list ## When peers broadcast their game list
- Only on changes, not on a timer. - 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. - Send `LibraryDelta` to known peers; send `LibrarySummary` on new connections.
## Local game scanning: fast and low cost ## 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): 1. Maintain a persistent on-disk index (per game):
- `manifest_hash`, total size, file list (optional), and a fingerprint - `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. 2. Use filesystem watchers to update only changed games.
3. Keep a fallback periodic scan with a long interval (minutes) to recover from 3. Keep a 300-second fallback scan to recover from missed events.
missed events.
### Fast-path scanning ### Fast-path scanning
- On startup, list only top-level game directories. - On startup, list only top-level game directories.
- For each game, read a cheap fingerprint: - For each game, read a cheap fingerprint:
- `.eti` size + mtime - root-level `.eti` file names, sizes, and mtimes
- `version.ini` mtime (if installed) - root-level `version.ini` mtime
- presence of `local/` content - presence of `local/` as a directory
- If fingerprint unchanged, reuse cached size and manifest hash. - If fingerprint unchanged, reuse cached size and manifest hash.
- Only run a recursive scan for new or changed games. - 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 `<game_root>/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 `<game_root>/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 ### Result
Most scans become O(number of game dirs), with full recursion only when needed. 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 - Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated
calls can be skipped when unchanged. calls can be skipped when unchanged.
- Downloads remain chunked QUIC streams with the existing integrity checks. - 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 ## Fault tolerance rules
+43 -29
View File
@@ -7,16 +7,17 @@ It is designed to run headless other crates (most notably
## Runtime Overview ## Runtime Overview
- `start_peer(game_dir, tx_events, peer_game_db)` boots the asynchronous runtime in the - `start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)` boots the
background and returns an `UnboundedSender<PeerCommand>` that the caller uses asynchronous runtime in the background and returns a `PeerRuntimeHandle` whose
for control. The initial game directory is installed directly into the peer sender controls the peer. The injected `Unpacker` keeps archive extraction out
context, the local library scan is attempted before discovery starts, and the of the peer crate's platform layer, and the catalog set gates which local game
provided `PeerGameDB` remains shared so the UI layer can observe live peer roots are announced or served.
metadata.
- `PeerCommand` represents the small control surface exposed to the UI layer: - `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: - `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 - `PeerGameDB` collects remote peer metadata. It aggregates discovered peers
`Game` definitions, tracks the latest ETI version per title, and keeps the `Game` definitions, tracks the latest ETI version per title, and keeps the
last seen list of `GameFileDescription` entries for each peer. last seen list of `GameFileDescription` entries for each peer.
@@ -34,12 +35,13 @@ lifetime of the process:
remains responsive. remains responsive.
3. **Ping service** (`run_ping_service`) periodically issues QUIC ping requests 3. **Ping service** (`run_ping_service`) periodically issues QUIC ping requests
to keep peer liveness up to date and prunes stale entries from `PeerGameDB`. to keep peer liveness up to date and prunes stale entries from `PeerGameDB`.
4. **Local game monitor** (`run_local_game_monitor`) periodically rescans the 4. **Local game monitor** (`run_local_game_monitor`) watches the configured
configured game directory and announces local library deltas to known peers. 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 `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 `GameDB` and protocol summaries. A game is downloaded only when its root-level
incoming metadata requests (`Request::ListGames` / `Request::GetGame`). `version.ini` sentinel exists; `local/` being a directory is the install signal.
## Networking and File Transfer ## Networking and File Transfer
@@ -60,19 +62,32 @@ When the UI asks to download a game:
manifests are merged inside `PeerGameDB`. manifests are merged inside `PeerGameDB`.
2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file 2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file
list back with `PeerCommand::DownloadGameFiles`. list back with `PeerCommand::DownloadGameFiles`.
3. `download_game_files` prepares the filesystem (creating directories and 3. `download_game_files` starts a version-sentinel transaction, parks any old
pre-sizing files where possible), emits `PeerEvent::DownloadGameFilesBegin`, `version.ini` as `.version.ini.discarded`, prepares non-sentinel files, emits
and builds a per-peer plan (`build_peer_plans`) that round-robins file chunks `PeerEvent::DownloadGameFilesBegin`, and builds a per-peer plan
across the available peers that advertise the latest version. (`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 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 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 keeps existing data intact and only truncates when we intentionally fall back
to a full file transfer, which prevents corruption when multiple peers fill to a full file transfer, which prevents corruption when multiple peers fill
different regions of the same file. different regions of the same file.
5. Failures are accumulated and retried (up to `MAX_RETRY_COUNT`) via 5. `version.ini` chunks are buffered in memory and committed last via
`retry_failed_chunks`. If everything succeeds, `.version.ini.tmp` followed by an atomic rename. Failures are accumulated and
`PeerEvent::DownloadGameFilesFinished` is emitted; otherwise the UI receives retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed or
`PeerEvent::DownloadGameFilesFailed`. 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` ## 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`: `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`:
- `LanSpreadState` holds onto the peer control channel, the latest aggregated - `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 - The Tauri commands (`request_games`, `install_game`, `update_game`, and
`update_game_directory`) translate UI actions into `PeerCommand`s. In `update_game_directory`) translate UI actions into `PeerCommand`s. In
particular, `update_game_directory` validates the filesystem path before particular, `update_game_directory` validates the filesystem path before
@@ -89,11 +105,9 @@ The Tauri application embeds this crate in
database. database.
- A background task consumes `PeerEvent`s and fans them out to the front-end via - A background task consumes `PeerEvent`s and fans them out to the front-end via
Tauri publish/subscribe events (`games-list-updated`, `game-download-*`, Tauri publish/subscribe events (`games-list-updated`, `game-download-*`,
`peer-*`). Successful downloads trigger an `unrar` sidecar to unpack ETI `game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only
archives and clean up the temporary backup folders that are created when provides the unrar sidecar through the injected `Unpacker`; rollback and
updates begin. cleanup live in the peer transaction code.
- When downloads fail the Tauri layer restores the on-disk backup, keeping the
previous installation consistent even after partial transfers.
## Security & Operational Notes ## Security & Operational Notes
@@ -102,9 +116,9 @@ The Tauri application embeds this crate in
- Peer discovery is restricted to the local link via mDNS. - Peer discovery is restricted to the local link via mDNS.
- Long-running blocking mDNS calls are isolated on dedicated threads which keeps - Long-running blocking mDNS calls are isolated on dedicated threads which keeps
the async runtime responsive even when discovery takes a long time. the async runtime responsive even when discovery takes a long time.
- File writes are chunk-safe: partial chunk downloads now open files without - File writes are chunk-safe: partial chunk downloads open files without
truncating existing data, avoiding the corruption that occurred previously truncating existing data, and root-level `version.ini` is written only after
when multiple peers collectively filled a file. the rest of the download has succeeded.
## Known Limitations ## Known Limitations