Cancelled outbound transfers previously returned from the streaming loop without
terminating the QUIC send half. A whole-file receiver relies on the stream
ending to distinguish EOF from an in-progress body, so cancellation could leave
it waiting on a truncated transfer until its own timeout fired.
Reset the send stream on every cancellation branch, including cancellation
while waiting for the final close acknowledgement. A reset is deliberately used
instead of a graceful close so truncated whole-file transfers cannot be
misinterpreted as a valid EOF.
Test Plan:
- just test
- just clippy
- git diff --check
Refs: Claude review finding #1
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
Centralize the bulk-transfer sizing in config.rs and bump the values used
on both ends of a QUIC connection:
- CHUNK_SIZE: 32 MiB -> 128 MiB
- QUIC_CONNECTION_DATA_WINDOW: 64 MiB -> 256 MiB
- QUIC_STREAM_DATA_WINDOW: 32 MiB -> 128 MiB
- QUIC_MAX_SEND_BUFFER_SIZE: 32 MiB -> 128 MiB
- QUIC_INITIAL_CONGESTION_WINDOW: 1 MiB -> 4 MiB
- FILE_TRANSFER_BUFFER_SIZE: 64 KiB -> 1 MiB (new constant)
The previous 32 MiB stream window was already comfortably above the
bandwidth-delay product of a sub-millisecond LAN at 2.5 GbE. The further
bump is deliberately generous: the goal is to push flow control and
per-syscall overhead far enough out of the way that they cannot be the
suspect when isolating the remaining LAN download bottleneck (disk, NIC,
or s2n-quic platform offload on the sending host). Memory pressure from
the larger windows is not observable on a desktop client moving GB-sized
games.
stream_file_bytes previously read the local file in 64 KiB chunks. At
multi-Gbit/s send rates that produced many thousands of disk reads per
second; bumping to 1 MiB keeps the per-file syscall load modest with no
measurable latency cost on streamed bulk transfers. The buffer size lives
in config.rs as FILE_TRANSFER_BUFFER_SIZE so it stays adjustable from one
place.
Also add a started/MiB-per-second log line at info level when a file
finishes streaming. This matches the S37 measurement methodology already
used in the peer-cli harness and makes per-file send throughput visible in
normal operation.
The peer-cli extended-scenarios harness uses CHUNK_SIZE as the tolerance
bound for chunk-boundary variance in its assertions, so its constant is
bumped to match. The multi-chunk planning unit test is rewritten to
reference CHUNK_SIZE symbolically (CHUNK_SIZE * 3 + CHUNK_SIZE / 2)
instead of a hardcoded 120 MiB; the previous literal would silently
degrade into a single-chunk test at the new chunk size and stop
exercising the spread-across-peers code path.
Test Plan:
- just fmt
- just clippy
- just test
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37 \
--build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37
Refs: local LAN download performance investigation on 2026-05-20.
Depends-on: d7f7dc737e (QUIC UDP socket buffer sizing).