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