63aa4bc77cc788e5332834825a40528ec93defaf
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
887e245638
|
fix(peer): honour change hints only from the claimed peer's address
Security audit findings NET-01 (partial) and Codex #9 ("unauthenticated hints can make the victim pull arbitrary known peers"). Inbound QUIC connections are intentionally anonymous: only the responder is authenticated, so any LAN host can open a stream and send `LibraryChanged`/`CallToPlayChanged` hints naming any `claimed_peer_id`. If the claimed peer was known and the forged session or revision did not match the cached snapshot, state sync scheduled a full pinned Hello pull to that peer. Sending one small forged hint to every node on the LAN therefore made all of them pull a victim's complete snapshot at once (reflected amplification), bounded only by the 5-second per-peer coalesce window. Full mutual TLS would bind hints to a verified identity but is a larger protocol change than this application warrants. Instead the hint now carries the source IP of the anonymous connection, and `hint_requires_pull_from_snapshot` discards any hint whose source IP differs from the address at which the claimed peer was last authenticated. On a LAN a QUIC connection cannot be established from a spoofed IP, so a third host can no longer select which peer this node pulls. A genuine peer whose address changed loses only the hint fast path; mDNS rediscovery and pinned liveness reconciliation still pick it up. `PeerEndpointGeneration::for_tests` is added under cfg(test) so unit tests can build a `PeerRevisionSnapshot`. Test plan: `just test`. The new test accepts a hint from the peer's address, rejects the same hint from another IP or with no address, and keeps the revision comparison for matching sources. Manual: with two peer-cli containers, adding a game on one still triggers the other to refresh its library promptly. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg |
||
|
|
3965e2544c
|
fix(peer): bound inbound request frames at 64 KiB instead of 8 MiB
Security audit findings NET-03 and Codex #6 ("control-frame prefixes can reserve about 512 MiB across concurrent decoders"). Both directions of the control plane shared MAX_CONTROL_FRAME_BYTES (8 MiB). That size exists for responses: a HelloSnapshot with 4096 library games and a maximal Call-to-Play author slice legitimately approaches it. Requests are tiny; the largest possible GetGameFileChunk with a 255-byte game ID and a 900-byte catalog path is under 2 KiB. Yet every anonymous inbound stream was decoded with an 8 MiB LengthDelimitedCodec, and tokio-util reserves the declared frame length as soon as the 4-byte prefix arrives. With 64 global control-stream permits a LAN host could make a responder reserve ~512 MiB by sending nothing but length prefixes. Changes: - lanspread-proto gains MAX_REQUEST_FRAME_BYTES (64 KiB). Request encode/decode enforce it in addition to the shared bound; Response keeps the 8 MiB allowance. - The server-side stream handler decodes inbound frames with a request-sized codec. The response writer is unchanged. - The server QUIC limits shrink the per-stream receive window to one request frame and size the connection window so every one of the 32 allowed streams can hold its allowance (2 MiB per connection instead of 8 MiB per stream). Client-side decoders (network.rs, discovery Hello pulls) still use the 8 MiB bound because they read responses from identity-pinned peers. Test plan: `just test` (proto tests assert the exact limits and that a maximal request encodes far below the bound; stream tests assert the inbound codec uses the request bound). Manual: three peer-cli containers still exchange snapshots and complete downloads. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg |
||
|
|
60fd7ba0c2
|
feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed |
||
|
|
a1013b028d
|
refactor(peer): centralize game root path policy
Game scanning, manifest validation, install recovery, migration, and download cleanup each carried their own spellings and case rules for reserved entries. Those copies had already diverged, which made it possible for one subsystem to accept or expose a path that another treated as application-owned state. Introduce one game_paths module for the canonical names and conservative portable comparison policy. Keep context-specific predicates for manifest and scanner protection versus cancellation preservation: cancellation still sweeps its own version transaction scratch files, while install and migration state survive. Reuse the constants for all production path construction sites. Test Plan: - `just test` -- passed (175 lanspread-peer tests and full workspace) - `just clippy` -- passed - `just fmt` -- Rust, TOML, and Prettier completed; the recipe remains blocked by 39 pre-existing rumdl issues in five unrelated Markdown files - `git diff --cached --check` -- passed |
||
|
|
e5d70ae56f
|
fix(call-to-play): acknowledge live replication
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 |
||
|
|
be7ad2e560
|
refactor(call-to-play): merge histories atomically
Replace per-event insertion with a transactional batch merge. The store now validates and deduplicates an entire history before committing it, rejects conflicting event IDs without partial mutation, evaluates retention after all batch events are present, and reports applied, duplicate, obsolete, and missing-root outcomes explicitly. Derive event identity from retained history instead of preserving an unbounded ID set. Expired histories can therefore be restored by a complete Create plus AddTime batch, while orphan actions request history and terminal tombstones continue to reject stale resurrection. Capacity applies only to unresolved history, allowing Start and Cancel to settle a full call. Only retained events reach the UI or live broadcast path. Handshake and live merge callers log invalid or incomplete histories without publishing events that compaction discarded. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `git diff --cached --check` -- passed |
||
|
|
29eacabcc0
|
fix(call-to-play): key actors by stable peer identity
Participant maps and creator authorization previously used display names, so two peers left at the default Commander name collapsed into one participant and could exercise each other's creator controls through the normal client. Carry a stable actor_id separately from actor_name. The peer overwrites actor_id on every local publish, and live event envelopes are accepted only when the known peer, source, and event actor match. The frontend keys participants and authorization by actor_id while retaining actor_name for display. This follows the trusted-LAN model and is not cryptographic authentication against a hostile peer. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 21 tests - `just build` -- passed - `just peer-cli-tests S48` -- passed - `git diff --cached --check` -- passed |
||
|
|
0f53bc4b78
|
feat(call-to-play)!: coordinate game sessions across peers
Implement the launcher design as a production peer-to-peer feature. Call to Play actions are immutable, validated events broadcast over the existing QUIC control channel, deduplicated in a bounded in-memory history, and exchanged in Hello/HelloAck so late joiners reconstruct current calls. Add the Tauri bridge and modular launcher surfaces for play-now and scheduled calls, check-in, readiness buffers, role-aware controls, chat, tickers, and actual caller launch. A deterministic frontend reducer derives presentation state from replicated history. Extend the JSONL peer harness with publish/list commands and a three-peer live-delivery and late-join scenario. This intentionally raises the only supported wire protocol from version 5 to version 6; older builds are not supported. Document the transport architecture and exclude generated peer-test state from Docker build contexts. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 20 tests - `just build` -- passed - `just peer-cli-tests S2 S48` -- passed - `git diff --cached --check` -- passed |
||
|
|
0693db94b2
|
fix(peer): clear remaining clippy warnings
`just clippy` was failing on peer test and helper code after the current lint set denied warnings. This keeps the cleanup local to the reported warnings and avoids changing runtime peer behavior or the wire protocol surface. The test helpers now avoid needless ownership, active-operation assertions take slices, and the local monitor watch-event helper no longer wraps a value that cannot fail. The install recovery matrix keeps the same cases, but moves the case table and per-case assertions into helpers so the test body stays below the clippy line limit. The remaining lint fixes replace similar temporary names and use an explicit `expect` message for test-only length conversion. No separate project documentation update is needed because this only changes internal test/helper structure and lint-only assertions; the existing peer architecture docs remain accurate. Test Plan: - just fmt - just clippy - just test - git diff --check - git diff --cached --check Refs: none |
||
|
|
373def6d44
|
feat(peer): prototype streamed installs
Add a streamed-install prototype that can receive archive-derived install bytes straight into local/ without first storing the peer-owned root archive payload. This is intended for low-disk clients that want to install a game but opt out of becoming a downloadable peer source for that game. The protocol gains a current-version-only StreamInstall request and framed StreamInstallFrame responses. The peer core owns the generic transport, transaction, path validation, size checks, CRC32 verification, and lifecycle state. The archive-specific work is hidden behind StreamInstallProvider so the prototype can use unrar while the final implementation can swap in a better provider without rewriting the peer command path. The receiver writes into .local.installing and only promotes to local/ after the full stream verifies. It deliberately does not write the root version.ini or archive files, so the settled local state is installed=true, downloaded=false, and availability=LocalOnly. That preserves the existing rule that local/ is not served to peers and makes streamed receivers non-sources by construction. The CLI is the only caller for now. It exposes stream-install and provides the prototype unrar implementation with unrar lt for entry metadata and unrar p for file bytes. This is simple and good enough to prove non-solid archive streaming, but it is not the production provider shape for solid archives because per-file unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes stream_install_provider: None, so the GUI behavior stays unchanged until a real product path is designed. Document the production-readiness work in NEXT_STEPS.md. The main follow-up is to make the provider abstraction final-ish and replace the per-file CLI unrar provider with a one-pass archive provider, then wire a deliberate GUI low-disk mode, retry semantics, and broader failure scenarios. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 --build-image - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Follow-up: NEXT_STEPS.md |
||
|
|
06398fe298
|
fix(peer): reject transfer paths outside requested game
Inbound file-transfer requests carry both a game ID and a relative path. The serve gate validated whether the requested game was currently servable, but it did not require the path itself to be rooted under that same game. A non-conforming peer could therefore register a guard for one game while asking to read files from another game root. Require normalized transfer paths to start with the requested game ID before the file can be dispatched. This keeps the outbound transfer guard, serve policy, and filesystem path aligned. Absolute, traversal, local-data, missing-sentinel, active-operation, and wrong-version paths remain rejected by the existing gates. Test Plan: - just test - just clippy - git diff --check Refs: Claude review finding #4 |
||
|
|
738095235f
|
feat(peer): coordinate outbound transfers with local game mutations
Updating or removing a local game rewrites its on-disk files. Peers that were mid-download of that game would keep streaming bytes from files that are being deleted or replaced, handing them a corrupt or stale copy. There was also no authoritative notion of which game version a peer should serve or accept, so a peer could serve whatever happened to be on disk and downloaders could aggregate files from peers running mismatched versions. This introduces a reader-writer coordination scheme between outbound file transfers (readers) and local mutation operations (writers), and gates both serving and downloading on an authoritative game catalog version. Reader-writer coordination: - Track active outbound transfers per game in a shared `OutboundTransfers` map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and registered by a `TransferGuard` in the stream service. The guard is registered *before* the serve-eligibility check to close a TOCTOU window where a writer could miss an in-flight reader. - `stream_file_bytes` now honors a cancellation token at every await point (file read, network send, stream close) via `tokio::select!`, so a transfer aborts promptly instead of hanging on a stalled receiver. - `begin_operation` marks a game active first, then cancels its outbound transfers and waits for the count to reach zero before any Updating/RemovingDownload work touches the filesystem. - Active games are now hidden from library snapshots entirely while an operation is in flight, instead of freezing their last announced state, so peers stop discovering a game that is being mutated. Authoritative version catalog: - Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each game id to its expected version (from the bundled game.db / ETI data). - Serving requires the local `version.ini` to match the catalog version (`local_download_matches_catalog`); peer selection, file aggregation, and majority size validation all filter on the expected version (`peers_with_expected_version`, `aggregated_game_files`, and friends). User-visible changes: - The GUI shows confirmation dialogs before Update and Remove, and surfaces a sharing-status indicator on game cards and the detail modal. - A new `OutboundTransferCountChanged` event lets the UI reflect live outbound transfer activity. Test Plan: - just test - just frontend-test - just clippy |
||
|
|
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 |
||
|
|
be00a7a298
|
fix(peer): exchange full library snapshots during handshake
Peer A failed to learn Peer B's games. The handshake only carried
library_rev/library_digest metadata, and the post-handshake sync path
compared those revisions against per-peer revision numbers that were
never advanced via this code path, so the games map for the remote
peer stayed empty and the UI never showed them.
The fix is to put the authoritative library data into the handshake
itself. Hello and HelloAck now carry a LibrarySnapshot directly, and
both perform_handshake_with_peer (outbound) and accept_inbound_hello
(inbound) apply that snapshot to the peer DB before emitting the UI
events. The initial peer-game-list event is now driven by the
handshake rather than by a follow-up LibrarySummary/LibrarySnapshot
roundtrip.
Bumps PROTOCOL_VERSION to 4 because the wire layout of Hello/HelloAck
changed. Per CLAUDE.md's protocol policy there is no compatibility
shim; older peers will fail the version check and be ignored.
Cleanups that fall out of the new design:
- The Hello / HelloAck library_rev and library_digest fields were
duplicated by the embedded LibrarySnapshot (which carries its own
library_rev, and whose digest is recomputed on apply). Collapsed
both messages to just `library: LibrarySnapshot` to remove the
foot-gun where the two could diverge.
- Request::LibrarySummary and Request::LibrarySnapshot are now dead
on the sender side and were removed along with their stream.rs
handlers and the LibrarySummary struct. LibraryDelta stays — it
is still sent from handlers.rs when the local library changes.
- record_remote_library previously called update_peer_library and
then apply_library_snapshot, which immediately overwrote the
rev/digest just written. Added update_peer_features and rewired
the call site so each peer-DB field is written exactly once.
update_peer_library is retained because discovery.rs still uses
it for the mDNS TXT-record path, where no snapshot is available.
- Removed the now-unused LibraryUpdate enum, select_library_update,
send_local_library_summary, send_local_library_update_if_needed,
LocalLibraryState::delta_since, build_library_summary,
send_library_summary, and send_library_snapshot.
Behavior change visible to users: when two peers come up on the LAN
they now see each other's full game lists immediately after the
handshake instead of waiting for a follow-up sync that, in the broken
case, never made the games visible at all.
Test Plan
- just clippy (clean for the touched crates)
- just test (workspace: all suites pass, including the two new
handshake tests: outbound_hello_carries_local_library_snapshot
and inbound_hello_applies_remote_library_snapshot, the latter
asserting PeerDiscovered + PeerCountUpdated + ListGames events
fire with the remote game visible)
- Manual: start `just peer-cli-alpha` and `just peer-cli-bravo` in
separate terminals; confirm each peer's game list shows the
other's library entries after discovery completes, without
requiring any additional command.
Refs
- FINDINGS.md: triage note that Claude's review surfaced only
in-scope cleanups (dead variants, duplicated header fields,
redundant DB writes, stale test fixture), all addressed here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ce51d92df0
|
refactor(peer): tighten listener-addr handshake invariant
Follow-up hardening for |
||
|
|
348a02c35f
|
fix(peer): record listener addresses during handshakes
Peers discovered over mDNS could still attribute later library sync traffic to temporary QUIC source ports. In a real GUI LAN run this made Host B try to push its library to Host A's outbound port instead of Host A's advertised listener, so Host A discovered the peer but never saw its games. Carry the stable listener address in Hello and HelloAck, and key library sync messages by peer_id instead of inferring identity from the transport source address. The handshake path now explicitly refreshes an empty peer library from the known listener address, matching the reliability of the direct-connect CLI path without overwriting richer snapshot state when it already arrived. This changes the current wire protocol, so PROTOCOL_VERSION is bumped to 3 and all peers must be rebuilt together. The architecture note now documents that listener addresses come from mDNS or Hello/HelloAck, never from ephemeral QUIC source ports. Test Plan: - just fmt - just test - just clippy - just build - git diff --check Refs: Local Linux/Win11 GUI LAN test logs from 2026-05-18. |
||
|
|
10a1f57183
|
fix(peer): preserve advertised addresses for QUIC peers
After renewing the dev certificate, peers could complete handshakes but then lost each other during liveness checks. Inbound QUIC streams report the client's ephemeral source port, while the peer database is supposed to track the peer's advertised listening address. Recording the ephemeral address created unstable peer entries that could not be pinged later. Resolve transport source addresses back to the unique known peer on the same IP, and keep an existing advertised address when an inbound Hello arrives from that peer. Goodbye events now report the stored peer address as well. This keeps the core peer behavior in lanspread-peer; the CLI only observes the resulting peer snapshots. Test Plan: - just fmt - just test - just clippy - just peer-cli-build - just peer-cli-image - just peer-cli-alpha, just peer-cli-bravo, just peer-cli-charlie - list-peers after the ping idle window shows advertised peer addresses with populated game lists instead of ephemeral-port peers disappearing Refs: PEER_CLI_SCENARIOS.md |
||
|
|
754afd5621
|
refactor(peer): drop --no-mdns toggle, mDNS is always on
The peer runtime previously accepted an `enable_mdns: bool` flag, plumbed
through `PeerStartOptions`, `spawn_peer_runtime`, `run_peer`, `Ctx`, and
`PeerCtx`. The lanspread-peer-cli harness exposed the toggle as
`--no-mdns` so test scenarios could fall back to explicit `connect`
commands when mDNS could not be relied on, in particular when multiple
peers ran inside `--network host` containers and could not advertise
independently.
That host-networking workaround no longer exists: the previous commit
moves harness containers onto a macvlan network, where each peer is a
real LAN device and mDNS just works between them. There is no scenario
left in the codebase where disabling mDNS is desirable. Per the project's
protocol policy in CLAUDE.md ("there is only one wire version, no
compatibility shims, no fallback paths"), an opt-out path with no current
caller is exactly the kind of dead code we should not carry.
Remove the flag and every plumbing point that exists only to support it:
- `PeerStartOptions::enable_mdns` and the custom `Default` impl that set
it to `true`; the struct now derives `Default` and just carries
`state_dir`.
- The `enable_mdns` parameter on `start_peer_with_options`,
`spawn_peer_runtime`, `run_peer`, and `Ctx::new`.
- The `enable_mdns` fields on `Ctx` and `PeerCtx` and the propagation
through `to_peer_ctx`.
- The `if ctx.enable_mdns` guard in `spawn_startup_services`;
`spawn_peer_discovery_service` is now always spawned.
- The `if ctx.enable_mdns { ... } else { ... }` branch in
`run_server_component`: the mDNS advertiser and event monitor are now
unconditionally started, and the no-mDNS-fallback log line that read
"mDNS disabled; direct peer address is ..." is gone. The
`direct_connect_addr` helper is kept because the mDNS-on branch still
uses it as a fallback when `local_peer_addr` has not yet been
populated.
- The internal test helpers in `handlers.rs`, `services/local_monitor.rs`,
and `services/stream.rs` that passed `true` as the trailing
`enable_mdns` arg to `Ctx::new`.
- In `lanspread-peer-cli`: the `--no-mdns` arg parsing, the
`Args::enable_mdns` field, the `mdns` key on the `cli-started` event
payload, and the `--no-mdns` mention in the help text and the crate
README.
The `Args::name` field is wired to the harness identity but is otherwise
untouched. The macvlan network created by `just peer-cli-net` is the
runtime prerequisite for this change to be observable across containers;
on a single workstation, two harness binaries on `127.0.0.1` discover
each other through mDNS on the loopback interface as before.
Test Plan:
- `just fmt`
- `just clippy`
- `just test`
- `just peer-cli-build`
- Two peers on macvlan: `just peer-cli-run alpha` and
`just peer-cli-run beta`; check that each emits `peer-discovered` and
`peer-connected` events without an explicit `connect` JSONL command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e711cf3454
|
fix(peer): settle current-protocol local state cleanup
The follow-up backlog had drifted into three settled peer/runtime issues: the legacy game-list fallback contradicted the one-wire-version policy, the Tauri shell still re-derived local install state from disk after peer snapshots, and `Availability::Downloading` existed even though active operations are already reported through a separate operation table. Remove the legacy `AnnounceGames` request and fallback service. Discovery now ignores peers that do not advertise the current protocol and a peer id, and library changes are sent through the current delta path only. This keeps the runtime aligned with the documented current-build-only interoperability model. Make peer `LocalGamesUpdated` snapshots authoritative for local fields in the Tauri database. The GUI-side catalog still owns static metadata such as names, sizes, and descriptions, but downloaded, installed, local version, and availability now come from the peer runtime instead of a second whole-library filesystem scan. Snapshot reconciliation also pins the missing-begin and missing-finish lifecycle cases in tests. Collapse availability back to the settled `Ready` and `LocalOnly` states. Aggregation now counts only `Ready` peers as download sources, and the frontend no longer carries a dead `Downloading` enum value. The core peer also exposes the small non-GUI hooks needed by scripted callers: startup options for state and mDNS, a local-ready event, direct connection, peer snapshots, and an explicit post-download install policy. Those hooks reuse the same current protocol path and do not add compatibility shims. Test Plan: - `git diff --check` - `just fmt` - `just clippy` - `just test` Refs: BACKLOG.md, FINDINGS.md, IMPL_DECISIONS.md |
||
|
|
894eb5af6a
|
test(peer): consolidate temp dir helper
Move the repeated test TempDir implementations into a single peer test_support module. The shared helper keeps the existing automatic cleanup behavior and uses an atomic suffix plus timestamp so parallel tests do not collide on the same path. This is intentionally limited to test hygiene. It does not change the availability model, split download.rs, or touch production scan/install behavior beyond importing the shared helper from test modules. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_2.md |
||
|
|
7731a9daa0
|
test(peer): cover serve gating dispatch
Add focused serve-side tests for the gates around peer requests. GetGame now has coverage for the non-catalog, active-operation, and missing-sentinel cases that should return GameNotFound instead of exposing local files. The full-file and chunk handlers both depend on the same transfer gate before touching the QUIC send stream. Extract that gate into a small helper and test the same cases there, plus the existing local-path exclusion, so both dispatch paths stay aligned without adding fake QUIC stream plumbing. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_2.md |
||
|
|
6c8a2bb9f0
|
feat(peer): add transactional local game operations
Implement the peer-owned state model from PLAN.md. A root-level version.ini is now the download completion sentinel, local/ as a directory is the install predicate, and exact root-level version.ini detection prevents nested files from becoming sentinels by accident. Add the peer operation table that gates downloads, installs, updates, and uninstalls by game ID. Serving paths now reject non-catalog games, active operations, missing sentinels, and any request that points under local/. Remote aggregation treats LocalOnly peers as non-downloadable so they do not contribute peer counts, candidate source selection, or latest-version checks. Move install-side filesystem mutation into lanspread-peer::install. The new module writes atomic .lanspread.json intents, uses .local.installing and .local.backup with .lanspread_owned markers, and performs startup recovery from recorded intent plus filesystem state. Downloads now buffer version.ini chunks in memory and commit the sentinel last through .version.ini.tmp. Replace the fixed 15-second monitor with notify-backed non-recursive watches, per-ID rescan gating, and a 300-second fallback scan. The optimized rescan path updates one cached library-index entry and active operation IDs preserve their previous summary during scans. Test Plan: - just fmt - just clippy - just test - just build Refs: PLAN.md |
||
|
|
2bbd2ac869
|
refactor(peer): adopt structured concurrency with supervised shutdown
Replace the detached tokio::spawn pattern in the peer runtime with a
supervised model built on tokio_util's CancellationToken and TaskTracker.
Long-lived services and child tasks now have an explicit parent, a
cancellation path, and a join point. Tauri can request a clean shutdown
on app exit instead of leaking work into process termination.
Background
~~~~~~~~~~
start_peer() previously returned only a command sender. The four startup
services (QUIC server, mDNS discovery, peer liveness, local library
monitor) and their child tasks (ping workers, handshake jobs, download
workers, announcement fan-outs, connection/stream handlers) were spawned
with raw tokio::spawn and detached. Closing the command channel sent
Goodbye notifications but did not stop those services. The mDNS blocking
worker had no cancellation path at all. Active downloads were stored as
JoinHandle<()> and force-aborted, which could interrupt file writes
mid-chunk.
Supervisor
~~~~~~~~~~
The runtime now owns a CancellationToken and a TaskTracker, threaded
through Ctx and PeerCtx. Each long-lived service is spawned through a
small supervisor (spawn_supervised_service) that wraps the service in
catch_unwind and enforces an explicit SupervisionPolicy:
QuicServer: Required (fatal; cancels the runtime if it dies)
Discovery: Restart(5s) (matches the prior self-restart loop)
Liveness: Restart(5s)
LocalMonitor: BestEffort (logs and exits, no restart)
A Required failure emits a new RuntimeFailed { component, error } event
to the UI and cancels the runtime; the command loop and goodbye
notifications still run to completion. The Tauri layer forwards the
event as "peer-runtime-failed" so a future UI can surface it.
mDNS cancellation
~~~~~~~~~~~~~~~~~
MdnsBrowser previously blocked on receiver.recv() forever. It now
exposes next_service_timeout(Duration) returning an MdnsServicePoll
enum (Service/Timeout/Closed) via recv_timeout(). The discovery worker
polls at 250ms and checks the shutdown flag between ticks, so
cancellation reaches the blocking thread within one poll interval
instead of waiting for the next mDNS event.
Downloads
~~~~~~~~~
active_downloads is now HashMap<String, CancellationToken>. Each
download gets a child token of the runtime shutdown, checked at chunk
and peer-attempt boundaries (never inside file writes). When all peers
with a game disappear, liveness cancels the token and emits
DownloadGameFilesAllPeersGone; the download exits Ok(()) without
emitting a duplicate Failed event.
DownloadStateGuard (context.rs) is held inside the download task and
clears downloading_games + active_downloads on Drop, covering the happy
path, error returns, cancellation, and task abort. Drop falls back to
spawning the cleanup if write-lock contention prevents try_write.
Public API and Tauri integration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
start_peer() now returns PeerRuntimeHandle exposing:
fn sender(&self) -> UnboundedSender<PeerCommand>
fn shutdown(&self)
async fn wait_stopped(&mut self)
The Tauri layer stores the handle in managed state and switches its
main loop from .run(ctx) to .build(ctx).run(|h, e| ...). On
RunEvent::Exit it calls handle.shutdown() and blocks up to 2s on
wait_stopped(), giving services time to cancel and Goodbye packets time
to flush over a healthy LAN while staying short enough not to delay
process exit noticeably on a dead network.
The command loop distinguishes graceful shutdown from unexpected
channel closure: if recv() returns None and shutdown.is_cancelled() is
set, the loop returns Ok(()) silently. Only an unexpected close (no
cancellation observed) still emits RuntimeFailed. This avoids a
spurious failure event on every normal app close.
User-visible behavior changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Closing the app no longer leaks services into process termination;
Goodbye notifications are reliably attempted before exit.
- Downloads cancel cleanly (between chunks) instead of force-aborting
mid-write.
- A new "peer-runtime-failed" Tauri event fires when a Required service
cannot recover. No frontend handler exists yet — that is a follow-up.
Tradeoffs
~~~~~~~~~
- Workspace tokio-util now requires the "rt" feature for TaskTracker.
- The mDNS worker still runs in spawn_blocking and may stay parked
briefly between 250ms polls — acceptable for a desktop app.
- The 2s shutdown timeout on app exit is a deliberate compromise.
Tests
~~~~~
New unit tests:
- DownloadStateGuard clears tracking on completion, cancellation, and
parent-task abort (context.rs).
- Required failure cancels the runtime and emits RuntimeFailed
(startup.rs).
- Restart policy restarts until shutdown is requested (startup.rs).
- PeerRuntimeHandle.shutdown() observable via wait_stopped()
(startup.rs).
- Peers-gone cancellation emits only PeersGone, no duplicate Failed
(services/liveness.rs).
Test plan
~~~~~~~~~
cargo test --workspace
cargo clippy --workspace --all-targets
Manual smoke test on two peers on the same LAN:
1. Start a download, verify chunks transfer.
2. Close the receiving app mid-download — verify the sending peer
logs a Goodbye, not a connection-reset error.
3. Stop the sending peer mid-download — verify the receiver emits
DownloadGameFilesAllPeersGone, not Failed.
Follow-ups
~~~~~~~~~~
- Frontend handler for "peer-runtime-failed".
- Consider exposing the runtime handle's stopped watch to the frontend
for a reconnecting indicator on Required failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
87d00e7df6
|
refactor(peer): make startup directory-driven
Peer startup used to bootstrap itself by spawning the runtime and immediately sending a SetGameDir command back through its own control channel. The Tauri integration then polled shared state until a directory appeared and waited two seconds before asking peers for games. That made startup ordering implicit and left a race-prone sleep in the UI bridge. Install the initial game directory directly into the peer context instead. The runtime now attempts the initial local-library scan before starting discovery, then launches the server, discovery, liveness, and local monitor services from that initialized context. Later directory changes still use SetGameDir, so the existing UI command surface stays intact. Use PathBuf and Path references across peer filesystem boundaries so directory state is represented as a path rather than an optional string. The Tauri layer now validates a selected game directory before storing it, loads the bundled catalog on first use, and starts or updates the peer runtime from one helper. Peer event fan-out is split into named handlers so the Tauri setup closure only wires state and starts the event loop. Shutdown goodbye notifications are still best-effort, but they are now awaited with a short timeout instead of being spawned and forgotten. The tradeoff is a small bounded wait during peer runtime shutdown in exchange for clearer task ownership. Test Plan: - cargo test -p lanspread-peer - cargo clippy - cargo clippy --benches - cargo clippy --tests - cargo +nightly fmt - git diff --check Refs: none |
||
|
|
b4585b663a
|
ChatGPT Codex 5.5 xhigh refactored even more |