mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb |
No files matched your search
@@ -64,7 +64,7 @@ jobs:
|
||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||
|
||||
# The seeded simulation runs (docs/MILESTONE-3.0.0.md §8.2): real FUSE
|
||||
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||
simulation:
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@ cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
||||
use a dedicated test account, never one holding data you cannot lose.
|
||||
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||
|
||||
## Making changes
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ scriptable CLI.
|
||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||
|
||||
@@ -148,7 +149,7 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
|
||||
|
||||
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||
the security of the machine is spelled out in the
|
||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -156,6 +157,13 @@ Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIB
|
||||
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||
privately.
|
||||
|
||||
## Support
|
||||
|
||||
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
|
||||
[buy me a coffee](https://buymeacoffee.com/narl).
|
||||
|
||||
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ the interesting boundaries are:
|
||||
or the wrong key material to Proton.
|
||||
|
||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||
than described is.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ const TOUCH_FLUSH_AT: usize = 256;
|
||||
///
|
||||
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
||||
/// this cache: the network read path validates against it too, so a block that
|
||||
/// comes back short is refused before it can truncate a file (bugs.md B84).
|
||||
/// comes back short is refused before it can truncate a file (`docs/BUGS.md` B84).
|
||||
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||
@@ -311,8 +311,7 @@ struct PinFile {
|
||||
pins: BTreeMap<String, Pin>,
|
||||
}
|
||||
|
||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
||||
/// (offline.md Phase 2).
|
||||
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||
///
|
||||
/// A staged file is **not** necessarily valid whole-file content, and that is
|
||||
/// the whole reason this exists. A partial overwrite commits by filling the
|
||||
@@ -439,9 +438,9 @@ pub struct ContentCache {
|
||||
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||
/// Emptied on open so a crashed run leaves no orphans.
|
||||
scratch_dir: PathBuf,
|
||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||
/// Every released write passes through here, since the upload is a queued
|
||||
/// op performed later. Unlike `scratch_dir` this is
|
||||
/// **never** emptied on open: these are the only copy of content the user
|
||||
/// authored, and the whole point is that they outlive the daemon.
|
||||
staging_dir: PathBuf,
|
||||
@@ -476,7 +475,7 @@ pub struct ContentCache {
|
||||
thumb_bytes: AtomicU64,
|
||||
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
||||
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||
/// scanning the cache directories (plan.md P4).
|
||||
/// scanning the cache directories.
|
||||
db: Arc<Db>,
|
||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||
///
|
||||
@@ -989,7 +988,7 @@ impl ContentCache {
|
||||
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
||||
/// cannot escape this cache even though this does not check. Validating a
|
||||
/// block is the *reader's* job, at the point it is fetched — see
|
||||
/// `Core::read_block` (bugs.md B84).
|
||||
/// `Core::read_block` (`docs/BUGS.md` B84).
|
||||
pub fn store_block(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
@@ -1491,9 +1490,8 @@ impl ContentCache {
|
||||
/// This is what makes a write survive its upload: the caller is releasing a
|
||||
/// write handle and would otherwise delete the file, so until the bytes are
|
||||
/// on the remote, staging holds the only copy. Every dirty handle goes
|
||||
/// through here — the upload is a queued op performed later (offline.md
|
||||
/// Phase 3), and a staged file is also what a human can recover from if the
|
||||
/// queue never drains.
|
||||
/// through here — the upload is a queued op performed later, and a staged
|
||||
/// file is also what a human can recover from if the queue never drains.
|
||||
///
|
||||
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
||||
/// failure here means we could not save the bytes at all, so it is reported
|
||||
@@ -2707,7 +2705,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The geometry the whole read path validates against, here and over the
|
||||
/// network (bugs.md B84): full blocks, then whatever is left.
|
||||
/// network (`docs/BUGS.md` B84): full blocks, then whatever is left.
|
||||
#[test]
|
||||
fn block_len_is_full_blocks_then_the_remainder() {
|
||||
let size = BLOCK_SIZE * 2 + 17;
|
||||
@@ -2787,7 +2785,7 @@ mod tests {
|
||||
/// A cached block is addressed by the range it covers, not by its index
|
||||
/// alone. Re-planning a file onto its real geometry must therefore miss the
|
||||
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
||||
/// wrong offset (bugs.md B85).
|
||||
/// wrong offset (`docs/BUGS.md` B85).
|
||||
#[test]
|
||||
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
||||
let (c, _d) = cache();
|
||||
@@ -2854,7 +2852,7 @@ mod tests {
|
||||
|
||||
/// A block of the wrong length cannot escape the cache even though the
|
||||
/// store does not check: the read validates it and reports a miss, so the
|
||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
||||
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
|
||||
#[test]
|
||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||
let (c, _d) = cache();
|
||||
@@ -2919,7 +2917,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
||||
/// A failed upload must never cost the user their bytes:
|
||||
/// the scratch file moves to staging intact, and staging survives a reopen
|
||||
/// the way scratch deliberately does not.
|
||||
#[test]
|
||||
|
||||
@@ -157,7 +157,7 @@ pub struct AppConfig {
|
||||
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||
/// syscall. `None` means on. `false` brings back the old path for one
|
||||
/// release (docs/MILESTONE-3.0.0.md §10). Defaulted for configs predating
|
||||
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
@@ -432,7 +432,7 @@ impl AppDirs {
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
/// not something this client established (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
@@ -483,7 +483,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
/// other file (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
@@ -504,7 +504,7 @@ mod tests {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
|
||||
@@ -443,11 +443,11 @@ pub enum Request {
|
||||
DeleteDevice { uid: String },
|
||||
/// Adopt an existing device as this machine's, pinning its uid in
|
||||
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
||||
/// of registering a duplicate (features.md 5.1). `uid: None` clears the pin.
|
||||
/// of registering a duplicate. `uid: None` clears the pin.
|
||||
/// Replies with [`Response::Ok`].
|
||||
AdoptDevice { uid: Option<String> },
|
||||
|
||||
// ---- device folder sync (devices.md) ----------------------------------
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||
AddSyncFolder { local_path: String },
|
||||
@@ -496,7 +496,7 @@ pub enum Request {
|
||||
/// [`Response::Ok`].
|
||||
ResolveConflict { path: String, keep: ConflictKeep },
|
||||
/// List the folders under this machine's device that can be synced here,
|
||||
/// each with a proposed local path (features.md 5.2). Replies with
|
||||
/// each with a proposed local path. Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
ListRestorableFolders,
|
||||
/// Attach the given remote device folders to local paths and sync them down.
|
||||
@@ -1903,17 +1903,17 @@ pub enum Response {
|
||||
/// The pin registry.
|
||||
pins: Vec<Pin>,
|
||||
/// False when the daemon is serving the cached tree because the API is
|
||||
/// unreachable (offline.md Phase 1). Cached and pinned content still
|
||||
/// unreachable. Cached and pinned content still
|
||||
/// reads; anything else fails until the network is back.
|
||||
#[serde(default = "default_online")]
|
||||
online: bool,
|
||||
/// Writes accepted locally but not yet uploaded (offline.md Phase 3).
|
||||
/// Writes accepted locally but not yet uploaded.
|
||||
/// Non-zero means the mount is ahead of the remote — either a copy is
|
||||
/// still draining, or it cannot drain because we are offline.
|
||||
#[serde(default)]
|
||||
pending_uploads: u64,
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
||||
/// Counted apart from `pending_uploads` because
|
||||
/// calling a queued `mkdir` an upload is a lie.
|
||||
#[serde(default)]
|
||||
pending_changes: u64,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Database health reporting and repair (features.md 4.3).
|
||||
//! Database health reporting and repair.
|
||||
//!
|
||||
//! Everything here exists for the case where something has already gone wrong:
|
||||
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
||||
|
||||
@@ -577,7 +577,7 @@ CREATE TABLE trash (
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
|
||||
/// Schema v8: device sync. This machine registers as one Proton Drive
|
||||
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
||||
/// row cached so we reuse the same device across restarts instead of creating a
|
||||
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||
@@ -638,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
||||
";
|
||||
|
||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it.
|
||||
/// The row outlives the process, so a write survives both a
|
||||
/// dead network and a restart.
|
||||
///
|
||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||
@@ -666,7 +666,7 @@ CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
|
||||
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
||||
/// revision op never had to describe — it always addressed a node the server had
|
||||
/// already minted a uid for (offline.md Phase 3b).
|
||||
/// already minted a uid for.
|
||||
///
|
||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||
@@ -1056,7 +1056,7 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
||||
/// as the last sync and left the file the same length — a fixed-size record
|
||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||
/// older content until something else about the file moved (bugs.md B25).
|
||||
/// older content until something else about the file moved (`docs/BUGS.md` B25).
|
||||
///
|
||||
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||
@@ -1168,8 +1168,8 @@ const MIGRATION_V35: &str = "
|
||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v36: every node row has a local id (`lid`), phase 2 of
|
||||
/// docs/MILESTONE-3.0.0.md.
|
||||
/// Schema v36: every node row has a local id (`lid`); see
|
||||
/// docs/ARCHITECTURE.md §2.
|
||||
///
|
||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||
|
||||
@@ -568,8 +568,7 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||
/// (offline.md Phase 1).
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||
let conn = self.read();
|
||||
let json: Option<String> = conn
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::Result;
|
||||
pub const OP_REVISION: &str = "revision";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
|
||||
/// locally, under a `local:` placeholder uid (offline.md Phase 3b).
|
||||
/// locally, under a `local:` placeholder uid.
|
||||
///
|
||||
/// The written bytes ride along on the same row rather than as a follow-on
|
||||
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
|
||||
@@ -29,7 +29,7 @@ pub const OP_CREATE: &str = "create";
|
||||
pub const OP_MKDIR: &str = "mkdir";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
|
||||
/// both — the queued form of `mv` (offline.md Phase 3b).
|
||||
/// both — the queued form of `mv`.
|
||||
///
|
||||
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
|
||||
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
|
||||
@@ -55,8 +55,7 @@ pub struct RenameMeta {
|
||||
pub original_name: Option<String>,
|
||||
}
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||
/// (offline.md Phase 3b).
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about.
|
||||
///
|
||||
/// A node that only ever existed locally never gets one of these: there is
|
||||
/// nothing to trash remotely, so deleting it just drops its queued ops
|
||||
@@ -311,7 +310,7 @@ pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
||||
|
||||
/// A mutation that has been accepted locally but not yet performed against the
|
||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||
/// API — the durable half of the write-back queue.
|
||||
///
|
||||
/// The daemon answers the FUSE call the moment this row and its staged blob are
|
||||
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
|
||||
@@ -887,7 +886,7 @@ impl Db {
|
||||
/// make — the node is still only a queued intent, and rewriting that intent
|
||||
/// *is* the rename. Returns false when the create has already drained, in
|
||||
/// which case the node has a real uid and the caller must rename it there
|
||||
/// instead (offline.md Phase 3b).
|
||||
/// instead.
|
||||
///
|
||||
/// A create backing off is made due: what it failed on may have been the
|
||||
/// name it no longer wants, such as one held by a queued change. A parked
|
||||
|
||||
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
||||
}
|
||||
|
||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||
/// last successful sync, so the next reconcile can tell which side changed
|
||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// last successful sync, so the next reconcile can tell which side changed.
|
||||
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// its modification time and size as strings — since no cheap content hash is
|
||||
/// exposed; change detection is `(mtime, size)` on each side.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -39,7 +39,7 @@ pub struct StoredSyncEntry {
|
||||
///
|
||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||
/// second as the last sync — and left the file the same length — from no
|
||||
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
||||
/// edit at all, so those never uploaded (`docs/BUGS.md` B25). `None` on a row
|
||||
/// written before schema 28, which the comparison reads as "compare whole
|
||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||
pub local_mtime_ns: Option<i64>,
|
||||
@@ -345,7 +345,7 @@ impl Db {
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
||||
/// leaves an empty baseline + full remote = pure download.
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
|
||||
@@ -675,8 +675,8 @@ fn foreign_folder(volume: &str, link: &str, parent: NodeUid, name: &str) -> Node
|
||||
node
|
||||
}
|
||||
|
||||
/// Recovering the root by uid is what lets the daemon mount offline
|
||||
/// (offline.md Phase 1): the uid is remembered in `sync_state`, the node
|
||||
/// Recovering the root by uid is what lets the daemon mount offline:
|
||||
/// the uid is remembered in `sync_state`, the node
|
||||
/// itself comes back out of `nodes`.
|
||||
#[test]
|
||||
fn node_by_uid_recovers_a_stored_node() {
|
||||
@@ -796,7 +796,7 @@ fn children_if_listed_gated_on_flag() {
|
||||
assert_eq!(kids.len(), 2);
|
||||
}
|
||||
|
||||
/// The shape of the `mv`-loses-the-file bug (bugs.md B1): a rename deletes the
|
||||
/// The shape of the `mv`-loses-the-file bug (`docs/BUGS.md` B1): a rename deletes the
|
||||
/// moved node's row, so a destination left marked `listed` is rebuilt from the DB
|
||||
/// without it — the file is gone from the source and absent from the destination,
|
||||
/// with `rename(2)` having reported success. Clearing the flag is what forces the
|
||||
@@ -5735,7 +5735,7 @@ fn cache_eviction_candidates_are_lru_ordered_and_limited() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The B5 checkpoint.** `improvements.md` P2.3 proposes concurrent SQLite
|
||||
/// **The B5 checkpoint.** A performance plan proposed concurrent SQLite
|
||||
/// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE
|
||||
/// workers. This measures what the connection is actually asked to do now that
|
||||
/// B3 and B4 have landed, so the proposal is decided on evidence.
|
||||
@@ -6291,7 +6291,7 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
||||
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||||
/// comparison fall back to whole seconds for rows written before the column
|
||||
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
||||
/// every already-synced file in the folder (bugs.md B25).
|
||||
/// every already-synced file in the folder (`docs/BUGS.md` B25).
|
||||
#[test]
|
||||
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -102,5 +102,5 @@ impl Db {
|
||||
Ok(seen)
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
// ---- device sync -----------------------------------------
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The machine profile that travels with a device (features.md 5.3).
|
||||
//! The machine profile that travels with a device.
|
||||
//!
|
||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
||||
//! Selective-sync ignore rules for mirror folders.
|
||||
//!
|
||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||
|
||||
@@ -143,7 +143,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
// create, and re-fetching would replace the size and mtime of the
|
||||
// write we just accepted with the stale revision's — making a file
|
||||
// that was copied in seconds ago read as empty until its upload
|
||||
// lands (offline.md Phase 3).
|
||||
// lands.
|
||||
//
|
||||
// A write that has already drained is the same story one step later:
|
||||
// the drain brought the tree level with the revision it sealed, so
|
||||
@@ -375,7 +375,7 @@ pub(super) async fn run_event_sync(
|
||||
// server head; persist it so the next restart resumes instead of
|
||||
// reseeding (which would skip everything that changed offline).
|
||||
// Seeding needs the network, and this task also runs on mounts that
|
||||
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
||||
// started offline — so retry rather than giving up,
|
||||
// which used to disable live sync for the life of the daemon.
|
||||
Ok(None) => {
|
||||
// Nothing says what changed before this cursor, so a persisted SDK
|
||||
@@ -660,7 +660,7 @@ pub(super) fn run_local_index(
|
||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||
}
|
||||
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
||||
// keep rewriting the index for a mount that is gone (`docs/BUGS.md` B44).
|
||||
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1816,7 +1816,7 @@ pub(crate) fn run_control_socket(
|
||||
info!("control socket listening");
|
||||
for conn in listener.incoming() {
|
||||
// Checked on every accept, including the deliberate poke teardown sends
|
||||
// to wake this thread out of a blocking accept (bugs.md B44).
|
||||
// to wake this thread out of a blocking accept (`docs/BUGS.md` B44).
|
||||
if core.shutdown.is_stopping() {
|
||||
info!("control socket closing");
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
||||
//! step with the remote (devices.md).
|
||||
//! step with the remote.
|
||||
//!
|
||||
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||
@@ -184,8 +184,8 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||
/// so hostname changes and reinstalls stop registering duplicates
|
||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
||||
/// so hostname changes and reinstalls stop registering duplicates.
|
||||
/// `None` clears the pin and returns to hostname matching.
|
||||
///
|
||||
/// Refuses a uid that is not in the account: the whole point of the pin is
|
||||
/// that it is authoritative, so accepting a bad one would defer the failure
|
||||
@@ -260,7 +260,7 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||
// ---- device folder sync -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||
@@ -270,8 +270,7 @@ impl Core {
|
||||
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
||||
/// cached DB row (validated remotely), then a hostname match, then create.
|
||||
/// The pin exists because the hostname heuristic silently registers a second
|
||||
/// device after a rename or reinstall, orphaning the first one's folders
|
||||
/// (features.md 5.1).
|
||||
/// device after a rename or reinstall, orphaning the first one's folders.
|
||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||
let name = this_hostname();
|
||||
// Enumerate the remote devices once: used to validate the pin, validate
|
||||
@@ -617,7 +616,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
||||
/// folder (devices.md Phase 3). It shares everything with this one,
|
||||
/// folder. It shares everything with this one,
|
||||
/// the tree included, except what belongs to a session: its root and its
|
||||
/// kernel channel, both filled in when the session is built.
|
||||
pub(crate) fn fork_state(&self) -> Core {
|
||||
@@ -986,8 +985,7 @@ impl Core {
|
||||
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
||||
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
||||
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
||||
/// own thread from `mount` so the network fetches never block startup
|
||||
/// (devices.md Phase 4).
|
||||
/// own thread from `mount` so the network fetches never block startup.
|
||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||
let folders = match self.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The write-back drain: turning queued mutations into remote calls.
|
||||
//!
|
||||
//! Every write the kernel hands us is answered the moment its bytes and its
|
||||
//! `pending_op` row are on disk (offline.md Phase 3), which is what lets a `cp`
|
||||
//! `pending_op` row are on disk, which is what lets a `cp`
|
||||
//! into the mount run at disk speed and lets an offline write succeed at all.
|
||||
//! This module is the other half: the worker that walks that queue and performs
|
||||
//! the uploads, creates, renames and trashes it recorded.
|
||||
@@ -276,8 +276,7 @@ fn run_authorized_drain(
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Drain the pending-op queue: the background half of every write
|
||||
/// (offline.md Phase 3).
|
||||
/// Drain the pending-op queue: the background half of every write.
|
||||
///
|
||||
/// Runs for the life of the mount. Ops are replayed oldest-first, each
|
||||
/// retried with doubling backoff and *never* dropped on failure — the staged
|
||||
@@ -306,7 +305,7 @@ impl Core {
|
||||
loop {
|
||||
// Between ops, never inside one: an op that has started is either
|
||||
// retired or released, so stopping here cannot leave the queue with
|
||||
// a row claimed by a worker that no longer exists (bugs.md B44).
|
||||
// a row claimed by a worker that no longer exists (`docs/BUGS.md` B44).
|
||||
if self.shutdown.is_stopping() {
|
||||
debug!(primary, "drain worker stopping");
|
||||
return;
|
||||
@@ -1150,7 +1149,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Make a node that so far exists only on this machine real, and adopt the
|
||||
/// uid the server gives it (offline.md Phase 3b).
|
||||
/// uid the server gives it.
|
||||
pub(crate) fn drain_local_node(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
@@ -1850,7 +1849,7 @@ impl Core {
|
||||
/// that window the node can be rewritten by another device, trashed, or
|
||||
/// deleted outright. Sending the blob anyway would silently drop whatever
|
||||
/// happened in between, which is exactly the thing the sync engine refuses
|
||||
/// to do (offline.md Phase 3b).
|
||||
/// to do.
|
||||
///
|
||||
/// Only checkable against a recorded baseline: a write staged before
|
||||
/// [`StagedWrite::based_on`] existed, or one against a node that has never
|
||||
@@ -2009,7 +2008,7 @@ impl Core {
|
||||
self.db.delete_op(op.id)?;
|
||||
// Dropping the pending entry hands the node back to the remote's truth:
|
||||
// reads stop coming from the staged blob, and the event sync stops
|
||||
// skipping it as "ahead of the server" (offline.md Phase 3a). Only if it
|
||||
// skipping it as "ahead of the server". Only if it
|
||||
// is still *this* write's entry — see [`Core::release_pending`].
|
||||
self.release_pending(uid, blob);
|
||||
self.cache.discard_staged(blob);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||
//! behind most recent bugs could only be found on a real account over a slow
|
||||
//! link (`docs/MILESTONE-3.0.0.md` §2, §8.1). [`DriveApi`] is the part of the
|
||||
//! link. [`DriveApi`] is the part of the
|
||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
|
||||
@@ -132,7 +132,7 @@ impl Filesystem for ProtonFs {
|
||||
// A provisional size is the *ciphertext* size, which is larger than the
|
||||
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
||||
// mmap, sendfile, a sized read loop — run off the end of the file and
|
||||
// fail. So resolve it before answering rather than after (bugs.md B14).
|
||||
// fail. So resolve it before answering rather than after (`docs/BUGS.md` B14).
|
||||
//
|
||||
// The cost is one batched round trip for the whole folder, not one per
|
||||
// file: `ls -l` is one `getattr` per entry and they collapse onto a
|
||||
@@ -172,7 +172,7 @@ impl Filesystem for ProtonFs {
|
||||
/// here — once per `opendir`, which the kernel pairs exactly with one
|
||||
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
||||
/// during an enumeration can no longer make the caller skip or repeat an
|
||||
/// entry (bugs.md B43).
|
||||
/// entry (`docs/BUGS.md` B43).
|
||||
///
|
||||
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
||||
/// the same reason `lookup` and `readdir` do.
|
||||
@@ -346,7 +346,7 @@ impl Filesystem for ProtonFs {
|
||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||
// The revision disagrees with a provisional size, and B84 fails
|
||||
// such a read outright, so settle the size first. On the network,
|
||||
// hence here and not above (bugs.md B100).
|
||||
// hence here and not above (`docs/BUGS.md` B100).
|
||||
let fsize = match provisional {
|
||||
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
||||
false => fsize,
|
||||
@@ -517,7 +517,7 @@ impl Filesystem for ProtonFs {
|
||||
// `> file`. This is the second write path into the API, and
|
||||
// queueing it is what lets a redirect work offline at all: it
|
||||
// never reaches `release`, so without this it failed before any
|
||||
// byte was written (offline.md Phase 2/3b).
|
||||
// byte was written.
|
||||
//
|
||||
// The one branch of `setattr` that can reach the network:
|
||||
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
||||
@@ -607,7 +607,7 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
||||
/// and performed in the background (offline.md Phase 3), so there is nothing
|
||||
/// and performed in the background, so there is nothing
|
||||
/// to push here — the written bytes are already in the scratch file.
|
||||
fn flush(
|
||||
&self,
|
||||
@@ -985,7 +985,7 @@ pub struct ProtonFs {
|
||||
}
|
||||
|
||||
/// How many times a write `open` will re-read its base after a `release`
|
||||
/// published a newer queued revision underneath it (bugs.md B32).
|
||||
/// published a newer queued revision underneath it (`docs/BUGS.md` B32).
|
||||
///
|
||||
/// One retry covers the race, which needs a close and an open of the same file
|
||||
/// to overlap on different threads. More than that is not contention, it is a
|
||||
@@ -1021,7 +1021,7 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
|
||||
///
|
||||
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
||||
/// continues exactly where the last one stopped — which is only true because
|
||||
/// the listing behind those indexes is a snapshot (bugs.md B43).
|
||||
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
|
||||
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
||||
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||
let ft = if *is_dir {
|
||||
@@ -1094,7 +1094,7 @@ impl ProtonFs {
|
||||
// `getattr` would, so `ls -l` — which is one `lookup`
|
||||
// per entry and no `getattr` at all — takes its sizes
|
||||
// from here. Resolving only in `getattr` left the whole
|
||||
// listing provisional (bugs.md B14).
|
||||
// listing provisional (`docs/BUGS.md` B14).
|
||||
let provisional = matches!(
|
||||
&e.node.kind,
|
||||
NodeKind::File {
|
||||
@@ -1276,7 +1276,7 @@ impl ProtonFs {
|
||||
// revision in between sends this around again. Without the re-check the
|
||||
// new handle carried the *older* base, and its own release published
|
||||
// content that silently discarded the revision closed a moment earlier
|
||||
// (bugs.md B32). A second attempt is already unlikely and a third would
|
||||
// (`docs/BUGS.md` B32). A second attempt is already unlikely and a third would
|
||||
// mean something is republishing continuously, so give up rather than
|
||||
// spin: the caller's `open` fails instead of its data being lost.
|
||||
//
|
||||
@@ -1286,7 +1286,7 @@ impl ProtonFs {
|
||||
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
||||
// The handle starts at the base's size. A provisional one is the
|
||||
// ciphertext size, so the file would grow by the encryption
|
||||
// overhead and reading the base would fail (bugs.md B100). A
|
||||
// overhead and reading the base would fail (`docs/BUGS.md` B100). A
|
||||
// queued revision stamps a real size, so this only goes to the
|
||||
// network for a file the listing has not sized yet.
|
||||
if provisional && self.core.pending_blob(&uid).is_none() {
|
||||
@@ -1541,7 +1541,7 @@ impl ProtonFs {
|
||||
let transient = is_transient_name(name);
|
||||
// Offline the server cannot mint a uid, so invent one and queue the
|
||||
// create. The file is real to the caller either way; only its identity is
|
||||
// provisional until the drain (offline.md Phase 3b).
|
||||
// provisional until the drain.
|
||||
//
|
||||
// A parent that is itself still queued forces the same path even when we
|
||||
// are online: the API has no folder to put this in yet.
|
||||
@@ -1652,7 +1652,7 @@ impl ProtonFs {
|
||||
.ok();
|
||||
// As in `create`: offline — or under a parent that is itself still
|
||||
// queued — the folder becomes a placeholder that the drain turns into a
|
||||
// real one (offline.md Phase 3b).
|
||||
// real one.
|
||||
// Losing the network on the way queues it the same way.
|
||||
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
@@ -1874,7 +1874,7 @@ impl ProtonFs {
|
||||
// rename, so the victim has to be removed first and the operation stops
|
||||
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
||||
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
||||
// at the very end of its transfer (bugs.md B13).
|
||||
// at the very end of its transfer (`docs/BUGS.md` B13).
|
||||
//
|
||||
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
||||
// without a window in which one of the two names does not exist.
|
||||
@@ -1956,8 +1956,7 @@ impl ProtonFs {
|
||||
};
|
||||
// A node whose own creation is still queued has no server-side identity
|
||||
// to rename: the queued op *is* the node, so rewriting its target is the
|
||||
// whole rename. Nothing reaches the API, which is why this works offline
|
||||
// (offline.md Phase 3b).
|
||||
// whole rename. Nothing reaches the API, which is why this works offline.
|
||||
// A create that lands meanwhile leaves no op to rewrite; the node is
|
||||
// then renamed by the uid Drive gave it (docs/BUGS.md B142).
|
||||
if is_local_uid(&uid) {
|
||||
@@ -2156,7 +2155,7 @@ impl ProtonFs {
|
||||
.is_some_and(|e| e.open_count > 0);
|
||||
// A node the server has never heard of cannot be trashed there; deleting
|
||||
// it just means its queued creation is no longer wanted. This works
|
||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
||||
// offline, which the remote path below cannot.
|
||||
if is_local_uid(&uid) {
|
||||
let dropped = if open_now {
|
||||
self.core.withdraw_queued_ops(&uid)
|
||||
@@ -2185,7 +2184,7 @@ impl ProtonFs {
|
||||
}
|
||||
// Offline: queue it. Trashing is the one mutation a user expects to work
|
||||
// regardless — the file is gone from their point of view the moment the
|
||||
// command returns (offline.md Phase 3b). The same when the network goes
|
||||
// command returns. The same when the network goes
|
||||
// away under the remote call below.
|
||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
||||
Ok(()) => reply.ok(),
|
||||
@@ -2234,7 +2233,7 @@ impl ProtonFs {
|
||||
self.core.invalidate_trash();
|
||||
// Every other trash site records itself; this one did not, which made a
|
||||
// file found in the trash impossible to attribute after the fact — the
|
||||
// activity log was the only record and it showed nothing (bugs.md B2).
|
||||
// activity log was the only record and it showed nothing (`docs/BUGS.md` B2).
|
||||
self.core
|
||||
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
||||
reply.ok();
|
||||
|
||||
+33
-35
@@ -226,7 +226,7 @@ const DRAIN_WORKERS: usize = 16;
|
||||
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
|
||||
/// First and longest delay between probes for the network coming back after an
|
||||
/// offline mount (offline.md Phase 1). Doubles from min to max: a laptop shut in
|
||||
/// offline mount. Doubles from min to max: a laptop shut in
|
||||
/// a bag is the common case, so the steady state must be cheap, while a brief
|
||||
/// blip should still recover in seconds.
|
||||
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
||||
@@ -334,7 +334,7 @@ const MAX_THUMBNAIL_MISSES: usize = 8192;
|
||||
///
|
||||
/// Matches the SDK's own `MAX_BATCH_COUNT`, so a chunk is exactly one request:
|
||||
/// chunking smaller would add round trips, larger would be split anyway and
|
||||
/// delay the waiters this chunking exists to release (bugs.md B14).
|
||||
/// delay the waiters this chunking exists to release (`docs/BUGS.md` B14).
|
||||
const SIZE_UPGRADE_CHUNK: usize = 150;
|
||||
|
||||
/// How many folders may have a size upgrade in flight at once.
|
||||
@@ -344,7 +344,7 @@ const SIZE_UPGRADE_CHUNK: usize = 150;
|
||||
/// both with the number of folders it walked. Eight keeps an interactive `ls -l`
|
||||
/// (one folder, sometimes a couple) entirely unaffected while putting a ceiling
|
||||
/// on the recursive case. A folder past the cap is queued, not dropped: one of
|
||||
/// the running threads takes it over when its own batch ends (bugs.md B100).
|
||||
/// the running threads takes it over when its own batch ends (`docs/BUGS.md` B100).
|
||||
const MAX_SIZE_UPGRADES: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -535,7 +535,7 @@ struct Core {
|
||||
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).
|
||||
/// from it on mount.
|
||||
db: Arc<Db>,
|
||||
/// Serializes only shared-list publication and access-loss invalidation.
|
||||
/// Network calls happen before taking it.
|
||||
@@ -545,8 +545,8 @@ struct Core {
|
||||
/// Successful refreshes suppress retries even if persisting their timestamp
|
||||
/// fails. Shared across primary/fork clones and cleared by access events.
|
||||
shared_refresh_deadlines: Arc<Mutex<SharedRefreshDeadlines>>,
|
||||
/// False while the API is unreachable and we are serving the cached tree
|
||||
/// (offline.md Phase 1). Set by the probe thread; read by front-ends through
|
||||
/// False while the API is unreachable and we are serving the cached tree.
|
||||
/// Set by the probe thread; read by front-ends through
|
||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||
/// infer it from a wall of EIO.
|
||||
online: Arc<AtomicBool>,
|
||||
@@ -557,8 +557,8 @@ struct Core {
|
||||
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||
sync_paused_until: Arc<AtomicI64>,
|
||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node.
|
||||
/// The in-memory face of the `pending_op` table, from
|
||||
/// which it is rebuilt on mount.
|
||||
///
|
||||
/// Two things read it: [`Core::read_range`], because until the op drains the
|
||||
@@ -586,7 +586,7 @@ struct Core {
|
||||
/// the queue instead of waiting out their backoff.
|
||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||
/// was cloned into so teardown can end and join them (bugs.md B44).
|
||||
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||
shutdown: Arc<shutdown::Shutdown>,
|
||||
/// How long this node's last upload actually took, in milliseconds, with
|
||||
/// the time it was measured for eviction. Feeds
|
||||
@@ -695,7 +695,7 @@ struct Core {
|
||||
/// Size upgrades currently running, per folder inode, across every mount. A `getattr` that
|
||||
/// needs a real size waits on the entry rather than issuing its own fetch —
|
||||
/// `ls -l` of a folder is one `getattr` per file, and they must collapse
|
||||
/// onto a single batch (bugs.md B14).
|
||||
/// onto a single batch (`docs/BUGS.md` B14).
|
||||
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
||||
/// Replies parked on those upgrades. Shared across every mount for the same
|
||||
/// reason the pool is: the bound that matters is per daemon.
|
||||
@@ -726,11 +726,11 @@ struct Core {
|
||||
/// can say what a pass is doing rather than just "syncing". An entry exists
|
||||
/// only while that folder's reconcile pass is running.
|
||||
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
|
||||
/// Channel to the folder-sync engine (devices.md Phase 2): nudges it to
|
||||
/// Channel to the folder-sync engine: nudges it to
|
||||
/// reconcile a folder, reconcile everything, or re-scan its watch set.
|
||||
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
|
||||
/// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder
|
||||
/// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote
|
||||
/// id. Each is a `ProtonFs` rooted at the folder's remote
|
||||
/// node, mounted over its local path, sharing this Core's client/cache/db and
|
||||
/// tree but with its own root (`fork_state`). Held so we can unmount on
|
||||
/// toggle back to `mirror` and on daemon shutdown. Each entry retains the
|
||||
@@ -1833,8 +1833,7 @@ impl Core {
|
||||
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
|
||||
}
|
||||
|
||||
/// Rebuild the in-memory pending map from the `pending_op` table on mount
|
||||
/// (offline.md Phase 3).
|
||||
/// Rebuild the in-memory pending map from the `pending_op` table on mount.
|
||||
///
|
||||
/// A queued write survives a restart — that is the point of persisting it —
|
||||
/// so until the drain worker gets to it, reads of that file must still come
|
||||
@@ -2774,7 +2773,7 @@ impl Core {
|
||||
missing.push(uid.clone());
|
||||
}
|
||||
// Returned, not awaited here: the caller is a `getattr` that must not
|
||||
// answer with a provisional size (bugs.md B14), and it parks its reply
|
||||
// answer with a provisional size (`docs/BUGS.md` B14), and it parks its reply
|
||||
// on this batch rather than holding a thread until it lands.
|
||||
self.upgrade_sizes(key, missing)
|
||||
}
|
||||
@@ -2791,7 +2790,7 @@ impl Core {
|
||||
/// `total_size_on_storage`, the *ciphertext* size, which is slightly larger
|
||||
/// than the real one. A read must not plan on it: since B84 a size the
|
||||
/// revision disagrees with fails the read, so `read` settles the size first
|
||||
/// ([`Core::resolve_provisional_size`], bugs.md B100). In `stat` it is a
|
||||
/// ([`Core::resolve_provisional_size`], `docs/BUGS.md` B100). In `stat` it is a
|
||||
/// discrepancy that closes within a round trip, not a repeat of B11 (which
|
||||
/// reported **0** and so suppressed reads entirely).
|
||||
///
|
||||
@@ -2865,11 +2864,11 @@ impl Core {
|
||||
/// Chunked so a waiter is released as soon as *its* file is resolved. A
|
||||
/// single 793-node batch took ~80 s, which outran [`SizeUpgrade::WAIT`] and
|
||||
/// put provisional sizes back in front of callers — the bug this was
|
||||
/// supposed to fix (bugs.md B14).
|
||||
/// supposed to fix (`docs/BUGS.md` B14).
|
||||
///
|
||||
/// When the batch ends, the thread takes over the oldest batch the cap held
|
||||
/// back, so at most [`MAX_SIZE_UPGRADES`] threads run however many folders
|
||||
/// a recursive walk touches (bugs.md B100).
|
||||
/// a recursive walk touches (`docs/BUGS.md` B100).
|
||||
fn run_size_upgrade(&self, key: u64, uids: Vec<NodeUid>, slot: Arc<SizeUpgrade>) {
|
||||
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
||||
loop {
|
||||
@@ -2897,7 +2896,7 @@ impl Core {
|
||||
/// size it is given against the revision's own block table and fails the
|
||||
/// read on a mismatch (B84), so reading on the provisional size is an `EIO`
|
||||
/// every time. `getattr` usually settles the size first, but not when its
|
||||
/// wait times out, so the read cannot rely on it (bugs.md B100).
|
||||
/// wait times out, so the read cannot rely on it (`docs/BUGS.md` B100).
|
||||
fn resolve_provisional_size(&self, ino: u64, uid: &NodeUid) -> Option<u64> {
|
||||
let parent = self.state().entries.get(&ino)?.parent;
|
||||
// Without a network the answer is `None` either way; the bound keeps a
|
||||
@@ -3317,8 +3316,7 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept a released write handle's bytes and queue their upload
|
||||
/// (offline.md Phase 3).
|
||||
/// Accept a released write handle's bytes and queue their upload.
|
||||
///
|
||||
/// This is what makes a copy into the mount run at disk speed: the caller's
|
||||
/// `close` returns once the bytes are staged on local disk and the intent is
|
||||
@@ -3785,8 +3783,8 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invent a node under `parent_uid` and queue the op that will make it real
|
||||
/// (offline.md Phase 3b). Returns the node to intern, exactly as the online
|
||||
/// Invent a node under `parent_uid` and queue the op that will make it real.
|
||||
/// Returns the node to intern, exactly as the online
|
||||
/// path returns the one the server minted.
|
||||
///
|
||||
/// The parent may itself be a placeholder — `mkdir -p` offline, or `cp -r` of
|
||||
@@ -3842,7 +3840,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Queue giving a node a new parent and/or name, and apply it to the tree
|
||||
/// now (offline.md Phase 3b).
|
||||
/// now.
|
||||
///
|
||||
/// The op records the desired end state rather than the step, so it both
|
||||
/// supersedes any earlier queued rename and lets the drain skip whichever
|
||||
@@ -3950,7 +3948,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Queue trashing a node the server knows about, and drop it from the tree
|
||||
/// now (offline.md Phase 3b).
|
||||
/// now.
|
||||
///
|
||||
/// Anything else queued for this node is discarded first: the user has said
|
||||
/// the file should not exist, so uploading bytes to it or renaming it are
|
||||
@@ -4081,7 +4079,7 @@ impl Core {
|
||||
///
|
||||
/// The path identifies the revision: publication stages a fresh file and
|
||||
/// swaps the map entry to point at it, so an unchanged path means the base a
|
||||
/// caller sampled earlier is still the base now (bugs.md B32).
|
||||
/// caller sampled earlier is still the base now (`docs/BUGS.md` B32).
|
||||
fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> {
|
||||
self.pending.lock().get(uid).map(|p| p.path.clone())
|
||||
}
|
||||
@@ -5124,7 +5122,7 @@ impl Core {
|
||||
/// `rename(2)` promises to replace an existing destination atomically. Proton
|
||||
/// offers no such primitive — `rename_node` refuses a name that is already
|
||||
/// taken — so this is the first half of an emulation that is *not* atomic:
|
||||
/// see [`Core::restore_replaced`] for the other half (bugs.md B13).
|
||||
/// see [`Core::restore_replaced`] for the other half (`docs/BUGS.md` B13).
|
||||
///
|
||||
/// A node whose own creation is still queued has never reached the server, so
|
||||
/// dropping its queued ops is the whole removal; nothing goes to the wire and
|
||||
@@ -5950,8 +5948,8 @@ fn parse_node_uid(s: &str) -> Option<NodeUid> {
|
||||
}
|
||||
|
||||
/// The uid a node made on this machine goes by until it lands, so it can be
|
||||
/// interned, listed and written to before the server has ever heard of it
|
||||
/// (offline.md Phase 3b): its row's local id, as [`pdfs_core::db::local_uid`]
|
||||
/// interned, listed and written to before the server has ever heard of it:
|
||||
/// its row's local id, as [`pdfs_core::db::local_uid`]
|
||||
/// has it.
|
||||
fn local_node_uid(lid: i64) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string()))
|
||||
@@ -6114,7 +6112,7 @@ fn dir_is_empty(dir: &Path) -> std::io::Result<bool> {
|
||||
|
||||
/// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE
|
||||
/// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files
|
||||
/// are the disk we're reclaiming (devices.md Phase 3).
|
||||
/// are the disk we're reclaiming.
|
||||
fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
@@ -6196,7 +6194,7 @@ struct Progress {
|
||||
///
|
||||
/// Waiters are released **per chunk**, not once at the end: a waiter only cares
|
||||
/// about its own file, and waiting for the other 792 is what let a large folder
|
||||
/// outrun the timeout (bugs.md B14).
|
||||
/// outrun the timeout (`docs/BUGS.md` B14).
|
||||
#[derive(Default)]
|
||||
struct SizeUpgrade {
|
||||
inner: Mutex<Progress>,
|
||||
@@ -6222,7 +6220,7 @@ enum SizeUpgradeClaim {
|
||||
/// Past the cap this used to return nothing, and the caller answered with the
|
||||
/// provisional size at once. Nothing retried the folder until the next `stat`,
|
||||
/// so a recursive walk over more than [`MAX_SIZE_UPGRADES`] fresh folders showed
|
||||
/// ciphertext sizes for most of them (bugs.md B100). Queued, the batch is still
|
||||
/// ciphertext sizes for most of them (`docs/BUGS.md` B100). Queued, the batch is still
|
||||
/// fetched, and a waiter gets the real size unless [`SizeUpgrade::WAIT`] runs out.
|
||||
fn claim_size_upgrade(
|
||||
in_flight: &mut HashMap<u64, Arc<SizeUpgrade>>,
|
||||
@@ -6425,7 +6423,7 @@ impl SizeWaitQueue {
|
||||
/// Split out from the handler because it is the part that is pure and the part
|
||||
/// that is dangerous: every `Err` here is a refusal that happens *before*
|
||||
/// anything is trashed, and getting one wrong turns a refusal into the
|
||||
/// destruction of the destination (bugs.md B13).
|
||||
/// destruction of the destination (`docs/BUGS.md` B13).
|
||||
///
|
||||
/// `dst_empty` is only meaningful when `dst_dir`; pass `true` otherwise.
|
||||
fn check_replaceable(src_dir: bool, dst_dir: bool, dst_empty: bool) -> Result<(), Errno> {
|
||||
@@ -7155,7 +7153,7 @@ mod size_upgrade_tests {
|
||||
}
|
||||
|
||||
/// Past the cap a folder used to get no upgrade at all, so a recursive
|
||||
/// walk reported ciphertext sizes for most of what it listed (bugs.md B100).
|
||||
/// walk reported ciphertext sizes for most of what it listed (`docs/BUGS.md` B100).
|
||||
#[test]
|
||||
fn a_folder_past_the_cap_is_queued_not_dropped() {
|
||||
let mut in_flight = HashMap::new();
|
||||
@@ -7208,7 +7206,7 @@ mod replace_tests {
|
||||
|
||||
/// The case that motivated all of this: rsync renaming its temp file over
|
||||
/// the real one. Two plain files, and it has to be allowed — refusing is
|
||||
/// what made every rsync transfer fail at the last step (bugs.md B13).
|
||||
/// what made every rsync transfer fail at the last step (`docs/BUGS.md` B13).
|
||||
#[test]
|
||||
fn a_file_may_replace_a_file() {
|
||||
assert!(check_replaceable(false, false, true).is_ok());
|
||||
|
||||
@@ -248,7 +248,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||
/// probe it back while it is down (offline.md Phase 1).
|
||||
/// probe it back while it is down.
|
||||
///
|
||||
/// A mount that started from the cache is down from the first moment; one
|
||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||
|
||||
@@ -193,7 +193,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
||||
}
|
||||
|
||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
||||
/// recover it from `nodes` without the API.
|
||||
const ROOT_UID_KEY: &str = "root_uid";
|
||||
|
||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||
@@ -401,7 +401,7 @@ pub(crate) fn mount_with(
|
||||
.all_share_access()
|
||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||
|
||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
||||
// The folder-sync engine runs on its own thread and is
|
||||
// nudged over this channel; the sender lives in Core so control-socket
|
||||
// handlers can trigger reconciles.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
@@ -501,7 +501,7 @@ pub(crate) fn mount_with(
|
||||
// Every long-lived thread started from here on is kept in `workers` and
|
||||
// joined at the end of this function. Leaving them running turned an
|
||||
// in-process remount into a second generation of drain workers, sync engines
|
||||
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
||||
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
@@ -738,7 +738,7 @@ pub(crate) fn mount_with(
|
||||
};
|
||||
|
||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
||||
// stale and the next start fails with EBUSY.
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
@@ -798,7 +798,7 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||
/// did not, and started a second full set on top of the first (bugs.md B44).
|
||||
/// did not, and started a second full set on top of the first (`docs/BUGS.md` B44).
|
||||
///
|
||||
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
||||
//! the restore that rebuilds a machine from one.
|
||||
//!
|
||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||
@@ -246,7 +246,7 @@ impl Core {
|
||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||
}
|
||||
|
||||
// ---- restore (features.md 5.2) ----------------------------------------
|
||||
// ---- restore ----------------------------------------
|
||||
|
||||
/// The folders under a device that could be synced here, each carrying a
|
||||
/// proposed local path. `device` names another registered device by uid;
|
||||
|
||||
@@ -464,7 +464,7 @@ impl Core {
|
||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||
/// first read, because learning it means opening a `RevisionReader`, and
|
||||
/// opening one per read is precisely the per-file key derivation the block
|
||||
/// cache exists to avoid (bugs.md B12, B85).
|
||||
/// cache exists to avoid (`docs/BUGS.md` B12, B85).
|
||||
///
|
||||
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
||||
/// real sizes and clamps to what was asked for either way. It costs a
|
||||
@@ -493,13 +493,13 @@ impl Core {
|
||||
) -> Result<Vec<u8>, Errno> {
|
||||
// A queued write has not reached the remote yet, so the remote's current
|
||||
// revision is stale and the staged blob is the truth. Serve from it until
|
||||
// the drain worker lands the upload (offline.md Phase 3).
|
||||
// the drain worker lands the upload.
|
||||
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
||||
return self.read_pending(&pending, offset, len);
|
||||
}
|
||||
// A node created offline and never written has no blob and no remote: it
|
||||
// is an empty file, and asking the API about a `local~` uid would only
|
||||
// earn a 404 (offline.md Phase 3b). Unless its create landed since the
|
||||
// earn a 404. Unless its create landed since the
|
||||
// read looked: the landing moved the queued write to the uid Drive
|
||||
// gave it, and the row says which (`docs/BUGS.md` B179).
|
||||
if is_local_uid(uid) {
|
||||
@@ -636,7 +636,7 @@ impl Core {
|
||||
///
|
||||
/// A block that comes back shorter than the file's size says it must be is
|
||||
/// never served, cached or promoted: it is repaired from the whole-file
|
||||
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
|
||||
/// download, or the read fails. See [`Core::repair_block`] (`docs/BUGS.md` B84).
|
||||
async fn read_block(
|
||||
&self,
|
||||
reader: &Arc<dyn RevisionRead>,
|
||||
@@ -651,7 +651,7 @@ impl Core {
|
||||
// file's boundaries are wrong, and the failure is not confined to short
|
||||
// reads: sizes that *overstate* a block shift every block after it and
|
||||
// return full-length reads of the wrong bytes. So a disagreement of any
|
||||
// kind takes the file off the range path entirely (bugs.md B84).
|
||||
// kind takes the file off the range path entirely (`docs/BUGS.md` B84).
|
||||
let bytes = if reader.size() != fsize {
|
||||
warn!(
|
||||
%uid, bidx = span.idx, fsize,
|
||||
@@ -664,7 +664,7 @@ impl Core {
|
||||
// Learned here rather than on a path of its own: this is where a
|
||||
// reader is open anyway, and recording the real geometry is what
|
||||
// lets the *next* read plan its blocks on the revision's own
|
||||
// boundaries instead of the 4 MiB fallback (bugs.md B85).
|
||||
// boundaries instead of the 4 MiB fallback (`docs/BUGS.md` B85).
|
||||
if cache_blocks {
|
||||
self.cache
|
||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||
@@ -706,7 +706,7 @@ impl Core {
|
||||
/// The range path derives every block boundary from the revision's recorded
|
||||
/// block sizes. When those understate the file, reads come back short with
|
||||
/// no error anywhere, and the caller hands userspace a truncated file that
|
||||
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
|
||||
/// looks like a clean EOF (`docs/BUGS.md` B84). `download_file_to` does not do that
|
||||
/// arithmetic — it streams the block list — so it returns the whole file
|
||||
/// where the range path cannot, which is why pinning was the workaround.
|
||||
/// This does the same thing on the user's behalf, once, and the resulting
|
||||
@@ -918,7 +918,7 @@ impl Core {
|
||||
let end = offset.saturating_add(len).min(fsize);
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
// On the revision's own boundaries where they are known, and on the
|
||||
// 4 MiB fallback where they are not (bugs.md B85).
|
||||
// 4 MiB fallback where they are not (`docs/BUGS.md` B85).
|
||||
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
||||
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||
return Ok(out);
|
||||
@@ -1038,7 +1038,7 @@ impl Core {
|
||||
// — the cache validates it on read, the network path in `read_block`
|
||||
// — so this refuses rather than quietly contributing fewer bytes
|
||||
// than the range covers, which is what turned a bad block into a
|
||||
// truncated file (bugs.md B84).
|
||||
// truncated file (`docs/BUGS.md` B84).
|
||||
if block.len() as u64 != span.len {
|
||||
error!(
|
||||
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||
//! longer exists (bugs.md B44).
|
||||
//! longer exists (`docs/BUGS.md` B44).
|
||||
//!
|
||||
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! An in-memory Drive for the simulation tests (`docs/MILESTONE-3.0.0.md` §8.1).
|
||||
//! An in-memory Drive for the simulation tests (`docs/TESTING.md`).
|
||||
//!
|
||||
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
|
||||
//! names, and an event log. Each simulated daemon reaches it through its own
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The simulation tests of `docs/MILESTONE-3.0.0.md` §8: a fake Drive with
|
||||
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
|
||||
//! the faults the real one has, and the tools to drive daemons against it.
|
||||
|
||||
pub(crate) mod daemon;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The reference file system of the simulation (`docs/MILESTONE-3.0.0.md`
|
||||
//! §8.2, property 4): what each syscall a simulated client makes should
|
||||
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||
//! answer, and the tree it should leave, on a POSIX file system.
|
||||
//!
|
||||
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The simulation runs of `docs/MILESTONE-3.0.0.md` §8.2.
|
||||
//! The simulation runs described in `docs/TESTING.md`.
|
||||
//!
|
||||
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
||||
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||
|
||||
@@ -183,7 +183,7 @@ impl WriteHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
||||
/// A released write whose upload has not happened yet.
|
||||
///
|
||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||
@@ -253,7 +253,7 @@ pub(crate) struct State {
|
||||
/// two pages shifted every later entry, and the caller silently skipped or
|
||||
/// repeated whatever crossed the page boundary. Each entry is
|
||||
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||
/// module stays free of `fuser` (bugs.md B43).
|
||||
/// module stays free of `fuser` (`docs/BUGS.md` B43).
|
||||
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||
/// Resident inode attributes whose effective access changed during an
|
||||
/// intern/root refresh. Core drains this set and notifies the matching
|
||||
@@ -1198,7 +1198,7 @@ impl State {
|
||||
/// re-enumerate, which is the cheaper way to stay honest about what the
|
||||
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
||||
/// the server has not been told yet in any case — so this *is* the tree's
|
||||
/// new truth until the op drains (offline.md Phase 3b).
|
||||
/// new truth until the op drains.
|
||||
pub(crate) fn rename_in_place(
|
||||
&mut self,
|
||||
ino: u64,
|
||||
@@ -1317,7 +1317,7 @@ impl State {
|
||||
/// created offline, nothing at all), so this row is the only record that the
|
||||
/// file is as long as the caller was told it is. Without it a restart serves
|
||||
/// the stale size and the file reads as truncated — or empty — while its
|
||||
/// bytes sit safely in staging (offline.md Phase 3).
|
||||
/// bytes sit safely in staging.
|
||||
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||
/// the one mutation here whose write-through the caller must do itself:
|
||||
/// failure has to be reported, not logged. Dropping the row silently means
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Core {
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
// Both waits are interruptible, so teardown does not have to sit out a
|
||||
// five-minute interval to join this thread (bugs.md B44).
|
||||
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Two-way folder sync engine (devices.md Phase 2).
|
||||
//! Two-way folder sync engine.
|
||||
//!
|
||||
//! Each `mirror`-mode synced folder is kept mirrored between a local directory
|
||||
//! and a folder under this machine's Proton Drive device. A local filesystem
|
||||
@@ -1342,7 +1342,7 @@ impl Core {
|
||||
/// rename below is the destructive step, and the download in between can run
|
||||
/// for minutes, so the destination is re-examined right before it: a local
|
||||
/// file that no longer matches the plan is set aside as a conflict copy
|
||||
/// instead of being replaced (bugs.md B39).
|
||||
/// instead of being replaced (`docs/BUGS.md` B39).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_file(
|
||||
&self,
|
||||
@@ -1441,7 +1441,7 @@ impl Core {
|
||||
/// the pre-upload identity is what makes the next pass see the path as
|
||||
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||
/// post-upload stat instead declared the torn revision synchronized and left
|
||||
/// it as the file's content on Drive (bugs.md B40).
|
||||
/// it as the file's content on Drive (`docs/BUGS.md` B40).
|
||||
async fn record_file_baseline(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
@@ -1505,7 +1505,7 @@ fn open_to_upload(path: &Path, rel: &str) -> Result<Option<std::fs::File>, Strin
|
||||
/// records the identity the upload *read* rather than the one on disk now, so
|
||||
/// the next pass classifies the path as locally changed and replaces the torn
|
||||
/// revision. Without the log there is nothing in the journal to explain the
|
||||
/// extra revision (bugs.md B40).
|
||||
/// extra revision (`docs/BUGS.md` B40).
|
||||
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||
let now = local_sig(path);
|
||||
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||
@@ -1537,7 +1537,7 @@ fn local_sig(path: &Path) -> Option<LocalSig> {
|
||||
/// transfer started, and a download of a large file can run for minutes. Without
|
||||
/// this, a local edit completed inside that window was replaced by the remote
|
||||
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
||||
/// engine must not have (`docs/BUGS.md` B39). Keeping the copy makes it re-upload as a
|
||||
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||
/// conflict already gets.
|
||||
///
|
||||
@@ -1573,7 +1573,7 @@ fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::R
|
||||
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||
/// every pass, forever. Every other error is still an error, and keeping the
|
||||
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
||||
/// a new local creation on the next pass and uploaded back (`docs/BUGS.md` B42).
|
||||
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||
match result {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
@@ -1830,7 +1830,7 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
||||
///
|
||||
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||
/// sync from no edit at all, and if the size did not change either, that file
|
||||
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
||||
/// was never uploaded (`docs/BUGS.md` B25). `None` for a clock or filesystem that
|
||||
/// cannot answer, which falls the comparison back to seconds rather than
|
||||
/// asserting an mtime of zero.
|
||||
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||
@@ -1845,7 +1845,7 @@ mod tests {
|
||||
|
||||
/// A removal of something already gone has produced the state the sync
|
||||
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
||||
/// Anything else stays an error and keeps the baseline (`docs/BUGS.md` B42).
|
||||
#[test]
|
||||
fn an_already_absent_path_counts_as_removed() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||
@@ -1862,7 +1862,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The download guard: a destination that still matches the plan is
|
||||
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
||||
/// replaced silently, one that has moved on since is kept (`docs/BUGS.md` B39).
|
||||
#[test]
|
||||
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||
@@ -2177,7 +2177,7 @@ mod tests {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
||||
/// A restored folder starts with an empty baseline against
|
||||
/// an empty local directory and a full remote. That must reconcile as
|
||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||
/// every restore would wedge its folder on the first pass.
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(crate) enum SyncMsg {
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
Rewatch,
|
||||
/// The daemon is tearing down: finish the current pass and end the engine
|
||||
/// thread, so the mount can join it (bugs.md B44).
|
||||
/// thread, so the mount can join it (`docs/BUGS.md` B44).
|
||||
Stop,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||
/// falls back to whole seconds, which is what a baseline row written before
|
||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||
/// (bugs.md B25).
|
||||
/// (`docs/BUGS.md` B25).
|
||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||
if self.size != other.size {
|
||||
return false;
|
||||
@@ -161,7 +161,7 @@ pub(super) enum Pending {
|
||||
/// was there. A download can run for minutes, so the apply step revalidates
|
||||
/// against it immediately before it renames over the destination: a writer
|
||||
/// that finished an edit in the meantime must not be overwritten without a
|
||||
/// conflict copy (bugs.md B39).
|
||||
/// conflict copy (`docs/BUGS.md` B39).
|
||||
Download {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
|
||||
@@ -218,7 +218,7 @@ pub(super) fn plan_file(
|
||||
}
|
||||
|
||||
let local_changed = local.is_some_and(|item| {
|
||||
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
||||
// Nanoseconds where both sides have them (`docs/BUGS.md` B25): whole seconds
|
||||
// could not see an edit made in the same second as the last sync that
|
||||
// left the file the same length, so it was never uploaded.
|
||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||
@@ -291,7 +291,7 @@ mod file_plan_tests {
|
||||
|
||||
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
||||
/// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
|
||||
#[test]
|
||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||
let mut base = baseline(10, 20, 30, 40);
|
||||
|
||||
@@ -832,8 +832,8 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open,
|
||||
/// offline, sharing, organising, and the destructive item last on its own.
|
||||
/// The menu for one entry, in a fixed order: open, offline, sharing,
|
||||
/// organising, and the destructive item last on its own.
|
||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||
//! with no FUSE session behind it (mount-architecture.md §4).
|
||||
//! with no FUSE session behind it.
|
||||
//!
|
||||
//! The device rows drive the same control requests the Computers page used to:
|
||||
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||
|
||||
@@ -133,7 +133,7 @@ impl Drop for Busy {
|
||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||
/// own inode space, or on someone else's volume, neither of which the primary
|
||||
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
||||
/// resolves it with `Core::resolve_anywhere`.
|
||||
///
|
||||
/// The two are separate request variants rather than one request with an
|
||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||
|
||||
+521
-468
File diff suppressed because it is too large.
Load diff
+3621
-3185
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -83,7 +83,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
||||
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
||||
| `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. |
|
||||
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. The upload stays queued. |
|
||||
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
|
||||
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
|
||||
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
||||
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
||||
|
||||
@@ -38,7 +38,7 @@ Inside `content/`:
|
||||
> not reached Proton Drive and exists nowhere else.
|
||||
|
||||
Everything in these directories except the keyring is **decrypted**. See
|
||||
[ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## `config.json`
|
||||
|
||||
@@ -190,13 +190,16 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
|
||||
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
||||
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
||||
|
||||
The acceptance suite has its own `PDFS_ACCEPTANCE_*` variables, listed in [TESTING.md](TESTING.md).
|
||||
The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
|
||||
listed in [TESTING.md](TESTING.md).
|
||||
|
||||
## The systemd service
|
||||
|
||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
||||
the unit without editing the packaged file, use a drop-in:
|
||||
`proton-drive.service` is a user unit started with your graphical session
|
||||
(`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
|
||||
when it stops answering for two minutes (`WatchdogSec=120`), throttles it above 2 GiB of memory
|
||||
(`MemoryHigh`) and stops it at 6 GiB (`MemoryMax`). To change the unit without editing the
|
||||
packaged file, use a drop-in:
|
||||
|
||||
```bash
|
||||
systemctl --user edit proton-drive.service
|
||||
|
||||
+16
-6
@@ -31,12 +31,12 @@ time when the installed library has them (`crates/pdfs-gui/src/compat.rs`).
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
||||
| `crates/pdfs-fuse` | The daemon: FUSE filesystem, read path (`reads`), write queue (`queue`, `drain`, `upload`), sync engine (`sync/`), remote events (`background`), push events for front ends (`events`), control handlers (`control`), photos, devices, supervisor |
|
||||
| `crates/pdfs-fuse` | The daemon: FUSE handlers (`filesystem`), the tree (`state`), reads (`reads`), the queue and the drain (`queue`, `drain`, `upload`), link state (`link`), the sync engine (`sync`, `sync/`), moves between locations (`relocate`), remote events (`background`), push events (`events`), control handlers (`control`), photos, devices, the supervisor, and the simulation tests (`sim/`) |
|
||||
| `crates/pdfs-cli` | The `pdfs` binary |
|
||||
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
||||
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
||||
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
||||
| `scripts/` | FUSE acceptance suite, map-data generators |
|
||||
| `scripts/` | FUSE acceptance suite, map-data generators, the wiki publisher |
|
||||
| `docs/` | This documentation |
|
||||
|
||||
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
||||
@@ -87,8 +87,9 @@ cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
||||
cases described in [TESTING.md](TESTING.md).
|
||||
For changes to the FUSE layer, the queue or the sync engine, also run the
|
||||
[simulation runs](TESTING.md#simulation-runs), which CI runs only after a push to `main`, and the
|
||||
[acceptance suite](TESTING.md#acceptance-suite) on a test account.
|
||||
|
||||
For changes to GUI strings, check that every catalog is complete:
|
||||
|
||||
@@ -136,6 +137,15 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
|
||||
`#[serde(default)]`, so older files still load, and document it in
|
||||
[CONFIGURATION.md](CONFIGURATION.md).
|
||||
|
||||
**A bug fix.** Add or update the entry in [BUGS.md](BUGS.md#adding-an-entry), cover the fix with a
|
||||
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
|
||||
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
|
||||
|
||||
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration)
|
||||
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies
|
||||
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`)
|
||||
to the GitHub wiki, so keep their file names.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
||||
@@ -160,5 +170,5 @@ git tag vX.Y.Z
|
||||
git push origin main vX.Y.Z
|
||||
```
|
||||
|
||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
||||
Before a release, work through [Before a release](TESTING.md#before-a-release). Open
|
||||
release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
|
||||
@@ -1,182 +0,0 @@
|
||||
# 3.0.0: what is done and what is left
|
||||
|
||||
Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase.
|
||||
|
||||
## Done
|
||||
|
||||
- **Phase 0**: fake Drive, simulation runs, CI job (2.9.0).
|
||||
- **Phase 1**: queued-op preconditions and own sealed revisions in the database (2.9.0).
|
||||
- **Phase 2**: local ids, inode = local id, one tree for every mount (2.10.0).
|
||||
- **Phase 3, first part**: mkdir, create, rename, unlink and rmdir on the mount are recorded
|
||||
locally and sent from the queue; `"local_first": false` brings back the old path (5e0f30c).
|
||||
- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B193.
|
||||
- **Sync issues, first cut** (§6.2): refusals sorted into issues, shown at once in the app,
|
||||
tray and CLI; Export button and `pdfs sync export`; `pdfs sync issues`; Discard button and
|
||||
`pdfs sync discard`; a warning badge on the node in the file browser.
|
||||
|
||||
## Phase 3: left for 3.0.0
|
||||
|
||||
1. **Sync issues (§6.2)**: first cut done.
|
||||
- [x] The drain sorts a refusal from Drive into an issue kind (quota, access, missing,
|
||||
limit, rejected) and stores it on the queued op (schema 38).
|
||||
- [x] An op with an issue counts as "needs attention" at once, not after six failures.
|
||||
- [x] The queue listing carries the issue kind and whether its content can be exported.
|
||||
- [x] Export a queued file's content (control request, `pdfs sync export`).
|
||||
- [x] `pdfs sync issues`, and the issue shown in `pdfs sync queue`.
|
||||
- [x] The app's Sync page shows each issue with what the user can do; Export and Retry
|
||||
buttons; translated in every catalog.
|
||||
- [x] A name collision lands under a conflict name (create, rename, move, re-homed create)
|
||||
and goes in the activity log, as does a queued edit kept as a conflict copy. The app
|
||||
words each one in the user's language (the last one since B181).
|
||||
- [x] Discard an issue on purpose (after export, or for a change with no content), and
|
||||
undo the local change it stood for: a node made here goes, a node Drive has goes back
|
||||
to what Drive has. Control request, `pdfs sync discard`, Discard button with a
|
||||
confirmation; translated in every catalog.
|
||||
- [x] Show the issue on the node itself: a folder listing carries each node's issue, and
|
||||
the file browser shows a warning badge with what it means. The listing is published
|
||||
again when the set of issues changes.
|
||||
2. **Planner and executor (§5.4, §5.5)**: ops keyed by local id; remove `mint_local_uid`,
|
||||
`finish_create` and `adopt_real_uid`; a landing create sets `remote_uid` on its row.
|
||||
- [x] Queued ops carry `lid` and `parent_lid` (schema 39). The claim reads the parent's uid
|
||||
through `parent_lid`, and a landing folder rewrites the ops inside it by local id.
|
||||
- [x] Node rows link to their parent by lid (`nodes.parent_lid`, schema 40), and a restart
|
||||
places each node under its parent's row as it is now.
|
||||
- [x] Queries over children, subtrees and ancestors by `parent_lid`, and no rewrite of the
|
||||
children's `parent_uid` when a folder lands (R3), nor of the ops made inside it (R2).
|
||||
- [x] No more placeholders: a node made here goes by its local id until it lands. In four
|
||||
parts:
|
||||
- [x] 6a: a node made here goes by `local~<lid>`, a function of its row, instead of a
|
||||
minted `local~<ms>-<n>`; schema 41 rewrites the old placeholders (nodes, ops, pins).
|
||||
- [x] 6b: an op is found by its node's local id under either uid, the claim keeps one op
|
||||
per local id on the wire, and the drain sends the uid the row has now;
|
||||
`remap_local_uid` is gone.
|
||||
- [x] 6c: children, subtrees, ancestors and ops' parents by lid, so a landing create
|
||||
rewrites no other row or op; what is read for a uid names the parent as its row has it
|
||||
now. The row keeps its stand-in in `nodes.uid` rather than `NULL`: it is as stable,
|
||||
and `NULL` would have changed some 40 node queries for no change in behaviour. Pins
|
||||
stay keyed by uid and move with the row in the landing transaction (B148).
|
||||
- [x] 6d: a node written under its stand-in after it landed updates its landed row
|
||||
instead of bringing the placeholder row back, so a write that finds its create
|
||||
landed takes the uid from the row and no longer polls the tree for up to 2 s
|
||||
(`landed_uid` is gone). In memory, `pending`, the uploads and the tree stay keyed by
|
||||
the uid Drive knows: ops are matched by lid in the database (6b), `hidden` and
|
||||
`creating` only ever hold remote uids, and `landed_row_uid` is the one place at the
|
||||
mount's edge where a stand-in a handle still holds becomes the real uid.
|
||||
- [x] An executor that runs ops in dependency order, many at once: 16 drain workers instead
|
||||
of 3, and a worker that lands an op wakes the idle ones, which slept through the ops it
|
||||
made claimable. The claim already keeps the order: a create waits for its parent's
|
||||
uid, a node's ops go one at a time. Ops stay blocking calls on worker threads: each
|
||||
one spends its time waiting on Drive, so tasks would buy nothing threads do not.
|
||||
3. **Speed (§7)**: run independent ops in parallel, batch trashes and moves; measure 1,000 files
|
||||
and 100 folders on Wi-Fi against LAN (target: within 10 %).
|
||||
- [x] The measurement: `a_thousand_files_drain_as_fast_on_wifi_as_on_lan` in `sim/daemon.rs`,
|
||||
run with `PDFS_SIM_MEASURE=1`. It prints the times and asserts nothing yet.
|
||||
- [x] Baseline on eb1c105, three drain threads: the syscalls take 3.7 s on LAN and 4.3 s on
|
||||
Wi-Fi; the drain takes 138 s on LAN and 938 s on Wi-Fi, 6.8 times as long.
|
||||
- [x] Sixteen workers: the syscalls take 5.2 s on LAN and 5.0 s on Wi-Fi, which meets the
|
||||
target; the drain takes 47 s on LAN and 247 s on Wi-Fi. Most of a create on Wi-Fi is
|
||||
its read-back waiting for Drive to list the new node (250 ms, then 1 s), so batching
|
||||
(step 8) helps trashes and moves, not creates.
|
||||
- [x] Batched trashes and moves: left for after 3.0.0 (see below). The target is met
|
||||
without them, the drain waits on the creates' read-back, and splitting one answer into
|
||||
many results is where the B15x to B17x races lived.
|
||||
4. **Done-when checks**:
|
||||
- [ ] Simulation passes on the Wi-Fi profile: `one_client_on_a_slow_link` failed in CI on
|
||||
seeds 2 and 3. Two bugs (B127, B151) and a harness gap: a settle did not read the log, so
|
||||
it could not explain a known bug's damage. All three fixed; not yet confirmed in CI.
|
||||
Replays then found two races on the LAN profile too (B152, B153), both fixed. CI on
|
||||
7269cb5 failed LAN seed 9, a regression from the B153 fix (B154), and Wi-Fi seed 2,
|
||||
a write whose answer was lost (B155). Both fixed. One slow-link run of seed 2 failed
|
||||
once with a rename answering `ENOENT` as a folder move landed. Not reproduced since;
|
||||
it may be B152. Replays of slow-link seed 4 failed two of six times on a file written
|
||||
again after its create lost its answer (B156, fixed). With sixteen drain workers
|
||||
(16fca10), seed 4 failed every replay on a write after an upload that was not read
|
||||
back (B157, fixed). Seed 3 then failed four of ten runs on two renames of one file
|
||||
landing out of order (B158, fixed). Flaky-link seed 4 failed one of ten runs on a
|
||||
lookup that answered `EAGAIN` while the root was listed again after a restart (B159,
|
||||
fixed). Slow-link seed 4 also had folders reach Drive 30 s late, after the
|
||||
drain's idle poll (B160, fixed). Slow-link seed 3 failed on a file moved while its
|
||||
create was on the wire, which the mount showed under its old name (B161, fixed).
|
||||
Seed 4 failed on a folder removed and made again, whose create adopted the removed
|
||||
folder (B162, fixed). CI on 903452a passed every profile. CI on f9b1638 failed
|
||||
slow-link seed 2: a step that unlinks an open file, appends and reads it back hung
|
||||
for 150 s. The B130 matcher did not fire, and CI kept no stacks; one replay passed.
|
||||
It may be a kernel notice sent from a runtime worker by another path than B130's,
|
||||
which a68dae9 (not yet pushed then) also takes off the runtime. CI now keeps a hung
|
||||
run's stacks.
|
||||
- [x] Known-bug list in `sim/run.rs` is empty: the run on 812eb3d (`PDFS_SIM_KNOWN=fail`,
|
||||
twelve seeds) passed every profile, so the matchers are gone. That confirms B125 to
|
||||
B133; B134 stays open with `local_first` off only, which the runs do not mount. The confirmation run (`PDFS_SIM_KNOWN=fail`)
|
||||
on da013c2 failed `one_client_on_a_flaky_link` seeds 6 and 11 on two new bugs, both
|
||||
after restarts: a write over a create never read back (B182) and a trash that forgot
|
||||
what its create sent (B183). Replays with those fixed found a third: a create renamed
|
||||
after it lost its answer (B184). All three fixed; no known-bug matcher fired. It was
|
||||
stopped before `three_clients`. The rerun on 3073052 found a create that waited out
|
||||
a retry after a write replaced its blob (B185, fixed), and a file deleted offline
|
||||
while open that lost the bytes its handle read (B186, fixed; older than 3.0.0).
|
||||
The run on 2baad67 found that a restart before such a file's trash went out queued
|
||||
its kept bytes again, for good (B188, fixed), and changes that sat out their retry
|
||||
after an outage only the drain had met (B189, fixed; older than 3.0.0), and a file
|
||||
saved just before a stop whose release the stop dropped (B190, fixed; older too). The run
|
||||
on 1436c5f found a file deleted after its failed create was written again left on Drive
|
||||
(B191, fixed). Its test for B193 found a new empty file waiting out the idle poll (B192,
|
||||
fixed), and seed 6 a deleted file's trash taking a file of the user's renamed to the name
|
||||
its create was sent under (B193, fixed).
|
||||
- [ ] A clean account run. The one on a6f36d5 (2026-10-04, LAN: 427 passed, 12 failed)
|
||||
failed B113 and B121 on every on-demand mount, two mirror-to-mirror move cases, and
|
||||
the journal check. B121 was a bug (B149, fixed); B113's case still expected creates
|
||||
to reach Drive at once and now accepts queued ones. The move cases were the harness:
|
||||
it retried every source after one was busy, and expected a refusal where a mirror to
|
||||
mirror move carries the local copy along. The journal showed a conflict copy retrying
|
||||
into a deleted folder (B150, fixed). Not confirmed yet. The run on 2.10.0
|
||||
(2026-10-04: 260 passed, 3 failed) failed a new file that read back empty (B163,
|
||||
fixed), a 128-entry folder that listed 129 (B164, fixed), and its cleanup, which ran
|
||||
while the daemon was restarting: the stop waited on a busy thread until systemd killed
|
||||
it (B165, bounded). The next run on the working tree (2026-10-04: 259 passed, 4
|
||||
failed) found a wide folder dropping files whose create landed while it was listed
|
||||
(B166, fixed), or listing one empty: the new file that read back empty again
|
||||
(B166 too). Its cleanup found no control socket because it ran 1.5 s after the
|
||||
last restart, before the new daemon had bound it: `pdfs status` reports that as not
|
||||
running, which the run's queue wait takes for an empty queue (the script's side).
|
||||
Looking into it found the start spending 1.7 s rebuilding listings (B167, fixed).
|
||||
Its journal check caught a file removed with its folder while uploading being
|
||||
sent again, to the root (B168, fixed), and a stop that let a mirror pass go on
|
||||
until the runtime ended under it (B169, fixed). CI on 9bbc994 failed
|
||||
`one_client_on_a_good_link`: a write opened as an upload landed took the revision
|
||||
it replaced as its base, and the next write failed with `EIO` (B170, fixed). The
|
||||
suite's seed 9 found a deleted file's trash waiting out its create's backoff (B171,
|
||||
fixed). Run three at a time on 2502af6, seed 8 failed two of nine runs: a file renamed
|
||||
while its upload was read back showed its old name again (B172, fixed). The loop
|
||||
on d850265 failed seed 12 once in nine runs: a file deleted just as its create
|
||||
landed stayed on Drive (B173, fixed). The suite on 378667f failed seed 6: a rename
|
||||
in the same gap was refused with `EACCES` (B174, fixed). The loop on 378667f failed
|
||||
seed 8: a file replaced by a rename in that gap stayed on Drive (B175, fixed), and
|
||||
seed 12: a write closed as its file's create landed was dropped (B176, fixed).
|
||||
The account run on 378667f: a folder listed as its create landed asked Drive by
|
||||
its stand-in and answered `EIO` (B177, fixed). A folder trashed while sync was
|
||||
paused kept a create below it as a trash: a paused drain worker had claimed it
|
||||
(B178, fixed). A file read as its create landed read empty (B179, fixed).
|
||||
The suite on 0f3c06f: a stop gave up on a drain worker that slept through its
|
||||
wake-up (B180, fixed). The account run on 903452a (2026-10-04, Wi-Fi: 262 passed,
|
||||
1 failed) failed only its cleanup, which found no control socket: after the mirror
|
||||
target's last restart the queue wait took the missing daemon for an empty queue
|
||||
(the script's side, fixed in 6539c73).
|
||||
5. **Release**: 2.10.0 is tagged; the 2.9.0 tag still waits for the user's go-ahead.
|
||||
|
||||
## After 3.0.0
|
||||
|
||||
- Remove the old online path in `filesystem.rs` and the `local_first` switch (the release after
|
||||
3.0.0).
|
||||
- **Phase 4**: one applier for remote changes.
|
||||
- **Phase 5**: mirror folders on the same planner and executor.
|
||||
- **Batched trashes and moves** (plan step 8). Not needed for the §7 target: the syscalls take
|
||||
5.2 s on LAN and 5.0 s on Wi-Fi. The drain's time goes to the creates' read-back (250 ms,
|
||||
then 1 s each), which batching does not shorten. A batch call answers for many ops at once,
|
||||
and splitting that answer back per op is the step the B15x to B17x races came from. First
|
||||
measure `rm -r` of 1,000 files on Wi-Fi. If it is slow, drop the queued trashes of a trashed
|
||||
folder's children before batching anything: trashing the folder takes them with it.
|
||||
- **Any time**: `Size` and block geometry as types; a deadline on the remote calls outside
|
||||
the drain (the drain's have one: `link::upload_deadline`, `Core::block_on_within`).
|
||||
- Open questions in §12 of the milestone.
|
||||
- `PLAN.md`: the acceptance script notices network drops and reports `interrupted` instead of
|
||||
TIMEOUT.
|
||||
+4
-5
@@ -5,7 +5,7 @@
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, sync issues and conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||
@@ -16,13 +16,12 @@
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
||||
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite |
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
|
||||
| [Architecture](ARCHITECTURE.md) | Crates, the local tree, read and write paths, the queue, the sync engine, the control protocol, threads, threat model |
|
||||
| [Testing](TESTING.md) | Unit tests, simulation runs and the FUSE acceptance suite |
|
||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
||||
| [UI/UX plan](UI_UX_PLAN.md) | Design audit and notes behind the 2.x desktop app |
|
||||
|
||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||
root.
|
||||
+47
-39
@@ -1,8 +1,7 @@
|
||||
# Roadmap
|
||||
|
||||
Planned work and open verification, as of 3.0.0. Individual defects and their status live in
|
||||
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme.
|
||||
Nothing here is a promise of a date.
|
||||
Planned work and open verification after 3.0.0. Individual defects and their status are in
|
||||
[BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
|
||||
|
||||
- [Sync engine](#sync-engine)
|
||||
- [Data safety and recovery](#data-safety-and-recovery)
|
||||
@@ -14,38 +13,42 @@ Nothing here is a promise of a date.
|
||||
## Sync engine
|
||||
|
||||
3.0.0 records changes made through the mount locally and sends them from the queue
|
||||
([MILESTONE-3.0.0.md](MILESTONE-3.0.0.md)). What comes after it:
|
||||
([ARCHITECTURE.md §4](ARCHITECTURE.md#4-write-path)). What comes next:
|
||||
|
||||
| Item | State | Notes |
|
||||
|---|---|---|
|
||||
| Remove the online path | Not started | The old way of sending a change before the syscall returns, and the `local_first` switch that brings it back, go in the release after 3.0.0 |
|
||||
| One applier for remote changes | Not started | Phase 4 of the milestone: a remote tree, and remote changes applied through the same planner as local ones |
|
||||
| Synced folders on the queue | Not started | Phase 5: synced folders sent by the same planner and executor as the mount |
|
||||
| Remove the online path | Not started | The old way of sending a change inside the syscall, and the `local_first` switch that brings it back, go in the next release |
|
||||
| One applier for remote changes | Not started | Everything that comes from Drive (events, listings) changes the tree in one place, which is also the only place that tells the kernel. Replaces today's invalidation helpers and in-memory overlays |
|
||||
| Synced folders on the queue | Not started | Mirrored folders feed the local tree and use the same queue as the mount; `sync_entry` becomes the record of the last agreed state. Switching a folder's mode then moves no data except to evict or fetch content |
|
||||
| Batch trashes and moves | Deferred | See below |
|
||||
| Provisional values as types | Not started | A size or block geometry that is not known yet as its own type, so it cannot reach the kernel as if it were known |
|
||||
| Deadlines on every remote call | Partly done | The drain's calls and calls a syscall waits on have one; background calls outside the drain do not yet |
|
||||
|
||||
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take up to 150 nodes per
|
||||
call, but the queue still sends one node per call. It was left out of 3.0.0 on purpose:
|
||||
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take many nodes per call,
|
||||
but the queue sends one node per call. It was left out of 3.0.0 on purpose:
|
||||
|
||||
- The speed target of the milestone (§7) is met without it. Making 1,000 files in 100 folders
|
||||
takes 5.2 s on a LAN and 5.0 s on Wi-Fi.
|
||||
- What the queue spends its time on is the creates. Each one waits for Drive to list the new node
|
||||
before it counts as landed (250 ms, then 1 s), and batching does not help with that.
|
||||
- Sending many ops in one call means splitting one answer back into many results. Races in
|
||||
exactly that step were the largest class of bugs found for 3.0.0 (B15x to B17x).
|
||||
- The 3.0.0 speed target is met without it. Making 1,000 files in 100 folders through the mount
|
||||
takes 5.2 s on a LAN profile and 5.0 s on a Wi-Fi profile in the simulation
|
||||
(`PDFS_SIM_MEASURE`, see [TESTING.md](TESTING.md#simulation-runs)).
|
||||
- The drain spends its time on creates. Each waits for Drive to list the new node before it
|
||||
counts as landed, and batching does not shorten that.
|
||||
- One call answering for many ops has to be split back into results per op. Races in exactly that
|
||||
step were the largest class of bugs found for 3.0.0 (B15x to B17x in [BUGS.md](BUGS.md)).
|
||||
|
||||
Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the
|
||||
cheaper fix is to drop the queued trashes of the files in a folder when the folder itself is
|
||||
trashed, since trashing the folder takes its contents with it.
|
||||
cheaper fix is to drop the queued trashes of a trashed folder's contents: trashing the folder takes
|
||||
them along.
|
||||
|
||||
## Data safety and recovery
|
||||
|
||||
| Item | State | Notes |
|
||||
|---|---|---|
|
||||
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a blocking `pdfs sync flush`. Today the queue is visible through `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `sync restore` does not apply them yet |
|
||||
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the UI (B68) |
|
||||
| Rotated credentials survive a keyring failure | Open | Surface and retry keyring write failures so a rotated single-use refresh token is not lost (B61) |
|
||||
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
||||
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a `pdfs sync flush` that waits for the queue. Today the queue is visible in `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `pdfs sync restore` does not apply them yet |
|
||||
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the app (B68) |
|
||||
| Rotated credentials survive a keyring failure | Open | Report and retry keyring write failures, so a rotated single-use refresh token is not lost (B61) |
|
||||
| Restore never picks a new device silently | Open | Ask when the machine's device is ambiguous after a loss of local state (B67) |
|
||||
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -53,9 +56,10 @@ trashed, since trashing the folder takes its contents with it.
|
||||
|---|---|---|
|
||||
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
||||
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
|
||||
| Multiple accounts | Not started | Separate config, state and mountpoint per profile, one daemon each |
|
||||
| Multiple accounts | Not started | Separate config, state and mountpoint per account, one daemon each |
|
||||
| Mount options | Not started | Read-only mount, `allow_other` |
|
||||
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, creating one fails with `EOPNOTSUPP` |
|
||||
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, `symlink` fails with `EPERM` |
|
||||
| `fsync` that waits for Drive | Idea | `fsync` means "durable locally". A mount option or an xattr could offer "on Drive" without slowing the default |
|
||||
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
||||
| Photo name search | Not started | Photos search by date and filters only |
|
||||
| Automatic photo upload from a folder | Not started | |
|
||||
@@ -66,34 +70,38 @@ trashed, since trashing the folder takes its contents with it.
|
||||
|
||||
| Item | Notes |
|
||||
|---|---|
|
||||
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate so an accounting drift cannot wrap in release builds |
|
||||
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate, so an accounting drift cannot wrap in release builds |
|
||||
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
|
||||
| Path-based commands under secondary mounts | `pin`, `rename`, `move`, `rm` and sharing cannot name a path inside an on-demand synced folder yet (B88) |
|
||||
| Unicode normalization | NFC and NFD names are distinct, deliberately (B36) |
|
||||
| Pipelined node enumeration | Faster cold listings of large folders |
|
||||
| Path-based commands under online-only folders | `pin`, `unpin`, opening a file, uploads, sharing and versions cannot name a path inside an online-only synced folder yet. `rm`, `rename`, `mkdir` and `move` can (B88) |
|
||||
| Unicode normalization | NFC and NFD names are distinct, on purpose (B36) |
|
||||
| Pipelined node enumeration | Faster first listings of large folders |
|
||||
|
||||
## Verification still owed
|
||||
|
||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been
|
||||
driven against a live account. The larger outstanding runs:
|
||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
|
||||
been driven against a live account. The larger runs still owed:
|
||||
|
||||
- **A clean account run on 3.0.0.** The last account run before the release (2026-10-04, on Wi-Fi)
|
||||
failed only its cleanup, on a script bug fixed since. No full run with `--journal-check` has
|
||||
passed on the released code yet.
|
||||
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
|
||||
with `pdfs sync restore` and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||
- **Second-account sharing.** Shared trees on a foreign volume, accepting invitations, changing
|
||||
with `pdfs sync restore`, and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||
- **Second-account sharing.** Shared folders on a foreign volume, accepting invitations, changing
|
||||
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
|
||||
- **Fault injection.** Network loss mid-upload, a full disk during staging, and crash points
|
||||
across the write publication path (after each fsync, rename and queue commit).
|
||||
- **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
|
||||
crashes at each step of publishing a write. The simulation covers link loss and restarts against
|
||||
the fake Drive, not a kill at an arbitrary point.
|
||||
- **Acceptance runs across a network drop.** A case that spans an outage times out while the queue
|
||||
replays, instead of being reported as interrupted.
|
||||
- **CAPTCHA sign-in** against a real verification request (B8).
|
||||
- **Managed live mode matrix** before each release:
|
||||
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md).
|
||||
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
|
||||
catch the next occurrence.
|
||||
- **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
|
||||
catch the next one.
|
||||
|
||||
## Release assurance
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Tag, workspace and PKGBUILD version agreement | Enforced by the release workflow |
|
||||
| Tag, workspace and PKGBUILD version agree | Enforced by the release workflow |
|
||||
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
||||
| Protocol version identity between front ends and daemon | Open (B63) |
|
||||
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
||||
+207
-451
@@ -1,509 +1,265 @@
|
||||
# Testing
|
||||
|
||||
The normal Rust suite exercises the metadata database, sync planner, offline
|
||||
queue, write staging, cache, and FUSE handler state without requiring a Proton
|
||||
account:
|
||||
Three layers check the client, from fast and offline to slow and against a real account:
|
||||
|
||||
```console
|
||||
| Layer | Needs | Runs in CI |
|
||||
|---|---|---|
|
||||
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
|
||||
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` |
|
||||
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
|
||||
|
||||
## Unit tests
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
## Filesystem API and live FUSE acceptance suite
|
||||
Tests sit in `#[cfg(test)]` modules next to the code. Database tests are in
|
||||
`crates/pdfs-core/src/db/tests.rs`; a schema change adds one that migrates from the previous
|
||||
version.
|
||||
|
||||
The account-free suite exercises the filesystem syscall contract against a
|
||||
temporary local directory. This validates the runner and covers creation/open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||
under concurrent mutation, metadata updates, extended attributes, allocation and
|
||||
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
|
||||
and real application workloads. It never reads application state or contacts
|
||||
Proton:
|
||||
To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
|
||||
ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```bash
|
||||
PDFS_MIGRATE_DB=~/.local/state/proton-drive-linux/cache.db \
|
||||
cargo test -p pdfs-core a_copy_of_a_real_database -- --ignored
|
||||
```
|
||||
|
||||
Kernel/FUSE behavior and remote convergence need a real mount. With no
|
||||
arguments, the script tests the account the running service is logged in to:
|
||||
## Simulation runs
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh # every case, every mount kind
|
||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four
|
||||
The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
|
||||
an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
|
||||
event feed, online probe, control socket); only its Drive is fake. `FakeDrive` has the faults the
|
||||
real one has: listings that lag a create, renames refused for a stale name hash, no atomic
|
||||
replace, delayed and reordered events, requests that stall or lose their answer, per-request
|
||||
latency, and blocks of uneven size.
|
||||
|
||||
A run draws a sequence of steps from a seed: syscalls through a client's mount, the link going
|
||||
down and up, restarts, settles (every queue must drain within a budget), and files deleted while
|
||||
the drain holds a write to them. Every syscall is checked against an in-memory model of the
|
||||
filesystem as it happens. At the end every link comes up and the run checks:
|
||||
|
||||
1. **No loss and POSIX.** Drive's copy of each client's folder is what its model says.
|
||||
2. **No false conflicts.** No conflict copy anywhere; each file has one writer.
|
||||
3. **Convergence.** Each mount shows Drive's tree.
|
||||
4. **Liveness.** No syscall took longer than its budget, and every queue drained once the links
|
||||
were up. A call that never returns is caught by a watchdog that writes every thread's stack to
|
||||
`stacks.txt` in the run's state directory.
|
||||
|
||||
| Test | Seeds |
|
||||
|---|---|
|
||||
| `one_client_on_a_good_link` | 12 |
|
||||
| `one_client_deleting_files_as_they_upload` | 3 |
|
||||
| `one_client_with_stale_hashes_and_reordered_echoes` | 6 |
|
||||
| `one_client_on_a_flaky_link` | 4 |
|
||||
| `one_client_on_a_slow_link` | 4 |
|
||||
| `three_clients_one_writer_each` | 3 |
|
||||
|
||||
`sim/daemon.rs` holds scenario tests for single bugs, run the same way.
|
||||
|
||||
The tests mount FUSE, so they are ignored by default:
|
||||
|
||||
```bash
|
||||
cargo test -p pdfs-fuse --lib sim:: -- --ignored --test-threads=1 # what CI runs
|
||||
PDFS_SIM_SEEDS=50 cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1
|
||||
PDFS_SIM_SEED=7 RUST_LOG=pdfs_fuse=debug cargo test -p pdfs-fuse --lib \
|
||||
sim::run::tests::one_client_on_a_flaky_link -- --ignored --exact --nocapture
|
||||
```
|
||||
|
||||
An account run does this, in order:
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `PDFS_SIM_SEEDS` | How many seeds each test runs, from 1 |
|
||||
| `PDFS_SIM_SEED` | Replay one seed. A failing run prints the seed, the last steps, and where it kept its state |
|
||||
| `PDFS_SIM_MEASURE` | Run `a_thousand_files_drain_as_fast_on_wifi_as_on_lan`, which times 1,000 files in 100 folders on a LAN and a Wi-Fi profile and prints the times |
|
||||
|
||||
1. Runs the account-free contract as the reference.
|
||||
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and
|
||||
diffs it against the reference. After the last case it reads back a file
|
||||
the run wrote and records its digest. A read that fails there, as one did
|
||||
with the link down (B119), is a failed result named "a file of this run
|
||||
reads back at the end", and the cleanup still runs.
|
||||
3. Registers two new sync folders under `~/.cache/pdfs-acceptance/run-<id>/`
|
||||
and runs the contract and the move cases in every on-demand/mirror pairing.
|
||||
4. Removes everything it created, locally and remotely, and reports a
|
||||
`cleanup` result. The run fails if anything is left behind.
|
||||
A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
|
||||
several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
|
||||
|
||||
Cleanup identifies remote folders by uid, never by name alone, so nothing of
|
||||
yours is touched. It removes each sync folder with `sync rm --delete-remote`,
|
||||
trashes the My files folder, and then deletes both permanently from the trash.
|
||||
It only counts a folder as gone once a fresh trash listing no longer shows it.
|
||||
It then reads `pdfs sync queue` and counts any queued op that still names one
|
||||
of its folders as left behind too, since such an op keeps its staged bytes and
|
||||
retries forever (B102).
|
||||
## Acceptance suite
|
||||
|
||||
Ops that were queued before a target starts are not the run's. The run prints
|
||||
one `NOTE` line naming them, and the queue waits ignore them. They are told
|
||||
apart by id and queue time together, since SQLite can give a new op the id of
|
||||
one that has drained.
|
||||
`scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
|
||||
`copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
|
||||
names, `readdir` under concurrent change, metadata, extended attributes, holes, refusals of
|
||||
unsupported operations, concurrent I/O, and real application workloads.
|
||||
|
||||
A write is queued when the kernel releases the file, and `close(2)` returns
|
||||
without waiting for that. A case that reads `pdfs sync queue` right after
|
||||
writing therefore waits for the ops it expects (`Daemon.queued`) instead of
|
||||
reading once. "regression B102" saw one revision of two before it did.
|
||||
> [!CAUTION]
|
||||
> Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
|
||||
> account with nothing you cannot lose.
|
||||
|
||||
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs,
|
||||
two more interrupts are ignored; a third abandons it.
|
||||
### Modes
|
||||
|
||||
Each run records what it is about to create in
|
||||
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and
|
||||
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a
|
||||
hard timeout, a power cut), the next run finds the unlocked manifest and
|
||||
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>`
|
||||
folders that no live run claims: in My files (older than
|
||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and
|
||||
in the trash. A run does not start while that reap leaves anything behind.
|
||||
|
||||
The run creates and destroys real remote folders, so a dedicated test account
|
||||
without irreplaceable data is still the safest target.
|
||||
|
||||
`--live PATH...` tests mounts you set up yourself and leaves the sync folder
|
||||
configuration alone. It runs destructive POSIX operations only below a fresh
|
||||
`pdfs-acceptance-*` directory, refuses paths that are not FUSE mounts, and
|
||||
removes its directory on success, failure, or interruption:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||
```bash
|
||||
scripts/fuse-acceptance.sh --offline-only # the reference run; no account
|
||||
scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
|
||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
|
||||
scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
|
||||
```
|
||||
|
||||
**The reference run** always runs first, against a temporary local directory. It records the
|
||||
facts each case establishes, and every mount is then **compared** against those facts. A case
|
||||
fails when the mount differs from an ordinary filesystem, not only when it raises. A fact that may
|
||||
legitimately differ (inode numbers, block counts, which optional syscalls exist) is **noted**
|
||||
instead, and listed under the target's `accepted` line. The choice is made in the test body, so
|
||||
the accepted differences are visible where they are accepted. Prefer `record`; use `note` only
|
||||
for something a network filesystem cannot do.
|
||||
|
||||
**An account run** (no mode):
|
||||
|
||||
1. Runs the reference.
|
||||
2. Runs the contract in a new `pdfs-acceptance-<id>` folder in My files, and at the end reads back
|
||||
a file the run wrote.
|
||||
3. Registers two sync folders under `~/.cache/pdfs-acceptance/run-<id>/` and runs the contract and
|
||||
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
||||
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
||||
pairing each folder must still hold the file written into it at setup.
|
||||
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||
anything behind fails.
|
||||
|
||||
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
||||
each sync folder with `sync rm --delete-remote`, trashes the My files folder, deletes both from
|
||||
the trash for good, and checks that a fresh trash listing no longer shows them. Queued ops that
|
||||
still name one of its folders count as left behind. Cleanup also runs on a failure, Ctrl-C,
|
||||
`SIGTERM` or `SIGHUP`; while it runs, two more interrupts are ignored and a third abandons it.
|
||||
|
||||
Each run writes what it is about to create to `~/.cache/pdfs-acceptance/run-<id>/manifest.json`
|
||||
first and holds a lock on that directory. A later run finishes the cleanup of a run that died, and
|
||||
removes stray `pdfs-acceptance-<32 hex>` folders no live run claims: in My files (older than
|
||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and in the trash. It
|
||||
does not start while anything is left.
|
||||
|
||||
Ops that were queued before the run started are not the run's: it prints one `NOTE` line naming
|
||||
them, and its queue waits ignore them.
|
||||
|
||||
**`--live PATH...`** runs the contract on mounts you set up, and leaves the sync configuration
|
||||
alone. It works only below a new `pdfs-acceptance-*` directory, refuses paths that are not FUSE
|
||||
mounts, and removes its directory afterwards. The first path must be a FUSE mount; later paths may
|
||||
be another mount or a mirrored folder. If all paths show the **same remote folder**, set
|
||||
`PDFS_ACCEPTANCE_CONVERGENCE=1` to also check that bytes written through the first appear through
|
||||
the others.
|
||||
|
||||
**`--managed-live A B`** takes two existing, empty, unmounted directories, registers them as sync
|
||||
folders, and runs every pairing as an account run does, then deletes the remote folders. It
|
||||
refuses non-empty paths, mounts, and paths already in `pdfs sync list`.
|
||||
|
||||
### Reading the output
|
||||
|
||||
A run opens with what it is about to test: the mode, the `pdfs` binary and its
|
||||
version, Python and the kernel, the per-case limit, the options and the start
|
||||
time. Each target then names its root, the storage behind it, the daemon it
|
||||
talks to and how many cases it runs, followed by one line per case:
|
||||
A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
|
||||
and the options. Each target then names its root, the storage behind it, the daemon and the
|
||||
number of cases, followed by one line per case:
|
||||
|
||||
```
|
||||
==> live FUSE /mnt/testmount
|
||||
-> root /mnt/testmount/pdfs-acceptance-…
|
||||
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
||||
-> daemon pdfs 2.8.3, user@proton.me, online, 0 queued ops
|
||||
-> daemon pdfs 3.0.0, user@proton.me, online, 0 queued ops
|
||||
-> cases 45 of 45
|
||||
[ 1/45] creation and open flags .................................. ok 0.41s
|
||||
…
|
||||
[21/45] throughput floors ........................................ ok 48.20s
|
||||
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
|
||||
…
|
||||
[43/45] regression B115: pdfs ls reports a file's real size ...... ok 6.12s
|
||||
-> reference matches the ordinary filesystem in 412 compared observations
|
||||
-> result 44 passed, 1 skipped in 11m02s
|
||||
```
|
||||
|
||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` when it does not apply to the
|
||||
target (no daemon, no share at that role), or `known` when an `open B<n>` case
|
||||
reproduced a bug that is still open (see below). The dimmed lines under a case
|
||||
are its details: the reason for a skip, measured rates, a hash. A failed
|
||||
`regression B<n>` case also points to its entry in `docs/BUGS.md`. When a case
|
||||
prints something itself, such as a service restart, the case line is printed
|
||||
again once the case ends.
|
||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
|
||||
`open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
|
||||
A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
|
||||
|
||||
The run ends with a table of every target's counts and time, the report paths,
|
||||
the slowest cases, the cases over `--budget`, the known issues that still
|
||||
reproduce, any errors the daemon logged, and each failure with its target. The
|
||||
last line is `PASS:` or `FAIL:` and the totals. Colour is used only on a
|
||||
terminal and never with `NO_COLOR` set.
|
||||
The run ends with a table of every target, the report paths, the slowest cases, cases over
|
||||
`--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
|
||||
last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
|
||||
`NO_COLOR` set.
|
||||
|
||||
### Edge cases and performance
|
||||
### What the cases cover
|
||||
|
||||
Besides the syscall contract, the suite covers:
|
||||
Besides the syscall contract:
|
||||
|
||||
- **Block boundaries.** Files of 0 bytes up to two 4 MiB blocks plus 3 bytes,
|
||||
including every size next to a boundary, then overwrites that grow, shrink
|
||||
and reshape them.
|
||||
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text,
|
||||
newline, tab, emoji, characters Windows forbids, leading and trailing
|
||||
spaces, and a case-only rename.
|
||||
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the
|
||||
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`.
|
||||
Online, each create is a round trip to the server, and the kernel runs
|
||||
creates in one directory one at a time. So the wide case gets twice the
|
||||
per-case timeout and notes the time per create as `wide.create_ms`.
|
||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an
|
||||
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out
|
||||
and back.
|
||||
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND`
|
||||
writers, 30 rewrite generations, and a truncate under a reader.
|
||||
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes,
|
||||
and metadata operations on 100 files. The rates are printed. The case fails
|
||||
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS`
|
||||
(default 20 operations per second, or 3 on a FUSE mount, where each create
|
||||
and unlink waits for Drive before it returns).
|
||||
- **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
|
||||
boundary, and overwrites that grow, shrink and reshape them.
|
||||
- **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
|
||||
characters Windows forbids, leading and trailing spaces, a case-only rename.
|
||||
- **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
|
||||
concurrently, unlinked concurrently, then `rm -rf`.
|
||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
|
||||
`EISDIR`, `ENOTDIR` and `ENOENT` refusals.
|
||||
- **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
|
||||
generations, a truncate under a reader.
|
||||
- **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
|
||||
fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
|
||||
operations per second, 3 on a FUSE mount). `0` turns a floor off.
|
||||
- **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
|
||||
probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
|
||||
work or be refused cleanly: a known errno, never `EIO`, never a hang.
|
||||
- **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
|
||||
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||
mirrored source holding something Drive lacks, a file still open for writing.
|
||||
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||
and `rm` by local path, and their refusals.
|
||||
|
||||
### The mount is diffed against an ordinary filesystem
|
||||
### Regressions and open bugs
|
||||
|
||||
The local reference run is not only a self-test of the runner. Every case
|
||||
records the facts it established, and a live run is compared against those
|
||||
recordings, so a case fails when the mount *differs* from an ordinary
|
||||
filesystem — not only when it raises. The target's `reference` line names the
|
||||
exact key:
|
||||
A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
|
||||
need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
|
||||
and the targets it runs on.
|
||||
|
||||
```
|
||||
-> reference 1 observation of 412 differs from the ordinary filesystem:
|
||||
! creation and open flags.truncated.size: live FUSE /mnt/testmount=6 reference=0
|
||||
```
|
||||
A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
|
||||
through the mount proves nothing: the local caches return the bytes just written whether or not
|
||||
they reached Drive. A write is queued when the kernel releases the file, after `close(2)` has
|
||||
returned, so a case that reads `pdfs sync queue` right after writing waits for the ops it expects.
|
||||
|
||||
Facts are recorded in one of two ways, chosen in the test body:
|
||||
A case named `open B<n>` checks a bug that is still open. While it reproduces, the case ends as
|
||||
`known`: listed at the end, but not a failure. When it passes, the bug is fixed: rename the case to
|
||||
`regression B<n>` and make it fail with `check`. No case is open at the moment.
|
||||
|
||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||
reported mode bits, and which optional syscalls exist at all. Listed under
|
||||
the target's `accepted` line, never a failure.
|
||||
Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
|
||||
shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
|
||||
synced folder.
|
||||
|
||||
Because the choice is made at each call site, the set of accepted divergences
|
||||
is visible in the test that accepts it. There is no separate allowlist to drift
|
||||
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
||||
ordinary filesystem can do something a network filesystem genuinely cannot.
|
||||
### Timeouts, reports and hung mounts
|
||||
|
||||
### Operations the filesystem does not implement
|
||||
A hung FUSE call cannot be interrupted from Python. Each case runs under a soft alarm (`--timeout`,
|
||||
default 180 s, or `PDFS_ACCEPTANCE_TIMEOUT`) that reports the case and lets cleanup run, backed by
|
||||
a hard stop that dumps every thread's stack and exits. After a timeout the run checks whether the
|
||||
mount still answers a `stat` and a listing within 15 s. If it does, the target goes on; if not, its
|
||||
remaining cases are skipped. Cases that make many round trips by design get twice the limit.
|
||||
|
||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
||||
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||
one that is dirty breaks software that never asked for the feature.
|
||||
|
||||
### Regressions from the bug ledger
|
||||
|
||||
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
||||
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
||||
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||
renamed after close must keep its content) need a real mount and a reachable
|
||||
daemon; they are skipped, not failed, without one.
|
||||
|
||||
**B100** (a copied tree reads back at its exact sizes) copies 48 files of
|
||||
assorted sizes into the mount at once, as `cp -r` does. On a live mount it
|
||||
waits for the queue, checks every size and byte, runs `pdfs refresh` on each
|
||||
folder to drop the cached listing, and checks again. The refresh is what
|
||||
brought back the provisional sizes the bug reported.
|
||||
|
||||
**B101** (a write during its create's upload reaches Drive) holds new files open for
|
||||
different times before writing, so their empty creates land before, during and after the
|
||||
bytes arrive, then checks every file after the queue drains.
|
||||
|
||||
**B113** (moving a file before its upload makes no conflict) pauses sync with
|
||||
`pdfs sync pause --for 10m` and writes three files. While their uploads are held,
|
||||
it renames one, moves one into a folder, and renames and moves the third. It then
|
||||
resumes sync, waits for the queue, and checks the bytes and names and that no
|
||||
conflict copy appeared. It skips when sync was already paused. If the run dies
|
||||
before the resume, the pause ends by itself after ten minutes.
|
||||
|
||||
**B88** (`pdfs mkdir`, `rename` and `rm` by local path) creates, renames and
|
||||
removes a folder and a file by their paths in the mount. Then it checks the
|
||||
refusals: `pdfs rename` of the mount's root to its own name, and `pdfs rm` of a
|
||||
file outside every mount. `pdfs rm` of the root itself is tried only on a sync
|
||||
folder the run created. In a mirrored sync folder all three commands must be
|
||||
refused instead, and nothing may change.
|
||||
|
||||
**pdfs output into a closed pipe** runs `pdfs ls`, `pdfs --json ls` and
|
||||
`pdfs status` with stdout on a pipe whose reader is already gone. Each must exit
|
||||
0 with nothing on stderr, as `pdfs ls | head -1` needs.
|
||||
|
||||
**B102** (removing a folder drops the uploads queued inside it) uploads a file
|
||||
into each of two nested folders, pauses sync and rewrites both. While the
|
||||
revisions are held, it removes one folder with `pdfs rm` and the other through
|
||||
the mount. Neither revision may stay queued, and neither folder may come back
|
||||
after the resume. It skips when sync was already paused.
|
||||
|
||||
**B94** (a deleted `.part` file leaves nothing queued) writes a `.part` file,
|
||||
checks that its create is parked, and unlinks it. The parked op must leave the
|
||||
queue within 30 seconds. The sweep that lets an hour-old park through is not
|
||||
driven.
|
||||
|
||||
**B95** (a `.tmp` file uploads at once) writes `index.tmp` and `preview.temp`
|
||||
and waits for the queue, which fails at once on a parked op. `pdfs ls` must
|
||||
list both, and their bytes must read back.
|
||||
|
||||
**B111** (renaming a file straight back works) renames a file there and back
|
||||
eight times through the mount. On a mount it also does so three times with
|
||||
`pdfs rename`, after the queue is empty, so the renames race nothing but Drive.
|
||||
It runs against the reference too.
|
||||
|
||||
**B121** (a file renamed and moved at once lands) renames a file, moves it into
|
||||
a subfolder and renames it back across folders through the mount, with no pause
|
||||
between the steps. On a mount it then runs `pdfs rename` and `pdfs move` back to
|
||||
back. The file must end up where it was sent, with its bytes, and the case
|
||||
reports how long each half took. It runs against the reference too.
|
||||
|
||||
**B98** (an upload superseded by a write ends) writes 64 MiB and waits for its
|
||||
upload to show in `pdfs transfers`, then appends to the file. The queue must
|
||||
drain, the old upload must leave the transfer list, and the file must hold
|
||||
every byte with no conflict copy. It skips when the upload is never seen on
|
||||
the wire, and it gets twice the per-case timeout.
|
||||
|
||||
**B80** (`pdfs search` finds a new file after a rewrite) writes a file whose
|
||||
name carries a unique token, waits for the queue, rewrites it and waits again.
|
||||
`pdfs search` must find it within 30 seconds.
|
||||
|
||||
**B48** (an emptied folder can be removed at once) writes twelve files into a
|
||||
folder, lists it, and moves one more in from elsewhere. It unlinks every
|
||||
child, runs `pdfs refresh` on a mount, and checks that nothing is listed and
|
||||
that `rmdir` works. It runs against the reference too.
|
||||
|
||||
**B114** (a name `pdfs rm` or `rename` took away stops resolving) looks up two
|
||||
files, removes one with `pdfs rm` and renames the other with `pdfs rename`. At
|
||||
once, neither old name may be listed or resolve.
|
||||
|
||||
**B115** (`pdfs ls` reports a file's real size) writes files of 6 bytes, 4097
|
||||
bytes and one block plus 7, and compares the sizes `pdfs ls` lists with the
|
||||
written ones, after the upload and again after `pdfs refresh`.
|
||||
|
||||
**B116** (trashing a folder drops the creates queued in its subfolders) makes
|
||||
`b116/sub/deeper` and waits for Drive. With sync paused, it writes a `.part`
|
||||
file there and renames it to its final name, so its create is due but held.
|
||||
It then runs `pdfs rm b116`, and the create must be gone from the queue. It
|
||||
restores the folder with `pdfs restore` before it resumes sync. If the file
|
||||
ends up in the mount's root anyway, the case fails and moves the file to the
|
||||
trash.
|
||||
|
||||
**B117** (a moved file's queued upload keeps its path) rewrites an uploaded
|
||||
file with sync paused and moves it with `pdfs move`. `pdfs sync queue` must
|
||||
list the upload under its new path, and the journal must not show the upload
|
||||
put off with "pending operation deferred". The bytes must arrive at the new
|
||||
path with no conflict copy. Without `journalctl`, only the queue is checked.
|
||||
|
||||
**B119** (an uploaded file stays cached) writes a small file and one of a block
|
||||
plus 119 bytes, waits for the queue, and expects `pdfs ls` to list both as
|
||||
cached, so reading them does not need the network.
|
||||
|
||||
B74 was found by exactly this mechanism: the B70 case failed, and narrowing it
|
||||
produced a smaller reproduction that got its own case. The defect is fixed and
|
||||
live-validated (2026-07-26); the case now guards against its return.
|
||||
|
||||
**B79** (an on-demand device folder accepts writes) discovers its target from
|
||||
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
||||
folder. It is the cheap standing guard against a *cross-mount* permission
|
||||
regression: the primary mount holds device-folder nodes too, and the queue guard
|
||||
intersects what every live inode space says about a uid, so a fail-closed
|
||||
classification in one mount denies writes in another.
|
||||
|
||||
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
||||
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
||||
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
||||
accepted share at that role — B34 needs a **second account** to have shared a
|
||||
folder read-only, which is the one part of the sharing suite that cannot be
|
||||
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
||||
gets its mode bits and `W_OK` checked but is never written to, because it is
|
||||
someone else's document.
|
||||
|
||||
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
||||
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
||||
alone would pass a fix that still admitted the write into `pending_op`, which is
|
||||
the actual harm — a background drain failing 403 forever.
|
||||
|
||||
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
||||
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
||||
the directory is absent (an account with no accepted shares).
|
||||
|
||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
||||
queue to drain before reading. Reading straight back through the mount proves
|
||||
nothing: the local attribute and content caches will happily return the bytes
|
||||
that were just written even when nothing reached Drive, which is precisely how
|
||||
B74 stayed invisible.
|
||||
|
||||
### Bugs that are still open
|
||||
|
||||
Cases named `open B<n>` check a bug that `docs/BUGS.md` still lists as open.
|
||||
While the bug reproduces, the case ends as `known`: it is listed at the end of
|
||||
the run, but does not fail it. So a known bug does not hide new failures, and
|
||||
the run notices when a fix lands, because the case then passes. Rename it to
|
||||
`regression B<n>` then, and fail it with `check` instead of `KnownIssue`. No
|
||||
bug is open at the moment. B114 to B117 started as such cases.
|
||||
|
||||
### What a run verifies
|
||||
|
||||
A full account run with `--journal-check` checks these `docs/BUGS.md` entries,
|
||||
many of them fixed but not yet verified:
|
||||
|
||||
- B48, B80, B94, B95, B98, B100, B101, B102, B111, B114, B115, B116, B117, B119
|
||||
and B121, each with its own `regression B<n>` case.
|
||||
- B118, with "application workloads" and "block boundaries", which failed on
|
||||
it. It needs Drive to be slow to list a new node, so a clean run is weak
|
||||
evidence.
|
||||
- B120, with `--journal-check`, but only in a run that goes offline while it
|
||||
writes. A mkdir made online goes to Drive at once, so only then does a
|
||||
folder land from the queue.
|
||||
- B86, with the mode-switch check in the full matrix.
|
||||
- B104, B106, B108 and B109, with the cases that found them: "rename
|
||||
patterns", "application workloads" and the account run's own setup.
|
||||
|
||||
B105, B107 and B110 are races. The suite hit each of them once, so a clean run
|
||||
is weak evidence for them. `--journal-check` reports B110's stall warning, and
|
||||
every case that writes checks for B105's conflict copies.
|
||||
|
||||
The suite does not drive:
|
||||
|
||||
- the GUI and the photo gallery (B81, B92, B93, B97);
|
||||
- states it cannot set up: a `parent_uid` cycle (B82), staged bytes that name
|
||||
no node (B96), a stuck trash refresh (B103), conflict copies left from an
|
||||
earlier start (B99), a foreign FUSE mount in the local-file index (B90),
|
||||
revisions without 4 MiB blocks (B85), and several shares from one owner
|
||||
(B112);
|
||||
- B94's hour-long expiry of a park;
|
||||
- load and failure injection (B22, B27, B30, B37, B42).
|
||||
|
||||
Check those by hand, or with the unit tests their entries name.
|
||||
|
||||
### Timeouts, reports, and hung mounts
|
||||
|
||||
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
||||
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
||||
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
||||
That backstop is the only way to learn *where* a mount hung. After a timeout the
|
||||
run checks whether the mount still answers a `stat` and a listing within 15
|
||||
seconds. If it does, the target goes on with its next case, so one slow stretch
|
||||
of the link does not cost every later case. If it does not, the target's later
|
||||
cases are not run: against a wedged mount they produce noise, not information.
|
||||
Cases that make many round trips by design ("application workloads", "unusual
|
||||
but legal names", "a wide directory" and "regression B98") get twice the limit.
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
||||
--timeout 300 --report-junit results.xml --budget 30
|
||||
```
|
||||
|
||||
`--report-json` also writes every recorded observation, which is what to attach
|
||||
to a bug report. A known issue has the status `known` there; JUnit has no such
|
||||
status, so `--report-junit` reports it as skipped. `--budget SECONDS` flags cases that pass but got slower —
|
||||
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
||||
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
||||
stops at the first failure.
|
||||
|
||||
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
||||
it; that has happened, and it was caught only by reading the journal by hand.
|
||||
Two warnings count as errors too: "queued write conflicts; keeping a conflict
|
||||
copy" (how B113 showed) and "a fuse worker has held the same job for a long
|
||||
time" (how B110 showed).
|
||||
|
||||
With `--live`, abandoned `pdfs-acceptance-*` roots from a crashed run are
|
||||
removed at startup, but only if they match the exact generated name and are
|
||||
over an hour old (`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never
|
||||
disturbed. An account run instead uses the manifest and reaper described
|
||||
above.
|
||||
|
||||
### Automated mode matrix
|
||||
|
||||
An account run drives this matrix itself, in folders it creates. To use
|
||||
directories of your choice instead, the managed live runner accepts two
|
||||
existing, empty, unmounted directories. It
|
||||
registers both as new sync folders, waits for initial synchronization, then
|
||||
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
|
||||
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
|
||||
on-demand folder must actually appear as a mount before its contract runs.
|
||||
|
||||
```console
|
||||
mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b
|
||||
scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b
|
||||
```
|
||||
|
||||
This creates remote folders and permanently deletes those test remotes during
|
||||
cleanup. It refuses non-empty paths, existing mounts, and paths already present
|
||||
in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
|
||||
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||
example `target/debug/pdfs`.
|
||||
|
||||
After the contract, each mode pair also runs the move cases (`--list` shows
|
||||
them as `managed`). They move trees between the two folders in both
|
||||
directions, move several sources in one `pdfs move`, and pass a tree through
|
||||
My files and out again. Every case waits for Drive before and after the move.
|
||||
It then checks that the tree is only at the destination, that every byte
|
||||
arrived, and that neither side made conflict copies. The refusal cases prove
|
||||
that nothing moves and nothing is lost when:
|
||||
|
||||
- the destination already has the name;
|
||||
- a folder would move into itself;
|
||||
- a mirror source holds something Drive lacks (a symlink);
|
||||
- a file in an on-demand source is still open for writing.
|
||||
|
||||
The My files case writes a test folder into your real My files and removes it
|
||||
afterwards. It skips when the daemon reports no My files mount, or when the
|
||||
locations are on different volumes.
|
||||
|
||||
Before a mirror folder switches to on-demand, the runner writes a new file into
|
||||
it and syncs. Once the folder is mounted, the file must be listed with its
|
||||
bytes (B86). Each switch reports a `regression B86` result under a separate
|
||||
`mode switches` target. The bug only shows in a folder that was mounted in an
|
||||
earlier pairing, because that mount's listing is stale. That happens in the
|
||||
third pairing, so `--quick` runs the check but does not replay the bug.
|
||||
|
||||
In every pairing, each folder must still hold the file the runner wrote into it
|
||||
at setup, byte for byte. These checks report under `mode switches` as well. A
|
||||
switch to on-demand frees the local copy, so these reads go to Drive. If the
|
||||
link drops, the daemon fails such a read with EIO once its two-minute read
|
||||
budget runs out. That is correct, so the runner waits until the daemon reports
|
||||
online again, tries once more and notes the retry under the result. Only the
|
||||
second try decides the result.
|
||||
|
||||
### Durability drill
|
||||
|
||||
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
||||
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
||||
only copy of a file whose upload has not finished, so this is the case that
|
||||
proves the queue is persistent rather than in-memory. It is opt-in because it
|
||||
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||
| `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
|
||||
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
|
||||
| `--fail-fast` | Stops at the first failure |
|
||||
| `--list` | Prints every case and its targets |
|
||||
|
||||
### Selecting cases
|
||||
|
||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||
contains the supplied text, ignoring case (for example
|
||||
`PDFS_ACCEPTANCE_ONLY=namespace`). Separate several texts with commas to run
|
||||
every case that matches any of them (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A
|
||||
release acceptance run must leave it unset so the complete contract executes.
|
||||
`PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
|
||||
with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
|
||||
typo it is. A release run leaves it unset.
|
||||
|
||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
||||
in the account-free reference run, which is not an error — that target simply has
|
||||
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `PDFS_ACCEPTANCE_PDFS` | The `pdfs` binary to use, for example `target/debug/pdfs` |
|
||||
| `PDFS_ACCEPTANCE_SYNC_TIMEOUT` | Seconds a sync folder may take to settle after a change of mode |
|
||||
| `PDFS_ACCEPTANCE_REAP_AGE` | Age in seconds after which a stray `pdfs-acceptance-*` folder is removed (default 3600) |
|
||||
| `PDFS_ACCEPTANCE_CONVERGENCE` | `1` checks that several `--live` paths show the same bytes |
|
||||
| `PDFS_ACCEPTANCE_MIN_MIBPS`, `PDFS_ACCEPTANCE_MIN_OPS` | Throughput floors |
|
||||
| `PDFS_ACCEPTANCE_UNIT` | The systemd unit `--durability` restarts |
|
||||
|
||||
Passing more paths runs the same filesystem suite independently against each.
|
||||
The first path must be the FUSE mount under test; later paths may be another
|
||||
FUSE mount or a normal local mirror filesystem:
|
||||
## Before a release
|
||||
|
||||
```console
|
||||
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount
|
||||
```
|
||||
1. The quality gates and the simulation runs pass in CI.
|
||||
2. A full account run with `--journal-check` passes on a test account:
|
||||
`scripts/fuse-acceptance.sh --journal-check`.
|
||||
3. Fixes marked *unverified* in [BUGS.md](BUGS.md) that a run now covers are marked verified.
|
||||
|
||||
If two paths really are views of the **same remote folder**, enable an
|
||||
additional byte-for-byte convergence check explicitly:
|
||||
|
||||
```console
|
||||
PDFS_ACCEPTANCE_CONVERGENCE=1 \
|
||||
scripts/fuse-acceptance.sh --live /mnt/first-view /mnt/second-view
|
||||
```
|
||||
|
||||
Do not enable that flag merely because two sync folders belong to the same
|
||||
account. Their configured remote UIDs must be identical.
|
||||
|
||||
Normally the live suite applies the same API contract independently to each
|
||||
supplied mount. In convergence mode it exercises the primary once and verifies
|
||||
that the resulting bytes appear through every secondary view. A pass is
|
||||
strong evidence for those paths, not a blanket data-loss guarantee. Before a
|
||||
release, also run the recovery drills in `docs/RECOVERY.md` and inspect
|
||||
`docs/BUGS.md` for live-verification items.
|
||||
|
||||
The runner accepts already-mounted paths instead of starting a daemon. This
|
||||
keeps authentication and keyring setup explicit and prevents it from silently
|
||||
reusing or replacing the normal application state directory.
|
||||
What no automated run covers yet is listed under
|
||||
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||
+19
-7
@@ -113,29 +113,41 @@ pdfs refresh Documents
|
||||
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
||||
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
||||
|
||||
**A program says "Operation not supported"** — Proton Drive cannot store symbolic links, hard
|
||||
links, device files or FIFOs. The error is intentional. Use a synced folder for
|
||||
projects that need them, or exclude those paths.
|
||||
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||
|
||||
## Uploads and sync
|
||||
|
||||
**Something is stuck uploading** — list the queue and retry:
|
||||
**A change shows here but not on other devices** — changes are recorded on this computer first
|
||||
and sent from a queue. List what is still waiting, and what needs attention:
|
||||
|
||||
```bash
|
||||
pdfs sync queue
|
||||
pdfs sync issues
|
||||
```
|
||||
|
||||
The app shows the same on **Sync → Overview**. A change waits while the service is offline or
|
||||
sync is paused (`pdfs status`), and backs off after a failure. To retry at once:
|
||||
|
||||
```bash
|
||||
pdfs sync retry # every failed operation
|
||||
pdfs sync retry 42 # one operation
|
||||
```
|
||||
|
||||
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its
|
||||
error there. A change Proton Drive will not take can be dropped instead, which undoes it here;
|
||||
save a file's content first if you want to keep it:
|
||||
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||
`pdfs sync issues` with what to do. It stays queued until you fix the cause or drop it. Dropping
|
||||
it undoes it here; save a file's content first if you want to keep it:
|
||||
|
||||
```bash
|
||||
pdfs sync export 42 ~/Desktop/
|
||||
pdfs sync discard 42
|
||||
```
|
||||
|
||||
If you suspect the local-first behaviour of 3.0.0 itself, `"local_first": false` in `config.json`
|
||||
makes the Proton Drive folder wait for Proton Drive again while online (see
|
||||
[CONFIGURATION.md](CONFIGURATION.md#local_first)), and please report the problem.
|
||||
|
||||
**A file in a synced folder does not upload** — check that:
|
||||
|
||||
- it is not matched by `.pdfsignore` or `ignore_patterns`;
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# UI/UX audit and improvement plan
|
||||
|
||||
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
|
||||
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
|
||||
stated otherwise.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The client is functionally rich but the UI is built page by page, and it shows:
|
||||
|
||||
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
|
||||
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
|
||||
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
|
||||
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
|
||||
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
|
||||
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
|
||||
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
|
||||
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
|
||||
pinned files, import and developer info all share the Settings page.
|
||||
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
|
||||
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
|
||||
folder in the title.
|
||||
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
|
||||
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
|
||||
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
|
||||
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
|
||||
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
|
||||
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
|
||||
- **Several visible bugs** (section 9).
|
||||
|
||||
## 2. Design principles
|
||||
|
||||
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
|
||||
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
|
||||
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
|
||||
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
|
||||
needs attention, and be one click from pausing or from the list of problems.
|
||||
3. **One term, one icon, one place** per concept (glossary in section 8).
|
||||
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
|
||||
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
|
||||
dialog, not as filled buttons in headers.
|
||||
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
|
||||
spinner, never flash the whole page to a "Loading…" status page.
|
||||
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
|
||||
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
|
||||
|
||||
## 3. New information architecture
|
||||
|
||||
### Sidebar
|
||||
|
||||
```
|
||||
Proton Drive (header: app name)
|
||||
My files
|
||||
Photos
|
||||
Shared with me (badge: pending invitations)
|
||||
Shared by me
|
||||
Computers
|
||||
Trash
|
||||
─────────────
|
||||
Sync (badge: issue count, icon reflects state)
|
||||
───────────── footer
|
||||
[●] Up to date · Pause ← sync status strip, click opens Sync
|
||||
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
|
||||
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
|
||||
```
|
||||
|
||||
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
|
||||
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
|
||||
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
|
||||
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
|
||||
version, user agent, daemon status and config path (replacing the "Developer" group).
|
||||
|
||||
### Sync page (new, replaces Locations + Activity + Settings→Activity)
|
||||
|
||||
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
|
||||
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
|
||||
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
|
||||
|
||||
Below, an `InlineViewSwitcher` with four views:
|
||||
|
||||
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
|
||||
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
|
||||
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
|
||||
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
|
||||
(thumbnail build, Takeout import, re-date) with their own cancel.
|
||||
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
|
||||
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
|
||||
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
|
||||
The primary mount appears first and is not removable.
|
||||
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
|
||||
activatable (open or reveal the target), and "Clear history".
|
||||
|
||||
### Preferences dialog (slimmed Settings)
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
|
||||
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
|
||||
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
|
||||
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
|
||||
|
||||
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
|
||||
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
|
||||
version/user agent (About → Debug info).
|
||||
|
||||
## 4. Backend work required
|
||||
|
||||
Everything below is new; nothing like it exists today.
|
||||
|
||||
### B-1 Pause and resume (global, then per folder)
|
||||
|
||||
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
|
||||
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
|
||||
`paused` and `paused_until`.
|
||||
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
|
||||
offline; gate on `paused` at the same point and reuse the wake path.
|
||||
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
|
||||
poll and watcher keep recording, they just do not apply.
|
||||
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
|
||||
pause means "no uploads, no mirror reconcile".
|
||||
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
|
||||
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
|
||||
(`pdfs-fuse/src/transfers.rs`).
|
||||
|
||||
### B-2 Pending ops and transfers
|
||||
|
||||
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
|
||||
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
|
||||
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
|
||||
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
|
||||
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
|
||||
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
|
||||
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
|
||||
|
||||
### B-3 Conflicts
|
||||
|
||||
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
|
||||
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
|
||||
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
|
||||
which side is local vs remote when known.
|
||||
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
|
||||
origin (drain vs mirror, device) is recorded instead of inferred from names.
|
||||
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
|
||||
- Original: trash the copy (re-verify like the enforcing sweep does).
|
||||
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
|
||||
content stays in version history, so this is reversible).
|
||||
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
|
||||
- Activity rows for each resolution.
|
||||
|
||||
## 5. Shell and visual polish (all pages)
|
||||
|
||||
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
|
||||
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
|
||||
in My files. Page actions move into the header; the extra toolbar row disappears.
|
||||
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
|
||||
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
|
||||
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
|
||||
explicitly labelled button when the service is down).
|
||||
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
|
||||
only on first load.
|
||||
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
|
||||
Replace hex colours with named colours; test light, dark and high contrast.
|
||||
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
|
||||
(`style-dark.css` if needed), so it can be edited and linted.
|
||||
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
|
||||
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
|
||||
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
|
||||
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
|
||||
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
|
||||
so later localisation is mechanical.
|
||||
|
||||
## 6. Page-by-page plan
|
||||
|
||||
### My files
|
||||
|
||||
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
|
||||
|
||||
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
|
||||
Upload folder. Replaces three separate icons, fixes the "send" icon.
|
||||
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
|
||||
Folders first, zoom slider. Persist choice (config or GSettings).
|
||||
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
|
||||
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
|
||||
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
|
||||
the search consistently.
|
||||
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
|
||||
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
|
||||
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
|
||||
Move to Trash (destructive).
|
||||
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
|
||||
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
|
||||
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
|
||||
hidden for them (fix `details.rs:259`).
|
||||
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
|
||||
Location for search results. Rubberband selection. One selection model shared between views.
|
||||
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
|
||||
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
|
||||
"All versions…"). Same actions as the context menu.
|
||||
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
|
||||
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
|
||||
reversible), or keep confirm and drop Undo — not both.
|
||||
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
|
||||
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
|
||||
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
|
||||
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
|
||||
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
|
||||
column, correct cached badges (search hits hardcode `cached: false`).
|
||||
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
|
||||
|
||||
### Photos (was Gallery)
|
||||
|
||||
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
|
||||
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
|
||||
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
|
||||
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
|
||||
two segmented rows where "Photos" appears twice. Counts follow the active filter.
|
||||
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
|
||||
"Filter by date" if kept.
|
||||
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
|
||||
page does not fill the viewport).
|
||||
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
|
||||
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
|
||||
shadow on a 2 px gap grid), last row justified up to a max stretch.
|
||||
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
|
||||
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
|
||||
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
|
||||
"No photos yet" with upload buttons.
|
||||
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
|
||||
Back returns to where the user came from.
|
||||
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
|
||||
hidden on the album grid, subtitle count, scroll position kept when switching back.
|
||||
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
|
||||
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
|
||||
pages beyond the loaded 60, counter against the real total, videos play inline with
|
||||
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
|
||||
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
|
||||
|
||||
### Shared with me / Shared by me
|
||||
|
||||
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
|
||||
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
|
||||
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
|
||||
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
|
||||
access…, Stop sharing….
|
||||
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
|
||||
human role names, link expiry.
|
||||
|
||||
### Computers
|
||||
|
||||
- Show the real device name for "This computer" with rename inline.
|
||||
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
|
||||
confirm that requires typing the device name since it deletes backups.
|
||||
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
|
||||
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
|
||||
- Terminology: "computer" everywhere in UI copy.
|
||||
|
||||
### Trash
|
||||
|
||||
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
|
||||
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
|
||||
with a destructive confirm, proper plurals, Retry on error.
|
||||
|
||||
### Login
|
||||
|
||||
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
|
||||
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
|
||||
|
||||
## 7. Tray
|
||||
|
||||
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
|
||||
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
|
||||
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
|
||||
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
|
||||
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
|
||||
running and the label must not suggest otherwise).
|
||||
|
||||
## 8. Glossary (one term, one icon)
|
||||
|
||||
| Concept | UI term | Icon |
|
||||
|---|---|---|
|
||||
| Root of Drive | My files | `folder-symbolic` |
|
||||
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
|
||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
||||
| Folder sync mode | Synced / Online only | — |
|
||||
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
|
||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
||||
| Remove a computer | Remove computer… | destructive, in menu |
|
||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
||||
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
|
||||
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
|
||||
| Photos section | Photos | `image-x-generic-symbolic` |
|
||||
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
|
||||
|
||||
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
|
||||
Proton's own apps. Title Case for
|
||||
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
|
||||
|
||||
## 9. Bugs found during the audit (quick wins)
|
||||
|
||||
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
|
||||
(`photo_viewer.rs:594-598`).
|
||||
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
|
||||
(`main.rs:779`, `takeout.rs:131-136`).
|
||||
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
|
||||
4. Search results show cached files as cloud-only (`browser.rs:2344`).
|
||||
5. Details "Available offline" switch shown for folders (`details.rs:259`).
|
||||
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
|
||||
7. F2 silently does nothing with multiple items selected.
|
||||
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
|
||||
(`photos.rs:958-997`).
|
||||
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
|
||||
counter "12 of 60".
|
||||
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
|
||||
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
|
||||
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
|
||||
12. Going on-demand deletes local copies without confirmation.
|
||||
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
|
||||
14. Toasts from Versions/Share dialogs land behind the modal.
|
||||
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
|
||||
raw lowercase link role.
|
||||
16. Bulk context menu shows a separator directly under the header when a folder is selected.
|
||||
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
|
||||
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
|
||||
`browser.rs:1155`).
|
||||
|
||||
## 10. Phased roadmap
|
||||
|
||||
Each phase is shippable on its own. Estimates are rough, for one developer.
|
||||
|
||||
| Phase | Content | Crates | Size |
|
||||
|---|---|---|---|
|
||||
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
|
||||
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
|
||||
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
|
||||
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
|
||||
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
|
||||
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
|
||||
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
|
||||
|
||||
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
|
||||
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
|
||||
because later phases build on the new header/page skeleton.
|
||||
|
||||
### Verification per phase
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo test --workspace`.
|
||||
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
|
||||
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
|
||||
the losing side stays recoverable (trash or version history).
|
||||
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
|
||||
each page, narrow-window check (sidebar collapse breakpoint).
|
||||
+37
-9
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
|
||||
- [My files](#my-files)
|
||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||
- [Syncing local folders](#syncing-local-folders)
|
||||
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||
- [Conflicts](#conflicts)
|
||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||
- [Photos](#photos)
|
||||
@@ -33,7 +34,7 @@ the service to do things over a local socket:
|
||||
|
||||
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
||||
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
||||
full-disk or home-directory encryption. [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||
full-disk or home-directory encryption. [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||
lists exactly what is stored.
|
||||
|
||||
The app uses these words consistently, and so does this guide:
|
||||
@@ -54,9 +55,11 @@ downloads only when a program reads it:
|
||||
|
||||
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
||||
header, downloads only those parts. Media streams while it plays.
|
||||
- **Writes are safe offline.** When a program closes a file it wrote, the service stores the new
|
||||
content on disk and queues the upload. The queue survives network loss, restarts and crashes.
|
||||
`pdfs sync queue` lists what has not reached Proton Drive yet.
|
||||
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||
stored on disk. Every change is queued and sent to Proton Drive in the background; the changes
|
||||
to one file are sent in the order you made them. The queue survives network loss, restarts and
|
||||
crashes. See [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive).
|
||||
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
||||
evicted oldest first.
|
||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
||||
@@ -143,6 +146,36 @@ places, and both apply:
|
||||
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
||||
simply no longer tracked, and its copy on Proton Drive stays.
|
||||
|
||||
## Changes waiting for Proton Drive
|
||||
|
||||
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
||||
Proton Drive. `pdfs status`, the tray and **Sync → Overview** count what is still waiting, and
|
||||
`pdfs sync queue` lists it.
|
||||
|
||||
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
|
||||
is gone, the folder holds too many items, or Drive does not accept a name. The change then needs
|
||||
your attention. It shows at once in **Sync → Overview**, in the tray, and in `pdfs sync issues`,
|
||||
with what it means and what you can do. A file or folder with such a change carries a warning
|
||||
badge in **Files**; hover it to see why.
|
||||
|
||||
Nothing is deleted. The change stays queued and is retried. You can:
|
||||
|
||||
- **Retry** it now, from the same list or with `pdfs sync retry`, for example after freeing space.
|
||||
- **Export** a file's content to keep your version (`pdfs sync export <id> <file>`).
|
||||
- **Discard** the change (`pdfs sync discard <id>`). It is undone here and never sent: a new file
|
||||
or folder goes away, anything else goes back to what Proton Drive has. Export first if you want
|
||||
to keep the content.
|
||||
|
||||
Two cases are not refused, and the activity log records both:
|
||||
|
||||
- A new file or folder whose name is already taken on Proton Drive by something this computer had
|
||||
not seen yet lands under a `(sync-conflict …)` name, as described below.
|
||||
- A new file whose folder was deleted on another device meanwhile lands at the top of My files,
|
||||
so its content is not lost.
|
||||
|
||||
Before you shut down a computer for good, check that nothing is waiting: see
|
||||
[RECOVERY.md](RECOVERY.md#lost-with-the-machine).
|
||||
|
||||
## Conflicts
|
||||
|
||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||
@@ -166,11 +199,6 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
||||
reading files keeps working. `pdfs sync resume` continues.
|
||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
|
||||
- **Discard** a change Proton Drive refused from the same list, or with `pdfs sync discard`: it is
|
||||
undone here and never sent. Export a file first to keep your version.
|
||||
- A file or folder with a change Proton Drive refused carries a warning badge in **Files**; hover
|
||||
it to see why.
|
||||
|
||||
## Photos
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
@@ -0,0 +1,156 @@
|
||||
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1920" y2="1080" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1A1240"/>
|
||||
<stop offset="1" stop-color="#0A0618"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="1390" cy="520" r="640" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6D4AFF" stop-opacity="0.55"/>
|
||||
<stop offset="0.5" stop-color="#6D4AFF" stop-opacity="0.15"/>
|
||||
<stop offset="1" stop-color="#6D4AFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="160" cy="1080" r="700" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A855F7" stop-opacity="0.25"/>
|
||||
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<pattern id="dots" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="1.6" fill="#FFFFFF"/>
|
||||
</pattern>
|
||||
<radialGradient id="dotfade" cx="1390" cy="520" r="900" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<mask id="dotmask" maskUnits="userSpaceOnUse" x="0" y="0" width="1920" height="1080">
|
||||
<rect width="1920" height="1080" fill="url(#dotfade)"/>
|
||||
</mask>
|
||||
<linearGradient id="title2" x1="140" y1="0" x2="760" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#C9BBFF"/>
|
||||
<stop offset="1" stop-color="#8A6CFF"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- app logo -->
|
||||
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A48BFF"/>
|
||||
<stop offset="0.55" stop-color="#7552FF"/>
|
||||
<stop offset="1" stop-color="#5B36F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#ECE6FF"/>
|
||||
</linearGradient>
|
||||
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="66" cy="57" r="23"/>
|
||||
<circle cx="42" cy="68" r="16"/>
|
||||
<circle cx="88" cy="69" r="15"/>
|
||||
<rect x="26" y="68" width="77" height="20" rx="10"/>
|
||||
</g>
|
||||
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M64 49v26"/>
|
||||
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
|
||||
</g>
|
||||
</mask>
|
||||
<filter id="logoshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
</filter>
|
||||
<filter id="chipshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="14"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- background -->
|
||||
<rect width="1920" height="1080" fill="url(#bg)"/>
|
||||
<rect width="1920" height="1080" fill="url(#glow2)"/>
|
||||
<rect width="1920" height="1080" fill="url(#glow1)"/>
|
||||
<rect width="1920" height="1080" fill="url(#dots)" opacity="0.07" mask="url(#dotmask)"/>
|
||||
|
||||
<!-- orbit rings -->
|
||||
<g fill="none" stroke="#FFFFFF">
|
||||
<circle cx="1390" cy="520" r="300" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<circle cx="1390" cy="520" r="420" stroke-opacity="0.06" stroke-width="2" stroke-dasharray="4 14" stroke-linecap="round"/>
|
||||
</g>
|
||||
|
||||
<!-- logo, 128 -> 384 -->
|
||||
<g transform="translate(1198 328) scale(3)">
|
||||
<rect x="16" y="24" width="96" height="92" rx="24" fill="#2A1080" opacity="0.8" filter="url(#logoshadow)"/>
|
||||
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
|
||||
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||
</g>
|
||||
|
||||
<!-- feature chips around the logo -->
|
||||
<g font-family="Adwaita Sans" font-size="26" font-weight="600" fill="#FFFFFF">
|
||||
<!-- folder, top left -->
|
||||
<g transform="translate(1000 200)">
|
||||
<rect x="0" y="10" width="236" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="236" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#6D4AFF"/>
|
||||
<path d="M8 13a2 2 0 0 1 2-2h5l2.5 2.5H26a2 2 0 0 1 2 2V25a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<text x="74" y="47">On demand</text>
|
||||
</g>
|
||||
<!-- sync, top right -->
|
||||
<g transform="translate(1590 290)">
|
||||
<rect x="0" y="10" width="164" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="164" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#1EA885"/>
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M26 15a8.5 8.5 0 0 0-15-2"/>
|
||||
<path d="M10 9v4.5h4.5"/>
|
||||
<path d="M10 21a8.5 8.5 0 0 0 15 2"/>
|
||||
<path d="M26 27v-4.5h-4.5"/>
|
||||
</g>
|
||||
</g>
|
||||
<text x="74" y="47">Sync</text>
|
||||
</g>
|
||||
<!-- photos, bottom right -->
|
||||
<g transform="translate(1560 690)">
|
||||
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#E8618C"/>
|
||||
<rect x="8" y="9" width="20" height="18" rx="3" fill="none" stroke="#FFFFFF" stroke-width="2.4"/>
|
||||
<circle cx="14" cy="15" r="2" fill="#FFFFFF"/>
|
||||
<path d="M9.5 25l6-6 4 4 3-3 4 4" fill="none" stroke="#FFFFFF" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="74" y="47">Photos</text>
|
||||
</g>
|
||||
<!-- terminal, bottom left -->
|
||||
<g transform="translate(980 790)">
|
||||
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#3B3F5C"/>
|
||||
<path d="M9 12l6 6-6 6" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18 25h9" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="74" y="48" font-family="JetBrainsMono NF" font-size="25" font-weight="700">pdfs</text>
|
||||
<text x="150" y="48" font-weight="500" fill="#B9B0D9">CLI</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- text block -->
|
||||
<g font-family="Adwaita Sans">
|
||||
<g transform="translate(140 292)">
|
||||
<rect width="410" height="48" rx="24" fill="#6D4AFF" fill-opacity="0.18" stroke="#8A6CFF" stroke-opacity="0.6" stroke-width="1.5"/>
|
||||
<circle cx="28" cy="24" r="6" fill="#A48BFF"/>
|
||||
<text x="46" y="32" font-size="21" font-weight="700" letter-spacing="2.4" fill="#D6CCFF">UNOFFICIAL · OPEN SOURCE</text>
|
||||
</g>
|
||||
<text x="134" y="478" font-size="124" font-weight="800" letter-spacing="-3" fill="#FFFFFF">Proton Drive</text>
|
||||
<text x="134" y="610" font-size="124" font-weight="800" letter-spacing="-3" fill="url(#title2)">for Linux</text>
|
||||
<text x="140" y="702" font-size="38" font-weight="400" fill="#B9B0D9">Your Drive as a folder, and a native GTK4 app.</text>
|
||||
<text x="140" y="752" font-size="38" font-weight="400" fill="#B9B0D9">No Electron.</text>
|
||||
</g>
|
||||
|
||||
<!-- footer -->
|
||||
<g font-family="JetBrainsMono NF" font-size="24" fill="#8C83B0">
|
||||
<text x="140" y="968">github.com/narrrl/proton-drive-linux</text>
|
||||
</g>
|
||||
<g font-family="Adwaita Sans" font-size="22" fill="#6F6894">
|
||||
<text x="1780" y="968" text-anchor="end">Not affiliated with Proton AG</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 2.10.0
|
||||
pkgver = 3.0.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=2.10.0
|
||||
provides = proton-drive-linux=2.10.0
|
||||
provides = proton-drive-for-linux=3.0.0
|
||||
provides = proton-drive-linux=3.0.0
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = proton-drive-linux-2.10.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.10.0/proton-drive-linux-2.10.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-2.10.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-2.10.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-2.10.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-2.10.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/proton-drive.service
|
||||
source = LICENSE-2.10.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/LICENSE
|
||||
sha256sums = b653dbd7d5939c504c55a91e14f1cd3e38aabc94d8bbd6344d2f79b3cd670313
|
||||
source = proton-drive-linux-3.0.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.0.0/proton-drive-linux-3.0.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.0.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.0.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/proton-drive.service
|
||||
source = LICENSE-3.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/LICENSE
|
||||
sha256sums = 4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=2.10.0
|
||||
pkgver=3.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||
sha256sums=('b653dbd7d5939c504c55a91e14f1cd3e38aabc94d8bbd6344d2f79b3cd670313'
|
||||
sha256sums=('4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 2.10.0.r6.ga6f36d5
|
||||
pkgver = 3.0.0.r0.g2177383
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=2.10.0.r6.ga6f36d5
|
||||
provides = proton-drive-linux=2.10.0.r6.ga6f36d5
|
||||
provides = proton-drive-for-linux=3.0.0.r0.g2177383
|
||||
provides = proton-drive-linux=3.0.0.r0.g2177383
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=2.10.0.r6.ga6f36d5
|
||||
pkgver=3.0.0.r0.g2177383
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 2.10.0
|
||||
pkgver = 3.0.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-linux=2.10.0
|
||||
provides = proton-drive-linux=3.0.0
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !lto
|
||||
source = proton-drive-for-linux-2.10.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.10.0.tar.gz
|
||||
sha256sums = 7aa5fcb76dca13262bd678c6f280795ab331a20d5549c4aa7d2c3d4eec6fcd8e
|
||||
source = proton-drive-for-linux-3.0.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.0.0.tar.gz
|
||||
sha256sums = 3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=2.10.0
|
||||
pkgver=3.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
options=('!lto')
|
||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('7aa5fcb76dca13262bd678c6f280795ab331a20d5549c4aa7d2c3d4eec6fcd8e')
|
||||
sha256sums=('3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
|
||||
@@ -1410,10 +1410,11 @@ def test_throughput(ctx: Context) -> None:
|
||||
on the network. PDFS_ACCEPTANCE_MIN_MIBPS and PDFS_ACCEPTANCE_MIN_OPS move
|
||||
the floors; 0 turns one off.
|
||||
|
||||
Metadata is the exception. On a Drive mount each create and each unlink
|
||||
waits for Drive before it returns (about 350 ms for a create), so 20
|
||||
operations a second is out of reach there by design. The default floor for
|
||||
a FUSE target is 3, which still catches a second round trip per operation.
|
||||
Metadata has a lower default floor on a FUSE target: 3 operations a
|
||||
second. A local-first mount records each create and unlink locally and
|
||||
clears it easily, but with `local_first` off each one waits for Drive
|
||||
before it returns (about 350 ms for a create), and 20 a second is out of
|
||||
reach. The floor still catches a second round trip per operation.
|
||||
"""
|
||||
min_rate = float(os.environ.get("PDFS_ACCEPTANCE_MIN_MIBPS", "10"))
|
||||
default_ops = "3" if is_fuse(ctx.root) else "20"
|
||||
@@ -2344,7 +2345,7 @@ def _shared_root(daemon: Daemon, mount: Path, role: str) -> Path:
|
||||
|
||||
Skips rather than fails when the account has no such share: the role cases
|
||||
need a *second* account to have shared something at that role, which most
|
||||
runs will not have (mount-architecture.md §7).
|
||||
runs will not have.
|
||||
"""
|
||||
entries = [e for e in _shared_with_me(daemon) if e.get("role") == role]
|
||||
if not entries:
|
||||
|
||||
Reference in new issue
Block a user