Files
vnidrop/crates/vnidrop/tests/output_sink.rs

216 lines
7.4 KiB
Rust

mod support;
use std::sync::Arc;
use std::time::{Duration, Instant};
use support::{
receive_with_sink_response, receive_with_sink_v2_response, share_path,
wait_for_receiver_request, MemoryOutputSink, TestNode,
};
use vnidrop::{PublishedOutput, ReceiveOutputSinkV2, VnidropError};
struct ExistingDestinationSink;
impl ReceiveOutputSinkV2 for ExistingDestinationSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
Err(VnidropError::DestinationExists {
reason: format!("destination already exists: {relative_path}"),
})
}
fn write_chunk(&self, _relative_path: String, _bytes: Vec<u8>) -> Result<(), VnidropError> {
unreachable!("a rejected file must not be written")
}
fn finish_file(&self, _relative_path: String) -> Result<PublishedOutput, VnidropError> {
unreachable!("a rejected file must not be finished")
}
fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> {
unreachable!("start_file failure must not be aborted")
}
}
#[test]
fn versioned_sink_records_published_locator() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("tracked.txt");
std::fs::write(&source_path, b"tracked").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 38, "tracked.txt", false);
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_v2_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink,
true,
)
.unwrap();
let artifacts = receiver.core.list_received_artifacts().unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].locator, "content://test/tracked.txt");
assert_eq!(artifacts[0].logical_size, 7);
}
#[test]
fn exports_nested_files_to_output_sink() {
let source_dir = tempfile::tempdir().unwrap();
let source_root = source_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_root, 18, "photos", true);
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink.clone(),
true,
)
.unwrap();
assert_eq!(output_sink.file("photos/cover.txt"), b"cover");
assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside");
assert_eq!(
output_sink.terminal_state("photos/cover.txt"),
Some("finished")
);
assert_eq!(
output_sink.terminal_state("photos/nested/inside.txt"),
Some("finished")
);
}
#[test]
fn reports_output_sink_write_failure() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 19, "hello.txt", false);
let output_sink = Arc::new(MemoryOutputSink::failing_writes());
let error = receive_with_sink_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink.clone(),
true,
)
.unwrap_err();
assert!(matches!(
error,
VnidropError::Filesystem { ref reason } if reason.contains("sink write failed")
));
assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted"));
}
#[test]
fn preserves_typed_output_sink_failure() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("existing.txt");
std::fs::write(&source_path, b"new content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 39, "existing.txt", false);
let error = receive_with_sink_v2_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
Arc::new(ExistingDestinationSink),
true,
)
.unwrap_err();
assert!(matches!(error, VnidropError::DestinationExists { .. }));
assert!(receiver
.core
.list_events(Some(39))
.unwrap()
.iter()
.any(|event| {
event.phase == "error"
&& event.kind == "failed"
&& event.data_json.contains("\"code\":\"destination_exists\"")
}));
}
#[test]
fn cancellation_during_export_aborts_open_sink_file() {
// Gate the first write so export is mid-file when cancel runs. A large
// "slow writes" file made this flaky/hang-prone on CI: concurrent cancel
// used to nest Runtime::block_on, and multi-megabyte sleep loops could
// starve the cancel path for a long time.
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("gated.bin");
std::fs::write(&source_path, vec![0u8; 256 * 1024]).unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 29, "gated.bin", false);
let output_sink = Arc::new(MemoryOutputSink::block_first_write());
let receiver_core = receiver.core.arc();
let sink_for_worker = output_sink.clone();
let ticket = share.ticket.clone();
let worker = std::thread::spawn(move || {
receiver_core.receive_with_output_sink(
ticket,
sink_for_worker,
Some("receiver".to_string()),
)
});
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
sender
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
let started = Instant::now();
while !output_sink.has_started("gated.bin") || !output_sink.has_entered_write() {
assert!(
started.elapsed() < Duration::from_secs(15),
"export never opened gated.bin for writing"
);
std::thread::sleep(Duration::from_millis(10));
}
// First write is parked in the sink. Cancel must complete from this thread
// without deadlocking against the receive worker's runtime driver.
receiver.core.cancel_transfer(share.transfer_id).unwrap();
// Unblock the in-flight write so the receive future can yield, observe
// cancel, and Drop(OutputSinkFile) can abort the open file.
output_sink.release_write_gate();
let result = worker
.join()
.expect("receive worker thread panicked")
.expect_err("cancelled receive should return an error");
let message = result.to_string();
assert!(
message.contains("cancel") || message.contains("interrupted") || !message.is_empty(),
"unexpected cancel error: {message}"
);
let abort_deadline = Instant::now();
while output_sink.terminal_state("gated.bin") != Some("aborted") {
assert!(
abort_deadline.elapsed() < Duration::from_secs(5),
"open sink file was not aborted after cancel"
);
std::thread::sleep(Duration::from_millis(10));
}
}