Files
lanspread/crates/lanspread-peer/ARCHITECTURE.md
T
ddidderr 9835e77e8d feat: store launcher state outside game dirs
Move launcher-owned metadata from game roots into the configured peer state
area. Peer identity, the local library index, install intent logs, and setup
markers now live under app/CLI state instead of being written beside games.
The Tauri shell passes its app data directory into the peer, and the peer CLI
runs the same path through its explicit --state-dir.

Add a dedicated pre-start migration phase for legacy files. It migrates the
old global library index, per-game install intents, and the old first-start
marker into app state, then deletes legacy files only after the replacement
write succeeds. Normal scan, install, recovery, and transfer paths no longer
read legacy state files.

Rename the old first-start meaning to setup_done and only set it after
launching game_setup.cmd. Start/setup scripts keep the shared argument shape,
while server_start.cmd now uses cmd /k and a visible window so server logs stay
open for inspection.

While validating the Docker scenario matrix, make download terminal events
come from the handler after local state refresh and operation cleanup. This
makes download-finished/download-failed safe points for immediate follow-up CLI
commands. Also update the multi-peer chunking scenario to use a sparse archive
large enough to actually span multiple production chunks.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py

Refs: local app-state migration discussion
2026-05-21 21:32:28 +02:00

9.4 KiB

lanspread-peer proposed protocol and architecture

This document proposes a tighter, more fault-tolerant protocol while keeping the current idea: mDNS discovery, QUIC transport, on-demand metadata, and chunked file transfers.

Goals (unchanged)

  • Local LAN discovery via mDNS.
  • QUIC + JSON messages for control, raw streams for file data.
  • UI drives operations through PeerCommand, peers remain headless.
  • Peers can appear/disappear at any time without data loss.

Peer lifecycle and message flow

1) Startup and advertise

  • Start QUIC server.
  • Advertise via mDNS with TXT records:
    • peer_id (stable ID, not tied to IP)
    • proto_ver
    • library_rev (monotonic local library revision)
    • optional hostname

2) Discovery and handshake

When a peer is discovered:

  1. Connect and send Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }. listen_addr is mandatory; the QUIC source port is only a temporary transport port and must not be recorded as the peer's listener.
  2. Receive HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }.
  3. If the remote peer_id is already known but the address changed, update it.
  4. If protocol versions are incompatible, drop the peer (and keep mDNS watching).
  5. If library digests match, do nothing else.
  6. If digests differ:
    • If we have a known library_rev for that peer, request LibraryDelta.
    • Otherwise request LibrarySnapshot.

3) Steady state

  • Any message updates last_seen.
  • Pings run only when idle (or on a longer interval), not every 5 seconds.
  • Library updates are pushed as deltas, debounced and coalesced.

4) Shutdown

  • Optional Goodbye { peer_id } lets others remove the peer quickly.
  • If a peer vanishes without goodbye, stale timeout + ping removal handle it.
  • Goodbye is a hint, never required for correctness.

Library sync protocol

Summary and snapshot

  • LibrarySummary { peer_id, summary: { library_rev, library_digest, game_count } }
  • LibrarySnapshot { peer_id, snapshot: { library_rev, games: Vec<GameSummary> } }

Delta updates

  • LibraryDelta { peer_id, delta: { from_rev, to_rev, added, updated, removed } }
  • removed is a list of game IDs.
  • Deltas are idempotent; ignore if to_rev <= known rev.

GameSummary (concept)

  • id, name, eti_version, size, downloaded, installed
  • manifest_hash (hash of file list + sizes)
  • availability (e.g., ready, downloading, local_only)

When peers broadcast their game list

  • Only on changes, not on a timer.
  • 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.
  • Local library scans emit LocalLibraryChanged only for real library changes, except that accepted game-directory changes can force a UI snapshot for the new path without sending a peer delta.
  • Active operation mutations emit ActiveOperationsChanged from the mutation path instead of riding on local library scans.
  • Send LibraryDelta to known peers; send LibrarySummary on new connections.

Local game scanning: fast and low cost

Strategy

  1. Maintain a persistent on-disk index (per game):
    • manifest_hash, total size, file list (optional), and a fingerprint (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 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:
    • 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 <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.
  • games/<game_id>/install_intent.json in the configured state directory is the atomic per-game intent log.
  • .lanspread_owned inside .local.* directories proves Lanspread ownership when the current intent is None.

Downloaded-file removal is not an uninstall transaction. It removes the whole game root only for a catalog ID that is a single direct child of the configured game directory, has a regular root-level version.ini, and has no local/, .local.installing/, or .local.backup/ path.

Recovery reads app-state install_intent.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.

Legacy .lanspread/, .lanspread.json, .lanspread.json.tmp, .softlan_game_installed, and local/.softlan_first_start_done files are handled only by the dedicated pre-start migration phase. Normal operation does not read legacy state paths.

Result

Most scans become O(number of game dirs), with full recursion only when needed.

File manifests and downloads

  • 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.
  • Cancelling a download discards the peer-owned root download payload and scratch sentinel files. local/ and install transaction metadata are preserved, so a cancelled update of an installed game settles as local-only.

Fault tolerance rules

  • Every peer is keyed by peer_id, not by IP address.
  • Peer addresses are listener addresses from mDNS or Hello/HelloAck, never ephemeral QUIC source ports.
  • library_rev is monotonic and guards against out-of-order updates.
  • Any mismatch or missing delta falls back to LibrarySnapshot.
  • Loss of goodbye is harmless; stale timeout is authoritative.

Roadmap from current design to this one

  1. Protocol updates in lanspread-proto:
    • Define Hello, HelloAck, LibrarySummary, LibrarySnapshot, LibraryDelta, and optional Goodbye messages.
    • Thread peer_id, library_rev, and manifest_hash through all library and manifest-bearing types.
    • Make Hello and HelloAck carry the sender's listen_addr, library_rev, and library_digest so both sides can record stable listener addresses and immediately select LibraryDelta vs LibrarySnapshot.
  2. Peer identity:
    • Persist a stable peer_id (UUID) in the peer config and inject it into PeerInfo and PeerGameDB at startup.
    • Track peer_id -> SocketAddr in the discovery table and update the address on any incoming handshake or mDNS refresh.
  3. Discovery handshake:
    • Publish peer_id and library_rev in mDNS TXT records to avoid immediate TCP/QUIC roundtrips when nothing changed.
    • Add a lightweight handshake in run_peer_discovery that exchanges Hello/HelloAck before any library sync.
    • Ignore peers that do not advertise the current protocol version.
  4. Library revisioning:
    • Store a monotonic library_rev locally and increment only after a successful index refresh completes.
    • Apply LibraryDelta when library_rev matches; reject stale or future revisions and request LibrarySnapshot instead.
    • Cache the last accepted manifest_hash per peer to short-circuit manifest requests when unchanged.
  5. Local index + scan optimizations:
    • Use the cached local_library/index.json file in the configured state directory to store per-root fingerprints and computed manifests.
    • Use filesystem watchers with a debounce window to collect changes and incrementally update the cache.
    • Schedule a low-frequency full scan to reconcile missed watcher events.
  6. Announce updates:
    • Broadcast LibraryDelta updates keyed by library_rev.
    • Send LibrarySummary on new connections to seed the delta flow.
  7. File manifest caching:
    • Store per-game manifest_hash and only fetch details when changed.
  8. Liveness:
    • Reduce ping frequency; update last_seen on any message.
    • Add optional Goodbye on shutdown paths.
  9. Tests:
    • Delta apply/merge, rev ordering, manifest hashing, and scan cache behavior.