feat: resume an interrupted download instead of refetching it (#18)
Downloads buffered the whole file in memory and wrote it out only on success, so an interrupted transfer left nothing behind and always started over. The START_DOWNLOAD offset was hardcoded to zero on the download side and discarded on the upload side, so neither half of the protocol's resume support was wired up. Bytes now stream into `<file>.part` as they arrive and it is renamed only once the file is whole. A new attempt sends the partial file's length as the START_DOWNLOAD offset, and `serve_file` seeks to the offset a peer asks for rather than replaying from the start. A `.part` at or past the expected size is treated as stale and restarted. Partial files are kept out of the share index, since the download directory is shared by default.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
//! Serving a shared file to a peer over an F (file transfer) connection.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -55,9 +55,16 @@ pub fn serve_file(
|
||||
stream.write_all(&init)?;
|
||||
stream.flush()?;
|
||||
|
||||
// The downloader replies with an 8-byte START_DOWNLOAD offset first.
|
||||
// The downloader replies with an 8-byte START_DOWNLOAD offset first. A
|
||||
// non-zero offset means it is resuming and already holds that prefix, so
|
||||
// seek past it and count it as sent to keep progress absolute.
|
||||
let mut offset = [0u8; 8];
|
||||
stream.read_exact(&mut offset)?;
|
||||
let offset = u64::from_le_bytes(offset);
|
||||
if offset > 0 {
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
bytes_sent.fetch_add(offset, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
@@ -86,24 +93,50 @@ pub fn serve_file(
|
||||
mod tests {
|
||||
use super::serve_file;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
/// Play the downloader: accept the F connection, drain the PeerInit frame
|
||||
/// and the raw token, then reply with the 8-byte START_DOWNLOAD offset.
|
||||
/// Returns the connected stream and the token the uploader announced.
|
||||
fn accept_and_send_offset(
|
||||
listener: &TcpListener,
|
||||
offset: u64,
|
||||
) -> (TcpStream, u32) {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).unwrap();
|
||||
let mut payload = vec![0u8; u32::from_le_bytes(len_buf) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let mut token = [0u8; 4];
|
||||
stream.read_exact(&mut token).unwrap();
|
||||
|
||||
stream.write_all(&offset.to_le_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
(stream, u32::from_le_bytes(token))
|
||||
}
|
||||
|
||||
fn scratch_file(name: &str, content: &[u8]) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("soulseek-upload-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("probe.bin");
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_file_streams_the_file_over_an_f_connection() {
|
||||
let content: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("soulseek-upload-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("probe.bin");
|
||||
std::fs::write(&path, &content).unwrap();
|
||||
let path = scratch_file("stream", &content);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = u32::from(listener.local_addr().unwrap().port());
|
||||
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
let sent_counter = bytes_sent.clone();
|
||||
let dir = path.parent().unwrap().to_path_buf();
|
||||
let uploader = std::thread::spawn(move || {
|
||||
serve_file(
|
||||
"127.0.0.1",
|
||||
@@ -116,20 +149,8 @@ mod tests {
|
||||
)
|
||||
});
|
||||
|
||||
// Downloader side: accept, read PeerInit(F) frame + raw token, send the
|
||||
// 8-byte START_DOWNLOAD offset, then read the streamed bytes.
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).unwrap();
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let mut token = [0u8; 4];
|
||||
stream.read_exact(&mut token).unwrap();
|
||||
assert_eq!(u32::from_le_bytes(token), 777);
|
||||
|
||||
stream.write_all(&[0u8; 8]).unwrap();
|
||||
stream.flush().unwrap();
|
||||
let (mut stream, token) = accept_and_send_offset(&listener, 0);
|
||||
assert_eq!(token, 777);
|
||||
|
||||
let mut received = Vec::new();
|
||||
stream.read_to_end(&mut received).unwrap();
|
||||
@@ -140,13 +161,49 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_file_resumes_from_a_non_zero_offset() {
|
||||
let content: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
|
||||
let path = scratch_file("resume", &content);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = u32::from(listener.local_addr().unwrap().port());
|
||||
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
let sent_counter = bytes_sent.clone();
|
||||
let dir = path.parent().unwrap().to_path_buf();
|
||||
let uploader = std::thread::spawn(move || {
|
||||
serve_file(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
"me",
|
||||
779,
|
||||
&path,
|
||||
&sent_counter,
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
});
|
||||
|
||||
// The downloader already holds the first 1000 bytes.
|
||||
let (mut stream, _) = accept_and_send_offset(&listener, 1000);
|
||||
|
||||
let mut received = Vec::new();
|
||||
stream.read_to_end(&mut received).unwrap();
|
||||
assert_eq!(received, content[1000..], "only the missing tail is sent");
|
||||
|
||||
let _ = uploader.join();
|
||||
assert_eq!(
|
||||
bytes_sent.load(Ordering::Relaxed),
|
||||
4096,
|
||||
"progress counts the resumed prefix"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_file_stops_when_cancelled() {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("soulseek-upload-cancel-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.bin");
|
||||
std::fs::write(&path, vec![7u8; 1024 * 1024]).unwrap();
|
||||
let path = scratch_file("cancel", &vec![7u8; 1024 * 1024]);
|
||||
let dir = path.parent().unwrap().to_path_buf();
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = u32::from(listener.local_addr().unwrap().port());
|
||||
@@ -167,15 +224,7 @@ mod tests {
|
||||
)
|
||||
});
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).unwrap();
|
||||
let mut payload = vec![0u8; u32::from_le_bytes(len_buf) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let mut token = [0u8; 4];
|
||||
stream.read_exact(&mut token).unwrap();
|
||||
stream.write_all(&[0u8; 8]).unwrap();
|
||||
stream.flush().unwrap();
|
||||
let (mut stream, _) = accept_and_send_offset(&listener, 0);
|
||||
|
||||
let mut received = Vec::new();
|
||||
let _ = stream.read_to_end(&mut received);
|
||||
|
||||
Reference in New Issue
Block a user