mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
concurrent reads
This commit is contained in:
1 parent
50c96cbbf4
commit
4720b2a5fb
6 files changed
+355
-176
No files matched your search
Generated
+4
-4
@@ -3463,7 +3463,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.1",
|
||||
@@ -3478,7 +3478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"ignore",
|
||||
@@ -3496,7 +3496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"image",
|
||||
@@ -3511,7 +3511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
|
||||
@@ -27,7 +27,7 @@ use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::db::{CacheEntryInput, Db};
|
||||
use crate::error::Result;
|
||||
|
||||
/// `cache_entries.kind` tag for whole-file content blobs.
|
||||
@@ -233,7 +233,11 @@ impl ContentCache {
|
||||
/// crash or an external file deletion, and picks up caches written by builds
|
||||
/// predating the index. In-run accesses then refine the ordering.
|
||||
fn reconcile(&self) -> Result<()> {
|
||||
self.db.cache_clear()?;
|
||||
// Both directories are walked before anything is written: the index is
|
||||
// replaced in a single transaction (see [`Db::cache_rebuild`]), so the
|
||||
// rows have to be in hand first. Names are owned for the same reason —
|
||||
// `DirEntry::file_name` does not outlive the iteration.
|
||||
let mut names: Vec<(String, &str, u64, i64)> = Vec::new();
|
||||
for (dir, kind) in [
|
||||
(&self.content_dir, KIND_BLOB),
|
||||
(&self.block_dir, KIND_BLOCK),
|
||||
@@ -257,9 +261,19 @@ impl ContentCache {
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
self.db.cache_touch(name, kind, meta.len(), at)?;
|
||||
names.push((name.to_string(), kind, meta.len(), at));
|
||||
}
|
||||
}
|
||||
let entries: Vec<CacheEntryInput<'_>> = names
|
||||
.iter()
|
||||
.map(|(key, kind, size, at)| CacheEntryInput {
|
||||
key,
|
||||
kind,
|
||||
size: *size,
|
||||
last_accessed: *at,
|
||||
})
|
||||
.collect();
|
||||
self.db.cache_rebuild(&entries)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+77
-51
@@ -129,6 +129,19 @@ pub struct PendingCounts {
|
||||
pub changes: i64,
|
||||
}
|
||||
|
||||
/// One cached blob or block as [`Db::cache_rebuild`] wants it: borrowed, because
|
||||
/// the caller builds the whole list from a directory walk before handing it over.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CacheEntryInput<'a> {
|
||||
/// On-disk filename of the blob or block.
|
||||
pub key: &'a str,
|
||||
/// `'blob'` or `'block'` — the two byte budgets are counted separately.
|
||||
pub kind: &'a str,
|
||||
pub size: u64,
|
||||
/// Initial LRU key, in unix milliseconds.
|
||||
pub last_accessed: i64,
|
||||
}
|
||||
|
||||
/// The outcome of folding freshly written bytes into a queued create.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachedBlob {
|
||||
@@ -368,48 +381,17 @@ impl Db {
|
||||
/// hot-cache maps rehydrate losslessly on the next mount. `listed` is never
|
||||
/// changed here — it is owned by [`set_listed`](Self::set_listed).
|
||||
pub fn upsert_node(&self, node: &Node) -> Result<()> {
|
||||
let json = serde_json::to_string(node)?;
|
||||
let uid = node.uid.to_string();
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
parent_uid = excluded.parent_uid,
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
trashed = excluded.trashed,
|
||||
node_json = excluded.node_json",
|
||||
params![
|
||||
uid,
|
||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node_size(node),
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
json,
|
||||
],
|
||||
)?;
|
||||
// FTS5 has no UPSERT, so refresh the row by delete-then-insert. Trashed
|
||||
// nodes are kept out of the index entirely so they never surface in
|
||||
// search results.
|
||||
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
|
||||
if !node.trashed {
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)",
|
||||
params![uid, node.name],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
self.upsert_nodes(std::slice::from_ref(node))
|
||||
}
|
||||
|
||||
/// Write-through multiple nodes in a single database transaction for performance.
|
||||
/// Write-through a batch of nodes as one transaction — a whole directory
|
||||
/// listing, typically. Otherwise identical to [`upsert_node`](Self::upsert_node),
|
||||
/// which is the single-node case of it.
|
||||
///
|
||||
/// The commit count is the point: SQLite autocommits every statement that is
|
||||
/// not in an explicit transaction, so interning a folder of a thousand
|
||||
/// children row-by-row cost a thousand fsyncs, and `ls` waited for all of
|
||||
/// them.
|
||||
pub fn upsert_nodes(&self, nodes: &[Node]) -> Result<()> {
|
||||
if nodes.is_empty() {
|
||||
return Ok(());
|
||||
@@ -442,6 +424,9 @@ impl Db {
|
||||
json,
|
||||
],
|
||||
)?;
|
||||
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
|
||||
// Trashed nodes are kept out of the index entirely so they never
|
||||
// surface in search results.
|
||||
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
|
||||
if !node.trashed {
|
||||
tx.execute(
|
||||
@@ -1412,12 +1397,36 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Wipe the entire cache index. The daemon calls this on open before
|
||||
/// rebuilding the index from the on-disk cache, so a crash or external file
|
||||
/// deletion can never leave a phantom row inflating the budget total.
|
||||
pub fn cache_clear(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM cache_entries", [])?;
|
||||
/// Replace the whole cache index with `entries`, as one transaction. The
|
||||
/// daemon calls this on open, so a crash or an external file deletion can
|
||||
/// never leave a phantom row inflating the budget total.
|
||||
///
|
||||
/// The clear and the refill are the same commit deliberately: they are one
|
||||
/// operation — "the index now describes what is on disk" — and a crash
|
||||
/// between them would otherwise leave an empty index against a full cache
|
||||
/// directory, i.e. a budget total of zero and nothing eviction can see.
|
||||
///
|
||||
/// One commit also matters for speed. A mount is the only caller and it runs
|
||||
/// on the open path, so a cache of thousands of blobs used to pay thousands
|
||||
/// of autocommit fsyncs before the mountpoint appeared.
|
||||
pub fn cache_rebuild(&self, entries: &[CacheEntryInput<'_>]) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM cache_entries", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
size_bytes = excluded.size_bytes,
|
||||
last_accessed = excluded.last_accessed",
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![e.key, e.kind, e.size as i64, e.last_accessed])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2893,14 +2902,31 @@ mod tests {
|
||||
assert_eq!(blobs, vec![("xyz".into(), 1)]);
|
||||
}
|
||||
|
||||
/// A rebuild is a replacement, not a merge: whatever the index said before
|
||||
/// is what a stale or externally-deleted cache file would leave behind.
|
||||
#[test]
|
||||
fn cache_index_clear() {
|
||||
fn cache_index_rebuild_replaces_every_row() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.cache_touch("k1", "blob", 1, 1).unwrap();
|
||||
db.cache_touch("k2", "block", 1, 1).unwrap();
|
||||
db.cache_clear().unwrap();
|
||||
assert!(db.cache_entries_by_kind("blob").unwrap().is_empty());
|
||||
db.cache_touch("gone", "blob", 1, 1).unwrap();
|
||||
db.cache_touch("also-gone", "block", 1, 1).unwrap();
|
||||
|
||||
db.cache_rebuild(&[CacheEntryInput {
|
||||
key: "kept",
|
||||
kind: "blob",
|
||||
size: 7,
|
||||
last_accessed: 42,
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.cache_entries_by_kind("blob").unwrap(),
|
||||
vec![("kept".to_string(), 7)]
|
||||
);
|
||||
assert!(db.cache_entries_by_kind("block").unwrap().is_empty());
|
||||
|
||||
// An empty rebuild is how a cache directory that vanished reports itself.
|
||||
db.cache_rebuild(&[]).unwrap();
|
||||
assert!(db.cache_entries_by_kind("blob").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+255
-116
@@ -282,6 +282,13 @@ impl State {
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
||||
}
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
|
||||
/// only. Every caller owes the DB a write-through — see the callers below;
|
||||
/// the split exists so a batch can pay for one transaction instead of `n`.
|
||||
fn intern_mem(&mut self, parent: u64, node: Node) -> u64 {
|
||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node = node;
|
||||
@@ -303,60 +310,25 @@ impl State {
|
||||
ino
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node loaded from the database
|
||||
/// without writing it back.
|
||||
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
||||
/// database, which is why nothing is written back.
|
||||
fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node = node;
|
||||
e.parent = parent;
|
||||
}
|
||||
return ino;
|
||||
}
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
self.by_uid.insert(node.uid.clone(), ino);
|
||||
self.entries.insert(
|
||||
ino,
|
||||
Entry {
|
||||
uid: node.uid.clone(),
|
||||
parent,
|
||||
node,
|
||||
},
|
||||
);
|
||||
ino
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) stable inodes for a batch of nodes, writing all of them
|
||||
/// to the DB in a single transaction.
|
||||
/// Allocate (or reuse) stable inodes for a whole listing, writing every node
|
||||
/// through in a single transaction.
|
||||
///
|
||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||
/// listing, rather than one autocommit — and one fsync — per child.
|
||||
fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
||||
warn!(error = %e, "db upsert_nodes failed");
|
||||
}
|
||||
let mut inos = Vec::with_capacity(nodes.len());
|
||||
for node in nodes {
|
||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node = node;
|
||||
e.parent = parent;
|
||||
}
|
||||
inos.push(ino);
|
||||
continue;
|
||||
}
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
self.by_uid.insert(node.uid.clone(), ino);
|
||||
self.entries.insert(
|
||||
ino,
|
||||
Entry {
|
||||
uid: node.uid.clone(),
|
||||
parent,
|
||||
node,
|
||||
},
|
||||
);
|
||||
inos.push(ino);
|
||||
}
|
||||
inos
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| self.intern_mem(parent, node))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
|
||||
@@ -561,6 +533,68 @@ async fn run_uploads(
|
||||
/// (two API calls and a node-key unlock) the next time that file is read.
|
||||
const MAX_OPEN_READERS: usize = 64;
|
||||
|
||||
/// How many FUSE handlers may run off the dispatch loop at once.
|
||||
///
|
||||
/// `fuser`'s session loop is non-concurrent: it reads one request, calls the
|
||||
/// handler, and only then reads the next. A handler that touches the network
|
||||
/// therefore stalls every `getattr`/`lookup` on the mount behind it. The slow
|
||||
/// handlers hand their `Reply` to this pool instead and answer from a worker,
|
||||
/// which frees the loop immediately.
|
||||
///
|
||||
/// Bounded on purpose: one worker can hold a 4 MiB block in flight, and an
|
||||
/// unbounded pool would let read-ahead on a big file spawn threads without
|
||||
/// limit. Sized just above the SDK's in-flight block cap so the semaphore
|
||||
/// there, not thread count, is what bounds download memory.
|
||||
const FUSE_WORKERS: usize = 8;
|
||||
|
||||
/// Bounded thread pool behind the network-touching FUSE handlers.
|
||||
///
|
||||
/// Shared by every session forked off one [`Core`] (the main mount plus each
|
||||
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
||||
struct Workers {
|
||||
tx: std::sync::mpsc::Sender<Box<dyn FnOnce() + Send + 'static>>,
|
||||
}
|
||||
|
||||
impl Workers {
|
||||
fn new(n: usize) -> std::io::Result<Self> {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Box<dyn FnOnce() + Send + 'static>>();
|
||||
let rx = Arc::new(Mutex::new(rx));
|
||||
for i in 0..n {
|
||||
let rx = rx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pdfs-fuse-{i}"))
|
||||
.spawn(move || {
|
||||
loop {
|
||||
// Held only to take the next job, never across it.
|
||||
let job = rx.lock().unwrap().recv();
|
||||
match job {
|
||||
// A panicking handler must not cost the pool a
|
||||
// worker for the rest of the run. The dropped
|
||||
// `Reply` answers EIO on its own, so the caller of
|
||||
// the failed op is told; the next job is unaffected.
|
||||
Ok(job) => {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
|
||||
}
|
||||
// Every sender is gone: the daemon is shutting down.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(Self { tx })
|
||||
}
|
||||
|
||||
/// Run `job` on a worker. Falls back to running it inline — the pre-pool
|
||||
/// behaviour — if no worker is left to take it, so a shut-down pool degrades
|
||||
/// to a slow mount rather than a mount that answers every read with EIO.
|
||||
fn run(&self, job: impl FnOnce() + Send + 'static) {
|
||||
if let Err(e) = self.tx.send(Box::new(job)) {
|
||||
warn!("fuse worker pool is gone; serving inline");
|
||||
(e.0)();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open reader plus the node metadata it was opened against.
|
||||
///
|
||||
/// The SDK pins a reader to the revision that was active at `open_revision`, so
|
||||
@@ -591,6 +625,10 @@ struct Core {
|
||||
/// Validated by `(mtime, size)` exactly like the content cache, and bounded
|
||||
/// by [`MAX_OPEN_READERS`].
|
||||
readers: Arc<Mutex<HashMap<NodeUid, CachedReader>>>,
|
||||
/// Threads that serve the FUSE handlers which touch the network, so the
|
||||
/// session's dispatch loop stays free to answer cheap metadata calls while a
|
||||
/// cold read is on the wire. See [`Workers`].
|
||||
workers: Arc<Workers>,
|
||||
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
|
||||
/// `State` maps. Every mutation writes through here, and the maps rehydrate
|
||||
/// from it on mount (plan.md P1).
|
||||
@@ -843,6 +881,14 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `ino`'s listing is already in memory, i.e. whether
|
||||
/// [`Core::ensure_children`] would return without touching the network.
|
||||
/// Lets a handler decide between answering inline and handing off to a
|
||||
/// worker, at the cost of one uncontended map lookup.
|
||||
fn children_cached(&self, ino: u64) -> bool {
|
||||
self.state.lock().unwrap().children.contains_key(&ino)
|
||||
}
|
||||
|
||||
/// Enumerate `ino`'s children from the remote and cache them. No-op if the
|
||||
/// directory has already been listed. Network I/O happens without the lock
|
||||
/// held so concurrent metadata reads aren't blocked behind a fetch.
|
||||
@@ -1760,6 +1806,12 @@ impl Core {
|
||||
/// retried with doubling backoff and *never* dropped on failure — the staged
|
||||
/// blob is the only copy of the user's bytes, so a failed op stays queued
|
||||
/// until it lands or the user deletes the file.
|
||||
///
|
||||
/// A failure does not pause the queue. Recording it pushes that op's
|
||||
/// `next_attempt_at` past `now`, so the next pass simply picks the next op
|
||||
/// that is due — one file wedged against a folder that no longer exists must
|
||||
/// not hold up an unrelated upload behind it. The worker only blocks once
|
||||
/// nothing is due at all.
|
||||
fn run_pending_drain(&self) {
|
||||
loop {
|
||||
let now = now_millis();
|
||||
@@ -1775,19 +1827,22 @@ impl Core {
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.drain_op(&op) {
|
||||
// Backoff is per-op, so one wedged file cannot starve the rest:
|
||||
// the next pass picks whichever op is due.
|
||||
let attempts = op.attempts + 1;
|
||||
let backoff = DRAIN_BACKOFF_MIN
|
||||
.saturating_mul(1u32 << attempts.min(6))
|
||||
.min(DRAIN_BACKOFF_MAX);
|
||||
warn!(uid = %op.uid, attempts, error = %e, "pending upload failed; will retry");
|
||||
let _ = self.db.record_op_failure(
|
||||
if let Err(e) = self.db.record_op_failure(
|
||||
op.id,
|
||||
&e.to_string(),
|
||||
now_millis() + backoff.as_millis() as i64,
|
||||
);
|
||||
self.wait_for_drain_work();
|
||||
) {
|
||||
// The backoff is the only thing keeping a failing op from
|
||||
// being picked again immediately, so without it the loop
|
||||
// would spin on this op as fast as the API can refuse it.
|
||||
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
||||
self.wait_for_drain_work();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1842,12 +1897,13 @@ impl Core {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if node.parent_uid.as_ref() != Some(&parent) {
|
||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
||||
}
|
||||
if node.name != name {
|
||||
// The name half goes first, so that a collision on the move half below is
|
||||
// about the name the node will actually land under rather than the one it
|
||||
// is about to lose.
|
||||
let mut landed = node.name.clone();
|
||||
if landed != name {
|
||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
||||
Ok(()) => {}
|
||||
Ok(()) => landed = name.clone(),
|
||||
// Someone took the name while we were offline. Renaming to a
|
||||
// *different* name is the non-destructive resolution: it neither
|
||||
// clobbers their file nor drops ours, and it is visible.
|
||||
@@ -1856,6 +1912,7 @@ impl Core {
|
||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
||||
landed = alt.clone();
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
@@ -1867,8 +1924,46 @@ impl Core {
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if node.parent_uid.as_ref() != Some(&parent) {
|
||||
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
|
||||
Ok(()) => {}
|
||||
// The destination holds a `landed` of its own. Same resolution as
|
||||
// a name collision, and it has to happen before the move: the API
|
||||
// has no move-and-rename, so the node is renamed out of the way
|
||||
// here and moved second.
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
let alt = conflict_name(&landed, now_secs());
|
||||
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
format!("destination already had that name; moved as {alt}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
// The destination folder is gone. Leaving the node in its current
|
||||
// parent is the honest outcome: it is not where the user asked for
|
||||
// it, but it exists, it is where it has always been, and the
|
||||
// rename half above still applied. Retrying could only fail again
|
||||
// — the folder is not coming back — and would wedge the queue.
|
||||
Err(e) if is_gone(&e) => {
|
||||
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
"destination folder no longer exists; the file was left in place".to_string(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
self.db.delete_op(op.id)?;
|
||||
info!(%uid, name, "pending rename landed");
|
||||
info!(%uid, name = %landed, "pending rename landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4799,6 +4894,9 @@ fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||
/// The Proton Drive VFS. FUSE callbacks are synchronous, so the Tokio handle
|
||||
/// bridges each one to the async SDK via [`Handle::block_on`]; the fuser
|
||||
/// session thread is not a runtime worker, so blocking on it is sound.
|
||||
/// Cloneable so a handler can move a copy onto a [`Workers`] thread and answer
|
||||
/// from there; every field is a handle or a plain id.
|
||||
#[derive(Clone)]
|
||||
pub struct ProtonFs {
|
||||
core: Core,
|
||||
uid: u32,
|
||||
@@ -4828,6 +4926,67 @@ impl ProtonFs {
|
||||
Self { core, uid, gid }
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::lookup`], on whichever thread ends up serving it.
|
||||
fn serve_lookup(&self, parent: u64, name: &str, reply: ReplyEntry) {
|
||||
if let Err(e) = self.core.ensure_children(parent) {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let st = self.core.state.lock().unwrap();
|
||||
let hit = st.children.get(&parent).and_then(|kids| {
|
||||
kids.iter().copied().find_map(|ino| {
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.filter(|e| e.node.name == name)
|
||||
.map(|e| (ino, &e.node))
|
||||
})
|
||||
});
|
||||
match hit {
|
||||
Some((ino, node)) => {
|
||||
let attr = self.attr(ino, node);
|
||||
reply.entry(&TTL, &attr, Generation(0));
|
||||
}
|
||||
None => reply.error(Errno::ENOENT),
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
|
||||
fn serve_readdir(&self, ino: u64, offset: u64, mut reply: ReplyDirectory) {
|
||||
if let Err(e) = self.core.ensure_children(ino) {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let st = self.core.state.lock().unwrap();
|
||||
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||
|
||||
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||
let mut listing: Vec<(u64, FileType, String)> = vec![
|
||||
(ino, FileType::Directory, ".".to_string()),
|
||||
(parent, FileType::Directory, "..".to_string()),
|
||||
];
|
||||
if let Some(kids) = st.children.get(&ino) {
|
||||
for &kid in kids {
|
||||
if let Some(e) = st.entries.get(&kid) {
|
||||
let ft = if e.node.is_folder() {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
};
|
||||
listing.push((kid, ft, e.node.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(st);
|
||||
|
||||
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
|
||||
// The stored offset is that of the *next* entry to resume at.
|
||||
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
fn attr(&self, ino: u64, node: &Node) -> FileAttr {
|
||||
let (kind, perm) = match node.kind {
|
||||
NodeKind::Folder => (FileType::Directory, 0o755),
|
||||
@@ -5230,27 +5389,19 @@ fn unix_secs(secs: i64) -> SystemTime {
|
||||
impl Filesystem for ProtonFs {
|
||||
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
|
||||
let parent = parent.0;
|
||||
if let Err(e) = self.core.ensure_children(parent) {
|
||||
reply.error(e);
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
// A folder that has not been listed yet is enumerated from the remote,
|
||||
// so serve it from a worker rather than stalling the dispatch loop. A
|
||||
// listed folder — the common case — is a map hit, and answering it
|
||||
// inline costs less than the handoff would.
|
||||
if self.core.children_cached(parent) {
|
||||
self.serve_lookup(parent, &name, reply);
|
||||
return;
|
||||
}
|
||||
let name = name.to_string_lossy();
|
||||
let st = self.core.state.lock().unwrap();
|
||||
let hit = st.children.get(&parent).and_then(|kids| {
|
||||
kids.iter().copied().find_map(|ino| {
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.filter(|e| e.node.name == name)
|
||||
.map(|e| (ino, &e.node))
|
||||
})
|
||||
});
|
||||
match hit {
|
||||
Some((ino, node)) => {
|
||||
let attr = self.attr(ino, node);
|
||||
reply.entry(&TTL, &attr, Generation(0));
|
||||
}
|
||||
None => reply.error(Errno::ENOENT),
|
||||
}
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(move || fs.serve_lookup(parent, &name, reply));
|
||||
}
|
||||
|
||||
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
|
||||
@@ -5270,42 +5421,19 @@ impl Filesystem for ProtonFs {
|
||||
ino: INodeNo,
|
||||
_fh: FileHandle,
|
||||
offset: u64,
|
||||
mut reply: ReplyDirectory,
|
||||
reply: ReplyDirectory,
|
||||
) {
|
||||
let ino = ino.0;
|
||||
if let Err(e) = self.core.ensure_children(ino) {
|
||||
reply.error(e);
|
||||
// Same split as `lookup`: only the cold, remote-enumerating path pays
|
||||
// for a worker.
|
||||
if self.core.children_cached(ino) {
|
||||
self.serve_readdir(ino, offset, reply);
|
||||
return;
|
||||
}
|
||||
let st = self.core.state.lock().unwrap();
|
||||
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||
|
||||
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||
let mut listing: Vec<(u64, FileType, String)> = vec![
|
||||
(ino, FileType::Directory, ".".to_string()),
|
||||
(parent, FileType::Directory, "..".to_string()),
|
||||
];
|
||||
if let Some(kids) = st.children.get(&ino) {
|
||||
for &kid in kids {
|
||||
if let Some(e) = st.entries.get(&kid) {
|
||||
let ft = if e.node.is_folder() {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
};
|
||||
listing.push((kid, ft, e.node.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(st);
|
||||
|
||||
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
|
||||
// The stored offset is that of the *next* entry to resume at.
|
||||
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reply.ok();
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(move || fs.serve_readdir(ino, offset, reply));
|
||||
}
|
||||
|
||||
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||
@@ -5376,6 +5504,11 @@ impl Filesystem for ProtonFs {
|
||||
// A file open for writing is served from its handle so reads see the
|
||||
// in-flight (possibly unsaved) content: authored bytes come from the
|
||||
// scratch file, untouched bytes from the base.
|
||||
//
|
||||
// This path stays on the dispatch loop. `write` runs there too, so
|
||||
// keeping its reads there as well preserves the ordering the write path
|
||||
// has always had between a write and a read of the same handle; a mostly
|
||||
// local read is not worth reasoning about that for.
|
||||
let handle = {
|
||||
let st = self.core.state.lock().unwrap();
|
||||
st.handles.values().find(|h| h.ino == ino.0).map(|h| {
|
||||
@@ -5421,13 +5554,18 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
}
|
||||
};
|
||||
match self
|
||||
.core
|
||||
.read_range(&uid, mtime, fsize, offset, size as u64)
|
||||
{
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
// Off the dispatch loop: this is the one handler that routinely goes to
|
||||
// the network (block fetch + decrypt, hundreds of ms on a cold file),
|
||||
// and until it returns the kernel's next request for this mount is not
|
||||
// even read off the FUSE device. It only reads `state`, so moving it
|
||||
// races with nothing. FUSE does not require replies in request order.
|
||||
let core = self.core.clone();
|
||||
self.core.workers.run(move || {
|
||||
match core.read_range(&uid, mtime, fsize, offset, size as u64) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn create(
|
||||
@@ -7056,6 +7194,7 @@ pub fn mount(
|
||||
})),
|
||||
cache: Arc::new(cache),
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
db,
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=0.4.0
|
||||
pkgver=0.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user