Raise the wire protocol to version 7 and add explicit Call to Play delivery outcomes. Live requests now wait for an application acknowledgement, allowing the sender to distinguish applied, duplicate, obsolete, incomplete, and rejected updates instead of treating a successful write as acceptance. Remove source-IP equality from actor verification. The receiver now requires the envelope peer ID to be present in its known roster and requires every live event actor to match that envelope. This matches the cooperative-LAN trust model without misrepresenting the shared TLS identity as per-peer authentication. Transport failures, malformed responses, NeedHandshake, and NeedHistory each trigger one asynchronous Hello/HelloAck resync. Rejections are logged without retry, and local publication remains independent of remote availability. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `git diff --cached --check` -- passed
13 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_verlibrary_rev(monotonic local library revision)- optional
hostname
2) Discovery and handshake
When a peer is discovered:
- Connect and send
Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }.listen_addris mandatory; the QUIC source port is only a temporary transport port and must not be recorded as the peer's listener. - Receive
HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }. - If the remote
peer_idis already known but the address changed, update it. - If protocol versions are incompatible, drop the peer (and keep mDNS watching).
- If library digests match, do nothing else.
- If digests differ:
- If we have a known
library_revfor that peer, requestLibraryDelta. - Otherwise request
LibrarySnapshot.
- If we have a known
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.
- Call to Play actions are broadcast as immutable, uniquely identified events.
Call to Play replication
Call to Play is transient peer-session state rather than database state. The
peer keeps a bounded event history, deduplicated by event ID. Every event and
chat message remains in the snapshot for the full lifetime of an active call,
so a peer joining mid-call receives the complete context. A creator's Start
or Cancel event replaces that terminal call with one small tombstone; this
lets a peer that missed the live action heal on its next handshake without
retaining the inactive call's full history. Active calls are never partially
trimmed. If genuinely active history reaches the bound, local publishes return
an error to the caller instead of appearing to succeed. A call whose deadline
elapses remains available for five minutes so the creator can start or extend
it, then its history is evicted as a unit. A
local action is applied to that history, sent to the UI, and broadcast to every
currently known peer. An incoming live event is applied once and sent to the UI
without being rebroadcast, which prevents forwarding loops.
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
and obsolete events need no follow-up. An unknown envelope peer, missing call
root, transport failure, or malformed acknowledgement makes the sender perform
one normal Hello / HelloAck exchange with that peer. The handshake carries
the full retained history in both directions, so a transient request failure
heals without waiting for mDNS rediscovery or a later reconnect. A rejected
event is logged without retry. Local publication remains successful while this
healing happens asynchronously, so an offline peer cannot block an action.
Actors are keyed by the peer's stable ID and carry a separate display name. The origin peer overwrites the actor ID on local actions. A live-event envelope must name a peer already in the receiver's roster, and every enclosed actor ID must match that envelope. This prevents accidental identity mixing and protects creator controls from other normal clients. It is not authentication against a hostile LAN peer: all peers use the shared application TLS identity, and stable peer IDs are self-asserted under the project's trusted-LAN model.
Hello and HelloAck include each side's event history. This lets peers that
join after a call was created reconstruct the same nominations, responses,
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
deterministically and derives deadlines and check-in phases from timestamps.
Those phases compare creator-supplied wall-clock timestamps with each viewer's
local wall clock, so LAN machines are assumed to be synchronized closely enough
for human-scale minute countdowns; clock skew shifts the displayed boundary by
the same amount.
There is deliberately no compatibility path for older protocol versions.
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 } }removedis a list of game IDs.- Deltas are idempotent; ignore if
to_rev<= known rev.
GameSummary (concept)
id,name,eti_version,size,downloaded,installedmanifest_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
LocalLibraryChangedonly 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
ActiveOperationsChangedfrom the mutation path instead of riding on local library scans. - Send
LibraryDeltato known peers; sendLibrarySummaryon new connections.
Local game scanning: fast and low cost
Strategy
- Maintain a persistent on-disk index (per game):
manifest_hash, total size, file list (optional), and a fingerprint (root-levelversion.inimtime, root-level.etimtime/size, andlocal/directory presence).
- Use filesystem watchers to update only changed games.
- 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
.etifile names, sizes, and mtimes - root-level
version.inimtime - presence of
local/as a directory
- root-level
- 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:
downloadedis true only when<game_root>/version.iniexists as a regular file. The sentinel is written last through.version.ini.tmpand atomic rename. An interrupted replacement leaves no restored old sentinel because archive bytes may already have changed.installedis true when<game_root>/local/is a directory. The contents oflocal/are user-owned and are skipped by manifests, fingerprints, and file serving.- Install and update transactions unpack into staging, then overwrite the first
discovered game-provided
account_name.txtandlanguage.txtfiles under the staged tree from launcher settings before promoting it tolocal/.
Reserved per-game paths:
.version.ini.tmpand.version.ini.discardedare 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.jsonin the configured state directory is the atomic per-game intent log..lanspread_ownedinside.local.*directories proves Lanspread ownership when the current intent isNone.
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 bymanifest_hashso 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.inisentinel 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.
Streamed install integrity
- Low-disk streamed installs request archive-derived file bytes from one peer and write them directly into the install transaction staging directory.
- The receiver verifies every streamed file against the sender archive's file size and RAR CRC32 before the transaction may commit. This catches truncated streams, transport corruption, and provider bugs.
- This is not malicious-peer protection: the peer controls both the archive metadata and the streamed bytes. A trusted-content model needs catalog-owned hashes, either for the root archives or for extracted files, and receiver-side SHA-256 verification against those catalog values before commit.
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_revis 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
- Protocol updates in
lanspread-proto:- Define
Hello,HelloAck,LibrarySummary,LibrarySnapshot,LibraryDelta, and optionalGoodbyemessages. - Thread
peer_id,library_rev, andmanifest_hashthrough all library and manifest-bearing types. - Make
HelloandHelloAckcarry the sender'slisten_addr,library_rev, andlibrary_digestso both sides can record stable listener addresses and immediately selectLibraryDeltavsLibrarySnapshot.
- Define
- Peer identity:
- Persist a stable
peer_id(UUID) in the peer config and inject it intoPeerInfoandPeerGameDBat startup. - Track
peer_id -> SocketAddrin the discovery table and update the address on any incoming handshake or mDNS refresh.
- Persist a stable
- Discovery handshake:
- Publish
peer_idandlibrary_revin mDNS TXT records to avoid immediate TCP/QUIC roundtrips when nothing changed. - Add a lightweight handshake in
run_peer_discoverythat exchangesHello/HelloAckbefore any library sync. - Ignore peers that do not advertise the current protocol version.
- Publish
- Library revisioning:
- Store a monotonic
library_revlocally and increment only after a successful index refresh completes. - Apply
LibraryDeltawhenlibrary_revmatches; reject stale or future revisions and requestLibrarySnapshotinstead. - Cache the last accepted
manifest_hashper peer to short-circuit manifest requests when unchanged.
- Store a monotonic
- Local index + scan optimizations:
- Use the cached
local_library/index.jsonfile 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.
- Use the cached
- Announce updates:
- Broadcast
LibraryDeltaupdates keyed bylibrary_rev. - Send
LibrarySummaryon new connections to seed the delta flow.
- Broadcast
- File manifest caching:
- Store per-game
manifest_hashand only fetch details when changed.
- Store per-game
- Liveness:
- Reduce ping frequency; update
last_seenon any message. - Add optional
Goodbyeon shutdown paths.
- Reduce ping frequency; update
- Tests:
- Delta apply/merge, rev ordering, manifest hashing, and scan cache behavior.