Reject exact manifest destinations that are not covered by the last committed ownership set before creating a baseline or parking version.ini. Align Windows device-name validation with the confined filesystem backend and keep cleanup capability-relative. Replace recursive downloaded-game removal with an empty ownership generation. The operation now removes only proven-owned files and the sentinel, preserves unknown files and directories, and remains recoverable and idempotent across crashes. Test Plan: - just clippy - just test - just fmt (Rust, TOML, and Prettier completed; rumdl still reports 39 pre-existing issues)
301 lines
15 KiB
Markdown
301 lines
15 KiB
Markdown
# 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.
|
|
- 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` makes the call terminal and read-only, but its complete roster and chat
|
|
history remain in snapshots for 15 minutes so late joiners can see the outcome.
|
|
After that display window the history compacts to the Start or Cancel tombstone
|
|
for the rest of the peer session. Active and recently terminal 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;
|
|
terminal histories and tombstones do not consume that active-history capacity. A
|
|
call whose deadline elapses remains available for five minutes so the creator
|
|
can start or extend it, then its unresolved 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 } }`
|
|
- `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. The old sentinel is parked before a pending ownership set is
|
|
published; recovery restores it only when a valid baseline proves payload
|
|
mutation never started.
|
|
- `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.
|
|
- Install and update transactions unpack into staging, then overwrite the first
|
|
discovered game-provided `account_name.txt` and `language.txt` files under the
|
|
staged tree from launcher settings before promoting it to `local/`.
|
|
|
|
Reserved per-game paths:
|
|
|
|
- `.version.ini.tmp` and `.version.ini.discarded` are download transaction
|
|
scratch files. Startup recovery interprets them together with the durable
|
|
ownership journal rather than sweeping them independently.
|
|
- `.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.
|
|
- `games/<game_id>/download_ownership.json` in that state directory records the
|
|
last committed and any pending downloader-owned regular-file set. The record
|
|
is bound to one canonical configured games directory.
|
|
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
|
when the current intent is `None`.
|
|
|
|
Downloaded-file removal is not an uninstall transaction. For a catalog ID that
|
|
is a single direct child of the configured game directory, it requires a valid
|
|
ownership record and regular root-level `version.ini`, and refuses `local/`,
|
|
`.local.installing/`, or `.local.backup/`. It journals an empty pending
|
|
generation, deletes only committed downloader-owned files and the sentinel, and
|
|
keeps the game root plus every unknown file or directory.
|
|
|
|
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.
|
|
- The complete remote description is converted into a
|
|
`ValidatedDownloadManifest` before any destination mutation. It contains
|
|
canonical game-root-relative paths and rejects aliases, reserved state, shape
|
|
conflicts, and bounded-size violations as one unit.
|
|
- A new manifest target that already exists without prior committed ownership is
|
|
rejected before the sentinel, ownership journal, or payload is mutated.
|
|
- Download mutation holds a capability handle for the direct catalog game root.
|
|
Directory components and final files are reopened relative to that handle
|
|
without following links or Windows reparse points; chunk writes and checks use
|
|
the same opened file handle.
|
|
- 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 or recovering a download removes only paths named by its durable
|
|
ownership journal and its validated pending manifest. Unknown user 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_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.
|