Cancelling an in-flight download via `PeerCommand::CancelDownload` previously
torn down the network transfer and cleared `active_downloads`, but left the
partial `.eti` archive(s) sitting in the game root forever. The next library
scan still picked up the half-written files as a "downloaded" game, and the
only escape was the `Remove files` action. This is the symmetric fix to
`62ceb06 feat(peer): remove downloaded game files safely`: the cancel path
must clean up after itself the same way an explicit remove does.
The fix introduces a dedicated `download/storage.rs` module that owns both the
existing pre-allocation step (`prepare_game_storage`, moved out of
`planning.rs` because pure file I/O has no business sitting next to chunk
planning) and a new `discard_cancelled_download` sweep. The orchestrator
calls the sweep at every cancellation exit point, immediately after
`rollback_version_ini_transaction` so the version sentinel transients are
gone before the bulk deletion runs.
The sweep deliberately preserves a known set of names so a cancelled update
of an installed game does not destroy user-extracted files:
- `local/` committed install directory
- `.local.installing/`,
`.local.backup/` in-flight install transaction state, needed by
`install::recover_game_root` on next startup
- `.lanspread.json` per-game install intent log
- `.softlan_game_installed` external softlan installer marker
- `.sync/` external sync tooling
Everything else under the game root (the `.eti` archives, any nested payload
directories, partial chunk files) is removed, and the game root itself is
removed if it ends up empty. The set matches `should_ignore_game_child` in
`services/local_monitor.rs` minus the version.ini transients (which the
rollback step removes itself just before the discard runs).
Tradeoff worth knowing: this does NOT restore the pre-update `version.ini`
sentinel. `begin_version_ini_transaction` parks the existing sentinel as
`.version.ini.discarded`, and `rollback_version_ini_transaction` deletes
that file rather than renaming it back. The user-visible consequence is
that cancelling a mid-flight update of an installed game leaves the local
install playable but no longer flagged as "downloaded" — the documented
"settles as local-only" behaviour now recorded in
`crates/lanspread-peer/ARCHITECTURE.md` and `README.md`. Restoring the
sentinel on cancel was considered, but it would mean a cancelled update
keeps advertising the OLD version as Ready, which is worse than the
current outcome.
Two unrelated correctness issues that surfaced while threading cancellation
through the orchestrator are bundled in here because they belong to the
same user-visible "Cancel button works" story:
1. `download_from_peer` now races `connect_to_peer` against
`cancel_token.cancelled()` (`download/transport.rs:314-322`). Previously
a cancel arriving while QUIC was still in its connect handshake had to
wait for the connect timeout to elapse before the cleanup could run.
2. The download task in `handlers.rs` now calls
`refresh_local_game_for_ending_operation` on every terminal branch —
success-without-install, install-handoff-failure, and the `Err(e)` /
cancel branch — before `end_download_operation` clears
`active_downloads`. Without this, the UI's settled snapshot on the
cancel path could lag behind the actual file system state because the
active-operation snapshot was cleared while the discard was still
running, leaving a brief window where the card showed the pre-cancel
state.
What this does NOT fix: a crash (process kill, power loss) during a
download still leaves orphan `.eti` files because `recover_download_transients`
in `install/transaction.rs` only sweeps the version.ini transients. Closing
that gap would mean calling the same discard from startup recovery for any
game root whose install intent is None and whose `version.ini` is absent.
Tracked in `FINDINGS.md` as a follow-up.
Test Plan:
- `just clippy && just test` — 102 unit tests pass, no new warnings.
- Two new storage tests:
- `discard_cancelled_download_removes_peer_owned_payload` exercises the
fresh-download cancel (no `local/`, root sweeps clean).
- `discard_cancelled_download_preserves_local_install_state` exercises
the update cancel (`local/`, `.lanspread.json`, `.local.backup/`
survive; `version.ini` and `.eti` go away).
- Manual GUI smoke (operator): start a fresh download of a multi-archive
game from a peer, click Cancel from the detail modal while the progress
bar is between 5% and 95%. Expect the game root to be empty (or absent)
afterwards and no orphan `.eti` files. Repeat against an installed game
by clicking Update, then Cancel mid-download; expect `local/` contents
intact and the card to drop back to Play (or Update if the newer-version
peer is still around).
- `lanspread-peer-cli` has no `cancel` command yet, so the headless
`PEER_CLI_SCENARIOS.md` matrix does not cover this end-to-end. Adding a
CLI cancel command + scenario is the natural follow-up.
Refs: 62ceb06 (feat(peer): remove downloaded game files safely)
Refs: b7df2de (fix(download): emit failure events on early-returns and update UI transition)
8.1 KiB
lanspread-peer
lanspread-peer is the networking runtime that lets Lanspread nodes find each
other on the local network, exchange library metadata, and transfer game files.
It is designed to run headless – other crates (most notably
lanspread-tauri-deno-ts) embed it and drive it through a channel-based API.
Runtime Overview
start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)boots the asynchronous runtime in the background and returns aPeerRuntimeHandlewhose sender controls the peer. The injectedUnpackerkeeps archive extraction out of the peer crate's platform layer, and the catalog set gates which local game roots are announced or served.PeerCommandrepresents the small control surface exposed to the UI layer:ListGames,GetGame,FetchLatestFromPeers,DownloadGameFiles,InstallGame,UninstallGame,RemoveDownloadedGame,CancelDownload,SetGameDir, andGetPeerCount.PeerEventenumerates everything the peer runtime reports back to the UI: library snapshots, download/install/uninstall lifecycle updates, runtime failures, and peer membership changes.PeerGameDBcollects remote peer metadata. It aggregates discovered peers’Gamedefinitions, tracks the latest ETI version per title, and keeps the last seen list ofGameFileDescriptionentries for each peer.
Internally the peer runtime owns four long-lived tasks that run for the lifetime of the process:
- Server component (
run_server_component) – listens for QUIC connections, advertises via mDNS, and servesRequest::ListGames,Request::GetGame,Request::GetGameFileData, andRequest::GetGameFileChunkby reading from the local game directory. - Discovery loop (
run_peer_discovery) – uses thelanspread-mdnshelper to discover other peers. The blocking mDNS work is executed on a dedicated thread viatokio::task::spawn_blockingso that the Tokio runtime remains responsive. - Ping service (
run_ping_service) – periodically issues QUIC ping requests to keep peer liveness up to date and prunes stale entries fromPeerGameDB. - 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, emits local-library changes separately from active operation snapshots, 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. 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
- Transport is handled by
s2n-quic; TLS cert/key material is compiled in from the repository root. - Protocol messages are JSON-encoded structures defined in
lanspread-proto::{Request, Response}. - File transfers stream raw bytes over dedicated bidirectional QUIC streams.
peer::send_game_file_datasends entire files, whilepeer::send_game_file_chunkservices ranged requests.
Download Pipeline
When the UI asks to download a game:
- The UI first issues
PeerCommand::GetGamefor a new download, orPeerCommand::FetchLatestFromPeersfor an update that must bypass local archives. The selected peers are queried viarequest_game_details_from_peer, and their file manifests are merged insidePeerGameDB. - Once the UI receives
PeerEvent::GotGameFiles, it forwards the selected file list back withPeerCommand::DownloadGameFiles. download_game_filesstarts a version-sentinel transaction, parks any oldversion.inias.version.ini.discarded, prepares non-sentinel files, emitsPeerEvent::DownloadGameFilesBegin, and builds a per-peer plan (build_peer_plans) that round-robins file chunks across the available peers that advertise the latest version.- 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. version.inichunks are buffered in memory and committed last via.version.ini.tmpfollowed by an atomic rename. Failures are accumulated and retried (up toMAX_RETRY_COUNT) viaretry_failed_chunks; failed downloads sweep.version.ini.tmpand.version.ini.discardedwithout restoring the previous sentinel. Cancelled downloads also discard the peer-owned download payload while preservinglocal/and install transaction metadata.- After a successful sentinel commit,
PeerEvent::DownloadGameFilesFinishedis emitted and the peer auto-runs the install transaction.
PeerCommand::CancelDownload cancels the tracked download token for an active
transfer. The transfer task remains responsible for clearing active_operations,
discarding partial payload files, and refreshing the settled local snapshot, so
the UI continues to treat active-operation snapshots as the single source of
truth for whether a download is still running.
Install Transactions
Install, update, uninstall, downloaded-file removal, 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.
Downloaded-file removal is deliberately separate from uninstall: it only accepts
catalog IDs that are direct children of the configured game directory, refuses
installed or in-flight roots, and deletes the whole game root only after finding
a regular root-level version.ini sentinel.
Integration with lanspread-tauri-deno-ts
The Tauri application embeds this crate in
crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs:
LanSpreadStateholds onto the peer control channel, the latest aggregatedGameDB, per-game operation state, the catalog set, and the user-selected game directory.- The Tauri commands (
request_games,install_game,update_game,remove_downloaded_game, andupdate_game_directory) translate UI actions intoPeerCommands. In particular,update_game_directoryvalidates the filesystem path before storing it, loads the bundled catalog on first use, kicks off the peer runtime on demand, and mirrors the installed/uninstalled state into the UI-facing database. - A background task consumes
PeerEvents and fans them out to the front-end via Tauri publish/subscribe events (games-list-updated,game-download-*,game-install-*,game-uninstall-*,peer-*). The Tauri crate now only provides the unrar sidecar through the injectedUnpacker; rollback and cleanup live in the peer transaction code.
Security & Operational Notes
- All QUIC connections are TLS encrypted; the shipped certificates are suitable for local-network trust but should be rotated for production deployments.
- 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 open files without
truncating existing data, and root-level
version.iniis written only after the rest of the download has succeeded.
Known Limitations
PeerGameDBcurrently models the latest metadata that other peers advertise. If the UI needs to surface titles that only exist locally, additional merging with the locally scannedGameDBwill be required.- The download planner uses a simple round-robin and does not yet take per-peer throughput or failures into account when distributing work.
Refer to the source (particularly src/lib.rs) for the exact message shapes and
state machines.