Files
proton-drive-linux/crates/pdfs-fuse/src/sim/daemon.rs
T
2026-11-14 02:57:11 +01:00

3841 lines
163 KiB
Rust

//! One simulated daemon: a real FUSE mount on its own state directory, whose
//! Drive is a [`FakeClient`].
//!
//! Everything the daemon does runs as in production (drain workers, sync
//! engine, event feed, online probe, control socket) except what the host
//! changes (`mount::Host::Simulation`): a channel stops it instead of a signal,
//! and it does not index the developer's home.
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::SyncSender;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppConfig;
use pdfs_core::control::{self, PendingOpInfo, Request, Response};
use pdfs_core::db::Db;
use proton_drive_rs::ProtonDriveClient;
use proton_drive_rs::proton_sdk::config::ProtonClientConfiguration;
use proton_drive_rs::proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use super::fake_drive::FakeClient;
use crate::mount::{Host, MountOptions, MountOutcome, mount_with};
/// How long a mount may take to come up.
const MOUNT_DEADLINE: Duration = Duration::from_secs(20);
/// How long a restart may wait for the last run to let go of its database.
const LAST_RUN_DEADLINE: Duration = Duration::from_secs(30);
pub(crate) struct Daemon {
pub(crate) client: FakeClient,
pub(crate) mountpoint: PathBuf,
dir: PathBuf,
socket: PathBuf,
stop: SyncSender<()>,
mount: Option<JoinHandle<io::Result<MountOutcome>>>,
rt: Option<tokio::runtime::Runtime>,
}
impl Daemon {
/// Mount a daemon on the state under `dir`, creating it on the first start.
/// A later start on the same `dir` is a restart: it finds the queue, the
/// cache and the database the last run left.
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
log_to_test_output();
isolate_from_the_developer();
let mountpoint = dir.join("mnt");
let socket = dir.join("control.sock");
std::fs::create_dir_all(&mountpoint)?;
// A thread of the last run that is still in a request holds the
// database, and with it the single-writer lock, until it returns. A
// real restart waits for the old process to exit in the same way.
let mut db = Db::open(&dir.join("pdfs.db"));
let deadline = Instant::now() + LAST_RUN_DEADLINE;
while db
.as_ref()
.is_err_and(|error| error.to_string().contains("already using"))
&& Instant::now() < deadline
{
std::thread::sleep(Duration::from_millis(100));
db = Db::open(&dir.join("pdfs.db"));
}
let db = Arc::new(db.map_err(io::Error::other)?);
let cache = ContentCache::open(dir.join("cache"), dir.join("pins.json"), 0, db.clone())
.map_err(io::Error::other)?;
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()?;
let (stop, stopped) = std::sync::mpsc::sync_channel(1);
let mount = {
let drive = Arc::new(client.clone());
let handle = rt.handle().clone();
let mountpoint = mountpoint.clone();
let socket = socket.clone();
std::thread::Builder::new()
.name("sim-mount".into())
.spawn(move || {
mount_with(
offline_client(),
drive,
handle,
&mountpoint,
cache,
&socket,
db,
MountOptions {
username: "sim".into(),
sweep_mode: AppConfig::default().resolved_conflict_sweep(),
upload_limit: 0,
download_limit: 0,
local_first: AppConfig::default().local_first.unwrap_or(true),
},
Host::Simulation(stopped),
)
})?
};
let deadline = Instant::now() + MOUNT_DEADLINE;
while !is_mounted(&mountpoint) {
if mount.is_finished() {
return Err(match mount.join() {
Ok(Err(error)) => error,
_ => io::Error::other("mount ended before it came up"),
});
}
if Instant::now() > deadline {
return Err(io::Error::other("mount did not come up"));
}
std::thread::sleep(Duration::from_millis(20));
}
Ok(Self {
client,
mountpoint,
dir: dir.to_path_buf(),
socket,
stop,
mount: Some(mount),
rt: Some(rt),
})
}
/// What the daemon still owes Drive, as `pdfs sync queue` shows it.
pub(crate) fn pending(&self) -> io::Result<Vec<PendingOpInfo>> {
match control::send(&self.socket, &Request::ListPendingOps).map_err(io::Error::other)? {
Response::PendingOps { items } => Ok(items),
other => Err(io::Error::other(format!("unexpected answer {other:?}"))),
}
}
/// Whether nothing is queued and no upload is still landing, as `pdfs
/// status` counts it. An empty [`Self::pending`] is not enough before a
/// read that needs the cache: the op goes before the uploaded bytes are
/// cached, so a test that went offline at once raced the drain.
pub(crate) fn drained(&self) -> bool {
self.pending().is_ok_and(|items| items.is_empty())
&& matches!(
self.request(&Request::Status),
Ok(Response::Status {
pending_uploads: 0,
pending_changes: 0,
..
})
)
}
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
/// it.
pub(crate) fn online(&self) -> io::Result<bool> {
match self.request(&Request::Status)? {
Response::Status { online, .. } => Ok(online),
other => Err(io::Error::other(format!("unexpected answer {other:?}"))),
}
}
/// Send one control request, as the `pdfs` CLI does, and fail on a
/// refusal.
pub(crate) fn request(&self, request: &Request) -> io::Result<Response> {
match control::send(&self.socket, request).map_err(io::Error::other)? {
Response::Error { message, .. } => Err(io::Error::other(message)),
response => Ok(response),
}
}
/// Write the new file `path` under the mount, and let the drain send its
/// create only once the create holds the bytes. Otherwise a drain worker
/// still busy with the op before can claim the create before the release
/// attaches them, which the kernel sends after `close` returns. The file
/// then goes out empty, and the bytes follow as a revision, past a hook on
/// the next create.
pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
let rel = path
.strip_prefix(&self.mountpoint)
.map_err(io::Error::other)?
.to_string_lossy()
.into_owned();
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
self.request(&paused(true))?;
std::fs::write(path, bytes)?;
let attached = wait_until(Duration::from_secs(30), || {
self.pending().is_ok_and(|items| {
items
.iter()
.any(|item| item.kind == "create" && item.path == rel && item.exportable)
})
});
self.request(&paused(false))?;
match attached {
true => Ok(()),
false => Err(io::Error::other(format!(
"{rel}: create never held the bytes"
))),
}
}
/// Stop the daemon and start it again on the same state and link.
pub(crate) fn restart(mut self) -> io::Result<Self> {
self.shut_down()?;
Self::start(&self.dir.clone(), self.client.clone())
}
/// Stop the daemon the way `systemctl --user stop` does.
pub(crate) fn stop(mut self) -> io::Result<MountOutcome> {
self.shut_down()
}
fn shut_down(&mut self) -> io::Result<MountOutcome> {
let Some(mount) = self.mount.take() else {
return Ok(MountOutcome::Shutdown);
};
let _ = self.stop.try_send(());
let outcome = mount
.join()
.unwrap_or_else(|_| Err(io::Error::other("mount thread panicked")));
if let Some(rt) = self.rt.take() {
rt.shutdown_timeout(Duration::from_secs(5));
}
outcome
}
}
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.shut_down();
}
}
/// Whether a file system is mounted at `path`: it sits on another device than
/// its parent.
fn is_mounted(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let device = |path: &Path| std::fs::metadata(path).map(|meta| meta.dev()).ok();
let parent = path.parent().and_then(device);
device(path).is_some_and(|dev| Some(dev) != parent)
}
/// The daemons' logs, filtered by `RUST_LOG` and shown with `--nocapture`.
/// What they log at `INFO` and above is also kept for [`take_logged`],
/// whatever the filter.
fn log_to_test_output() {
use tracing_subscriber::prelude::*;
let _ = tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_test_writer()
.with_filter(tracing_subscriber::EnvFilter::from_default_env()),
)
.with(KeepLogged.with_filter(tracing_subscriber::filter::LevelFilter::INFO))
.try_init();
}
/// What the daemons logged at `INFO` and above since the last call, each as
/// its message followed by its fields: `message key=value ...`.
pub(crate) fn take_logged() -> Vec<String> {
std::mem::take(&mut *LOGGED.lock())
}
static LOGGED: parking_lot::Mutex<Vec<String>> = parking_lot::Mutex::new(Vec::new());
struct KeepLogged;
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for KeepLogged {
fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
let mut message = Message(String::new());
event.record(&mut message);
LOGGED.lock().push(message.0);
}
}
struct Message(String);
impl tracing::field::Visit for Message {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0.insert_str(0, &format!("{value:?}"));
} else {
self.0.push_str(&format!(" {}={value:?}", field.name()));
}
}
}
/// Point the XDG directories of the whole test process at a scratch
/// directory, once, before the first daemon starts.
///
/// The daemon reads `config.json` through `AppDirs` in a few places (the
/// adopted device, the ignore rules, the profile), and those would otherwise
/// be the developer's own: an adopted device of their real account made every
/// sync folder fail to register against the fake Drive.
fn isolate_from_the_developer() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let home = scratch("xdg");
for (var, sub) in [
("XDG_CONFIG_HOME", "config"),
("XDG_STATE_HOME", "state"),
("XDG_CACHE_HOME", "cache"),
("XDG_DATA_HOME", "data"),
] {
let path = home.join(sub);
std::fs::create_dir_all(&path).expect("create a simulation XDG directory");
// SAFETY: the simulations run one at a time (`--test-threads=1`,
// docs/TESTING.md), and this runs before the first of them starts a
// daemon, so no other thread reads the environment meanwhile.
unsafe { std::env::set_var(var, &path) };
}
});
}
/// A client for what `DriveApi` does not cover (sharing, Photos, devices),
/// pointed at a closed local port so none of it leaves the machine.
fn offline_client() -> ProtonDriveClient {
let config = ProtonClientConfiguration::new("external-drive-pdfs-sim@0.0.0-dev")
.with_base_url("http://127.0.0.1:9");
let session = ProtonApiSession::resume(
config,
ResumeParameters {
session_id: "sim".to_owned().into(),
username: "sim".into(),
user_id: "sim".to_owned().into(),
access_token: String::new(),
refresh_token: String::new(),
scopes: Vec::new(),
is_waiting_for_second_factor_code: false,
password_mode: PasswordMode::Single,
},
)
.expect("an offline session needs no network");
ProtonDriveClient::new(&session, Vec::new())
}
/// A fresh directory for one simulation, under the system temp dir.
pub(crate) fn scratch(label: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-sim-{label}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create a simulation directory");
dir
}
/// Wait up to `limit` for `done`, polling.
pub(crate) fn wait_until(limit: Duration, mut done: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + limit;
loop {
if done() {
return true;
}
if Instant::now() > deadline {
return false;
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::fake_drive::{Entry, FakeDrive, Faults};
use pdfs_core::control::{RefreshScope, SyncIssue};
use std::collections::BTreeMap;
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_daemon_on_the_fake_drive_syncs_both_ways() {
let drive = FakeDrive::new();
let dir = scratch("smoke");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::write(daemon.mountpoint.join("hello.txt"), b"hi").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("hello.txt") == Some(&Entry::File(Arc::new(b"hi".to_vec())))
}),
"a local write landed on Drive: {:?}",
drive.tree()
);
drive.device().write("phone.txt", b"from the phone");
let remote = daemon.mountpoint.join("phone.txt");
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(&remote).ok().as_deref() == Some(b"from the phone".as_slice())
}),
"a remote write reached the mount"
);
daemon.client.set_online(false);
std::fs::write(daemon.mountpoint.join("offline.txt"), b"queued").unwrap();
std::thread::sleep(Duration::from_secs(2));
assert!(!drive.tree().contains_key("offline.txt"));
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(60), || drive
.tree()
.contains_key("offline.txt")),
"a write made offline landed once the link came back"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// A folder with files in it is not mounted over, and its files stay where
/// they are; once it is empty, the mount comes up (B277, #67).
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_mount_over_local_files_is_refused_and_leaves_them_alone() {
let drive = FakeDrive::new();
let dir = scratch("not-empty");
let mountpoint = dir.join("mnt");
std::fs::create_dir_all(mountpoint.join("Work")).unwrap();
std::fs::write(mountpoint.join("notes.txt"), b"local only").unwrap();
let error = Daemon::start(&dir, drive.client(1, Faults::lan()))
.err()
.expect("the mount must be refused");
let found = error
.get_ref()
.and_then(|inner| inner.downcast_ref::<pdfs_core::mountpoint::NotEmpty>())
.unwrap_or_else(|| panic!("refused for another reason: {error}"));
assert_eq!(found.entries, ["Work", "notes.txt"]);
assert!(!is_mounted(&mountpoint));
assert_eq!(
std::fs::read(mountpoint.join("notes.txt")).unwrap(),
b"local only"
);
assert!(
drive.tree().is_empty(),
"nothing went to Drive: {:?}",
drive.tree()
);
std::fs::remove_file(mountpoint.join("notes.txt")).unwrap();
std::fs::remove_dir(mountpoint.join("Work")).unwrap();
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Write `bytes` at `offset` into an existing file, without truncating it.
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
use std::os::unix::fs::FileExt;
std::fs::OpenOptions::new()
.write(true)
.open(path)?
.write_all_at(bytes, offset)
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_closed_empty_takes_a_second_write_at_once() {
// The empty revision never counted as complete, so the next write
// open before it drained was refused with EIO (B123).
let drive = FakeDrive::new();
let dir = scratch("empty-rewrite");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("empty.txt");
std::fs::write(&path, b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("empty.txt")
}));
std::fs::write(&path, b"").unwrap();
write_at(&path, 0, b"filled").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("empty.txt") == Some(&Entry::File(Arc::new(b"filled".to_vec())))
}),
"the second write landed: {:?}",
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_made_while_the_drain_is_idle_reaches_drive_at_once() {
// A new folder queued its create without waking the drain, so it sat
// until the workers' 30 s idle poll (B160).
let drive = FakeDrive::new();
let dir = scratch("idle-mkdir");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
std::fs::create_dir(daemon.mountpoint.join("x")).unwrap();
assert!(
wait_until(Duration::from_secs(10), || drive.lookup("x").is_some()),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_made_empty_while_the_drain_is_idle_reaches_drive_at_once() {
// A new file's create waits for the release that attaches its bytes
// to wake the drain. A file closed without any attached none, so it
// sat until the workers' 30 s idle poll (B192).
let drive = FakeDrive::new();
let dir = scratch("idle-empty-create");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
std::fs::write(daemon.mountpoint.join("e.md"), b"").unwrap();
assert!(
wait_until(Duration::from_secs(10), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("e.md").is_some()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_whose_upload_was_not_read_back_takes_two_writes_at_once() {
// The read-back after an upload went unanswered, and the drain let go of
// the uploaded bytes without caching them. A partial write had nothing
// to fill its gaps from, and the next open was refused with EIO (B157).
let drive = FakeDrive::new();
let dir = scratch("upload-not-read-back");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
std::fs::write(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"first")
}));
daemon.client.lose_read_back_of_next_revision();
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"0123456789")
}));
write_at(&path, 2, b"ab").unwrap();
write_at(&path, 6, b"cd").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() {
// Nothing here ever read the file, so the release of a partial write
// had no cached base to fill its gaps from and queued an incomplete
// revision. The next write open on it failed with EIO (B204).
let drive = FakeDrive::new();
drive.device().write("f.txt", b"0123456789");
let dir = scratch("partial-write-unread-base");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
write_at(&path, 2, b"ab").unwrap();
write_at(&path, 6, b"cd").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
// Queuing the trash dropped the cached bytes the open handle reads,
// and a partial write offline failed with EIO (B186).
use std::io::{Read, Seek, SeekFrom, Write};
let drive = FakeDrive::new();
let dir = scratch("unlink-open-offline");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.drained()
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
}));
daemon.client.set_online(false);
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
std::fs::remove_file(&path).unwrap();
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(b"ab").unwrap();
let mut read = Vec::new();
file.seek(SeekFrom::Start(0)).unwrap();
file.read_to_end(&mut read).unwrap();
assert_eq!(read, b"0123456789ab");
drop(file);
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
// The bytes kept for the handle went once the trash landed with it
// closed. The queue empties as the trash's row goes, a moment before
// the eviction, so this waits too.
let cached = || -> Vec<_> {
std::fs::read_dir(dir.join("cache"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.map(|e| e.file_name())
.collect()
};
assert!(
wait_until(Duration::from_secs(30), || cached().is_empty()),
"{:?}",
cached()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_stop_keeps_what_a_handle_wrote_before_its_release() {
// close(2) returns before the kernel sends the release, and the stop
// aborted the connection with the release still queued: the bytes of a
// file just saved were never queued, and Drive kept it empty (B190).
// A handle still open at the stop is the same case, held open.
use std::io::Write;
let drive = FakeDrive::new();
let dir = scratch("stop-before-release");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mut file = std::fs::File::create(daemon.mountpoint.join("f.txt")).unwrap();
file.write_all(b"0123456789").unwrap();
let daemon = daemon.restart().unwrap();
drop(file);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt")
== Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_write_that_failed_on_a_short_outage_goes_out_soon_after_the_link_returns() {
// Only the drain met the outage, and it never took the mount offline:
// no probe saw the link come back, and the write sat out its backoff
// (B189).
let drive = FakeDrive::new();
let dir = scratch("short-outage");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::write(daemon.mountpoint.join("g.txt"), b"g").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().len() == 1
}));
daemon.client.set_online(false);
std::fs::write(daemon.mountpoint.join("f.txt"), b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon
.pending()
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
}));
// The drain records the failure just before it takes the mount
// offline. Left online, only the event poll would, much later.
assert!(wait_until(Duration::from_secs(2), || daemon
.online()
.is_ok_and(|online| !online)));
daemon.client.set_online(true);
// The probe that sees the link return runs 2 s after the drop, then
// 6 s, and a loaded run can reach this line after the first one. So
// the 3 s below count from the mount being back, not from the link:
// an op left on its backoff waits 4 s or more after its failure.
assert!(wait_until(Duration::from_secs(30), || daemon
.online()
.unwrap_or(false)));
assert!(
wait_until(Duration::from_secs(3), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt")
== Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
}),
"{:?}",
daemon.pending()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_new_file_deleted_while_open_as_its_create_lands_still_reads() {
// The create's op was retired and the tree still had the placeholder.
// The queue read empty, and the delete trashed the node by its real
// uid, which the tree did not have yet: it took the open file for
// closed and dropped the bytes its handle reads (B257).
use std::io::{Read, Seek, SeekFrom, Write};
let drive = FakeDrive::new();
let dir = scratch("delete-open-as-create-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let landing = daemon.client.hold_next_landing_of_any();
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
assert!(!daemon.drained(), "the landing create is counted");
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
drop(landing);
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.client.set_online(false);
std::fs::remove_file(&path).unwrap();
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(b"ab").unwrap();
let mut read = Vec::new();
file.seek(SeekFrom::Start(0)).unwrap();
file.read_to_end(&mut read).unwrap();
assert_eq!(read, b"0123456789ab");
drop(file);
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
// The bytes kept for the open handle outlived a restart, which queued
// them again as a revision; the trash then landed and removed them, and
// the revision retried on a missing blob for good (B188).
use std::io::Write;
let drive = FakeDrive::new();
let dir = scratch("unlink-open-restart");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.drained()
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
}));
daemon.client.set_online(false);
std::fs::write(&path, b"abcdef").unwrap();
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
std::fs::remove_file(&path).unwrap();
file.write_all(b"xy").unwrap();
drop(file);
let daemon = daemon.restart().unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_write_opened_as_an_upload_lands_takes_the_landed_revision_as_its_base() {
// An upload landed and the drain let go of its blob before the tree had
// the node. A partial write opened in between took the revision the
// upload replaced as its base, found none of its bytes cached, and
// queued an incomplete revision: the next write open failed with EIO
// (B170).
let drive = FakeDrive::new();
let dir = scratch("write-opened-as-upload-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
std::fs::write(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"first")
}));
let uid = drive.lookup("f.txt").unwrap();
let landing = daemon.client.hold_next_landing(&uid);
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
write_at(&path, 2, b"ab").unwrap();
drop(landing);
write_at(&path, 6, b"cd").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_renamed_while_its_upload_is_read_back_keeps_its_new_name() {
// The rename landed while the drain read the uploaded file back. The
// answer still had the old name, and the drain found no rename queued,
// so the mount showed the file under its old name and the new one
// answered ENOENT (B172).
let drive = FakeDrive::new();
let dir = scratch("renamed-while-read-back");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
std::fs::write(mnt.join("a.txt"), b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("a.txt").cloned() == file(b"first")
}));
let uid = drive.lookup("a.txt").unwrap();
let mut held = daemon.client.hold_answer_to_next_read(&uid);
std::fs::write(mnt.join("a.txt"), b"second").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::rename(mnt.join("a.txt"), mnt.join("f.txt")).unwrap();
// Each read is answered in turn until the upload's op is retired; the
// answer to the read-back after it is held until the rename has landed.
let reading_back = loop {
if daemon
.pending()
.is_ok_and(|items| items.iter().all(|op| op.kind != "revision"))
{
break held;
}
let next = daemon.client.hold_answer_to_next_read(&uid);
drop(held);
assert!(wait_until(Duration::from_secs(30), || next.reached()));
held = next;
};
assert!(
wait_until(Duration::from_secs(30), || drive.lookup("f.txt").is_some()),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
drop(reading_back);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"second")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
// Long enough for the drain to have put the read-back into the tree.
std::thread::sleep(Duration::from_millis(200));
assert_eq!(std::fs::read(mnt.join("f.txt")).unwrap(), b"second");
assert!(!mnt.join("a.txt").exists());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_as_its_create_lands_is_trashed_on_drive() {
// The drain had retired the create and the tree still had the file
// under its stand-in. The delete found no op left to drop and took the
// file for one Drive had never seen, so Drive kept it (B173).
let drive = FakeDrive::new();
let dir = scratch("deleted-as-create-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("c.bin");
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&path, b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("c.bin").unwrap();
let landing = daemon.client.hold_next_landing(&uid);
drop(create);
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
std::fs::remove_file(&path).unwrap();
drop(landing);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("c.bin").is_none()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(!path.exists());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_replaced_as_its_create_lands_is_trashed_on_drive() {
// The same race as B173, through a rename onto the file: the replace
// found no op left to drop and took the file for one Drive had never
// seen. Drive kept it under the name, and the next rename of that name
// moved it instead of the file that replaced it (B175).
let drive = FakeDrive::new();
let dir = scratch("replaced-as-create-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
std::fs::write(mnt.join("b.txt"), b"new").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("b.txt").is_some()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("a.txt").unwrap();
let landing = daemon.client.hold_next_landing(&uid);
drop(create);
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
std::fs::rename(mnt.join("b.txt"), mnt.join("a.txt")).unwrap();
drop(landing);
std::fs::rename(mnt.join("a.txt"), mnt.join("d")).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("a.txt").is_none()
&& drive.tree().get("d") == Some(&Entry::File(Arc::new(b"new".to_vec())))
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(std::fs::read(mnt.join("d")).unwrap(), b"new");
assert!(!mnt.join("a.txt").exists());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_read_as_its_create_lands_has_its_bytes() {
// The landing moved the queued write to the uid Drive gave the file.
// A read that still knew the file by its stand-in found no write
// queued under it, took the file for one never written and read it
// empty (B179).
let drive = FakeDrive::new();
let dir = scratch("read-as-create-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
let uid = drive.lookup("a.txt").unwrap();
let landing = daemon.client.hold_next_landing(&uid);
drop(create);
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
assert_eq!(std::fs::read(mnt.join("a.txt")).unwrap(), b"newer");
drop(landing);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("a.txt") == Some(&Entry::File(Arc::new(b"newer".to_vec())))
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_discarded_change_is_undone_here_and_never_sent() {
let drive = FakeDrive::new();
for name in ["keep.txt", "edit.txt", "gone.txt"] {
drive.device().write(name, b"remote");
}
let before = drive.tree();
let dir = scratch("discard");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::rename(mnt.join("keep.txt"), mnt.join("moved.txt")).unwrap();
std::fs::write(mnt.join("edit.txt"), b"local").unwrap();
std::fs::remove_file(mnt.join("gone.txt")).unwrap();
std::fs::write(mnt.join("new.txt"), b"new").unwrap();
std::fs::create_dir(mnt.join("dir")).unwrap();
// Discarding one change drops the others queued on its node.
for _ in 0..10 {
let Some(op) = daemon.pending().unwrap().into_iter().next() else {
break;
};
let reply = daemon.request(&Request::DiscardPendingOp { id: op.id });
assert!(
matches!(reply, Ok(Response::Ok { .. })),
"{op:?}: {reply:?}"
);
}
assert!(daemon.pending().unwrap().is_empty());
let mut names: Vec<_> = std::fs::read_dir(&mnt)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.filter(|name| name.ends_with(".txt") || name == "dir")
.collect();
names.sort();
assert_eq!(names, ["edit.txt", "gone.txt", "keep.txt"]);
for name in &names {
assert_eq!(std::fs::read(mnt.join(name)).unwrap(), b"remote", "{name}");
}
daemon.request(&paused(false)).unwrap();
std::thread::sleep(Duration::from_secs(2));
assert_eq!(drive.tree(), before);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_refused_change_shows_on_its_node_until_it_is_discarded() {
let drive = FakeDrive::new();
let dir = scratch("issue-on-node");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
drive.set_quota(10);
std::fs::write(mnt.join("big.bin"), [0u8; 64]).unwrap();
std::fs::write(mnt.join("small.txt"), b"ok").unwrap();
let list = || {
let path = mnt.to_string_lossy().into_owned();
match daemon.request(&Request::ListDir { path }) {
Ok(Response::Entries { entries }) => entries
.into_iter()
.filter(|entry| entry.name.contains('.'))
.map(|entry| (entry.name, entry.issue))
.collect::<BTreeMap<_, _>>(),
other => panic!("the daemon did not list the root: {other:?}"),
}
};
let refused = BTreeMap::from([
("big.bin".to_string(), Some(SyncIssue::Quota)),
("small.txt".to_string(), None),
]);
assert!(
wait_until(Duration::from_secs(30), || list() == refused),
"{:?} {:?}",
list(),
daemon.pending()
);
let op = daemon.pending().unwrap().remove(0);
let reply = daemon.request(&Request::DiscardPendingOp { id: op.id });
assert!(matches!(reply, Ok(Response::Ok { .. })), "{reply:?}");
assert_eq!(list(), BTreeMap::from([("small.txt".to_string(), None)]));
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_whose_mkdir_is_queued_lists_after_a_refresh() {
// Once its listing was dropped, the folder was asked of Drive by its
// stand-in, and every listing and lookup in it failed with EIO until
// its mkdir landed (B125).
let drive = FakeDrive::new();
let dir = scratch("queued-folder-refresh");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::create_dir(mnt.join("d")).unwrap();
std::fs::write(mnt.join("d/f.txt"), b"queued").unwrap();
let path = mnt.join("d").to_string_lossy().into_owned();
let refresh = Request::Refresh {
scope: RefreshScope::Dir { path: path.clone() },
};
assert!(matches!(daemon.request(&refresh), Ok(Response::Ok { .. })));
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir { path }) else {
panic!("the daemon did not list d");
};
let names: Vec<_> = entries.iter().map(|entry| entry.name.as_str()).collect();
assert_eq!(names, ["f.txt"]);
assert_eq!(std::fs::read(mnt.join("d/f.txt")).unwrap(), b"queued");
daemon.request(&paused(false)).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("d/f.txt")
== Some(&Entry::File(Arc::new(b"queued".to_vec())))
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_moved_while_its_create_was_on_the_wire_keeps_its_new_name() {
// The move queued behind the create landed while the drain read the
// new node back. The answer still had the old name, and the drain no
// longer found the move queued, so the mount showed the old name in
// the new folder (B161).
let drive = FakeDrive::new();
let dir = scratch("moved-while-created");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
std::fs::create_dir(mnt.join("x")).unwrap();
std::fs::create_dir(mnt.join("y")).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("y").is_some()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
let uid = drive.lookup("x/c.bin").unwrap();
// Each read-back is answered in turn until the create is retired; the
// answer to the next one is held until the move has landed. A new
// folder wakes the drain for the move.
let mut held = create;
let adopting = loop {
let next = daemon.client.hold_answer_to_next_read(&uid);
drop(held);
assert!(wait_until(Duration::from_secs(30), || next.reached()));
if daemon
.pending()
.is_ok_and(|items| items.iter().all(|op| op.kind != "create"))
{
break next;
}
held = next;
};
std::fs::create_dir(mnt.join("z")).unwrap();
assert!(
wait_until(Duration::from_secs(10), || drive.lookup("y/d").is_some()),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
drop(adopting);
let file = Some(Entry::File(Arc::new(b"abc".to_vec())));
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("y/d").cloned() == file
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
let names: Vec<_> = std::fs::read_dir(mnt.join("y"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, ["d"]);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_read_back_before_drive_committed_it_keeps_its_bytes() {
// Drive listed the new file a moment before its create was committed,
// with no revision and no size. The drain took that answer for the
// file, and a read right after the create landed found it empty
// (B163).
let drive = FakeDrive::new();
let dir = scratch("read-back-unrevised");
// The feed reports the file as it is long after the read.
let faults = Faults {
event_delay: Duration::from_secs(60),
..Faults::lan()
};
let daemon = Daemon::start(&dir, drive.client(1, faults)).unwrap();
let mnt = daemon.mountpoint.clone();
// A folder made here is listed from the tree, not from Drive.
std::fs::create_dir(mnt.join("x")).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
take_logged();
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("x/c.bin").unwrap();
// The read-back after the create, the one adopting it, and the size
// fetch a listing makes.
daemon.client.answer_reads_unrevised(&uid, 3);
drop(create);
let mut logged = Vec::new();
assert!(
wait_until(Duration::from_secs(30), || {
logged.extend(take_logged());
logged
.iter()
.any(|line| line.starts_with("pending create landed") && line.contains("c.bin"))
}),
"{logged:?}"
);
// A listing refreshes the size the kernel holds, as the account run's
// did before it read the file.
let sizes: Vec<_> = std::fs::read_dir(mnt.join("x"))
.unwrap()
.map(|e| e.unwrap().metadata().unwrap().len())
.collect();
assert_eq!(sizes, [3]);
assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_create_drive_lists_before_it_is_read_back_is_listed_once() {
// The feed reported a new file while the drain still read it back, so
// the folder was listed from Drive again, and the file came twice: as
// Drive's node and as the queued create's stand-in. A 128-file folder
// listed 129 (B164).
let drive = FakeDrive::new();
let dir = scratch("listed-before-retired");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
std::fs::create_dir(mnt.join("x")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some()
}));
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("x/c.bin").unwrap();
let read_back = daemon.client.hold_next_read(&uid);
drop(create);
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
// Long enough for the next event poll to bring the file's event.
std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3));
let names: Vec<_> = std::fs::read_dir(mnt.join("x"))
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
drop(read_back);
assert_eq!(names, ["c.bin"]);
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_listing_taken_before_a_create_commits_keeps_the_landed_file() {
// Drive listed a new file before its create was committed, with no
// revision, and the answer came back after the create landed: the
// queue no longer held the file, so Drive's empty node was taken, and
// a 17-byte file read back as 0 bytes (B166). The size upgrade's read
// still finds it so, and the daemon's own listing is checked: the
// kernel still has the size the write left.
let drive = FakeDrive::new();
let dir = scratch("listed-before-commit");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
std::fs::create_dir(mnt.join("x")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some()
}));
let create = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
// Long enough for the next event poll to bring the file's event, which
// drops the folder's listing.
std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3));
let folder = drive.lookup("x").unwrap();
let uid = drive.lookup("x/c.bin").unwrap();
let listing = daemon.client.hold_answer_to_next_listing(&folder, &uid);
let lister = {
let x = mnt.join("x");
std::thread::spawn(move || std::fs::read_dir(x).unwrap().count())
};
assert!(wait_until(Duration::from_secs(30), || listing.reached()));
drop(create);
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
daemon.client.answer_reads_unrevised(&uid, 1);
drop(listing);
assert_eq!(lister.join().unwrap(), 1);
let path = mnt.join("x").to_string_lossy().into_owned();
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir { path }) else {
panic!("the daemon did not list x");
};
let sizes: Vec<_> = entries
.iter()
.map(|entry| (entry.name.as_str(), entry.size))
.collect();
assert_eq!(sizes, [("c.bin", 3)]);
assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// The database of a daemon just stopped, once the threads it left in a
/// request have let go of it, as [`Daemon::start`] waits for them.
fn reopen(dir: &Path) -> Db {
let mut db = None;
assert!(wait_until(LAST_RUN_DEADLINE, || {
db = Db::open(&dir.join("pdfs.db")).ok();
db.is_some()
}));
db.unwrap()
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn close_syncs_a_written_file_and_fsync_accepts_a_read_only_one() {
// `close` is answered by flush, which now syncs the scratch file so a
// failure reaches the program; `fsync` on a descriptor opened for
// reading failed with EBADF (B222).
use std::io::{Read, Write};
let drive = FakeDrive::new();
let dir = scratch("flush-fsync");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("notes.txt");
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(b"written then closed").unwrap();
drop(file);
let mut again = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
again.write_all(b", twice").unwrap();
again.flush().unwrap();
drop(again);
let mut read_only = std::fs::File::open(&path).unwrap();
read_only.sync_all().unwrap();
let mut text = String::new();
read_only.read_to_string(&mut text).unwrap();
assert_eq!(text, "written then closed, twice");
drop(read_only);
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
assert!(drive.lookup("notes.txt").is_some());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending() {
// A stop that came while a mirror pass was uploading waited for it,
// and the pass went on to the next level of folders until the stop
// gave up on it and ended the runtime under it (B169).
let drive = FakeDrive::new();
let dir = scratch("mirror-stop");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("mirrored").is_some()
}));
let remote = drive.lookup("mirrored").unwrap();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let local = dir.join("local");
std::fs::create_dir_all(local.join("deeper")).unwrap();
std::fs::write(local.join("first"), b"abc").unwrap();
std::fs::write(local.join("deeper/second"), b"def").unwrap();
let folder = reopen(&dir)
.sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "")
.unwrap();
let client = drive.client(1, Faults::lan());
let upload = client.hold_next_create();
let daemon = Daemon::start(&dir, client).unwrap();
assert!(wait_until(Duration::from_secs(30), || upload.reached()));
let socket = daemon.socket.clone();
let stopping = std::thread::spawn(move || daemon.stop());
assert!(wait_until(Duration::from_secs(30), || !socket.exists()));
drop(upload);
assert!(matches!(
stopping.join().unwrap(),
Ok(MountOutcome::Shutdown)
));
assert!(drive.lookup("mirrored/first").is_some());
assert!(drive.lookup("mirrored/deeper").is_some());
assert!(drive.lookup("mirrored/deeper/second").is_none());
let logged = take_logged();
assert!(
logged
.iter()
.any(|line| line.contains("pass cut short by the stop"))
);
assert!(
!logged
.iter()
.any(|line| line.contains("did not finish in time")),
"{logged:#?}"
);
let db = reopen(&dir);
assert_ne!(
db.sync_folder_get(folder).unwrap().unwrap().state,
"syncing"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A folder `mirrored` on Drive, synced in mirror mode with `dir/local`,
/// which holds `files` before the first pass. Returns the daemon, the
/// folder's id and the link it uses.
fn start_mirror(
drive: &Arc<FakeDrive>,
dir: &Path,
files: &[(&str, &[u8])],
) -> (Daemon, i64, FakeClient) {
let daemon = Daemon::start(dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("mirrored").is_some()
}));
let remote = drive.lookup("mirrored").unwrap();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let local = dir.join("local");
std::fs::create_dir_all(&local).unwrap();
for (name, bytes) in files {
std::fs::write(local.join(name), bytes).unwrap();
}
let folder = reopen(dir)
.sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "")
.unwrap();
let client = drive.client(1, Faults::lan());
let daemon = Daemon::start(dir, client.clone()).unwrap();
(daemon, folder, client)
}
/// Wait until a pass of the mirror that began after `since` (epoch
/// seconds) is over without errors.
fn wait_for_pass(daemon: &Daemon, folder: i64, since: i64) -> bool {
wait_until(Duration::from_secs(30), || {
let Ok(Response::SyncFolders { items }) = daemon.request(&Request::ListSyncFolders)
else {
return false;
};
items.iter().any(|f| {
f.id == folder && f.state == "idle" && f.progress.is_none() && f.last_sync >= since
})
})
}
fn conflict_copies(dir: &Path, stem: &str) -> Vec<PathBuf> {
let prefix = format!("{stem} (sync-conflict ");
std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&prefix))
})
.collect()
}
fn on_drive(drive: &FakeDrive, path: &str) -> Option<Vec<u8>> {
match drive.tree().get(path) {
Some(Entry::File(content)) => Some(content.to_vec()),
_ => None,
}
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_joining_mirror_keeps_a_same_size_different_file_as_a_conflict() {
// A computer joining a folder another one syncs found a file of the
// same size at the same path and took it for the same file, so the two
// went on holding different content (B248). One that is the same is
// still taken as it is.
let drive = FakeDrive::new();
let dir = scratch("join-same-size");
let theirs: &[u8] = b"from the other computer";
let ours: &[u8] = b"written on this machine";
assert_eq!(theirs.len(), ours.len());
let daemon = {
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("mirrored").is_some()
}));
daemon
};
drive.device().write("mirrored/notes.txt", theirs);
drive.device().write("mirrored/same.txt", b"identical");
let remote = drive.lookup("mirrored").unwrap();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let local = dir.join("local");
std::fs::create_dir_all(&local).unwrap();
std::fs::write(local.join("notes.txt"), ours).unwrap();
std::fs::write(local.join("same.txt"), b"identical").unwrap();
reopen(&dir)
.sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "")
.unwrap();
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
assert!(wait_until(Duration::from_secs(60), || {
conflict_copies(&local, "notes").len() == 1
&& std::fs::read(local.join("notes.txt")).is_ok_and(|b| b == theirs)
&& drive
.tree()
.keys()
.any(|k| k.starts_with("mirrored/notes (sync-conflict "))
}));
let copy = &conflict_copies(&local, "notes")[0];
assert_eq!(std::fs::read(copy).unwrap(), ours);
assert_eq!(on_drive(&drive, "mirrored/notes.txt").unwrap(), theirs);
assert!(conflict_copies(&local, "same").is_empty());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_mirror_edit_does_not_replace_a_revision_another_computer_made_since() {
// A local edit went up as a new revision of whatever Drive held at the
// time of the upload, from a plan made before another computer saved
// its own edit. Its edit was left only in the version history (B249).
let drive = FakeDrive::new();
let dir = scratch("mirror-revision-race");
let (daemon, folder, client) =
start_mirror(&drive, &dir, &[("a.txt", b"one"), ("b.txt", b"two")]);
let local = dir.join("local");
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("mirrored/a.txt").is_some() && drive.lookup("mirrored/b.txt").is_some()
}));
assert!(wait_for_pass(&daemon, folder, 0));
// The echoes of the first uploads read the files too.
std::thread::sleep(Duration::from_secs(2));
let a = drive.lookup("mirrored/a.txt").unwrap();
let check = client.hold_next_read(&a);
std::fs::write(local.join("a.txt"), b"edited here").unwrap();
assert!(wait_until(Duration::from_secs(30), || check.reached()));
drive
.device()
.write("mirrored/a.txt", b"edited on the other computer");
drop(check);
assert!(wait_until(Duration::from_secs(30), || {
take_logged()
.iter()
.any(|line| line.contains("changed on Drive since this pass looked"))
}));
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(60), || {
std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"edited on the other computer")
&& conflict_copies(&local, "a").len() == 1
&& drive
.tree()
.keys()
.any(|k| k.starts_with("mirrored/a (sync-conflict "))
}));
assert_eq!(
std::fs::read(&conflict_copies(&local, "a")[0]).unwrap(),
b"edited here"
);
assert_eq!(
on_drive(&drive, "mirrored/a.txt").unwrap(),
b"edited on the other computer"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_revision_made_during_a_mirror_download_still_arrives() {
// The baseline after a download read the node again, and recorded a
// revision another computer made while the download ran as synced.
// The disk held the older one, and the newer never arrived (B250).
let drive = FakeDrive::new();
let dir = scratch("mirror-download-race");
let (daemon, folder, client) =
start_mirror(&drive, &dir, &[("a.txt", b"one"), ("b.txt", b"two")]);
let local = dir.join("local");
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("mirrored/a.txt").is_some() && drive.lookup("mirrored/b.txt").is_some()
}));
assert!(wait_for_pass(&daemon, folder, 0));
let a = drive.lookup("mirrored/a.txt").unwrap();
drive.device().write("mirrored/a.txt", b"second");
let download = client.hold_next_download(&a);
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || download.reached()));
drive.device().write("mirrored/a.txt", b"thirdd");
drop(download);
assert!(wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"second")
}));
assert!(wait_for_pass(&daemon, folder, 0));
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"thirdd")
}));
assert!(conflict_copies(&local, "a").is_empty());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_removed_with_its_folder_while_uploading_stays_removed() {
// A folder removed while a file in it was on its way to Drive: the
// folder's trash landed first, Drive refused the file's create, and
// the drain took the folder for trashed elsewhere and made the deleted
// file again in the root, failing only because its bytes were gone.
// Its queued trash then re-read the folder it had been sent to (B168).
let drive = FakeDrive::new();
let dir = scratch("removed-while-uploading");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
std::fs::create_dir(mnt.join("x")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some()
}));
let folder = drive.lookup("x").unwrap();
let create = daemon.client.hold_next_create();
daemon
.write_new(&mnt.join("x/removed-while-uploading"), b"abc")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
std::fs::remove_dir(mnt.join("x")).unwrap();
assert!(wait_until(Duration::from_secs(30), || drive
.lookup("x")
.is_none()));
drop(create);
assert!(wait_until(Duration::from_secs(60), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
assert_eq!(drive.names_ever_in_root(), ["x"]);
assert!(drive.tree().is_empty());
let rehomed = take_logged().into_iter().find(|line| {
line.contains("creating in the root") && line.contains("removed-while-uploading")
});
assert_eq!(rehomed, None);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let db = Db::open(&dir.join("pdfs.db")).unwrap();
assert!(db.node_by_uid(&folder.to_string()).unwrap().is_none());
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_made_again_while_the_old_one_is_trashed_is_a_new_folder() {
// A folder removed and made again at once: the new one's create met
// the old folder, its trash still on the wire, and took it for the
// node an unanswered create of its own had made. What went into the
// new folder went into the old one, and the trash took it all along
// (B162).
let drive = FakeDrive::new();
let dir = scratch("remade-while-trashed");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
std::fs::create_dir(mnt.join("z")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("z").is_some()
}));
let old = drive.lookup("z").unwrap();
let trash = daemon.client.hold_next_trash(&old);
std::fs::remove_dir(mnt.join("z")).unwrap();
assert!(wait_until(Duration::from_secs(30), || trash.reached()));
std::fs::create_dir(mnt.join("z")).unwrap();
std::fs::write(mnt.join("z/f"), b"abc").unwrap();
// Sent, and either waiting for the name or landed.
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| {
!items
.iter()
.any(|op| op.kind == "mkdir" && op.attempts == 0)
})
}));
drop(trash);
let file = Some(Entry::File(Arc::new(b"abc".to_vec())));
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("z/f").cloned() == file
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_ne!(drive.lookup("z"), Some(old));
assert_eq!(std::fs::read(mnt.join("z/f")).unwrap(), b"abc");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn the_echo_of_our_own_rename_keeps_the_cached_bytes() {
// The echo read as someone else's change and evicted the bytes, so a
// partial write could not gap-fill, and the next write open on the
// file was refused with EIO (B123).
let drive = FakeDrive::new();
let dir = scratch("rename-echo");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let before = daemon.mountpoint.join("a.txt");
std::fs::write(&before, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("a.txt")
}));
assert_eq!(std::fs::read(&before).unwrap(), b"0123456789");
let after = daemon.mountpoint.join("b.txt");
std::fs::rename(&before, &after).unwrap();
// Long enough for the next event poll to bring the echo.
std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3));
write_at(&after, 3, b"x").unwrap();
write_at(&after, 5, b"y").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("b.txt") == Some(&Entry::File(Arc::new(b"012x4y6789".to_vec())))
}),
"both writes landed: {:?}",
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_rename_across_folders_ignores_the_new_name_in_the_old_folder() {
// The rename went to Drive as a rename in the old folder and then a
// move, so a sibling there with the new name failed it with EIO
// (B122).
let drive = FakeDrive::new();
let dir = scratch("rename-across");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
std::fs::create_dir(mount.join("x")).unwrap();
std::fs::create_dir(mount.join("z")).unwrap();
std::fs::write(mount.join("x/f.txt"), b"moved").unwrap();
std::fs::write(mount.join("x/d"), b"sibling").unwrap();
std::fs::write(mount.join("z/d"), b"replaced").unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(30), || {
let tree = drive.tree();
[
("x/f.txt", b"moved".as_slice()),
("x/d", b"sibling"),
("z/d", b"replaced"),
]
.iter()
.all(|(path, bytes)| tree.get(*path).cloned() == file(bytes))
}));
std::fs::rename(mount.join("x/f.txt"), mount.join("z/d")).unwrap();
assert_eq!(std::fs::read(mount.join("z/d")).unwrap(), b"moved");
// The rename is recorded locally and sent from the queue.
assert!(
wait_until(Duration::from_secs(30), || {
let tree = drive.tree();
tree.get("z/d").cloned() == file(b"moved")
&& tree.get("x/d").cloned() == file(b"sibling")
&& !tree.contains_key("x/f.txt")
}),
"{:?}",
drive.tree().keys().collect::<Vec<_>>()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_write_over_a_create_whose_read_back_failed_is_not_a_conflict() {
// The create landed and the link went down before it was read back.
// The next write was based on the file as it was made locally, and
// landed as a conflict copy of it (B143).
let drive = FakeDrive::new();
let dir = scratch("unadopted");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let path = daemon.mountpoint.join("f.txt");
// Drive stamps a create with the second it arrives, so it has to land
// a second after the file was made here for the two to differ.
daemon.client.set_online(false);
std::fs::write(&path, b"first").unwrap();
std::thread::sleep(Duration::from_millis(1100));
daemon.client.drop_link_after_next_create();
daemon.client.set_online(true);
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("f.txt").cloned() == file(b"first")
}));
write_at(&path, 5, b" and second").unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("f.txt").cloned() == file(b"first and second")
}),
"{:?}",
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_write_after_a_restart_over_a_create_never_read_back_is_not_a_conflict() {
// The create landed and the link went down before it was read back,
// then the daemon restarted. What was to read it back did not outlive
// the restart, so the next write was based on the file as it was made
// here and landed as a conflict copy of it (B182).
let drive = FakeDrive::new();
let dir = scratch("unadopted-restart");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let path = daemon.mountpoint.join("f.txt");
// Drive stamps a create with the second it arrives, so it has to land
// a second after the file was made here for the two to differ.
daemon.client.set_online(false);
std::fs::write(&path, b"first").unwrap();
std::thread::sleep(Duration::from_millis(1100));
daemon.client.drop_link_after_next_create();
daemon.client.set_online(true);
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("f.txt").cloned() == file(b"first")
&& daemon.pending().is_ok_and(|items| items.is_empty())
}));
let daemon = daemon.restart().unwrap();
let path = daemon.mountpoint.join("f.txt");
write_at(&path, 5, b" and second").unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("f.txt").cloned() == file(b"first and second")
}),
"{:?}",
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_create_whose_answer_was_lost_lands_once() {
// Drive made the file, but its answer was lost. The retry found the
// name taken by a node holding the file's bytes, which was not empty
// and so not adopted, and uploaded the file again as a conflict copy
// (B127).
let drive = FakeDrive::new();
let dir = scratch("lost-create-reply");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
daemon.client.lose_reply_to_next_create();
daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"once")
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"once")
}),
"{:?}",
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_written_again_after_its_create_lost_its_answer_lands_once() {
// Drive made the file, but its answer was lost, and the file was
// written again before the retry. The retry found the name taken by a
// node holding the bytes the first attempt sent, not the ones the op
// held now, and uploaded the file as a conflict copy (B156).
let drive = FakeDrive::new();
let dir = scratch("lost-create-reply-rewritten");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let held = daemon.client.hold_reply_to_next_create();
daemon.client.lose_reply_to_next_create();
daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap();
drop(held);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"second")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert_eq!(std::fs::read(&path).unwrap(), b"second");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_after_its_create_lost_its_answer_leaves_drive() {
// Drive made the file, but its answer was lost, and the file was
// deleted before the retry. The delete dropped the queued create and
// left the node Drive had made in place (B151).
let drive = FakeDrive::new();
let dir = scratch("lost-create-reply-deleted");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
daemon.client.lose_reply_to_next_create();
daemon.write_new(&path, b"gone").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("f.txt")
}));
std::fs::remove_file(&path).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(!path.exists());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_after_a_restart_leaves_drive_with_what_its_create_sent() {
// Drive made the file with the bytes its create sent, but the answer
// was lost, and the file was written again. After a restart the file
// was deleted. The trash knew only the bytes the file held last, which
// were not the ones Drive had, so it left that file on Drive (B183).
let drive = FakeDrive::new();
let dir = scratch("lost-create-reply-restart-deleted");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let held = daemon.client.hold_reply_to_next_create();
daemon.client.lose_reply_to_next_create();
daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap();
// The kernel sends the release after `close` returns. Attaching the
// bytes resets the create's attempts, so a release that came after the
// lost reply had it retried at once, and the retry landed before
// anything here saw it fail. The bytes it replaced go only once they
// are attached.
let staged = dir.join("cache").join("staging");
assert!(wait_until(Duration::from_secs(30), || {
let blobs: Vec<Vec<u8>> = std::fs::read_dir(&staged)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter_map(|e| std::fs::read(e.path()).ok())
.collect();
blobs.iter().any(|b| b == b"second") && !blobs.iter().any(|b| b == b"first")
}));
drop(held);
assert!(wait_until(Duration::from_secs(30), || {
daemon
.pending()
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
}));
daemon.client.set_online(false);
let daemon = daemon.restart().unwrap();
let path = daemon.mountpoint.join("f.txt");
std::fs::remove_file(&path).unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(!path.exists());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_renamed_after_its_create_lost_its_answer_lands_once() {
// Drive made the file, but the answer was lost, and the file was
// renamed before the retry. The retry looked for the file Drive made
// under the new name only, and made a second one there (B184).
let drive = FakeDrive::new();
let dir = scratch("lost-create-reply-renamed");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
daemon.client.lose_reply_to_next_create();
daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon
.pending()
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
}));
daemon.client.set_online(false);
std::fs::rename(
daemon.mountpoint.join("f.txt"),
daemon.mountpoint.join("g.txt"),
)
.unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("g.txt").cloned() == file(b"first")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_renamed_and_deleted_after_its_create_lost_its_answer_leaves_nothing() {
// As above, and the file was deleted after the rename. The trash
// looked for the file Drive made under the last name only, and left
// it on Drive under the first (B184).
let drive = FakeDrive::new();
let dir = scratch("lost-create-reply-renamed-deleted");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
daemon.client.lose_reply_to_next_create();
daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon
.pending()
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
}));
daemon.client.set_online(false);
std::fs::rename(
daemon.mountpoint.join("f.txt"),
daemon.mountpoint.join("g.txt"),
)
.unwrap();
std::fs::remove_file(daemon.mountpoint.join("g.txt")).unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_withdrawn_create_leaves_the_file_renamed_to_the_name_it_was_sent_under() {
// A create was sent as d and refused, the file renamed to q, and an
// empty file of the user's renamed to d. Deleting q left a trash that
// looked for what the create may have made under d, took the user's
// empty file for it and trashed it (B193).
let drive = FakeDrive::new();
let dir = scratch("withdrawn-create-renamed-over");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
let empty = Some(Entry::File(Arc::new(Vec::new())));
std::fs::write(mnt.join("e.md"), b"").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("e.md").cloned() == empty
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
drive.set_quota(0);
std::fs::write(mnt.join("d"), b"refused").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon
.pending()
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
}));
std::fs::rename(mnt.join("d"), mnt.join("q")).unwrap();
std::fs::rename(mnt.join("e.md"), mnt.join("d")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("d").cloned() == empty
}));
std::fs::remove_file(mnt.join("q")).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}),
"{:?}",
daemon.pending()
);
let tree = drive.tree();
assert_eq!(tree.get("d").cloned(), empty, "{tree:?}");
assert_eq!(tree.len(), 1, "{tree:?}");
assert_eq!(std::fs::read(mnt.join("d")).unwrap(), b"");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_made_under_the_name_of_a_create_on_the_wire_waits_for_it() {
// A file renamed while its create was on the wire, and a new one made
// under its old name. The new file's create found the name held by the
// first and woke itself again after every try: it spent its waits in
// milliseconds and landed under a conflict name, which the mount then
// showed instead of the user's (B154).
let drive = FakeDrive::new();
let dir = scratch("create-waits-for-create");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let held = daemon.client.hold_reply_to_next_create();
daemon.write_new(&mount.join("c.bin"), b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
std::fs::write(mount.join("c.bin"), b"second").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon
.pending()
.is_ok_and(|items| items.iter().any(|op| op.attempts > 0))
}));
// Long enough for the waits to run out, were they not waits.
std::thread::sleep(Duration::from_millis(300));
drop(held);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("d").cloned() == file(b"first")
&& drive.tree().get("c.bin").cloned() == file(b"second")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 2, "{:?}", drive.tree());
assert_eq!(std::fs::read(mount.join("c.bin")).unwrap(), b"second");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_write_whose_answer_was_lost_lands_once() {
// Drive made the revision, but its answer was lost. The retry found
// the file at a revision it did not know and kept the write as a
// conflict copy of itself (B155).
let drive = FakeDrive::new();
let dir = scratch("lost-revision-reply");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
std::fs::write(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"first")
}));
daemon.client.lose_reply_to_next_revision();
std::fs::write(&path, b"second").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"second")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes() {
let drive = FakeDrive::new();
let dir = scratch("pdfs-copy");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
std::fs::create_dir_all(mount.join("src/inner")).unwrap();
std::fs::create_dir_all(mount.join("dest")).unwrap();
std::fs::write(mount.join("src/a.txt"), b"alpha").unwrap();
std::fs::write(mount.join("src/inner/b.txt"), b"beta").unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("src/inner/b.txt").cloned() == file(b"beta")
&& daemon.pending().is_ok_and(|ops| ops.is_empty())
}));
let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned();
let copy = |from: &str, to: &str| {
daemon.request(&Request::Copy {
path: path(from),
new_parent: path(to),
})
};
let uploads = drive.tree().len();
copy("src/a.txt", "dest").unwrap();
// A name already taken gets a suffix rather than an error.
copy("src/a.txt", "dest").unwrap();
copy("src", "dest").unwrap();
assert_eq!(std::fs::read(mount.join("dest/a.txt")).unwrap(), b"alpha");
assert_eq!(
std::fs::read(mount.join("dest/a (1).txt")).unwrap(),
b"alpha"
);
assert_eq!(
std::fs::read(mount.join("dest/src/inner/b.txt")).unwrap(),
b"beta"
);
let tree = drive.tree();
assert_eq!(tree.get("dest/src/a.txt").cloned(), file(b"alpha"));
assert_eq!(
tree.len(),
uploads + 6,
"{:?}",
tree.keys().collect::<Vec<_>>()
);
// Drive would copy what it has, not the bytes still queued.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::write(mount.join("src/a.txt"), b"alpha, edited").unwrap();
match copy("src/a.txt", "dest") {
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
Ok(answer) => panic!("the copy went ahead: {answer:?}"),
}
match copy("src", "dest") {
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
Ok(answer) => panic!("the folder copy went ahead: {answer:?}"),
}
daemon.request(&paused(false)).unwrap();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pdfs_rename_after_a_queued_rename_is_not_undone() {
// `pdfs rename` and `pdfs move` went straight to Drive, and the
// mount's own rename, still queued, landed after them and put the old
// name back (B149, the account run's B121 case).
let drive = FakeDrive::new();
let dir = scratch("pdfs-rename-after-queued");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
std::fs::create_dir_all(mount.join("b/inner")).unwrap();
std::fs::write(mount.join("b/first.txt"), b"moved").unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("b/first.txt").cloned() == file(b"moved")
}));
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::rename(mount.join("b/first.txt"), mount.join("b/back.txt")).unwrap();
let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned();
daemon
.request(&Request::Rename {
path: path("b/back.txt"),
new_name: "first.txt".into(),
})
.unwrap();
daemon
.request(&Request::Move {
path: path("b/first.txt"),
new_parent: path("b/inner"),
})
.unwrap();
assert_eq!(
std::fs::read(mount.join("b/inner/first.txt")).unwrap(),
b"moved"
);
daemon.request(&paused(false)).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
let tree = drive.tree();
tree.get("b/inner/first.txt").cloned() == file(b"moved")
&& daemon.pending().is_ok_and(|ops| ops.is_empty())
}),
"{:?}",
drive.tree().keys().collect::<Vec<_>>()
);
let tree = drive.tree();
assert!(!tree.contains_key("b/back.txt") && !tree.contains_key("b/inner/back.txt"));
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_queued_write_whose_folder_was_trashed_lands_in_the_root() {
// The conflict copy of a write to a file trashed elsewhere went into
// the file's folder. With that folder gone too, the upload failed on
// every retry, every two minutes (B150).
let drive = FakeDrive::new();
let dir = scratch("conflict-copy-folder-gone");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
std::fs::create_dir(mount.join("d")).unwrap();
std::fs::write(mount.join("d/f.txt"), b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("d/f.txt") && daemon.pending().is_ok_and(|ops| ops.is_empty())
}));
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::write(mount.join("d/f.txt"), b"second").unwrap();
drive.device().trash("d/f.txt");
drive.device().trash("d");
daemon.request(&paused(false)).unwrap();
let copy = |tree: &BTreeMap<String, Entry>| {
tree.iter()
.find(|(path, _)| path.starts_with("f (sync-conflict "))
.map(|(_, entry)| entry.clone())
};
assert!(
wait_until(Duration::from_secs(30), || {
copy(&drive.tree()) == Some(Entry::File(Arc::new(b"second".to_vec())))
&& daemon.pending().is_ok_and(|ops| ops.is_empty())
}),
"{:?} {:?}",
drive.tree().keys().collect::<Vec<_>>(),
daemon.pending()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Make 100 folders of 10 empty files each through a mount on `faults`, and
/// time the syscalls and then the drain until Drive holds all of it.
fn time_a_thousand_files(faults: Faults) -> (Duration, Duration) {
let drive = FakeDrive::new();
let dir = scratch("thousand");
let daemon = Daemon::start(&dir, drive.client(1, faults)).unwrap();
let start = Instant::now();
for folder in 0..100 {
let folder = daemon.mountpoint.join(format!("d{folder:03}"));
std::fs::create_dir(&folder).unwrap();
for file in 0..10 {
std::fs::File::create(folder.join(format!("f{file}"))).unwrap();
}
}
let syscalls = start.elapsed();
let start = Instant::now();
assert!(
wait_until(Duration::from_secs(1800), || {
drive.tree().len() == 1100 && daemon.pending().is_ok_and(|ops| ops.is_empty())
}),
"the drain did not finish: {} of 1100 on Drive",
drive.tree().len()
);
let drain = start.elapsed();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
(syscalls, drain)
}
/// The milestone's speed target: a thousand files reach Drive as fast on
/// Wi-Fi as on a LAN. It only measures for now, and takes minutes, so it
/// runs when `PDFS_SIM_MEASURE` is set.
#[test]
#[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_thousand_files_drain_as_fast_on_wifi_as_on_lan() {
if std::env::var_os("PDFS_SIM_MEASURE").is_none() {
eprintln!("skipped: set PDFS_SIM_MEASURE to measure the drain");
return;
}
let (lan_syscalls, lan_drain) = time_a_thousand_files(Faults::lan());
let (wifi_syscalls, wifi_drain) = time_a_thousand_files(Faults::wifi());
eprintln!("lan: syscalls {lan_syscalls:?}, drain {lan_drain:?}");
eprintln!("wifi: syscalls {wifi_syscalls:?}, drain {wifi_drain:?}");
}
/// `copy_file_range` inside the mount is answered from the bytes the daemon
/// holds, and left to the kernel for bytes it would have to download. Every
/// source kind has to come out byte for byte: cached, queued, open with
/// unsaved writes, and on Drive only.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_copy_inside_the_mount_has_the_source_bytes() {
use std::os::fd::AsRawFd as _;
use std::os::unix::fs::FileExt as _;
fn copy_range(from: &std::fs::File, at: u64, to: &std::fs::File, to_at: u64, len: usize) {
let (mut at, mut to_at) = (at as i64, to_at as i64);
let mut left = len;
while left > 0 {
// SAFETY: both descriptors are open; the offsets are ours.
let n = unsafe {
libc::copy_file_range(
from.as_raw_fd(),
&mut at,
to.as_raw_fd(),
&mut to_at,
left,
0,
)
};
assert!(n > 0, "copy_file_range: {}", io::Error::last_os_error());
left -= n as usize;
}
}
let pattern = |len: usize, seed: u8| -> Vec<u8> {
(0..len).map(|i| (i % 253) as u8 ^ seed).collect()
};
let drive = FakeDrive::new();
// On Drive before the mount knows it, so nothing of it is cached.
let remote = pattern(9 << 20, 1);
drive.device().write("remote.bin", &remote);
let dir = scratch("copy-range");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
// Downloaded through the kernel's own copy, then served from the cache.
std::fs::copy(mount.join("remote.bin"), mount.join("remote-1.bin")).unwrap();
assert!(std::fs::read(mount.join("remote-1.bin")).unwrap() == remote);
std::fs::copy(mount.join("remote.bin"), mount.join("remote-2.bin")).unwrap();
assert!(std::fs::read(mount.join("remote-2.bin")).unwrap() == remote);
// Queued and not on Drive yet: the staged blob is the content.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
let queued = pattern(20 << 20, 2);
std::fs::write(mount.join("queued.bin"), &queued).unwrap();
std::fs::copy(mount.join("queued.bin"), mount.join("queued-copy.bin")).unwrap();
assert!(std::fs::read(mount.join("queued-copy.bin")).unwrap() == queued);
// Open with writes not saved yet: the copy sees them, as a read would.
let open = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(mount.join("queued.bin"))
.unwrap();
open.write_all_at(b"UNSAVED", 100).unwrap();
let target = std::fs::File::create(mount.join("unsaved-copy.bin")).unwrap();
copy_range(&open, 96, &target, 0, 16);
drop(target);
drop(open);
let mut expected = queued[96..112].to_vec();
expected[4..11].copy_from_slice(b"UNSAVED");
assert_eq!(
std::fs::read(mount.join("unsaved-copy.bin")).unwrap(),
expected
);
// A range into the middle of a file keeps the bytes around it.
std::fs::write(mount.join("middle.bin"), b"0123456789").unwrap();
let source = std::fs::File::open(mount.join("remote.bin")).unwrap();
let target = std::fs::OpenOptions::new()
.write(true)
.open(mount.join("middle.bin"))
.unwrap();
copy_range(&source, 5, &target, 3, 4);
drop(target);
let mut expected = b"0123456789".to_vec();
expected[3..7].copy_from_slice(&remote[5..9]);
assert_eq!(std::fs::read(mount.join("middle.bin")).unwrap(), expected);
daemon.request(&paused(false)).unwrap();
let mut edited = queued.clone();
edited[100..107].copy_from_slice(b"UNSAVED");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(120), || {
let tree = drive.tree();
daemon.pending().is_ok_and(|ops| ops.is_empty())
&& tree.get("queued.bin").cloned() == file(&edited)
&& tree.get("queued-copy.bin").cloned() == file(&queued)
&& tree.get("remote-2.bin").cloned() == file(&remote)
&& tree.get("middle.bin").cloned() == file(&expected)
}));
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// How long `cp` inside the mount takes for a file the daemon already
/// holds, cold and from the page cache. It decides whether the daemon
/// should answer `copy_file_range` itself rather than leave the kernel to
/// copy through read and write. Runs when `PDFS_SIM_MEASURE` is set.
#[test]
#[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_copy_inside_the_mount_measures_its_speed() {
if std::env::var_os("PDFS_SIM_MEASURE").is_none() {
eprintln!("skipped: set PDFS_SIM_MEASURE to measure the copy");
return;
}
const SIZE: usize = 256 << 20;
let drive = FakeDrive::new();
let dir = scratch("copy-speed");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
let bytes: Vec<u8> = (0..SIZE).map(|i| (i % 251) as u8).collect();
std::fs::write(mount.join("big.bin"), &bytes).unwrap();
assert!(wait_until(Duration::from_secs(120), || {
daemon.pending().is_ok_and(|ops| ops.is_empty())
}));
for (round, name) in ["copy-1.bin", "copy-2.bin", "copy-3.bin"]
.into_iter()
.enumerate()
{
let start = Instant::now();
let status = std::process::Command::new("cp")
.arg(mount.join("big.bin"))
.arg(mount.join(name))
.status()
.unwrap();
let took = start.elapsed();
assert!(status.success());
let mib_per_s = (SIZE >> 20) as f64 / took.as_secs_f64();
eprintln!("cp round {round}: {took:?}, {mib_per_s:.0} MiB/s");
assert!(std::fs::read(mount.join(name)).unwrap() == bytes);
}
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half() {
// The acceptance suite's wide directory on Wi-Fi. A create that landed
// while the folder's listing was on the wire was in neither Drive's
// answer nor the queue read after it, and a file that was never
// unlinked dropped out of the listing until the next one (B166).
let drive = FakeDrive::new();
let dir = scratch("wide-unlink");
let daemon = Daemon::start(&dir, drive.client(1, Faults::wifi())).unwrap();
let root = daemon.mountpoint.join("wide");
std::fs::create_dir(&root).unwrap();
let names: Vec<String> = (0..128).map(|i| format!("entry-{i:04}.txt")).collect();
let in_parallel = |op: &(dyn Fn(&str) + Sync), names: &[String]| {
std::thread::scope(|scope| {
for chunk in names.chunks(names.len().div_ceil(8)) {
scope.spawn(move || chunk.iter().for_each(|name| op(name)));
}
});
};
in_parallel(
&|name| {
let mut file = std::fs::File::create(root.join(name)).unwrap();
std::io::Write::write_all(&mut file, name.as_bytes()).unwrap();
file.sync_all().unwrap();
},
&names,
);
let listed = |root: &std::path::Path| {
let mut names: Vec<String> = std::fs::read_dir(root)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
names.sort();
names
};
assert_eq!(listed(&root), names);
in_parallel(
&|name| std::fs::remove_file(root.join(name)).unwrap(),
&names[..64],
);
assert_eq!(listed(&root), names[64..]);
let mut settled = None;
while settled.is_none_or(|at: Instant| at.elapsed() < crate::POLL_INTERVAL * 2) {
assert_eq!(listed(&root), names[64..]);
if settled.is_none() && daemon.pending().is_ok_and(|ops| ops.is_empty()) {
settled = Some(Instant::now());
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
let drive = FakeDrive::new();
let dir = scratch("pin-folder");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/sub/b.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
}),
"the folder's files were downloaded in the background"
);
let a = entry("Docs", "a.txt").unwrap();
assert!(a.pinned && a.pinned_by_folder);
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
assert!(
daemon
.request(&Request::Unpin {
path: "Docs/a.txt".into(),
})
.is_err(),
"a file kept through its folder cannot be unpinned on its own"
);
daemon
.request(&Request::Unpin {
path: "Docs".into(),
})
.unwrap();
let a = entry("Docs", "a.txt").unwrap();
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_pinned_while_a_create_in_it_lands_keeps_that_file() {
// The pin's download of a file started before the file's landing set
// its modification time from Drive, and ended after it: the blob was
// stored under the old time, and the file never read as cached (B208).
let drive = FakeDrive::new();
let dir = scratch("pin-folder-landing");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
let entry = || match daemon.request(&Request::ListDir {
path: "Docs".into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"),
_ => None,
};
// Drive stamps the file with the time it landed, a second after it
// was written, so the landing moves its modification time.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::thread::sleep(Duration::from_millis(1100));
let landing = daemon.client.hold_next_landing_of_any();
daemon.request(&paused(false)).unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
let real = drive.lookup("Docs/a.txt").unwrap();
let read_back = daemon.client.hold_next_read(&real);
drop(landing);
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
let written = entry().unwrap().modified;
// Without the blob the landing kept, the pin downloads the file.
daemon.request(&Request::PurgeCache).unwrap();
let download = daemon.client.hold_next_download(&real);
take_logged();
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || download.reached()));
drop(read_back);
assert!(wait_until(Duration::from_secs(30), || {
entry().is_some_and(|e| e.modified != written)
}));
drop(download);
let mut logged = Vec::new();
assert!(
wait_until(Duration::from_secs(30), || {
logged.extend(take_logged());
logged
.iter()
.any(|line| line.starts_with("pin folder: done"))
}),
"{logged:?}"
);
assert!(
entry().is_some_and(|e| e.cached),
"the file kept through its folder is available offline: {:?}",
entry()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attaching_my_files_preserves_local_files_and_syncs_both_ways() {
use std::os::unix::fs::MetadataExt;
let drive = FakeDrive::new();
let dir = scratch("attach-my-files");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some()
}));
drive.device().write("Work/cloud.txt", b"from cloud");
drive.device().write("Work/common.txt", b"remote version");
drive.device().write("Work/identical.txt", b"same bytes");
let local = dir.join("local");
std::fs::create_dir(&local).unwrap();
std::fs::write(local.join("local.txt"), b"only on this computer").unwrap();
std::fs::write(local.join("common.txt"), b"local version").unwrap();
std::fs::write(local.join("identical.txt"), b"same bytes").unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into(),
})
.unwrap();
let folder = match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_str().unwrap())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
};
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/local.txt").is_some()
&& std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud")
&& std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version")
&& !conflict_copies(&local, "common").is_empty()
}));
assert_ne!(
std::fs::metadata(&local).unwrap().dev(),
std::fs::metadata(&daemon.mountpoint).unwrap().dev()
);
assert!(
conflict_copies(&local, "common")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version"))
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
let duplicate = dir.join("duplicate");
std::fs::create_dir(&duplicate).unwrap();
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "../Work".into()
})
.is_err()
);
// Parent/child mappings, the account root and files cannot be attached.
std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Work/Sub").is_some()
}));
for remote in ["Work/Sub", ".", "Work/cloud.txt"] {
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: remote.into(),
})
.is_err()
);
}
// Local overlap and symlink aliases into the actual cloud mount are refused.
let alias = dir.join("cloud-alias");
std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap();
std::fs::create_dir_all(local.join("Sub")).unwrap();
for path in [
local.join("Sub"),
dir.clone(),
daemon.mountpoint.join("Work"),
alias,
] {
let error = daemon
.request(&Request::AttachSyncFolder {
local_path: path.to_string_lossy().into_owned(),
remote_path: "Work/Sub".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("overlaps"), "{error}");
}
assert!(conflict_copies(&local, "identical").is_empty());
assert_eq!(
std::fs::read(local.join("identical.txt")).unwrap(),
b"same bytes"
);
// Simultaneous requests cannot register two local trees for one cloud root.
std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some()
}));
let candidate_a = dir.join("candidate-a");
let candidate_b = dir.join("candidate-b");
std::fs::create_dir(&candidate_a).unwrap();
std::fs::create_dir(&candidate_b).unwrap();
let outcomes = std::thread::scope(|scope| {
let a = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_a.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
let b = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_b.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
[a.join().unwrap(), b.join().unwrap()]
});
assert_eq!(
outcomes.iter().filter(|result| result.is_ok()).count(),
1,
"exactly one mapping may own a remote root: {outcomes:?}"
);
drive
.device()
.write("Work/cloud.txt", b"new cloud revision");
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || std::fs::read(
local.join("cloud.txt")
)
.is_ok_and(|b| b == b"new cloud revision")));
std::fs::write(local.join("local.txt"), b"new local revision").unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || drive
.tree()
.get("Work/local.txt")
== Some(&Entry::File(Arc::new(b"new local revision".to_vec())))));
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "idle")));
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: true,
})
.unwrap();
let before = drive.tree();
for entry in std::fs::read_dir(&local).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
std::fs::remove_dir_all(path).unwrap();
} else {
std::fs::remove_file(path).unwrap();
}
}
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: false,
})
.unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "error")));
assert_eq!(
drive.tree(),
before,
"the wipe guard must preserve the remote tree"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let db = Db::open(&dir.join("pdfs.db")).unwrap();
assert_eq!(
db.sync_folder_get(folder).unwrap().unwrap().remote_share_id,
"my-files-share"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.lookup("Work").is_some()
}));
std::fs::create_dir(local).unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_string_lossy().into_owned(),
remote_path: "Work".into(),
})
.unwrap();
match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_string_lossy())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
}
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_views_preserve_concurrent_edits() {
let drive = FakeDrive::new();
let dir = scratch("attach-views");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
let mounted = daemon.mountpoint.join("Work");
// Prime the mount listing before a mirror upload, to catch stale listings.
assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0);
std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read_dir(&mounted)
.unwrap()
.any(|e| e.unwrap().file_name() == "mirror.txt")
&& std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes")
}),
"mirror upload must reach the mounted view without a restart"
);
std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}),
"mounted writes must reach the mirror"
);
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing")
}));
std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
std::fs::write(local.join("mount.txt"), b"local changed").unwrap();
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed")
&& conflict_copies(&local, "mount")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed"))
&& drive.tree().iter().any(|(path, entry)| {
path.starts_with("Work/mount (sync-conflict")
&& entry == &Entry::File(Arc::new(b"local changed".to_vec()))
})
}),
"both edits must survive the next pass"
);
let error = daemon
.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("only support mirror"), "{error}");
let error = daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: true,
})
.unwrap_err()
.to_string();
assert!(error.contains("cannot be deleted"), "{error}");
let folder = listed_folder(&daemon, id).unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert!(folder.pending_mode.is_none());
// Restore must not reclassify a My files root as a device backup,
// which would make the device-only destructive operations available.
let restore_path = dir.join("forged-device-restore");
daemon
.request(&Request::RestoreSyncFolders {
items: vec![control::RestoreItem {
remote_uid: folder.remote_uid.clone(),
local_path: restore_path.to_string_lossy().into_owned(),
mode: "ondemand".into(),
}],
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
matches!(daemon.request(&Request::ListActivity { limit: 100 }),
Ok(Response::Activity { items }) if items.iter().any(|item| {
!item.ok && item.detail.contains("not a folder under this device")
}))
}));
assert!(!restore_path.exists());
match daemon.request(&Request::ListLocations).unwrap() {
Response::Locations { items } => assert!(items.iter().any(|m| {
matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id)
})),
other => panic!("unexpected reply: {other:?}"),
}
daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: false,
})
.unwrap();
assert!(listed_folder(&daemon, id).is_none());
assert!(drive.lookup("Work/mount.txt").is_some());
assert_eq!(
std::fs::read(local.join("mount.txt")).unwrap(),
b"mount changed"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_trashed_root_keeps_local_files() {
attached_root_loss_keeps_local_files(false);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_root_moved_outside_my_files_keeps_local_files() {
attached_root_loss_keeps_local_files(true);
}
fn attached_root_loss_keeps_local_files(move_outside: bool) {
let drive = FakeDrive::new();
let dir = scratch("attach-root-gone");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/keep.txt").is_some()
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}));
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
if move_outside {
use crate::drive::DriveApi;
let rt = daemon.rt.as_ref().unwrap();
let device = rt
.block_on(
daemon
.client
.create_device("other computer", proton_drive_rs::DeviceType::Linux),
)
.unwrap();
let root = drive.lookup("Work").unwrap();
rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid))
.unwrap();
} else {
drive.device().trash("Work");
}
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1);
let daemon = daemon.restart().unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
/// Register `local` as a synced folder in `mode` and wait until the daemon
/// lists it that way, returning its id.
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
std::fs::create_dir_all(local).unwrap();
let path = local.canonicalize().unwrap().to_string_lossy().into_owned();
daemon
.request(&Request::AddSyncFolder {
local_path: path.clone(),
})
.unwrap();
let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) {
Ok(Response::SyncFolders { items }) => items
.into_iter()
.find(|f| {
f.local_path == path
&& f.mode == mode
&& f.pending_mode.is_none()
&& f.last_sync > 0
})
.map(|f| f.id),
_ => None,
};
assert!(
wait_until(Duration::from_secs(30), || listed("mirror").is_some()),
"{} was registered: {:?}",
local.display(),
daemon.request(&Request::ListSyncFolders)
);
let id = listed("mirror").unwrap();
// Reads lag behind creates, so the remote folder the first pass made
// is not readable at once; switching modes reads it.
std::thread::sleep(Duration::from_millis(200));
if mode != "mirror" {
daemon
.request(&Request::SetSyncFolderMode {
id,
mode: mode.into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed(mode).is_some()
&& is_mounted(local)),
"{} switched to {mode}: {:?}",
local.display(),
daemon.request(&Request::ListSyncFolders)
);
}
id
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() {
// The account run's "move refuses a file still being written" case let
// a move from an on-demand synced folder into a mirror folder go ahead
// while the file was open, so the write landed after the move.
let drive = FakeDrive::new();
let dir = scratch("move-open-out-of-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
let mirror = dir.join("mirror");
sync_folder(&daemon, &ondemand, "ondemand");
sync_folder(&daemon, &mirror, "mirror");
let source = ondemand.join("open");
std::fs::create_dir(&source).unwrap();
daemon.request(&Request::SyncNow { id: None }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
// The account run opened the file a moment after its create landed:
// the queue was already empty, and the tree still had the
// placeholder uid it gave the file.
let landing = daemon.client.hold_next_landing_of_any();
{
let mut file = std::fs::File::create(source.join("open.txt")).unwrap();
std::io::Write::write_all(&mut file, b"before").unwrap();
file.sync_all().unwrap();
}
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(source.join("open.txt"))
.unwrap();
std::io::Write::write_all(&mut file, b" and after").unwrap();
let moved = daemon.request(&Request::Move {
path: source.to_string_lossy().into_owned(),
new_parent: mirror
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned(),
});
drop(file);
drop(landing);
match moved {
Err(e) => assert!(
e.to_string().contains("not uploaded yet"),
"refused for another reason: {e}"
),
Ok(answer) => panic!("the move went ahead: {answer:?}"),
}
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
// Keeping a file offline took only paths under the primary mount, so
// the copy in an on-demand synced folder could not be kept, and a
// program reading it there downloaded it every time (B281).
let drive = FakeDrive::new();
let dir = scratch("pin-in-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
sync_folder(&daemon, &ondemand, "ondemand");
let content = b"not a real png, but kept all the same".repeat(64);
let file = ondemand.join("photo.png");
std::fs::write(&file, &content).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
daemon.request(&Request::PurgeCache).unwrap();
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
let pinned = daemon
.request(&Request::Pin { path: path.clone() })
.unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
let pins = || match daemon.request(&Request::ListPins) {
Ok(Response::Pins { pins }) => pins,
other => panic!("pins: {other:?}"),
};
let listed = pins();
assert!(
listed.iter().any(|pin| pin.path == path && pin.cached),
"the pin opens by the path it was made through: {listed:?}"
);
// With the link gone, only a kept copy can answer the read.
daemon.client.set_online(false);
assert_eq!(std::fs::read(&file).unwrap(), content);
daemon.client.set_online(true);
let unpinned = daemon
.request(&Request::Unpin { path: path.clone() })
.unwrap();
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
assert!(pins().is_empty());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
// A change from another device deleted a pinned file's copy and
// nothing downloaded it again, and a file added to a pinned folder
// elsewhere was never downloaded: both were listed as kept offline
// and read only with a network (B282).
let drive = FakeDrive::new();
let dir = scratch("pin-keeper");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/a.txt").is_some()
&& drive.lookup("solo.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
for path in ["Docs", "solo.txt"] {
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
}
assert!(wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("", "solo.txt").is_some_and(|e| e.cached)
}));
// Another device edits both files and adds one to the kept folder.
drive.device().write("Docs/a.txt", b"second, and longer");
drive.device().write("solo.txt", b"alone, edited");
drive.device().write("Docs/new.txt", b"made elsewhere");
assert!(
wait_until(Duration::from_secs(60), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
}),
"the keeper downloaded the new content: {:?} {:?} {:?}",
entry("Docs", "a.txt"),
entry("", "solo.txt"),
entry("Docs", "new.txt"),
);
// With the link gone, only the kept copies can answer.
daemon.client.set_online(false);
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
assert_eq!(read("Docs/a.txt"), b"second, and longer");
assert_eq!(read("solo.txt"), b"alone, edited");
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
daemon.client.set_online(true);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// The synced folder `id` as the daemon lists it.
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
match daemon.request(&Request::ListSyncFolders) {
Ok(Response::SyncFolders { items }) => items.into_iter().find(|f| f.id == id),
_ => None,
}
}
/// A mirror folder holding `a.txt` and `sub/b.txt`, synced to Drive, whose
/// local copy is then deleted in full while the folder is paused, so no
/// pass sees half of it. Waits for the pass that refuses to run (#46).
fn folder_whose_local_copy_is_gone(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
let id = sync_folder(daemon, local, "mirror");
std::fs::write(local.join("a.txt"), b"alpha").unwrap();
std::fs::create_dir(local.join("sub")).unwrap();
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
let on_drive = |name: &str, content: &[u8]| {
drive.tree().iter().any(|(path, entry)| {
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
})
};
assert!(
wait_until(Duration::from_secs(30), || on_drive("a.txt", b"alpha")
&& on_drive("sub/b.txt", b"beta")
&& listed_folder(daemon, id).is_some_and(|f| f.state == "idle")),
"the folder synced: {:?} {:?}",
listed_folder(daemon, id),
drive.tree().keys().collect::<Vec<_>>()
);
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
std::fs::remove_file(local.join("a.txt")).unwrap();
std::fs::remove_dir_all(local.join("sub")).unwrap();
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed_folder(daemon, id)
.is_some_and(|f| f.state == "error")),
"the pass refused to trash everything: {:?}",
listed_folder(daemon, id)
);
assert!(
on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
"nothing was trashed on Drive: {:?}",
drive.tree().keys().collect::<Vec<_>>()
);
id
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset() {
let drive = FakeDrive::new();
let dir = scratch("reset-gone-local-copy");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("docs");
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
daemon.request(&Request::ResetSyncFolder { id }).unwrap();
let has = |rel: &str, content: &[u8]| {
std::fs::read(local.join(rel)).is_ok_and(|bytes| bytes == content)
};
assert!(
wait_until(Duration::from_secs(30), || has("a.txt", b"alpha")
&& has("sub/b.txt", b"beta")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
"the reset downloaded the folder again: {:?}",
listed_folder(&daemon, id)
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_whose_local_copy_is_gone_still_goes_on_demand() {
let drive = FakeDrive::new();
let dir = scratch("ondemand-gone-local-copy");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("docs");
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
daemon
.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
&& is_mounted(&local)),
"the queued switch landed: {:?}",
listed_folder(&daemon, id)
);
assert_eq!(std::fs::read(local.join("a.txt")).unwrap(), b"alpha");
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Emptied before any pass saw it, the folder is still `idle`, and the
/// switch was refused for good: every path read as "deleted here but not on
/// Proton Drive yet" (B255, found by the account run).
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_emptied_before_any_pass_still_goes_on_demand() {
let drive = FakeDrive::new();
let dir = scratch("ondemand-emptied-idle");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("docs");
let id = sync_folder(&daemon, &local, "mirror");
std::fs::create_dir(local.join("sub")).unwrap();
std::fs::write(local.join("sub/a.txt"), b"alpha").unwrap();
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
let on_drive = |name: &str, content: &[u8]| {
drive.tree().iter().any(|(path, entry)| {
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
})
};
assert!(
wait_until(Duration::from_secs(30), || on_drive("sub/a.txt", b"alpha")
&& on_drive("sub/b.txt", b"beta")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
"the folder synced: {:?}",
listed_folder(&daemon, id)
);
// In one rename, so no pass sees half of it, and asked at once, well
// inside the watcher's debounce, so no pass sees it empty either.
std::fs::rename(local.join("sub"), dir.join("moved-away")).unwrap();
let asked = daemon.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
});
assert!(
matches!(asked, Ok(Response::Ok { .. })),
"the switch was refused: {asked:?}"
);
assert!(
listed_folder(&daemon, id)
.is_some_and(|f| f.pending_mode.as_deref() == Some("ondemand")),
"the switch waits for a pass: {:?}",
listed_folder(&daemon, id)
);
assert!(
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
&& is_mounted(&local)),
"the queued switch landed: {:?}",
listed_folder(&daemon, id)
);
assert_eq!(std::fs::read(local.join("sub/a.txt")).unwrap(), b"alpha");
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
assert!(
on_drive("sub/a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
"nothing was trashed on Drive: {:?}",
drive.tree().keys().collect::<Vec<_>>()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// A video in My files gets a thumbnail from a frame of its own, but a
/// recording can be gigabytes: only its start, and an MP4's index, are
/// downloaded for it. A video with a thumbnail from its uploader is not
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
eprintln!("skipped: needs ffmpeg");
return;
}
let dir = scratch("video-thumbs");
std::fs::create_dir_all(&dir).unwrap();
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
// An MP4 written this way has its index at the end.
let encode = |name: &str| {
let path = dir.join(name);
let status = std::process::Command::new("ffmpeg")
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
.args(["-c:v", "libx264", "-preset", "ultrafast"])
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
.arg(&path)
.status()
.unwrap();
assert!(status.success());
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
bytes
};
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
let source = b"export const answer = 42;\n".repeat(64 * 1024);
let drive = FakeDrive::new();
drive.device().write("recording.ts", &recording);
drive.device().write("clip.mp4", &clip);
drive.device().write("app.ts", &source);
let phone = drive.device().write("phone.mp4", &clip);
let mut frame = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut frame)
.encode_image(&image::RgbImage::new(320, 180))
.unwrap();
let client = drive.client(1, Faults::lan());
client.set_thumbnail(&phone, frame);
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
path: String::new(),
}) else {
panic!("listing");
};
let items: Vec<control::FileThumbRequest> = entries
.iter()
.filter(|entry| !entry.is_dir)
.map(|entry| control::FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
})
.collect();
let Ok(Response::FileThumbGeneration { generation }) =
daemon.request(&Request::ReserveFileThumbGeneration)
else {
panic!("generation");
};
let read_before = client.content_read();
let mut paths = std::collections::HashMap::new();
let settled = wait_until(Duration::from_secs(120), || {
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
items: items.clone(),
generation,
}) else {
return false;
};
paths = replies
.iter()
.filter_map(|reply| {
let name = &items.iter().find(|item| item.uid == reply.uid)?.name;
Some((name.clone(), (reply.path.clone(), reply.pending)))
})
.collect();
paths.values().all(|(_, pending)| !pending) && paths.len() == 4
});
assert!(settled, "{paths:?}");
let read = client.content_read() - read_before;
for name in ["recording.ts", "clip.mp4", "phone.mp4"] {
assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}");
}
assert_eq!(paths["app.ts"].0, None, "TypeScript is no video");
// Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing
// of the video that came with a thumbnail.
assert!(
read < 34 << 20,
"downloaded {} MiB for three thumbnails",
read >> 20
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
}