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