fix(peer): harden streamed install lifecycle

Claude Fable 5's branch review found that receiver cancellation or a QUIC
send failure could leave the sender-side archive producer blocked on the
bounded frame channel. That kept the outbound transfer guard alive and could
block later installs or updates of the same game.

Route archive frames through a cancellable StreamInstallFrameSink instead of
exposing the raw channel sender to providers. The QUIC forwarder now cancels
and closes the receive side before awaiting the producer, so a blocked send
wakes and the transfer guard can drop normally.

Make PeerCommand::StreamInstallGame own its peer metadata preflight inside the
peer core. The Tauri layer now sends the command directly, and the peer runtime
fetches file details from catalog-version peers before running the existing
majority validation and retry logic. This removes the UI-only pending streamed
install set and gives PeerEvent::GotGameFiles one meaning again: continue a
normal archive download.

Tighten the receiver transaction edge cases too. Rollback removes a newly
created empty game root, but preserves pre-existing roots. Once streamed
staging has been promoted to local/, intent or launch-settings cleanup failures
are logged for startup recovery instead of reporting a failed install for bytes
that are already committed.

Accept missing RAR CRC32 metadata for zero-byte files as CRC32 00000000 while
still requiring CRC32 metadata for non-empty files. Update the peer README,
scenario docs, and next-steps handoff so the documented ownership and remaining
trust limitation match the implementation.

Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- git diff --check
- python3 -m py_compile \
  crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image

