mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
A switch asked for before any pass saw the folder empty found it still idle, so the check for what only this computer has ran at once and refused it for good: every synced path read as "deleted here but not on Proton Drive yet". The account run hit it in the B255 case. A local copy gone in full is the wipe guard's case, which the push pass of a queued switch settles without trashing anything on Drive. The switch now returns NotNow for it, so the request is queued and that pass runs first. A partial deletion is still refused as before.
3071 lines
130 KiB
Rust
3071 lines
130 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 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);
|
|
}
|
|
|
|
/// 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.pending().is_ok_and(|items| items.is_empty())
|
|
&& 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.
|
|
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!(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_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.pending().is_ok_and(|items| items.is_empty())
|
|
&& 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();
|
|
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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|