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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user