Refs: streamed-install review handoff from Claude Fable 5
This commit is contained in:
2026-06-11 07:33:34 +02:00
parent 9c765aba9c
commit 66c7d5912b
10 changed files with 448 additions and 240 deletions
+92 -52
View File
@@ -77,11 +77,37 @@ impl SenderArchiveIntegrity {
pub type StreamInstallFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
#[derive(Clone)]
pub struct StreamInstallFrameSink {
frames: mpsc::Sender<StreamInstallFrame>,
cancel_token: CancellationToken,
}
impl StreamInstallFrameSink {
fn new(frames: mpsc::Sender<StreamInstallFrame>, cancel_token: CancellationToken) -> Self {
Self {
frames,
cancel_token,
}
}
pub async fn send(&self, frame: StreamInstallFrame) -> eyre::Result<()> {
tokio::select! {
() = self.cancel_token.cancelled() => {
eyre::bail!("streamed install frame send was cancelled");
}
result = self.frames.send(frame) => {
result.map_err(|_| eyre::eyre!("streamed install frame receiver closed"))
}
}
}
}
pub trait StreamInstallProvider: Send + Sync {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
frames: mpsc::Sender<StreamInstallFrame>,
frames: StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> StreamInstallFuture<'a>;
}
@@ -93,7 +119,7 @@ impl StreamInstallProvider for NoopStreamInstallProvider {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
_frames: mpsc::Sender<StreamInstallFrame>,
_frames: StreamInstallFrameSink,
_cancel_token: CancellationToken,
) -> StreamInstallFuture<'a> {
Box::pin(async move {
@@ -121,7 +147,7 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
frames: mpsc::Sender<StreamInstallFrame>,
frames: StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> StreamInstallFuture<'a> {
Box::pin(async move {
@@ -132,15 +158,13 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider {
.unwrap_or("archive.eti")
.to_string();
send_stream_frame(
&frames,
StreamInstallFrame::ArchiveBegin {
frames
.send(StreamInstallFrame::ArchiveBegin {
archive_name: archive_name.clone(),
solid: listing.solid,
unpacked_size: listing.unpacked_size(),
},
)
.await?;
})
.await?;
stream_unrar_entries(
&self.program,
@@ -151,7 +175,9 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider {
)
.await?;
send_stream_frame(&frames, StreamInstallFrame::ArchiveEnd { archive_name }).await
frames
.send(StreamInstallFrame::ArchiveEnd { archive_name })
.await
})
}
}
@@ -268,9 +294,13 @@ fn push_rar_entry(entries: &mut Vec<RarEntry>, draft: RarEntryDraft) -> eyre::Re
let size = draft
.size
.ok_or_else(|| eyre::eyre!("RAR file entry {relative_path} has no Size"))?;
let crc32 = draft
.crc32
.ok_or_else(|| eyre::eyre!("RAR file entry {relative_path} has no CRC32"))?;
let crc32 = match (size, draft.crc32) {
(_, Some(crc32)) => crc32,
(0, None) => 0,
(_, None) => {
eyre::bail!("RAR file entry {relative_path} has no CRC32");
}
};
(size, Some(crc32))
}
RarEntryKind::Directory => (0, None),
@@ -289,7 +319,7 @@ async fn stream_unrar_entries(
program: &Path,
archive: &Path,
entries: &[RarEntry],
frames: &mpsc::Sender<StreamInstallFrame>,
frames: &StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let mut child = Command::new(program)
@@ -315,27 +345,23 @@ async fn stream_unrar_entries(
match entry.kind {
RarEntryKind::Directory => {
send_stream_frame(
frames,
StreamInstallFrame::Directory {
frames
.send(StreamInstallFrame::Directory {
relative_path: entry.relative_path.clone(),
},
)
.await?;
})
.await?;
}
RarEntryKind::File => {
let Some(crc32) = entry.crc32 else {
eyre::bail!("RAR file entry {} has no CRC32", entry.relative_path);
};
send_stream_frame(
frames,
StreamInstallFrame::FileBegin {
frames
.send(StreamInstallFrame::FileBegin {
relative_path: entry.relative_path.clone(),
size: entry.size,
crc32,
},
)
.await?;
})
.await?;
stream_unrar_file_from_stdout(
&mut stdout,
archive,
@@ -345,13 +371,11 @@ async fn stream_unrar_entries(
&cancel_token,
)
.await?;
send_stream_frame(
frames,
StreamInstallFrame::FileEnd {
frames
.send(StreamInstallFrame::FileEnd {
relative_path: entry.relative_path.clone(),
},
)
.await?;
})
.await?;
}
}
}
@@ -388,7 +412,7 @@ async fn stream_unrar_file_from_stdout(
stdout: &mut (impl AsyncRead + Unpin),
archive: &Path,
entry: &RarEntry,
frames: &mpsc::Sender<StreamInstallFrame>,
frames: &StreamInstallFrameSink,
buffer: &mut [u8],
cancel_token: &CancellationToken,
) -> eyre::Result<()> {
@@ -405,13 +429,11 @@ async fn stream_unrar_file_from_stdout(
);
}
send_stream_frame(
frames,
StreamInstallFrame::FileChunk {
frames
.send(StreamInstallFrame::FileChunk {
bytes: Bytes::copy_from_slice(&buffer[..read]),
},
)
.await?;
})
.await?;
remaining = remaining.saturating_sub(u64::try_from(read)?);
}
@@ -446,16 +468,6 @@ async fn wait_unrar_child(
}
}
async fn send_stream_frame(
frames: &mpsc::Sender<StreamInstallFrame>,
frame: StreamInstallFrame,
) -> eyre::Result<()> {
frames
.send(frame)
.await
.map_err(|_| eyre::eyre!("streamed install frame receiver closed"))
}
pub(crate) async fn send_stream_install_error(
tx: SendStream,
message: impl Into<String>,
@@ -501,10 +513,12 @@ pub(crate) async fn send_game_install_stream(
let (frame_tx, mut frame_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let producer_cancel = cancel_token.child_token();
let frame_sink = StreamInstallFrameSink::new(frame_tx, producer_cancel.clone());
let game_id_for_producer = game_id.to_string();
let producer = tokio::spawn({
let provider = provider.clone();
let producer_cancel = producer_cancel.clone();
let frame_sink = frame_sink.clone();
async move {
for archive in archives {
if producer_cancel.is_cancelled() {
@@ -512,16 +526,16 @@ pub(crate) async fn send_game_install_stream(
}
if let Err(err) = provider
.stream_archive(&archive, frame_tx.clone(), producer_cancel.clone())
.stream_archive(&archive, frame_sink.clone(), producer_cancel.clone())
.await
{
let message = err.to_string();
let _ = frame_tx.send(StreamInstallFrame::Error { message }).await;
let _ = frame_sink.send(StreamInstallFrame::Error { message }).await;
return Err(err);
}
}
let _ = frame_tx.send(StreamInstallFrame::Complete).await;
let _ = frame_sink.send(StreamInstallFrame::Complete).await;
Ok(())
}
});
@@ -536,6 +550,7 @@ pub(crate) async fn send_game_install_stream(
break;
}
}
drop(frame_rx);
let close_result = framed_tx
.close()
@@ -876,6 +891,31 @@ Details: RAR 5
assert!(err.to_string().contains("has no CRC32"));
}
#[test]
fn accepts_zero_size_unrar_file_entries_without_crc32() {
let listing = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5
Name: bin/empty.cfg
Type: File
Size: 0
"#,
)
.expect("empty file without CRC32 should parse as CRC32 zero");
assert_eq!(
listing.entries,
vec![RarEntry {
relative_path: "bin/empty.cfg".to_string(),
kind: RarEntryKind::File,
size: 0,
crc32: Some(0),
}]
);
}
#[test]
fn sender_archive_integrity_accepts_matching_size_and_crc32() {
let bytes = b"payload";