mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb | ||
|
|
217738318e | ||
|
|
9291580fd6 | ||
|
|
812eb3d0bc | ||
|
|
31863a1da1 | ||
|
|
57a825887c | ||
|
|
1436c5f37e | ||
|
|
f0a43fb923 | ||
|
|
b47620651f | ||
|
|
2baad6720d | ||
|
|
c271a1897c | ||
|
|
d26dc8ada4 | ||
|
|
93f02672e6 | ||
|
|
307305282e | ||
|
|
a8ed6060af | ||
|
|
9dfe4c5450 | ||
|
|
da013c29d6 | ||
|
|
457a9d26f4 | ||
|
|
a68dae9422 | ||
|
|
e743cd60cf | ||
|
|
f9b1638603 | ||
|
|
6539c7314b | ||
|
|
903452a15b | ||
|
|
4e0fd3c0b1 | ||
|
|
a6f019ebe6 | ||
|
|
0f3c06f982 | ||
|
|
4f141c78ca | ||
|
|
ce60e006ac | ||
|
|
1612a9d07a | ||
|
|
378667fb37 | ||
|
|
3b81cc5240 | ||
|
|
d850265225 | ||
|
|
e32f72bfca | ||
|
|
2502af6e3c | ||
|
|
4757b957d2 | ||
|
|
8e54ed0517 | ||
|
|
9bbc994a4f | ||
|
|
ffa0fe698d | ||
|
|
25c46cfe0a | ||
|
|
573a29e596 | ||
|
|
ce2bc0ac84 | ||
|
|
b37d5e7928 | ||
|
|
18587124ac | ||
|
|
cd18029944 | ||
|
|
d6dd41407f | ||
|
|
60445d3b26 | ||
|
|
c2f93449ad | ||
|
|
4676a4e32b | ||
|
|
16fca10e6c | ||
|
|
dbc4d9a8c5 | ||
|
|
9c93860cd8 | ||
|
|
85ec91d6b3 | ||
|
|
d5d3b2da3f | ||
|
|
e5f3f612f8 | ||
|
|
3f74b95b34 | ||
|
|
7269cb5118 | ||
|
|
6d75a56b00 | ||
|
|
eb1c105bbf | ||
|
|
85e80d8aac | ||
|
|
23dc5c3723 | ||
|
|
50ae25bb88 | ||
|
|
9b2b9f24c5 | ||
|
|
ae6650f7e0 | ||
|
|
f029e652a1 | ||
|
|
9b3651e4e8 | ||
|
|
0dc476dfb7 | ||
|
|
de258cbd60 | ||
|
|
4ea18e9187 | ||
|
|
a6f36d56f3 | ||
|
|
77d0f254fc | ||
|
|
4eccdeeda6 | ||
|
|
473842eefc | ||
|
|
5e0f30cc2c | ||
|
|
b3e33a7002 |
No files matched your search
@@ -67,7 +67,13 @@ jobs:
|
|||||||
User aur
|
User aur
|
||||||
EOF
|
EOF
|
||||||
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
||||||
ssh-keyscan -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts"
|
# The scan sometimes returns nothing from the runner, so retry it.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
ssh-keyscan -T 15 -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts" &&
|
||||||
|
[ -s "$HOME/.ssh/known_hosts" ] && break
|
||||||
|
echo "ssh-keyscan attempt $attempt returned no key; retrying"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
||||||
chown -R builder: "$HOME/.ssh"
|
chown -R builder: "$HOME/.ssh"
|
||||||
chmod 600 "$HOME/.ssh/aur"
|
chmod 600 "$HOME/.ssh/aur"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
# 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.
|
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||||
simulation:
|
simulation:
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -101,6 +101,16 @@ jobs:
|
|||||||
- name: Simulation runs
|
- name: Simulation runs
|
||||||
run: 'cargo test -p pdfs-fuse --lib --locked sim:: -- --ignored --test-threads=1'
|
run: 'cargo test -p pdfs-fuse --lib --locked sim:: -- --ignored --test-threads=1'
|
||||||
|
|
||||||
|
# A hung step writes every daemon thread's stack next to the run's
|
||||||
|
# state; without them a hang that does not replay cannot be told apart.
|
||||||
|
- name: Keep the stacks of a hung run
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: simulation-stacks
|
||||||
|
path: /tmp/pdfs-sim-*/stacks.txt
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
# Keeps the release-profile dependencies cached on main, where the release
|
# Keeps the release-profile dependencies cached on main, where the release
|
||||||
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
||||||
release-cache:
|
release-cache:
|
||||||
|
|||||||
+3
-3
@@ -39,9 +39,9 @@ cargo test --workspace --locked
|
|||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||||
use a dedicated test account, never one holding data you cannot lose.
|
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
## Making changes
|
## Making changes
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-4
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "2.10.0"
|
version = "3.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3489,7 +3489,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "2.10.0"
|
version = "3.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
@@ -3513,7 +3513,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "2.10.0"
|
version = "3.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3535,7 +3535,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "2.10.0"
|
version = "3.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.10.0"
|
version = "3.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ scriptable CLI.
|
|||||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://buymeacoffee.com/narl)
|
||||||
|
|
||||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||||
|
|
||||||
@@ -38,8 +39,9 @@ scriptable CLI.
|
|||||||
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
||||||
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||||
instead of overwrites, pause and bandwidth limits.
|
instead of overwrites, pause and bandwidth limits.
|
||||||
- **Safe writes.** Changes are staged on disk and queued before they are acknowledged, so they
|
- **Works at local speed.** Making, renaming and deleting files and folders returns at once, online
|
||||||
survive a lost connection, a crash or a reboot and upload when they can.
|
or offline. Changes are staged on disk and queued before they are acknowledged, so they survive
|
||||||
|
a lost connection, a crash or a reboot and reach Drive when they can.
|
||||||
|
|
||||||
**Desktop app**
|
**Desktop app**
|
||||||
|
|
||||||
@@ -50,7 +52,8 @@ scriptable CLI.
|
|||||||
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||||
expiry, accept invitations.
|
expiry, accept invitations.
|
||||||
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||||
what happened.
|
what happened. A change Drive refuses (storage full, access taken away) shows at once, on the
|
||||||
|
file too, and can be exported or discarded.
|
||||||
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
||||||
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||||
rofi or wofi, or live in `fzf`.
|
rofi or wofi, or live in `fzf`.
|
||||||
@@ -146,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
|
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
|
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
|
## Contributing
|
||||||
|
|
||||||
@@ -154,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
|
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||||
privately.
|
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
|
## License
|
||||||
|
|
||||||
Released under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ the interesting boundaries are:
|
|||||||
or the wrong key material to Proton.
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
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
|
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||||
than described is.
|
than described is.
|
||||||
|
|
||||||
|
|||||||
+123
-1
@@ -14,7 +14,7 @@ use pdfs_core::cache::ContentCache;
|
|||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase,
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||||
pending_summary,
|
pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
@@ -677,6 +677,25 @@ enum SyncCmd {
|
|||||||
},
|
},
|
||||||
/// List uploads and changes not yet on Proton Drive.
|
/// List uploads and changes not yet on Proton Drive.
|
||||||
Queue,
|
Queue,
|
||||||
|
/// List queued changes Proton Drive refused or that keep failing, and what to do.
|
||||||
|
Issues,
|
||||||
|
/// Save the content of a queued upload (by id from `sync queue`) to a file.
|
||||||
|
Export {
|
||||||
|
/// Queue id.
|
||||||
|
id: i64,
|
||||||
|
/// Where to save it: a new file, or an existing folder to save it in.
|
||||||
|
dest: PathBuf,
|
||||||
|
},
|
||||||
|
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
|
||||||
|
/// or folder goes, anything else goes back to what Proton Drive has. Its
|
||||||
|
/// content is lost unless saved with `sync export` first.
|
||||||
|
Discard {
|
||||||
|
/// Queue id.
|
||||||
|
id: i64,
|
||||||
|
/// Do not ask first.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||||
Retry {
|
Retry {
|
||||||
/// Queue id; omit to retry every failed operation.
|
/// Queue id; omit to retry every failed operation.
|
||||||
@@ -1028,7 +1047,17 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
})?)?
|
})?)?
|
||||||
}
|
}
|
||||||
SyncCmd::Queue => return cmd_sync_queue(),
|
SyncCmd::Queue => return cmd_sync_queue(),
|
||||||
|
SyncCmd::Issues => return cmd_sync_issues(),
|
||||||
|
SyncCmd::Export { id, dest } => return cmd_sync_export(id, &dest),
|
||||||
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
||||||
|
SyncCmd::Discard { id, yes } => {
|
||||||
|
if !yes {
|
||||||
|
confirm(&format!(
|
||||||
|
"Discard queued change {id} and undo it here? Content not exported is lost."
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
ok_or_bail(control_request(CtlRequest::DiscardPendingOp { id })?)?
|
||||||
|
}
|
||||||
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
||||||
SyncCmd::Resume { folder: None } => {
|
SyncCmd::Resume { folder: None } => {
|
||||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||||
@@ -1106,6 +1135,9 @@ fn cmd_sync_queue() -> Result<()> {
|
|||||||
(false, _) => "due now".to_string(),
|
(false, _) => "due now".to_string(),
|
||||||
};
|
};
|
||||||
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
||||||
|
if let Some(issue) = op.issue {
|
||||||
|
println!(" needs attention: {}", issue_text(issue));
|
||||||
|
}
|
||||||
if let Some(error) = &op.last_error {
|
if let Some(error) = &op.last_error {
|
||||||
println!(" failed {} time(s): {error}", op.attempts);
|
println!(" failed {} time(s): {error}", op.attempts);
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1149,91 @@ fn cmd_sync_queue() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pdfs sync issues`: the queued changes that need the user, and what to do.
|
||||||
|
fn cmd_sync_issues() -> Result<()> {
|
||||||
|
let response = control_request(CtlRequest::ListPendingOps)?;
|
||||||
|
let items = match response {
|
||||||
|
CtlResponse::PendingOps { items } => items,
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
};
|
||||||
|
let issues: Vec<_> = items.into_iter().filter(|op| op.failing).collect();
|
||||||
|
if json_enabled() {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&issues)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if issues.is_empty() {
|
||||||
|
println!("No sync issues.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for op in issues {
|
||||||
|
println!("[{}] {} {}", op.id, op.kind, op.path);
|
||||||
|
match op.issue {
|
||||||
|
Some(issue) => println!(" {}", issue_text(issue)),
|
||||||
|
None => println!(" keeps failing; it is retried."),
|
||||||
|
}
|
||||||
|
if let Some(error) = &op.last_error {
|
||||||
|
println!(" last error: {error}");
|
||||||
|
}
|
||||||
|
if op.exportable {
|
||||||
|
println!(
|
||||||
|
" save a copy: pdfs sync export {} <file or folder>",
|
||||||
|
op.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a sync issue means, and what the user can do about it.
|
||||||
|
fn issue_text(issue: SyncIssue) -> &'static str {
|
||||||
|
match issue {
|
||||||
|
SyncIssue::Quota => {
|
||||||
|
"Proton Drive storage is full. Free up space or upgrade; the change is retried."
|
||||||
|
}
|
||||||
|
SyncIssue::Access => {
|
||||||
|
"you can no longer change this on Proton Drive, for example because a share was \
|
||||||
|
revoked. Save a copy to keep the content."
|
||||||
|
}
|
||||||
|
SyncIssue::Missing => {
|
||||||
|
"the file or its folder is gone from Proton Drive. Save a copy to keep the content."
|
||||||
|
}
|
||||||
|
SyncIssue::Limit => {
|
||||||
|
"the folder holds too many items or is nested too deep. Move the file to another \
|
||||||
|
folder."
|
||||||
|
}
|
||||||
|
SyncIssue::Rejected => {
|
||||||
|
"Proton Drive rejected the change, for example the name. Rename the file."
|
||||||
|
}
|
||||||
|
SyncIssue::Unknown => "Proton Drive refused the change.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs sync export`: save a queued upload's content. A folder as `dest`
|
||||||
|
/// gets the file under its own name.
|
||||||
|
fn cmd_sync_export(id: i64, dest: &Path) -> Result<()> {
|
||||||
|
let mut dest =
|
||||||
|
std::path::absolute(dest).with_context(|| format!("resolving {}", dest.display()))?;
|
||||||
|
if dest.is_dir() {
|
||||||
|
let items = match control_request(CtlRequest::ListPendingOps)? {
|
||||||
|
CtlResponse::PendingOps { items } => items,
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
};
|
||||||
|
let Some(op) = items.iter().find(|op| op.id == id) else {
|
||||||
|
bail!("no queued change {id}; see `pdfs sync queue`");
|
||||||
|
};
|
||||||
|
let name = Path::new(&op.path)
|
||||||
|
.file_name()
|
||||||
|
.with_context(|| format!("{} has no file name", op.path))?;
|
||||||
|
dest.push(name);
|
||||||
|
}
|
||||||
|
ok_or_bail(control_request(CtlRequest::ExportPendingOp {
|
||||||
|
id,
|
||||||
|
dest: dest.display().to_string(),
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
|
||||||
/// `pdfs sync restore`: list what this machine's device holds, confirm where
|
/// `pdfs sync restore`: list what this machine's device holds, confirm where
|
||||||
/// each folder should land, then hand the mapping to the daemon.
|
/// each folder should land, then hand the mapping to the daemon.
|
||||||
///
|
///
|
||||||
@@ -1983,6 +2100,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
sweep_mode: config.resolved_conflict_sweep(),
|
sweep_mode: config.resolved_conflict_sweep(),
|
||||||
upload_limit: config.upload_limit.unwrap_or(0),
|
upload_limit: config.upload_limit.unwrap_or(0),
|
||||||
download_limit: config.download_limit.unwrap_or(0),
|
download_limit: config.download_limit.unwrap_or(0),
|
||||||
|
local_first: config.local_first.unwrap_or(true),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1990,6 +2108,9 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
||||||
}
|
}
|
||||||
|
// Dropping the runtime waits for every blocking task, however long; a call
|
||||||
|
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
||||||
|
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||||
|
|
||||||
let outcome = result.context("mount failed")?;
|
let outcome = result.context("mount failed")?;
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
@@ -3433,6 +3554,7 @@ mod login_wait_tests {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
};
|
};
|
||||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||||
entry.shared_by = "alice@proton.me".into();
|
entry.shared_by = "alice@proton.me".into();
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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 {
|
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||||
@@ -311,8 +311,7 @@ struct PinFile {
|
|||||||
pins: BTreeMap<String, Pin>,
|
pins: BTreeMap<String, Pin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||||
/// (offline.md Phase 2).
|
|
||||||
///
|
///
|
||||||
/// A staged file is **not** necessarily valid whole-file content, and that is
|
/// 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
|
/// 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).
|
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||||
/// Emptied on open so a crashed run leaves no orphans.
|
/// Emptied on open so a crashed run leaves no orphans.
|
||||||
scratch_dir: PathBuf,
|
scratch_dir: PathBuf,
|
||||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
/// Every released write passes through here, since the upload is a queued
|
||||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
/// op performed later. Unlike `scratch_dir` this is
|
||||||
/// **never** emptied on open: these are the only copy of content the user
|
/// **never** emptied on open: these are the only copy of content the user
|
||||||
/// authored, and the whole point is that they outlive the daemon.
|
/// authored, and the whole point is that they outlive the daemon.
|
||||||
staging_dir: PathBuf,
|
staging_dir: PathBuf,
|
||||||
@@ -476,7 +475,7 @@ pub struct ContentCache {
|
|||||||
thumb_bytes: AtomicU64,
|
thumb_bytes: AtomicU64,
|
||||||
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
/// 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
|
/// 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>,
|
db: Arc<Db>,
|
||||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||||
///
|
///
|
||||||
@@ -845,6 +844,9 @@ impl ContentCache {
|
|||||||
}
|
}
|
||||||
let bytes = std::fs::metadata(&tmp)?.len();
|
let bytes = std::fs::metadata(&tmp)?.len();
|
||||||
std::fs::rename(&tmp, &blob)?;
|
std::fs::rename(&tmp, &blob)?;
|
||||||
|
// Linked from the blob already cached, the temp name was a second name
|
||||||
|
// of it, and the rename did nothing (`docs/BUGS.md` B187).
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
||||||
std::fs::write(self.meta_path(uid), meta)?;
|
std::fs::write(self.meta_path(uid), meta)?;
|
||||||
self.db
|
self.db
|
||||||
@@ -986,7 +988,7 @@ impl ContentCache {
|
|||||||
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
||||||
/// cannot escape this cache even though this does not check. Validating a
|
/// 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
|
/// 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(
|
pub fn store_block(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -1488,9 +1490,8 @@ impl ContentCache {
|
|||||||
/// This is what makes a write survive its upload: the caller is releasing a
|
/// 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
|
/// 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
|
/// on the remote, staging holds the only copy. Every dirty handle goes
|
||||||
/// through here — the upload is a queued op performed later (offline.md
|
/// through here — the upload is a queued op performed later, and a staged
|
||||||
/// Phase 3), and a staged file is also what a human can recover from if the
|
/// file is also what a human can recover from if the queue never drains.
|
||||||
/// queue never drains.
|
|
||||||
///
|
///
|
||||||
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
/// 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
|
/// failure here means we could not save the bytes at all, so it is reported
|
||||||
@@ -2197,6 +2198,24 @@ mod tests {
|
|||||||
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
|
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A landed create and the revision after it can adopt the same staged
|
||||||
|
/// blob. The link to the temp name is then a second name of the cached
|
||||||
|
/// blob, and a rename between two names of one file does nothing: the
|
||||||
|
/// temp name stayed until the next start swept it (`docs/BUGS.md` B187).
|
||||||
|
#[test]
|
||||||
|
fn store_file_twice_from_the_same_blob_leaves_no_temp_file() {
|
||||||
|
let (c, d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
let src = d.path().join("staged-blob");
|
||||||
|
std::fs::write(&src, b"staged").unwrap();
|
||||||
|
|
||||||
|
c.store_file(&u, 100, 6, &src).unwrap();
|
||||||
|
c.store_file(&u, 100, 6, &src).unwrap();
|
||||||
|
|
||||||
|
assert!(!c.blob_path(&u).with_extension("tmp").exists());
|
||||||
|
assert_eq!(c.read_range(&u, 100, 6, 0, 6).unwrap(), b"staged");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconcile_rebuilds_index_from_disk() {
|
fn reconcile_rebuilds_index_from_disk() {
|
||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
@@ -2686,7 +2705,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The geometry the whole read path validates against, here and over the
|
/// 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]
|
#[test]
|
||||||
fn block_len_is_full_blocks_then_the_remainder() {
|
fn block_len_is_full_blocks_then_the_remainder() {
|
||||||
let size = BLOCK_SIZE * 2 + 17;
|
let size = BLOCK_SIZE * 2 + 17;
|
||||||
@@ -2766,7 +2785,7 @@ mod tests {
|
|||||||
/// A cached block is addressed by the range it covers, not by its index
|
/// 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
|
/// 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
|
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
||||||
/// wrong offset (bugs.md B85).
|
/// wrong offset (`docs/BUGS.md` B85).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2833,7 +2852,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A block of the wrong length cannot escape the cache even though the
|
/// 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
|
/// 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]
|
#[test]
|
||||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2898,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 scratch file moves to staging intact, and staging survives a reopen
|
||||||
/// the way scratch deliberately does not.
|
/// the way scratch deliberately does not.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ pub struct AppConfig {
|
|||||||
/// configs predating the field.
|
/// configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub online_map: bool,
|
pub online_map: bool,
|
||||||
|
/// 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/ROADMAP.md). Defaulted for configs predating
|
||||||
|
/// the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_first: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The My Files page's view choices, remembered between runs.
|
/// The My Files page's view choices, remembered between runs.
|
||||||
@@ -228,6 +235,7 @@ impl Default for AppConfig {
|
|||||||
tray_hidden: false,
|
tray_hidden: false,
|
||||||
language: None,
|
language: None,
|
||||||
online_map: false,
|
online_map: false,
|
||||||
|
local_first: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,7 +432,7 @@ impl AppDirs {
|
|||||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||||
/// happening to be `0700`, which is a convention of the user's system and
|
/// 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
|
/// Applied on every start, not just at creation: a directory that already
|
||||||
/// exists with a permissive mode — restored from a backup that flattened
|
/// exists with a permissive mode — restored from a backup that flattened
|
||||||
@@ -475,7 +483,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
|||||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
/// 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
|
/// 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
|
/// carries the umask's mode. It is closed in practice by the containing
|
||||||
@@ -496,7 +504,7 @@ mod tests {
|
|||||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
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,
|
/// directories hold decrypted content, a plaintext index of every node name,
|
||||||
/// and the control socket that commands the daemon's session.
|
/// and the control socket that commands the daemon's session.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -584,6 +592,7 @@ mod tests {
|
|||||||
tray_hidden: true,
|
tray_hidden: true,
|
||||||
language: Some("de".to_string()),
|
language: Some("de".to_string()),
|
||||||
online_map: true,
|
online_map: true,
|
||||||
|
local_first: Some(false),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|||||||
@@ -443,11 +443,11 @@ pub enum Request {
|
|||||||
DeleteDevice { uid: String },
|
DeleteDevice { uid: String },
|
||||||
/// Adopt an existing device as this machine's, pinning its uid in
|
/// 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
|
/// `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`].
|
/// Replies with [`Response::Ok`].
|
||||||
AdoptDevice { uid: Option<String> },
|
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
|
/// Add a local folder to this machine's device, uploading its tree and
|
||||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||||
AddSyncFolder { local_path: String },
|
AddSyncFolder { local_path: String },
|
||||||
@@ -477,6 +477,15 @@ pub enum Request {
|
|||||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||||
RetryPendingOp { id: Option<i64> },
|
RetryPendingOp { id: Option<i64> },
|
||||||
|
/// Copy the content of a queued upload (`id` from
|
||||||
|
/// [`Request::ListPendingOps`]) to `dest`, an absolute path that must not
|
||||||
|
/// exist yet. The op stays queued. Replies with [`Response::Ok`].
|
||||||
|
ExportPendingOp { id: i64, dest: String },
|
||||||
|
/// Drop a queued change (`id` from [`Request::ListPendingOps`]) and undo
|
||||||
|
/// it locally: a node made here goes, a node Drive has goes back to what
|
||||||
|
/// Drive has, along with every other change queued on it. Its content is
|
||||||
|
/// lost unless it was exported first. Replies with [`Response::Ok`].
|
||||||
|
DiscardPendingOp { id: i64 },
|
||||||
/// List the `(sync-conflict …)` copies under My Files and in synced
|
/// List the `(sync-conflict …)` copies under My Files and in synced
|
||||||
/// folders. Replies with
|
/// folders. Replies with
|
||||||
/// [`Response::Conflicts`].
|
/// [`Response::Conflicts`].
|
||||||
@@ -487,7 +496,7 @@ pub enum Request {
|
|||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
ResolveConflict { path: String, keep: ConflictKeep },
|
ResolveConflict { path: String, keep: ConflictKeep },
|
||||||
/// List the folders under this machine's device that can be synced here,
|
/// 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`].
|
/// [`Response::RestorableFolders`].
|
||||||
ListRestorableFolders,
|
ListRestorableFolders,
|
||||||
/// Attach the given remote device folders to local paths and sync them down.
|
/// Attach the given remote device folders to local paths and sync them down.
|
||||||
@@ -880,6 +889,59 @@ pub struct PendingOpInfo {
|
|||||||
pub parked: bool,
|
pub parked: bool,
|
||||||
/// Failed often enough to count as stuck.
|
/// Failed often enough to count as stuck.
|
||||||
pub failing: bool,
|
pub failing: bool,
|
||||||
|
/// Why Drive refused it, when it did; such an op needs attention at once.
|
||||||
|
#[serde(default)]
|
||||||
|
pub issue: Option<SyncIssue>,
|
||||||
|
/// Carries file content that [`Request::ExportPendingOp`] can save.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exportable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why Drive refused a queued change (in [`PendingOpInfo`]). The change stays
|
||||||
|
/// queued and keeps being retried; nothing is deleted.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SyncIssue {
|
||||||
|
/// The account's storage is full.
|
||||||
|
Quota,
|
||||||
|
/// The account may no longer change the file or its folder, for example
|
||||||
|
/// because a share was revoked.
|
||||||
|
Access,
|
||||||
|
/// The file or the folder it goes into is gone from Drive.
|
||||||
|
Missing,
|
||||||
|
/// The folder holds too many items, or is nested too deep.
|
||||||
|
Limit,
|
||||||
|
/// Drive rejected the change, for example a name it does not accept.
|
||||||
|
Rejected,
|
||||||
|
/// An issue from a newer daemon.
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncIssue {
|
||||||
|
/// The name stored in the database and sent on the wire.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SyncIssue::Quota => "quota",
|
||||||
|
SyncIssue::Access => "access",
|
||||||
|
SyncIssue::Missing => "missing",
|
||||||
|
SyncIssue::Limit => "limit",
|
||||||
|
SyncIssue::Rejected => "rejected",
|
||||||
|
SyncIssue::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The issue stored as `name`.
|
||||||
|
pub fn parse(name: &str) -> Self {
|
||||||
|
match name {
|
||||||
|
"quota" => SyncIssue::Quota,
|
||||||
|
"access" => SyncIssue::Access,
|
||||||
|
"missing" => SyncIssue::Missing,
|
||||||
|
"limit" => SyncIssue::Limit,
|
||||||
|
"rejected" => SyncIssue::Rejected,
|
||||||
|
_ => SyncIssue::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||||
@@ -1234,6 +1296,10 @@ pub struct DirEntry {
|
|||||||
/// not known, and for anything not in the trash.
|
/// not known, and for anything not in the trash.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trashed_from: Option<String>,
|
pub trashed_from: Option<String>,
|
||||||
|
/// Why Drive refused a change queued on the node, for a [`Request::ListDir`]
|
||||||
|
/// entry; `None` otherwise.
|
||||||
|
#[serde(default)]
|
||||||
|
pub issue: Option<SyncIssue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
@@ -1837,17 +1903,17 @@ pub enum Response {
|
|||||||
/// The pin registry.
|
/// The pin registry.
|
||||||
pins: Vec<Pin>,
|
pins: Vec<Pin>,
|
||||||
/// False when the daemon is serving the cached tree because the API is
|
/// 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.
|
/// reads; anything else fails until the network is back.
|
||||||
#[serde(default = "default_online")]
|
#[serde(default = "default_online")]
|
||||||
online: bool,
|
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
|
/// Non-zero means the mount is ahead of the remote — either a copy is
|
||||||
/// still draining, or it cannot drain because we are offline.
|
/// still draining, or it cannot drain because we are offline.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_uploads: u64,
|
pending_uploads: u64,
|
||||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
||||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
/// Counted apart from `pending_uploads` because
|
||||||
/// calling a queued `mkdir` an upload is a lie.
|
/// calling a queued `mkdir` an upload is a lie.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_changes: u64,
|
pending_changes: u64,
|
||||||
@@ -3290,6 +3356,17 @@ mod tests {
|
|||||||
},
|
},
|
||||||
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
Request::ExportPendingOp {
|
||||||
|
id: 7,
|
||||||
|
dest: "/tmp/out.bin".into(),
|
||||||
|
},
|
||||||
|
r#"{"ExportPendingOp":{"id":7,"dest":"/tmp/out.bin"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::DiscardPendingOp { id: 7 },
|
||||||
|
r#"{"DiscardPendingOp":{"id":7}}"#,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
for (request, wire) in cases {
|
for (request, wire) in cases {
|
||||||
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||||
@@ -3298,6 +3375,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A daemon that predates sync issues sends neither field, and a newer one
|
||||||
|
/// may send an issue this build does not know.
|
||||||
|
#[test]
|
||||||
|
fn a_queued_op_from_another_daemon_version_still_decodes() {
|
||||||
|
let old = r#"{"id":1,"kind":"create","path":"a.txt","attempts":0,"last_error":null,
|
||||||
|
"queued_at":5,"next_attempt_at":5,"parked":false,"failing":false}"#;
|
||||||
|
let op: PendingOpInfo = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(op.issue, None);
|
||||||
|
assert!(!op.exportable);
|
||||||
|
|
||||||
|
let newer = old.replace(r#""failing":false"#, r#""failing":true,"issue":"embargo""#);
|
||||||
|
let op: PendingOpInfo = serde_json::from_str(&newer).unwrap();
|
||||||
|
assert_eq!(op.issue, Some(SyncIssue::Unknown));
|
||||||
|
assert_eq!(
|
||||||
|
SyncIssue::parse(SyncIssue::Quota.as_str()),
|
||||||
|
SyncIssue::Quota
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A front-end that predates the favorites filter sends no `favorites`
|
/// A front-end that predates the favorites filter sends no `favorites`
|
||||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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:
|
//! 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
|
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
//! is already on disk somewhere, so a change here would silently diverge from
|
//! is already on disk somewhere, so a change here would silently diverge from
|
||||||
//! what an existing install actually has. Add a new one instead.
|
//! what an existing install actually has. Add a new one instead.
|
||||||
|
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 36;
|
pub(super) const SCHEMA_VERSION: i64 = 42;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -348,6 +350,96 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V36)?;
|
tx.execute_batch(MIGRATION_V36)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 37 {
|
||||||
|
// Same guards as V26-V36.
|
||||||
|
let sql: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if sql.is_some_and(|sql| !sql.contains("AUTOINCREMENT")) {
|
||||||
|
tx.execute_batch(MIGRATION_V37)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 38 {
|
||||||
|
// Same guards as V26-V37.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'issue'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V38)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 39 {
|
||||||
|
// Same guards as V26-V38.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'lid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V39)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 40 {
|
||||||
|
// Same guards as V26-V39.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'parent_lid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V40)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 41 {
|
||||||
|
// Needs both tables; on a database that never had them there is
|
||||||
|
// nothing to rewrite.
|
||||||
|
let tables: i64 = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name IN ('nodes', 'pending_op', 'pins')",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if tables == 3 {
|
||||||
|
tx.execute_batch(MIGRATION_V41)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 42 {
|
||||||
|
// Same guards as V26-V40.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'sent_sha1'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V42)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -485,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
|
/// **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
|
/// 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,
|
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||||
@@ -546,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
|||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
/// 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
|
/// 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 row outlives the process, so a write survives both a
|
||||||
/// dead network and a restart.
|
/// dead network and a restart.
|
||||||
///
|
///
|
||||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||||
@@ -574,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
|
/// 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
|
/// 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
|
/// 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
|
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||||
@@ -964,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
|
/// 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
|
/// 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
|
/// *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
|
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||||
@@ -1076,8 +1168,8 @@ const MIGRATION_V35: &str = "
|
|||||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v36: every node row has a local id (`lid`), phase 2 of
|
/// Schema v36: every node row has a local id (`lid`); see
|
||||||
/// docs/MILESTONE-3.0.0.md.
|
/// docs/ARCHITECTURE.md §2.
|
||||||
///
|
///
|
||||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
/// 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
|
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||||
@@ -1112,3 +1204,129 @@ CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
|||||||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v37: a `pending_op` id is never given out twice.
|
||||||
|
///
|
||||||
|
/// A plain rowid is the largest one plus one, so an op queued right after the
|
||||||
|
/// newest one was dropped took its id. The drain retires the op it is
|
||||||
|
/// working on by id, and so deleted the new op: a trash queued over a rename
|
||||||
|
/// on the wire was lost when the rename landed. `AUTOINCREMENT` never reuses
|
||||||
|
/// an id. Ids are kept, as is every index.
|
||||||
|
const MIGRATION_V37: &str = "
|
||||||
|
CREATE TABLE pending_op_v37 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
blob_path TEXT,
|
||||||
|
meta_json TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
parent_uid TEXT,
|
||||||
|
name TEXT,
|
||||||
|
claimed_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
access_deferred_since INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO pending_op_v37
|
||||||
|
(id, kind, uid, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at,
|
||||||
|
parent_uid, name, claimed_at, access_deferred_since)
|
||||||
|
SELECT id, kind, uid, blob_path, meta_json, created_at, attempts, last_error,
|
||||||
|
next_attempt_at, parent_uid, name, claimed_at, access_deferred_since
|
||||||
|
FROM pending_op;
|
||||||
|
DROP TABLE pending_op;
|
||||||
|
ALTER TABLE pending_op_v37 RENAME TO pending_op;
|
||||||
|
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||||
|
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||||
|
CREATE INDEX idx_pending_op_claimed
|
||||||
|
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v38: why Drive refused a queued op, when it did (a sync issue).
|
||||||
|
/// `NULL` for an op that has not been refused, which is every op migrated.
|
||||||
|
const MIGRATION_V38: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN issue TEXT;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v39: a queued op names its node and its parent by local id too.
|
||||||
|
///
|
||||||
|
/// A create made inside a folder that had not landed named the folder by its
|
||||||
|
/// placeholder uid, and every landing had to find and rewrite those strings.
|
||||||
|
/// The local id stays the same when the folder lands. An op whose node has no
|
||||||
|
/// row keeps `NULL` and is matched by uid, as before.
|
||||||
|
const MIGRATION_V39: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN lid INTEGER;
|
||||||
|
ALTER TABLE pending_op ADD COLUMN parent_lid INTEGER;
|
||||||
|
UPDATE pending_op
|
||||||
|
SET lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.uid),
|
||||||
|
parent_lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.parent_uid);
|
||||||
|
CREATE INDEX pending_op_lid ON pending_op(lid);
|
||||||
|
CREATE INDEX pending_op_parent_lid ON pending_op(parent_lid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v40: a node row names its parent by local id too.
|
||||||
|
///
|
||||||
|
/// The local id stays when a folder made here lands and its row takes the uid
|
||||||
|
/// Drive gave it, so a child linked by it needs no rewrite. `NULL` while the
|
||||||
|
/// parent has no row, as `parent_uid` then names nothing either.
|
||||||
|
const MIGRATION_V40: &str = "
|
||||||
|
ALTER TABLE nodes ADD COLUMN parent_lid INTEGER;
|
||||||
|
UPDATE nodes SET parent_lid = (SELECT p.lid FROM nodes p WHERE p.uid = nodes.parent_uid);
|
||||||
|
CREATE INDEX idx_nodes_parent_lid ON nodes(parent_lid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v41: a node made on this machine goes by `local~<lid>` until it
|
||||||
|
/// lands, a function of its row, where it used to get a `local~<ms>-<n>`
|
||||||
|
/// placeholder. Every placeholder still stored is rewritten to its row's
|
||||||
|
/// stand-in: the row itself and its `node_json`, its children, the ops on it
|
||||||
|
/// or into it, and its pins. A placeholder op with no row keeps its uid and
|
||||||
|
/// drains as before.
|
||||||
|
const MIGRATION_V41: &str = "
|
||||||
|
CREATE TEMP TABLE local_v41 AS
|
||||||
|
SELECT uid AS old, 'local~' || lid AS new, lid FROM nodes
|
||||||
|
WHERE uid LIKE 'local~%' AND uid <> 'local~' || lid;
|
||||||
|
UPDATE pending_op SET uid = (SELECT new FROM local_v41 WHERE old = pending_op.uid)
|
||||||
|
WHERE uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pending_op SET parent_uid = (SELECT new FROM local_v41 WHERE old = pending_op.parent_uid)
|
||||||
|
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pending_op
|
||||||
|
SET meta_json = json_set(meta_json, '$.uid',
|
||||||
|
(SELECT new FROM local_v41 WHERE old = json_extract(pending_op.meta_json, '$.uid')))
|
||||||
|
WHERE json_valid(meta_json)
|
||||||
|
AND json_extract(meta_json, '$.uid') IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pending_op
|
||||||
|
SET meta_json = json_set(meta_json, '$.original_parent_uid',
|
||||||
|
(SELECT new FROM local_v41
|
||||||
|
WHERE old = json_extract(pending_op.meta_json, '$.original_parent_uid')))
|
||||||
|
WHERE json_valid(meta_json)
|
||||||
|
AND json_extract(meta_json, '$.original_parent_uid') IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pins SET uid = (SELECT new FROM local_v41 WHERE old = pins.uid)
|
||||||
|
WHERE uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE nodes
|
||||||
|
SET node_json = json_set(node_json, '$.parent_uid.link_id',
|
||||||
|
(SELECT CAST(lid AS TEXT) FROM local_v41 WHERE old = nodes.parent_uid))
|
||||||
|
WHERE parent_uid IN (SELECT old FROM local_v41)
|
||||||
|
AND json_valid(node_json)
|
||||||
|
AND json_extract(node_json, '$.parent_uid.volume_id') = 'local';
|
||||||
|
UPDATE nodes SET parent_uid = (SELECT new FROM local_v41 WHERE old = nodes.parent_uid)
|
||||||
|
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE nodes
|
||||||
|
SET node_json = json_set(node_json, '$.uid.link_id', CAST(lid AS TEXT))
|
||||||
|
WHERE lid IN (SELECT lid FROM local_v41)
|
||||||
|
AND json_valid(node_json)
|
||||||
|
AND json_extract(node_json, '$.uid.volume_id') = 'local';
|
||||||
|
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
|
||||||
|
DROP TABLE local_v41;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v42: the SHA-1 of each blob a queued create sent, space-separated.
|
||||||
|
/// A create whose answer was lost may have made its file with any of them,
|
||||||
|
/// and the op that finds that file again may run after a restart. `NULL` for
|
||||||
|
/// an op that has sent nothing, which is every op migrated: what the daemon
|
||||||
|
/// that wrote it sent is not known. Likewise the parent and name each attempt
|
||||||
|
/// was sent under, as a JSON list of pairs: a rename since then moves the op
|
||||||
|
/// but not the file Drive may have made.
|
||||||
|
const MIGRATION_V42: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||||
|
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
||||||
|
";
|
||||||
@@ -56,9 +56,9 @@ pub use local::LocalFileHit;
|
|||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
AttachedBlob, CreateLanding, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME,
|
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
|
||||||
OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta,
|
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
|
||||||
op_supersedes,
|
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||||
pub use pins::PinRow;
|
pub use pins::PinRow;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use rusqlite::types::Value;
|
||||||
use rusqlite::{OptionalExtension, Transaction, params};
|
use rusqlite::{OptionalExtension, Transaction, params};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
|
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, local_lid, local_uid, wake_ops_waiting_for_tx};
|
||||||
use crate::{Access, Result};
|
use crate::{Access, Result};
|
||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{Node, NodeKind};
|
use proton_drive_rs::{Node, NodeKind};
|
||||||
@@ -64,12 +66,13 @@ impl Db {
|
|||||||
/// snapshot while offline, including after a live event expired its TTL.
|
/// snapshot while offline, including after a live event expired its TTL.
|
||||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||||
"SELECT node_json FROM nodes
|
let mut stmt = conn.prepare(&format!(
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
ORDER BY name, uid",
|
WHERE n.{key} = ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
ORDER BY n.name, n.uid"
|
||||||
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
|
))?;
|
||||||
|
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
nodes.push(serde_json::from_str(&row?)?);
|
nodes.push(serde_json::from_str(&row?)?);
|
||||||
@@ -266,10 +269,25 @@ impl Db {
|
|||||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||||
/// that row after a crash; removing the row first could strand a now
|
/// that row after a crash; removing the row first could strand a now
|
||||||
/// unauthorizable op. One transaction closes both windows.
|
/// unauthorizable op. One transaction closes both windows.
|
||||||
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
///
|
||||||
|
/// An op that found the name still held by this trash failed with a
|
||||||
|
/// backoff, and the name is free now. It is made due at once
|
||||||
|
/// ([`Db::wake_ops_waiting_for`]).
|
||||||
|
///
|
||||||
|
/// Returns the staged blob of the create the trash withdrew, if it kept
|
||||||
|
/// one, for the caller to discard.
|
||||||
|
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<Option<String>> {
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
let (name, blob): (Option<String>, Option<String>) = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT name, blob_path FROM pending_op WHERE id = ?1",
|
||||||
|
params![op_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or_default();
|
||||||
let rowid: Option<i64> = tx
|
let rowid: Option<i64> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
@@ -282,18 +300,23 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||||
}
|
}
|
||||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||||
|
if let Some(name) = name {
|
||||||
|
wake_ops_waiting_for_tx(&tx, &name)?;
|
||||||
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a folder node has any non-trashed children in the database.
|
/// Check if a folder node has any non-trashed children in the database.
|
||||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||||
let uid_str = parent_uid.to_string();
|
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
let (key, parent) = parent_key(&conn, &parent_uid.to_string())?;
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM nodes
|
&format!(
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL",
|
"SELECT COUNT(*) FROM nodes
|
||||||
params![uid_str],
|
WHERE {key} = ?1 AND trashed = 0 AND node_json IS NOT NULL"
|
||||||
|
),
|
||||||
|
params![parent],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
@@ -330,11 +353,11 @@ impl Db {
|
|||||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_lid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_lid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
SELECT n.uid, n.parent_lid, n.name, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||||
WHERE anc.depth < 1024
|
WHERE anc.depth < 1024
|
||||||
)
|
)
|
||||||
SELECT uid, name FROM anc ORDER BY depth",
|
SELECT uid, name FROM anc ORDER BY depth",
|
||||||
@@ -383,12 +406,13 @@ impl Db {
|
|||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY name LIMIT ?2",
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
)?;
|
ORDER BY n.name LIMIT ?2"
|
||||||
|
))?;
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
@@ -398,13 +422,14 @@ impl Db {
|
|||||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT n.node_json, n.uid, n.path
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
|
LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2"
|
||||||
)?;
|
))?;
|
||||||
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -441,20 +466,22 @@ impl Db {
|
|||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
ORDER BY name LIMIT ?2",
|
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
ORDER BY n.name LIMIT ?2"
|
||||||
|
))?;
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
let expression = terms.join(" OR ");
|
let expression = terms.join(" OR ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT n.node_json, n.uid, n.path
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
|
LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2"
|
||||||
)?;
|
))?;
|
||||||
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||||
};
|
};
|
||||||
// Trigram rank does not know that a name *starts* with the query, and a
|
// Trigram rank does not know that a name *starts* with the query, and a
|
||||||
@@ -464,12 +491,13 @@ impl Db {
|
|||||||
if !terms.is_empty()
|
if !terms.is_empty()
|
||||||
&& let Some(first) = query.chars().next()
|
&& let Some(first) = query.chars().next()
|
||||||
{
|
{
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
AND trashed = 0 AND node_json IS NOT NULL
|
WHERE n.name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
ORDER BY n.name COLLATE NOCASE LIMIT ?2"
|
||||||
|
))?;
|
||||||
let mut extra = Vec::new();
|
let mut extra = Vec::new();
|
||||||
for pattern in [
|
for pattern in [
|
||||||
format!("{}%", like_escape(query)),
|
format!("{}%", like_escape(query)),
|
||||||
@@ -505,18 +533,26 @@ impl Db {
|
|||||||
/// re-enumerates from the remote on next access.
|
/// re-enumerates from the remote on next access.
|
||||||
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
|
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
|
let (key, uid) = row_key(&uid.to_string());
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
|
&format!("UPDATE nodes SET listed = ?2 WHERE {key} = ?1"),
|
||||||
params![uid.to_string(), listed as i64],
|
params![uid, listed as i64],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||||
|
///
|
||||||
|
/// Each node names its parent as the parent's row has it now, found by
|
||||||
|
/// local id: a folder that landed has its new uid there, while the node
|
||||||
|
/// stored below it may still hold the placeholder.
|
||||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt =
|
let mut stmt = conn.prepare(&format!(
|
||||||
conn.prepare("SELECT node_json, listed, lid FROM nodes WHERE node_json IS NOT NULL")?;
|
"SELECT {NODE_NOW}, n.listed, n.lid
|
||||||
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.node_json IS NOT NULL"
|
||||||
|
))?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
let json: String = row.get(0)?;
|
let json: String = row.get(0)?;
|
||||||
let listed: i64 = row.get(1)?;
|
let listed: i64 = row.get(1)?;
|
||||||
@@ -532,13 +568,15 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
/// 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
|
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||||
/// (offline.md Phase 1).
|
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
&format!(
|
||||||
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.uid = ?1 AND n.node_json IS NOT NULL"
|
||||||
|
),
|
||||||
params![uid],
|
params![uid],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
@@ -549,16 +587,24 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The local id of the node stored under `uid`, or `None` when it has no
|
/// The uid of the node with local id `lid`: `None` when it has no row, or
|
||||||
/// row. It stays the same for as long as the row exists, including when a
|
/// is not on Drive yet.
|
||||||
/// create lands and the row takes the uid Drive gave it.
|
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
|
||||||
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
|
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
|
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||||
r.get(0)
|
r.get(0)
|
||||||
})
|
})
|
||||||
.optional()?)
|
.optional()?
|
||||||
|
.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local id of the node stored under `uid`, or `None` when it has no
|
||||||
|
/// row. It stays the same for as long as the row exists, including when a
|
||||||
|
/// create lands and the row takes the uid Drive gave it: a node made on
|
||||||
|
/// this machine is found by its stand-in ([`local_uid`]) before and after.
|
||||||
|
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
|
||||||
|
node_lid(&self.read(), uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The local id of each node, in order, giving a row to each that has none.
|
/// The local id of each node, in order, giving a row to each that has none.
|
||||||
@@ -574,23 +620,31 @@ impl Db {
|
|||||||
{
|
{
|
||||||
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
|
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
|
||||||
let mut stub = tx.prepare_cached(
|
let mut stub = tx.prepare_cached(
|
||||||
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed)
|
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
|
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
|
||||||
Some(lid) => lid,
|
Some(lid) => lid,
|
||||||
None => {
|
None => {
|
||||||
|
let parent = node.parent_uid.as_ref().map(ToString::to_string);
|
||||||
|
let parent_lid = match &parent {
|
||||||
|
Some(parent) => node_lid(&tx, parent)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
stub.execute(params![
|
stub.execute(params![
|
||||||
uid,
|
uid,
|
||||||
node.parent_uid.as_ref().map(ToString::to_string),
|
parent,
|
||||||
node.name,
|
node.name,
|
||||||
node.is_folder() as i64,
|
node.is_folder() as i64,
|
||||||
node.modification_time,
|
node.modification_time,
|
||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
|
parent_lid,
|
||||||
])?;
|
])?;
|
||||||
tx.last_insert_rowid()
|
let lid = tx.last_insert_rowid();
|
||||||
|
adopt_children_tx(&tx, &uid, lid)?;
|
||||||
|
lid
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
lids.push(lid);
|
lids.push(lid);
|
||||||
@@ -600,15 +654,51 @@ impl Db {
|
|||||||
Ok(lids)
|
Ok(lids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Give a node being made on this machine its row under `parent_uid`, and
|
||||||
|
/// return its local id. The row goes by [`local_uid`] of that id until
|
||||||
|
/// the node lands.
|
||||||
|
///
|
||||||
|
/// Like [`Db::lids_for`], only a stub: the caller upserts the node.
|
||||||
|
pub fn add_local_row(
|
||||||
|
&self,
|
||||||
|
parent_uid: &str,
|
||||||
|
name: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
mtime: i64,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||||
|
VALUES (NULL, ?1, ?2, ?3, ?4, 0,
|
||||||
|
COALESCE(?5, (SELECT lid FROM nodes WHERE uid = ?1)))",
|
||||||
|
params![
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
is_dir as i64,
|
||||||
|
mtime,
|
||||||
|
local_lid(parent_uid)
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let lid = tx.last_insert_rowid();
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||||||
|
params![lid, local_uid(lid)],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(lid)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
|
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
|
||||||
/// The daemon resumes from this on restart instead of reseeding to the
|
/// The daemon resumes from this on restart instead of reseeding to the
|
||||||
/// server head, so changes made while unmounted are still applied (P2).
|
/// server head, so changes made while unmounted are still applied (P2).
|
||||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
let (key, folder) = row_key(&parent.to_string());
|
||||||
let listed: Option<i64> = conn
|
let listed: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
&format!("SELECT listed FROM nodes WHERE {key} = ?1"),
|
||||||
params![parent.to_string()],
|
params![folder],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
@@ -624,11 +714,12 @@ impl Db {
|
|||||||
/// had: a listing dropped as stale may miss or keep a child.
|
/// had: a listing dropped as stale may miss or keep a child.
|
||||||
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||||
"SELECT node_json FROM nodes
|
let mut stmt = conn.prepare(&format!(
|
||||||
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
)?;
|
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0"
|
||||||
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?;
|
))?;
|
||||||
|
let rows = stmt.query_map(params![parent], |r| r.get::<_, String>(0))?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for json in rows {
|
for json in rows {
|
||||||
out.push(serde_json::from_str(&json?)?);
|
out.push(serde_json::from_str(&json?)?);
|
||||||
@@ -636,6 +727,40 @@ impl Db {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a queued change does to `parent`'s listing, which Drive does not
|
||||||
|
/// show until it lands: the children made or moved in by a queued create,
|
||||||
|
/// mkdir or rename, as the DB holds them, and the nodes a queued rename
|
||||||
|
/// moves elsewhere.
|
||||||
|
///
|
||||||
|
/// A listing read from Drive is laid over with these, or a folder listed
|
||||||
|
/// while a create in it is queued, after a restart say, loses the file
|
||||||
|
/// until the create lands (`docs/BUGS.md` B132).
|
||||||
|
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
|
||||||
|
AND EXISTS (SELECT 1 FROM pending_op o
|
||||||
|
WHERE (o.lid = n.lid OR o.uid = n.uid) AND o.kind IN (?2, ?3, ?4))"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
|
||||||
|
r.get::<_, String>(0)
|
||||||
|
})?;
|
||||||
|
let mut here = Vec::new();
|
||||||
|
for json in rows {
|
||||||
|
here.push(serde_json::from_str(&json?)?);
|
||||||
|
}
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT DISTINCT n.uid FROM pending_op p JOIN nodes n ON n.lid = p.lid OR n.uid = p.uid
|
||||||
|
WHERE p.kind = ?2 AND n.{key} IS NOT ?1"
|
||||||
|
))?;
|
||||||
|
let gone = stmt
|
||||||
|
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
|
||||||
|
.collect::<std::result::Result<HashSet<_>, _>>()?;
|
||||||
|
Ok((here, gone))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Content-cache LRU index (P4) -------------------------------------
|
// --- Content-cache LRU index (P4) -------------------------------------
|
||||||
//
|
//
|
||||||
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
|
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
|
||||||
@@ -655,8 +780,53 @@ struct PriorRow {
|
|||||||
trashed: bool,
|
trashed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a drained placeholder's row, and the rows below it, to the real uid it
|
/// [`Db::lid_of`] on `conn`, which may be a transaction.
|
||||||
/// landed as.
|
pub(super) fn node_lid(conn: &rusqlite::Connection, uid: &str) -> Result<Option<i64>> {
|
||||||
|
let row = match local_lid(uid) {
|
||||||
|
Some(lid) => conn.query_row("SELECT lid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
}),
|
||||||
|
None => conn.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Ok(row.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The column and value that find `uid`'s own row. A stand-in is found by its
|
||||||
|
/// local id: a create that lands gives the row the uid Drive made before the
|
||||||
|
/// tree has it, and the tree asks by the stand-in until then (`docs/BUGS.md`
|
||||||
|
/// B177).
|
||||||
|
fn row_key(uid: &str) -> (&'static str, Value) {
|
||||||
|
match local_lid(uid) {
|
||||||
|
Some(lid) => ("lid", Value::Integer(lid)),
|
||||||
|
None => ("uid", Value::Text(uid.to_owned())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The column and value a row names `parent` by: its local id when it has a
|
||||||
|
/// row, which the children keep when it lands, or its uid when it has none,
|
||||||
|
/// as a device folder's root does.
|
||||||
|
fn parent_key(conn: &rusqlite::Connection, parent: &str) -> Result<(&'static str, Value)> {
|
||||||
|
Ok(match node_lid(conn, parent)? {
|
||||||
|
Some(lid) => ("parent_lid", Value::Integer(lid)),
|
||||||
|
None => ("parent_uid", Value::Text(parent.to_owned())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `n.node_json` naming its parent as the parent's row `p`, joined by
|
||||||
|
/// `n.parent_lid`, has it now. A folder that lands takes its real uid in its
|
||||||
|
/// own row only, so a node stored below it still names the stand-in.
|
||||||
|
const NODE_NOW: &str = "CASE WHEN p.uid IS NULL OR p.uid = n.parent_uid THEN n.node_json
|
||||||
|
ELSE json_set(n.node_json, '$.parent_uid', json_object(
|
||||||
|
'volume_id', substr(p.uid, 1, instr(p.uid, '~') - 1),
|
||||||
|
'link_id', substr(p.uid, instr(p.uid, '~') + 1))) END";
|
||||||
|
|
||||||
|
/// Hand a drained placeholder's row to the real uid it landed as.
|
||||||
|
///
|
||||||
|
/// Nothing else is rewritten. The rows below it and the ops made inside it
|
||||||
|
/// name it by its local id, which stays; what reads them for a uid takes the
|
||||||
|
/// one the row has now ([`NODE_NOW`], `PARENT_NOW` in `ops.rs`).
|
||||||
///
|
///
|
||||||
/// Dropping the placeholder row and waiting for the server's copy of the real
|
/// Dropping the placeholder row and waiting for the server's copy of the real
|
||||||
/// node left a window with no row for either uid. A queued child drained in
|
/// node left a window with no row for either uid. A queued child drained in
|
||||||
@@ -671,10 +841,13 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
local: &str,
|
local: &str,
|
||||||
real: &str,
|
real: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// A folder pinned before it landed stays pinned: the pin keyed by the
|
||||||
|
// placeholder was dropped with it (`docs/BUGS.md` B148).
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
|
"UPDATE OR IGNORE pins SET uid = ?2 WHERE uid = ?1",
|
||||||
params![local, real],
|
params![local, real],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?;
|
||||||
let row: Option<(i64, Option<String>)> = tx
|
let row: Option<(i64, Option<String>)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
||||||
@@ -682,6 +855,12 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
// Ops queued before schema 39 have no `parent_lid`, nor do ops made
|
||||||
|
// under a placeholder that never had a row.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_lid IS NULL AND parent_uid = ?1",
|
||||||
|
params![local, real],
|
||||||
|
)?;
|
||||||
let Some((lid, json)) = row else {
|
let Some((lid, json)) = row else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -693,6 +872,19 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if let Some((known_lid, _)) = known {
|
if let Some((known_lid, _)) = known {
|
||||||
|
// Ops queued against the row Drive listed now name the landing one.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET lid = ?2 WHERE lid = ?1",
|
||||||
|
params![known_lid, lid],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET parent_lid = ?2 WHERE parent_lid = ?1",
|
||||||
|
params![known_lid, lid],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET parent_lid = ?2 WHERE parent_lid = ?1",
|
||||||
|
params![known_lid, lid],
|
||||||
|
)?;
|
||||||
tx.execute("DELETE FROM nodes WHERE lid = ?1", params![known_lid])?;
|
tx.execute("DELETE FROM nodes WHERE lid = ?1", params![known_lid])?;
|
||||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
|
||||||
}
|
}
|
||||||
@@ -709,9 +901,36 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
upsert_node_tx(tx, &node)
|
upsert_node_tx(tx, &node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Link the rows that name `uid` as their parent to its new row `lid`: they
|
||||||
|
/// were stored before it, or under a row of the same uid since dropped.
|
||||||
|
fn adopt_children_tx(tx: &Transaction<'_>, uid: &str, lid: i64) -> Result<()> {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET parent_lid = ?2 WHERE parent_uid = ?1 AND parent_lid IS NOT ?2",
|
||||||
|
params![uid, lid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||||
let json = serde_json::to_string(node)?;
|
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
|
// A node written under its stand-in after it landed is its row, which has
|
||||||
|
// the uid Drive gave it by now: written under the stand-in, it would bring
|
||||||
|
// the placeholder row back beside it.
|
||||||
|
if let Some(lid) = local_lid(&uid) {
|
||||||
|
let landed: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT uid FROM nodes WHERE lid = ?1 AND uid IS NOT ?2",
|
||||||
|
params![lid, uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(real) = landed.as_deref().and_then(parse_node_uid) {
|
||||||
|
let mut node = node.clone();
|
||||||
|
node.uid = real;
|
||||||
|
return upsert_node_tx(tx, &node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string(node)?;
|
||||||
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||||
|
|
||||||
let prior: Option<PriorRow> = tx
|
let prior: Option<PriorRow> = tx
|
||||||
@@ -733,26 +952,34 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
// where resolving it from scratch is a recursive walk to the root. A node
|
// where resolving it from scratch is a recursive walk to the root. A node
|
||||||
// whose parent is not cached (a device folder's root lives in `device`, never
|
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||||
// in `nodes`) starts a path of its own, which is what the walk did too.
|
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||||
let path = match &parent_uid {
|
let (parent_lid, path) = match &parent_uid {
|
||||||
None => String::new(),
|
None => (None, String::new()),
|
||||||
Some(parent) => {
|
Some(parent) => {
|
||||||
let parent_path: Option<Option<String>> = tx
|
let parent_row: Option<(i64, Option<String>)> = match node_lid(tx, parent)? {
|
||||||
.query_row(
|
Some(lid) => tx
|
||||||
"SELECT path FROM nodes WHERE uid = ?1",
|
.query_row(
|
||||||
params![parent],
|
"SELECT lid, path FROM nodes WHERE lid = ?1",
|
||||||
|row| row.get(0),
|
params![lid],
|
||||||
)
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
.optional()?;
|
)
|
||||||
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
.optional()?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let (lid, parent_path) = parent_row.unzip();
|
||||||
|
(
|
||||||
|
lid,
|
||||||
|
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO nodes
|
"INSERT INTO nodes
|
||||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path, parent_lid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||||
ON CONFLICT(uid) DO UPDATE SET
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
parent_uid = excluded.parent_uid,
|
parent_uid = excluded.parent_uid,
|
||||||
|
parent_lid = excluded.parent_lid,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
is_dir = excluded.is_dir,
|
is_dir = excluded.is_dir,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
@@ -770,6 +997,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
json,
|
json,
|
||||||
path,
|
path,
|
||||||
|
parent_lid,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let rowid: i64 = tx.query_row(
|
let rowid: i64 = tx.query_row(
|
||||||
@@ -777,6 +1005,9 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
params![uid],
|
params![uid],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
if prior.is_none() {
|
||||||
|
adopt_children_tx(tx, &uid, rowid)?;
|
||||||
|
}
|
||||||
|
|
||||||
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||||
let indexable = node_is_indexable_tx(tx, &uid)?;
|
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||||
@@ -799,7 +1030,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
|| prior.trashed != node.trashed
|
|| prior.trashed != node.trashed
|
||||||
});
|
});
|
||||||
if node.is_folder() && subtree_moved {
|
if node.is_folder() && subtree_moved {
|
||||||
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
reindex_subtree_tx(tx, rowid, &path, indexable)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -820,22 +1051,22 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
/// this deep.
|
/// this deep.
|
||||||
fn reindex_subtree_tx(
|
fn reindex_subtree_tx(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
folder: &str,
|
folder: i64,
|
||||||
folder_path: &str,
|
folder_path: &str,
|
||||||
folder_indexable: bool,
|
folder_indexable: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let descendants: Vec<(i64, String, bool)> = {
|
let descendants: Vec<(i64, String, bool)> = {
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
"WITH RECURSIVE sub(rowid, name, path, blocked, depth) AS (
|
||||||
SELECT n.rowid, n.uid, n.name,
|
SELECT n.rowid, n.name,
|
||||||
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||||
n.trashed, 0
|
n.trashed, 0
|
||||||
FROM nodes n WHERE n.parent_uid = ?1
|
FROM nodes n WHERE n.parent_lid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.rowid, n.uid, n.name,
|
SELECT n.rowid, n.name,
|
||||||
sub.path || '/' || n.name,
|
sub.path || '/' || n.name,
|
||||||
MAX(sub.blocked, n.trashed), sub.depth + 1
|
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||||
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
FROM nodes n JOIN sub ON n.parent_lid = sub.rowid
|
||||||
WHERE sub.depth < 256
|
WHERE sub.depth < 256
|
||||||
)
|
)
|
||||||
SELECT rowid, path, blocked FROM sub",
|
SELECT rowid, path, blocked FROM sub",
|
||||||
@@ -876,7 +1107,8 @@ fn reindex_subtree_tx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
||||||
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
|
let (key, parent) = parent_key(tx, parent)?;
|
||||||
|
let mut stmt = tx.prepare(&format!("SELECT uid FROM nodes WHERE {key} = ?1"))?;
|
||||||
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||||
let mut uids = Vec::new();
|
let mut uids = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -901,14 +1133,14 @@ fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String
|
|||||||
/// ends at one whose parent is a row we have already visited.
|
/// ends at one whose parent is a row we have already visited.
|
||||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||||
let indexable: i64 = tx.query_row(
|
let indexable: i64 = tx.query_row(
|
||||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
|
"WITH RECURSIVE ancestors(lid, parent_lid, trashed, depth, path) AS (
|
||||||
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
|
SELECT lid, parent_lid, trashed, 0, ',' || lid || ','
|
||||||
FROM nodes WHERE uid = ?1
|
FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
|
SELECT n.lid, n.parent_lid, n.trashed, a.depth + 1,
|
||||||
a.path || n.uid || char(31)
|
a.path || n.lid || ','
|
||||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
FROM ancestors a JOIN nodes n ON n.lid = a.parent_lid
|
||||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
WHERE instr(a.path, ',' || n.lid || ',') = 0
|
||||||
)
|
)
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN COUNT(*) = 0 THEN 0
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
@@ -916,8 +1148,7 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
|||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1 FROM ancestors a
|
SELECT 1 FROM ancestors a
|
||||||
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
||||||
AND a.parent_uid IS NOT NULL
|
AND EXISTS (SELECT 1 FROM nodes p WHERE p.lid = a.parent_lid)
|
||||||
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
|
|
||||||
) THEN 0
|
) THEN 0
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END
|
END
|
||||||
@@ -935,7 +1166,7 @@ fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
|
|||||||
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
||||||
UNION
|
UNION
|
||||||
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
||||||
JOIN subtree s ON n.parent_uid = s.uid
|
JOIN subtree s ON n.parent_lid = s.rowid
|
||||||
)
|
)
|
||||||
SELECT rowid, uid, node_json FROM subtree",
|
SELECT rowid, uid, node_json FROM subtree",
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
+604
-87
File diff suppressed because it is too large.
Load diff
@@ -64,11 +64,11 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
||||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
/// is pinned recursively. The ancestor check walks `parent_lid` to the root
|
||||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||||
///
|
///
|
||||||
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
|
/// The walk is depth-capped like [`super::utils::path_of`]: a parent
|
||||||
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
||||||
/// terminates — while holding the daemon's only SQLite connection, on a path
|
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||||
/// that runs per cached read.
|
/// that runs per cached read.
|
||||||
@@ -85,11 +85,11 @@ impl Db {
|
|||||||
let anc: Option<i64> = conn
|
let anc: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_lid, depth) AS (
|
||||||
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_lid, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, anc.depth + 1
|
SELECT n.uid, n.parent_lid, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
)
|
)
|
||||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||||
@@ -109,10 +109,11 @@ impl Db {
|
|||||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid, lid) AS (
|
||||||
SELECT uid FROM pins WHERE recursive = 1
|
SELECT p.uid, n.lid FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||||
|
WHERE p.recursive = 1
|
||||||
UNION
|
UNION
|
||||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||||
)
|
)
|
||||||
SELECT uid FROM sub
|
SELECT uid FROM sub
|
||||||
UNION
|
UNION
|
||||||
@@ -126,15 +127,16 @@ impl Db {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
/// Every descendant uid of `folder` (all depths), via a `parent_lid` CTE.
|
||||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid, lid) AS (
|
||||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
SELECT c.uid, c.lid FROM nodes c JOIN nodes f ON c.parent_lid = f.lid
|
||||||
|
WHERE f.uid = ?1
|
||||||
UNION
|
UNION
|
||||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||||
)
|
)
|
||||||
SELECT uid FROM sub",
|
SELECT uid FROM sub",
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -95,11 +95,27 @@ impl Db {
|
|||||||
/// Resolve a persisted node's effective access through its nearest recorded
|
/// Resolve a persisted node's effective access through its nearest recorded
|
||||||
/// share root. `None` means the node itself is not persisted, which callers
|
/// share root. `None` means the node itself is not persisted, which callers
|
||||||
/// must not treat as owned: stale handles need to fail closed.
|
/// must not treat as owned: stale handles need to fail closed.
|
||||||
|
///
|
||||||
|
/// A stand-in ([`local_uid`](super::local_uid)) finds its row by local id,
|
||||||
|
/// so it still does once its create has landed and the row has the uid
|
||||||
|
/// Drive gave it. The tree learns that uid a moment later, and a rename in
|
||||||
|
/// between was refused (`docs/BUGS.md` B174).
|
||||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
let key = uid.to_string();
|
||||||
|
let key = match super::local_lid(&key) {
|
||||||
|
Some(lid) => conn
|
||||||
|
.query_row("SELECT uid FROM nodes WHERE lid = ?1", [lid], |row| {
|
||||||
|
row.get::<_, Option<String>>(0)
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or(key),
|
||||||
|
None => key,
|
||||||
|
};
|
||||||
let exists: bool = conn.query_row(
|
let exists: bool = conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL)",
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL)",
|
||||||
[uid.to_string()],
|
[&key],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -107,25 +123,26 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let value = conn
|
let value = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
"WITH RECURSIVE ancestors(uid, parent_uid, parent_lid, depth, path) AS (
|
||||||
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
SELECT uid, parent_uid, parent_lid, 0, char(31) || uid || char(31)
|
||||||
FROM nodes WHERE uid = ?1
|
FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
SELECT COALESCE(n.uid, a.parent_uid), n.parent_uid, n.parent_lid,
|
||||||
a.path || a.parent_uid || char(31)
|
a.depth + 1,
|
||||||
|
a.path || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||||
FROM ancestors a
|
FROM ancestors a
|
||||||
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
LEFT JOIN nodes n ON n.lid = a.parent_lid
|
||||||
WHERE a.parent_uid IS NOT NULL
|
WHERE a.parent_uid IS NOT NULL
|
||||||
AND instr(
|
AND instr(
|
||||||
a.path,
|
a.path,
|
||||||
char(31) || a.parent_uid || char(31)
|
char(31) || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||||
) = 0
|
) = 0
|
||||||
)
|
)
|
||||||
SELECT sa.access
|
SELECT sa.access
|
||||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||||
ORDER BY a.depth ASC
|
ORDER BY a.depth ASC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[uid.to_string()],
|
[&key],
|
||||||
|row| row.get::<_, String>(0),
|
|row| row.get::<_, String>(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
/// 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
|
/// last successful sync, so the next reconcile can tell which side changed.
|
||||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||||
/// its modification time and size as strings — since no cheap content hash is
|
/// its modification time and size as strings — since no cheap content hash is
|
||||||
/// exposed; change detection is `(mtime, size)` on each side.
|
/// exposed; change detection is `(mtime, size)` on each side.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -39,7 +39,7 @@ pub struct StoredSyncEntry {
|
|||||||
///
|
///
|
||||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
/// `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
|
/// 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
|
/// written before schema 28, which the comparison reads as "compare whole
|
||||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||||
pub local_mtime_ns: Option<i64>,
|
pub local_mtime_ns: Option<i64>,
|
||||||
@@ -345,7 +345,7 @@ impl Db {
|
|||||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
/// 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
|
/// 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
|
/// 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<()> {
|
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
+1424
-19
File diff suppressed because it is too large.
Load diff
@@ -102,5 +102,5 @@ impl Db {
|
|||||||
Ok(seen)
|
Ok(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -72,18 +72,18 @@ pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
/// Resolve a path by walking `parent_lid` to the root via a recursive CTE.
|
||||||
///
|
///
|
||||||
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
/// A cycle in the parents is corrupt data the API can still hand us, and
|
||||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||||
/// and returns the truncated path rather than hanging the caller.
|
/// and returns the truncated path rather than hanging the caller.
|
||||||
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(parent_lid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT parent_lid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
SELECT n.parent_lid, n.parent_uid, n.name, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
)
|
)
|
||||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
||||||
|
|||||||
@@ -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
|
//! 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,
|
//! `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
|
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
//! 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
|
// create, and re-fetching would replace the size and mtime of the
|
||||||
// write we just accepted with the stale revision's — making a file
|
// write we just accepted with the stale revision's — making a file
|
||||||
// that was copied in seconds ago read as empty until its upload
|
// 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:
|
// 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
|
// 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
|
// server head; persist it so the next restart resumes instead of
|
||||||
// reseeding (which would skip everything that changed offline).
|
// reseeding (which would skip everything that changed offline).
|
||||||
// Seeding needs the network, and this task also runs on mounts that
|
// 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.
|
// which used to disable live sync for the life of the daemon.
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Nothing says what changed before this cursor, so a persisted SDK
|
// 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);
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||||
}
|
}
|
||||||
// Interruptible, so teardown joins this thread instead of leaving it to
|
// 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) {
|
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
| R::RestoreSyncFolders { .. }
|
| R::RestoreSyncFolders { .. }
|
||||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
|
R::DiscardPendingOp { .. } => &[Topic::Queue, Topic::Files],
|
||||||
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
R::ShareNode { .. }
|
R::ShareNode { .. }
|
||||||
| R::ShareNodeByUid { .. }
|
| R::ShareNodeByUid { .. }
|
||||||
@@ -1404,6 +1405,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(items) => CtlResponse::PendingOps { items },
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::ExportPendingOp { id, dest }) => {
|
||||||
|
match core.export_pending_op(id, Path::new(&dest)) {
|
||||||
|
Ok(written) => CtlResponse::Ok {
|
||||||
|
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::DiscardPendingOp { id }) => match core.discard_pending_op(id) {
|
||||||
|
Ok(path) => CtlResponse::Ok {
|
||||||
|
message: format!("discarded the queued change to {path}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||||
Ok(0) if id.is_some() => CtlResponse::Ok {
|
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||||
message: "nothing to retry: the operation is already due or waiting for a rename"
|
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||||
@@ -1801,7 +1816,7 @@ pub(crate) fn run_control_socket(
|
|||||||
info!("control socket listening");
|
info!("control socket listening");
|
||||||
for conn in listener.incoming() {
|
for conn in listener.incoming() {
|
||||||
// Checked on every accept, including the deliberate poke teardown sends
|
// 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() {
|
if core.shutdown.is_stopping() {
|
||||||
info!("control socket closing");
|
info!("control socket closing");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
//! 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
|
//! 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
|
//! 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`
|
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||||
/// so hostname changes and reinstalls stop registering duplicates
|
/// so hostname changes and reinstalls stop registering duplicates.
|
||||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
/// `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
|
/// 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
|
/// 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
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
/// 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
|
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
||||||
/// cached DB row (validated remotely), then a hostname match, then create.
|
/// cached DB row (validated remotely), then a hostname match, then create.
|
||||||
/// The pin exists because the hostname heuristic silently registers a second
|
/// The pin exists because the hostname heuristic silently registers a second
|
||||||
/// device after a rename or reinstall, orphaning the first one's folders
|
/// device after a rename or reinstall, orphaning the first one's folders.
|
||||||
/// (features.md 5.1).
|
|
||||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||||
let name = this_hostname();
|
let name = this_hostname();
|
||||||
// Enumerate the remote devices once: used to validate the pin, validate
|
// 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
|
/// 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
|
/// the tree included, except what belongs to a session: its root and its
|
||||||
/// kernel channel, both filled in when the session is built.
|
/// kernel channel, both filled in when the session is built.
|
||||||
pub(crate) fn fork_state(&self) -> Core {
|
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).
|
/// 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
|
/// 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
|
/// 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
|
/// own thread from `mount` so the network fetches never block startup.
|
||||||
/// (devices.md Phase 4).
|
|
||||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||||
let folders = match self.db.sync_folder_list() {
|
let folders = match self.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
|
|||||||
+1188
-212
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@
|
|||||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
//! `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
|
//! 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
|
//! 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
|
//! 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
|
//! real client implements it by forwarding, and the simulation tests implement
|
||||||
//! it with an in-memory Drive that injects Drive's known faults.
|
//! it with an in-memory Drive that injects Drive's known faults.
|
||||||
@@ -153,6 +153,13 @@ pub(crate) trait DriveApi: Send + Sync {
|
|||||||
|
|
||||||
/// The account's storage, across every Proton product.
|
/// The account's storage, across every Proton product.
|
||||||
async fn quota(&self) -> Result<Quota>;
|
async fn quota(&self) -> Result<Quota>;
|
||||||
|
|
||||||
|
/// Called by the drain once it has let go of the blob of an upload to
|
||||||
|
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||||
|
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||||
|
/// worker it is called on.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn landing(&self, _uid: &NodeUid) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The generic signatures of the client, over the object-safe ones above.
|
/// The generic signatures of the client, over the object-safe ones above.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! through one place (the status, transfers, sync progress) and publishes them
|
//! through one place (the status, transfers, sync progress) and publishes them
|
||||||
//! when they differ.
|
//! when they differ.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -153,13 +153,15 @@ pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Op
|
|||||||
|
|
||||||
/// Watch what changes without a single place to publish it from, and publish it
|
/// Watch what changes without a single place to publish it from, and publish it
|
||||||
/// when it differs from the last look: the status (queue counts, cache use,
|
/// when it differs from the last look: the status (queue counts, cache use,
|
||||||
/// online, pause), the transfers snapshot, and the synced folders with their
|
/// online, pause), the transfers snapshot, the synced folders with their
|
||||||
/// live progress. Looks only while someone is subscribed.
|
/// live progress, and which nodes have a sync issue. Looks only while someone
|
||||||
|
/// is subscribed.
|
||||||
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||||
let mut transfers: Option<Arc<str>> = None;
|
let mut transfers: Option<Arc<str>> = None;
|
||||||
let mut status: Option<String> = None;
|
let mut status: Option<String> = None;
|
||||||
let mut queue: Option<String> = None;
|
let mut queue: Option<String> = None;
|
||||||
let mut locations: Option<String> = None;
|
let mut locations: Option<String> = None;
|
||||||
|
let mut issues: Option<HashMap<i64, String>> = None;
|
||||||
let mut tick = 0u32;
|
let mut tick = 0u32;
|
||||||
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||||
if !core.events.has_subscribers() {
|
if !core.events.has_subscribers() {
|
||||||
@@ -169,6 +171,7 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
|
|||||||
status = None;
|
status = None;
|
||||||
queue = None;
|
queue = None;
|
||||||
locations = None;
|
locations = None;
|
||||||
|
issues = None;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||||
@@ -196,6 +199,15 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
|
|||||||
}
|
}
|
||||||
queue = Some(queue_key);
|
queue = Some(queue_key);
|
||||||
|
|
||||||
|
// An issue shows on its node in the file browser: raised, cleared,
|
||||||
|
// landed or discarded, the listing is out of date.
|
||||||
|
if let Ok(now) = core.db.node_issues() {
|
||||||
|
if issues.as_ref().is_some_and(|last| *last != now) {
|
||||||
|
core.events.publish(&[Topic::Files]);
|
||||||
|
}
|
||||||
|
issues = Some(now);
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(now) = core.list_locations() {
|
if let Ok(now) = core.list_locations() {
|
||||||
let key = serde_json::to_string(&now).unwrap_or_default();
|
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||||
if locations.as_ref().is_some_and(|last| *last != key) {
|
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// A provisional size is the *ciphertext* size, which is larger than the
|
// A provisional size is the *ciphertext* size, which is larger than the
|
||||||
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
// 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
|
// 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
|
// 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
|
// 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
|
/// here — once per `opendir`, which the kernel pairs exactly with one
|
||||||
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
/// `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
|
/// 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
|
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
||||||
/// the same reason `lookup` and `readdir` do.
|
/// the same reason `lookup` and `readdir` do.
|
||||||
@@ -346,7 +346,7 @@ impl Filesystem for ProtonFs {
|
|||||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||||
// The revision disagrees with a provisional size, and B84 fails
|
// The revision disagrees with a provisional size, and B84 fails
|
||||||
// such a read outright, so settle the size first. On the network,
|
// 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 {
|
let fsize = match provisional {
|
||||||
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
||||||
false => fsize,
|
false => fsize,
|
||||||
@@ -517,7 +517,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// `> file`. This is the second write path into the API, and
|
// `> file`. This is the second write path into the API, and
|
||||||
// queueing it is what lets a redirect work offline at all: it
|
// queueing it is what lets a redirect work offline at all: it
|
||||||
// never reaches `release`, so without this it failed before any
|
// 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:
|
// The one branch of `setattr` that can reach the network:
|
||||||
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
// 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`
|
/// `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.
|
/// to push here — the written bytes are already in the scratch file.
|
||||||
fn flush(
|
fn flush(
|
||||||
&self,
|
&self,
|
||||||
@@ -985,7 +985,7 @@ pub struct ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How many times a write `open` will re-read its base after a `release`
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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) {
|
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) {
|
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||||
let ft = if *is_dir {
|
let ft = if *is_dir {
|
||||||
@@ -1094,7 +1094,7 @@ impl ProtonFs {
|
|||||||
// `getattr` would, so `ls -l` — which is one `lookup`
|
// `getattr` would, so `ls -l` — which is one `lookup`
|
||||||
// per entry and no `getattr` at all — takes its sizes
|
// per entry and no `getattr` at all — takes its sizes
|
||||||
// from here. Resolving only in `getattr` left the whole
|
// from here. Resolving only in `getattr` left the whole
|
||||||
// listing provisional (bugs.md B14).
|
// listing provisional (`docs/BUGS.md` B14).
|
||||||
let provisional = matches!(
|
let provisional = matches!(
|
||||||
&e.node.kind,
|
&e.node.kind,
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -1217,7 +1217,7 @@ impl ProtonFs {
|
|||||||
/// open.
|
/// open.
|
||||||
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||||
let (uid, base_mtime, base_size, provisional, base_revision_id) = {
|
let (uid, mut base_mtime, mut base_size, mut provisional, mut base_revision_id) = {
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
match st.entries.get_mut(&ino.0) {
|
match st.entries.get_mut(&ino.0) {
|
||||||
Some(e) if e.node.is_file() => {
|
Some(e) if e.node.is_file() => {
|
||||||
@@ -1276,29 +1276,31 @@ impl ProtonFs {
|
|||||||
// revision in between sends this around again. Without the re-check the
|
// revision in between sends this around again. Without the re-check the
|
||||||
// new handle carried the *older* base, and its own release published
|
// new handle carried the *older* base, and its own release published
|
||||||
// content that silently discarded the revision closed a moment earlier
|
// 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
|
// mean something is republishing continuously, so give up rather than
|
||||||
// spin: the caller's `open` fails instead of its data being lost.
|
// spin: the caller's `open` fails instead of its data being lost.
|
||||||
//
|
//
|
||||||
// `reply` is consumed by whichever arm answers, so the loop yields an
|
// `reply` is consumed by whichever arm answers, so the loop yields an
|
||||||
// outcome and the answer is sent once, after it.
|
// outcome and the answer is sent once, after it.
|
||||||
let outcome = 'install: {
|
let outcome = 'install: {
|
||||||
// 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 queued revision
|
|
||||||
// stamps a real size, so this only goes to the network for a file
|
|
||||||
// the listing has not sized yet.
|
|
||||||
let base_size = match provisional && self.core.pending_blob(&uid).is_none() {
|
|
||||||
false => base_size,
|
|
||||||
true => match self.core.resolve_provisional_size(ino.0, &uid) {
|
|
||||||
Some(size) => size,
|
|
||||||
None => {
|
|
||||||
error!(%uid, "could not settle the size of the base for a write open");
|
|
||||||
break 'install Err(Errno::EIO);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
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 (`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() {
|
||||||
|
match self.core.resolve_provisional_size(ino.0, &uid) {
|
||||||
|
Some(size) => {
|
||||||
|
base_size = size;
|
||||||
|
provisional = false;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
error!(%uid, "could not settle the size of the base for a write open");
|
||||||
|
break 'install Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||||
error!(%uid, "refusing write over incomplete queued revision");
|
error!(%uid, "refusing write over incomplete queued revision");
|
||||||
@@ -1332,17 +1334,49 @@ impl ProtonFs {
|
|||||||
// a lock-tight comparison — against the multi-gigabyte copy
|
// a lock-tight comparison — against the multi-gigabyte copy
|
||||||
// above, which is the window that actually loses data.
|
// above, which is the window that actually loses data.
|
||||||
let current = self.core.pending_blob(&uid);
|
let current = self.core.pending_blob(&uid);
|
||||||
|
let landed = self.core.landed.lock().get(&uid).cloned();
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
// Only a handle this open is about to *create* can carry a stale
|
// Only a handle this open is about to *create* can carry a stale
|
||||||
// base; if one already exists, that handle is authoritative and
|
// base; if one already exists, that handle is authoritative and
|
||||||
// the scratch prepared above is discarded either way.
|
// the scratch prepared above is discarded either way.
|
||||||
let creating = !st.active_writes.contains_key(&ino.0);
|
let creating = !st.active_writes.contains_key(&ino.0);
|
||||||
if creating && current != pending_base.map(|p| p.path) {
|
if creating && current.as_ref() != pending_base.as_ref().map(|p| &p.path) {
|
||||||
drop(st);
|
drop(st);
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
warn!(%uid, attempt, "queued revision changed while opening for write; retrying");
|
warn!(%uid, attempt, "queued revision changed while opening for write; retrying");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// With no queued revision, the base's bytes come from the cache,
|
||||||
|
// keyed by the base's mtime and size. The ones sampled above can
|
||||||
|
// name a revision this daemon has since replaced: an upload that
|
||||||
|
// lands lets go of its blob before the tree has its node, and
|
||||||
|
// the cache holds its bytes under the node only. So the base is
|
||||||
|
// the landing node while there is one, and the tree's otherwise
|
||||||
|
// (docs/BUGS.md B170).
|
||||||
|
if creating && pending_base.is_none() {
|
||||||
|
let now = landed.or_else(|| st.entries.get(&ino.0).map(|e| e.node.clone()));
|
||||||
|
if let Some(node) = now
|
||||||
|
&& (node.modification_time != base_mtime
|
||||||
|
|| node_revision_id(&node) != base_revision_id)
|
||||||
|
{
|
||||||
|
base_mtime = node.modification_time;
|
||||||
|
base_size = node_size(&node);
|
||||||
|
base_revision_id = node_revision_id(&node);
|
||||||
|
provisional = matches!(
|
||||||
|
&node.kind,
|
||||||
|
NodeKind::File {
|
||||||
|
claimed_size: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
);
|
||||||
|
debug!(%uid, base_mtime, base_size, "write open took the base the file has now");
|
||||||
|
if provisional {
|
||||||
|
drop(st);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let fh = st.next_fh;
|
let fh = st.next_fh;
|
||||||
st.next_fh += 1;
|
st.next_fh += 1;
|
||||||
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
||||||
@@ -1507,14 +1541,14 @@ impl ProtonFs {
|
|||||||
let transient = is_transient_name(name);
|
let transient = is_transient_name(name);
|
||||||
// Offline the server cannot mint a uid, so invent one and queue the
|
// 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
|
// 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
|
// 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.
|
// are online: the API has no folder to put this in yet.
|
||||||
//
|
//
|
||||||
// A create that set out online and lost the network on the way lands in
|
// A create that set out online and lost the network on the way lands in
|
||||||
// the queue too, so a Wi-Fi drop costs the caller a wait, never an EIO.
|
// the queue too, so a Wi-Fi drop costs the caller a wait, never an EIO.
|
||||||
let minted = if !transient && self.core.is_online() && !is_local_uid(&parent_uid) {
|
let minted = if !transient && self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
@@ -1618,9 +1652,9 @@ impl ProtonFs {
|
|||||||
.ok();
|
.ok();
|
||||||
// As in `create`: offline — or under a parent that is itself still
|
// As in `create`: offline — or under a parent that is itself still
|
||||||
// queued — the folder becomes a placeholder that the drain turns into a
|
// 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.
|
// Losing the network on the way queues it the same way.
|
||||||
let minted = if self.core.is_online() && !is_local_uid(&parent_uid) {
|
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
@@ -1770,10 +1804,13 @@ impl ProtonFs {
|
|||||||
self.core.cache.clear_scratch_durable(&h.path);
|
self.core.cache.clear_scratch_durable(&h.path);
|
||||||
reply.ok();
|
reply.ok();
|
||||||
}
|
}
|
||||||
(Some(h), false) => match self.core.queue_revision(&h) {
|
(Some(mut h), false) => {
|
||||||
Ok(()) => reply.ok(),
|
self.core.follow_landed_create(&mut h);
|
||||||
Err(e) => reply.error(e),
|
match self.core.queue_revision(&mut h) {
|
||||||
},
|
Ok(()) => reply.ok(),
|
||||||
|
Err(e) => reply.error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
(None, _) => reply.ok(),
|
(None, _) => reply.ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1796,7 +1833,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (ino, uid) = match self.core.lookup_child(parent, name) {
|
let (ino, mut uid) = match self.core.lookup_child(parent, name) {
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
@@ -1837,7 +1874,7 @@ impl ProtonFs {
|
|||||||
// rename, so the victim has to be removed first and the operation stops
|
// 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
|
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
||||||
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
// 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
|
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
||||||
// without a window in which one of the two names does not exist.
|
// without a window in which one of the two names does not exist.
|
||||||
@@ -1909,7 +1946,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = self.core.remove_replaced(victim_uid, newname) {
|
if let Err(e) = self.core.remove_replaced(*victim_ino, victim_uid, newname) {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1919,8 +1956,9 @@ impl ProtonFs {
|
|||||||
};
|
};
|
||||||
// A node whose own creation is still queued has no server-side identity
|
// 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
|
// 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
|
// whole rename. Nothing reaches the API, which is why this works offline.
|
||||||
// (offline.md Phase 3b).
|
// 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) {
|
if is_local_uid(&uid) {
|
||||||
match self.core.db.rewrite_op_target(
|
match self.core.db.rewrite_op_target(
|
||||||
&uid.to_string(),
|
&uid.to_string(),
|
||||||
@@ -1931,6 +1969,8 @@ impl ProtonFs {
|
|||||||
self.core
|
self.core
|
||||||
.state()
|
.state()
|
||||||
.rename_in_place(ino, newparent, &new_parent_uid, newname);
|
.rename_in_place(ino, newparent, &new_parent_uid, newname);
|
||||||
|
// A create that failed on its old name is due again.
|
||||||
|
self.core.wake_drain();
|
||||||
// Finalize: a transient scratch file (its create parked, bytes
|
// Finalize: a transient scratch file (its create parked, bytes
|
||||||
// held back) renamed to a finished name is the moment the
|
// held back) renamed to a finished name is the moment the
|
||||||
// completed file is meant to reach Drive. Un-park the create so
|
// completed file is meant to reach Drive. Un-park the create so
|
||||||
@@ -1949,23 +1989,29 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
debug!(%uid, newname, "renamed a node whose create is still queued");
|
debug!(%uid, newname, "renamed a node whose create is still queued");
|
||||||
reply.ok();
|
reply.ok();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// The create drained underneath us, so the node has a real uid
|
Ok(false) => match self.core.landed_row_uid(ino) {
|
||||||
// now and this handle's is stale. A retry resolves it.
|
Some(real) => {
|
||||||
Ok(false) => {
|
debug!(local = %uid, %real, newname, "create landed under a rename");
|
||||||
warn!(%uid, name, newname, "queued create vanished under a rename");
|
uid = real;
|
||||||
self.core
|
}
|
||||||
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
None => {
|
||||||
reply.error(Errno::EBUSY);
|
warn!(%uid, name, newname, "queued create vanished under a rename");
|
||||||
}
|
self.core
|
||||||
|
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
||||||
|
reply.error(Errno::EBUSY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(%uid, error = %e, "rewriting a queued create's target failed");
|
error!(%uid, error = %e, "rewriting a queued create's target failed");
|
||||||
self.core
|
self.core
|
||||||
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
||||||
reply.error(Errno::EIO);
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Offline, or into a folder that does not exist remotely yet: queue the
|
// Offline, or into a folder that does not exist remotely yet: queue the
|
||||||
// rename rather than 404 or fail. Online moves into a real parent still
|
// rename rather than 404 or fail. Online moves into a real parent still
|
||||||
@@ -2020,7 +2066,7 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if rename_needs_queue(
|
if rename_needs_queue(
|
||||||
self.core.is_online(),
|
self.core.sends_inline(),
|
||||||
is_local_uid(&new_parent_uid),
|
is_local_uid(&new_parent_uid),
|
||||||
newparent != parent,
|
newparent != parent,
|
||||||
newname != name,
|
newname != name,
|
||||||
@@ -2093,7 +2139,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (ino, uid) = match self.core.lookup_child(parent, name) {
|
let (ino, mut uid) = match self.core.lookup_child(parent, name) {
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
@@ -2109,7 +2155,7 @@ impl ProtonFs {
|
|||||||
.is_some_and(|e| e.open_count > 0);
|
.is_some_and(|e| e.open_count > 0);
|
||||||
// A node the server has never heard of cannot be trashed there; deleting
|
// 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
|
// 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) {
|
if is_local_uid(&uid) {
|
||||||
let dropped = if open_now {
|
let dropped = if open_now {
|
||||||
self.core.withdraw_queued_ops(&uid)
|
self.core.withdraw_queued_ops(&uid)
|
||||||
@@ -2121,19 +2167,30 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.core.state().forget_or_unlink(&uid);
|
self.core.state().forget_or_unlink(&uid);
|
||||||
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
// A create that landed before the ops were dropped left none: Drive
|
||||||
reply.ok();
|
// has the node, and the tree had not taken its uid yet. It is
|
||||||
return;
|
// trashed there like any other (docs/BUGS.md B173).
|
||||||
|
match self.core.landed_row_uid(ino) {
|
||||||
|
Some(real) => {
|
||||||
|
debug!(local = %uid, %real, name, "create landed under a delete");
|
||||||
|
uid = real;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
||||||
|
reply.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Offline: queue it. Trashing is the one mutation a user expects to work
|
// 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
|
// 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.
|
// away under the remote call below.
|
||||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
};
|
};
|
||||||
if !self.core.is_online() {
|
if !self.core.sends_inline() {
|
||||||
queue_it(reply);
|
queue_it(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2176,7 +2233,7 @@ impl ProtonFs {
|
|||||||
self.core.invalidate_trash();
|
self.core.invalidate_trash();
|
||||||
// Every other trash site records itself; this one did not, which made a
|
// 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
|
// 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
|
self.core
|
||||||
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
||||||
reply.ok();
|
reply.ok();
|
||||||
|
|||||||
+764
-164
File diff suppressed because it is too large.
Load diff
@@ -86,13 +86,32 @@ pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The slowest upload rate an upload's deadline allows for, past the
|
||||||
|
/// metadata budget it starts with. Far below any link that works, so only a
|
||||||
|
/// transfer that has stopped moving runs into it.
|
||||||
|
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
||||||
|
|
||||||
|
/// How long an upload of `len` bytes may take before the link is treated as
|
||||||
|
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
||||||
|
pub(crate) fn upload_deadline(len: u64) -> Duration {
|
||||||
|
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD)
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||||
pub(crate) async fn bounded<T>(
|
pub(crate) async fn bounded<T>(
|
||||||
call: impl Future<Output = Result<T, ProtonError>>,
|
call: impl Future<Output = Result<T, ProtonError>>,
|
||||||
) -> Result<T, ProtonError> {
|
) -> Result<T, ProtonError> {
|
||||||
match tokio::time::timeout(INTERACTIVE_CALL_TIMEOUT, call).await {
|
bounded_by(INTERACTIVE_CALL_TIMEOUT, call).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a remote call, giving up after `limit`.
|
||||||
|
pub(crate) async fn bounded_by<T>(
|
||||||
|
limit: Duration,
|
||||||
|
call: impl Future<Output = Result<T, ProtonError>>,
|
||||||
|
) -> Result<T, ProtonError> {
|
||||||
|
match tokio::time::timeout(limit, call).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(_) => Err(no_answer(INTERACTIVE_CALL_TIMEOUT)),
|
Err(_) => Err(no_answer(limit)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +132,15 @@ impl Core {
|
|||||||
self.online.load(Ordering::Relaxed)
|
self.online.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a namespace change through the mount goes to Drive inside the
|
||||||
|
/// syscall. Only with [`AppConfig::local_first`] off, and only online;
|
||||||
|
/// otherwise it is queued and the drain sends it.
|
||||||
|
///
|
||||||
|
/// [`AppConfig::local_first`]: pdfs_core::config::AppConfig::local_first
|
||||||
|
pub(crate) fn sends_inline(&self) -> bool {
|
||||||
|
!self.local_first && self.is_online()
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a remote call a caller is waiting on, giving up after
|
/// Run a remote call a caller is waiting on, giving up after
|
||||||
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
||||||
/// FUSE worker has no reactor of its own.
|
/// FUSE worker has no reactor of its own.
|
||||||
@@ -123,6 +151,16 @@ impl Core {
|
|||||||
self.rt.block_on(bounded(call))
|
self.rt.block_on(bounded(call))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a remote call, giving up after `limit`: an upload, whose budget
|
||||||
|
/// grows with its size ([`upload_deadline`]).
|
||||||
|
pub(crate) fn block_on_within<T>(
|
||||||
|
&self,
|
||||||
|
limit: Duration,
|
||||||
|
call: impl Future<Output = Result<T, ProtonError>>,
|
||||||
|
) -> Result<T, ProtonError> {
|
||||||
|
self.rt.block_on(bounded_by(limit, call))
|
||||||
|
}
|
||||||
|
|
||||||
/// Report a failed remote call. Returns whether it failed for want of a
|
/// Report a failed remote call. Returns whether it failed for want of a
|
||||||
/// network, in which case the mount is now offline and the caller should
|
/// network, in which case the mount is now offline and the caller should
|
||||||
/// take its queued path instead of failing.
|
/// take its queued path instead of failing.
|
||||||
@@ -210,7 +248,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
/// 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
|
/// 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.
|
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||||
@@ -301,6 +339,26 @@ mod tests {
|
|||||||
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upload_gets_longer_the_bigger_it_is() {
|
||||||
|
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT);
|
||||||
|
assert_eq!(
|
||||||
|
upload_deadline(1 << 30),
|
||||||
|
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_time()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let limit = Duration::from_millis(20);
|
||||||
|
let result: Result<(), _> = rt.block_on(bounded_by(limit, std::future::pending()));
|
||||||
|
assert!(result.is_err_and(|e| is_network_error(&e)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
||||||
let young = Duration::from_secs(60);
|
let young = Duration::from_secs(60);
|
||||||
|
|||||||
@@ -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
|
/// `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";
|
const ROOT_UID_KEY: &str = "root_uid";
|
||||||
|
|
||||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||||
@@ -244,6 +244,9 @@ pub struct MountOptions {
|
|||||||
/// Upload and download caps in bytes per second (`0` = no cap).
|
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||||
pub upload_limit: u64,
|
pub upload_limit: u64,
|
||||||
pub download_limit: u64,
|
pub download_limit: u64,
|
||||||
|
/// Namespace changes through the mount are queued, not sent in the
|
||||||
|
/// syscall. See [`pdfs_core::config::AppConfig::local_first`].
|
||||||
|
pub local_first: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
@@ -385,6 +388,7 @@ pub(crate) fn mount_with(
|
|||||||
sweep_mode,
|
sweep_mode,
|
||||||
upload_limit,
|
upload_limit,
|
||||||
download_limit,
|
download_limit,
|
||||||
|
local_first,
|
||||||
} = options;
|
} = options;
|
||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
@@ -397,7 +401,7 @@ pub(crate) fn mount_with(
|
|||||||
.all_share_access()
|
.all_share_access()
|
||||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
.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
|
// nudged over this channel; the sender lives in Core so control-socket
|
||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
@@ -429,6 +433,10 @@ pub(crate) fn mount_with(
|
|||||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
creating: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sealed_unread: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
@@ -442,6 +450,7 @@ pub(crate) fn mount_with(
|
|||||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
|
local_first,
|
||||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||||
@@ -492,7 +501,7 @@ pub(crate) fn mount_with(
|
|||||||
// Every long-lived thread started from here on is kept in `workers` and
|
// 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
|
// joined at the end of this function. Leaving them running turned an
|
||||||
// in-process remount into a second generation of drain workers, sync engines
|
// 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();
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
for worker in 0..DRAIN_WORKERS {
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
@@ -670,7 +679,9 @@ pub(crate) fn mount_with(
|
|||||||
// The photos volume has its own event stream; without it the gallery only
|
// The photos volume has its own event stream; without it the gallery only
|
||||||
// learns about a phone-side deletion when the timeline goes stale.
|
// learns about a phone-side deletion when the timeline goes stale.
|
||||||
rt.spawn(run_photos_event_sync(core.clone()));
|
rt.spawn(run_photos_event_sync(core.clone()));
|
||||||
if online {
|
// Not in a simulation: `client` there reaches no API, and the thread, still
|
||||||
|
// waiting on it after the stop, held the database a test opens next.
|
||||||
|
if online && matches!(host, Host::Daemon) {
|
||||||
let repair_core = core.clone();
|
let repair_core = core.clone();
|
||||||
let root_uid = core.primary_root_uid.clone();
|
let root_uid = core.primary_root_uid.clone();
|
||||||
if let Err(error) = std::thread::Builder::new()
|
if let Err(error) = std::thread::Builder::new()
|
||||||
@@ -727,7 +738,7 @@ pub(crate) fn mount_with(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
// 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
|
if let Some(worker) = restore_worker
|
||||||
&& worker.join().is_err()
|
&& worker.join().is_err()
|
||||||
{
|
{
|
||||||
@@ -739,6 +750,7 @@ pub(crate) fn mount_with(
|
|||||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
core.queue_unreleased_writes();
|
||||||
|
|
||||||
// Last, because the workers may legitimately be finishing an upload, and
|
// Last, because the workers may legitimately be finishing an upload, and
|
||||||
// nothing they can still do is harmful once the mounts are down.
|
// nothing they can still do is harmful once the mounts are down.
|
||||||
@@ -775,9 +787,10 @@ fn signal_stop(rt: &tokio::runtime::Handle) -> std::sync::mpsc::Receiver<()> {
|
|||||||
sig_rx
|
sig_rx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long teardown waits for the FUSE worker pool before proceeding without
|
/// How long teardown waits for the background workers and then the FUSE worker
|
||||||
/// it. Long enough for an in-flight block to finish, short enough that
|
/// pool before proceeding without them. Long enough for an in-flight block to
|
||||||
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
/// finish, short enough that `systemctl --user stop` still returns inside
|
||||||
|
/// `TimeoutStopSec`.
|
||||||
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Signal every background loop, wake the ones that are blocked, and join them.
|
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||||
@@ -785,14 +798,15 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
|||||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
/// 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
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
/// 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
|
/// 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
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
/// inside `accept`, which only returns when a connection arrives — so it gets
|
/// inside `accept`, which only returns when a connection arrives — so it gets
|
||||||
/// one, from us, after the flag is already set. A join that takes a while is
|
/// one, from us, after the flag is already set. A join that takes a while is
|
||||||
/// expected and correct: a worker part-way through an upload finishes it rather
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
/// than abandoning the user's bytes mid-flight.
|
/// than abandoning the user's bytes mid-flight. Up to [`WORKER_JOIN_DEADLINE`]:
|
||||||
|
/// past it, systemd kills the daemon anyway, and a queued op is sent again.
|
||||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
// Say so before the joins: a teardown that takes a while must not be read as
|
// Say so before the joins: a teardown that takes a while must not be read as
|
||||||
// a missed watchdog ping and turned into a kill.
|
// a missed watchdog ping and turned into a kill.
|
||||||
@@ -806,25 +820,71 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
|||||||
// unlink cannot put it back to sleep.
|
// unlink cannot put it back to sleep.
|
||||||
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||||
let _ = std::fs::remove_file(control_socket);
|
let _ = std::fs::remove_file(control_socket);
|
||||||
for worker in workers {
|
let deadline = Instant::now() + WORKER_JOIN_DEADLINE;
|
||||||
if worker.join().is_err() {
|
let running = join_until(workers, deadline);
|
||||||
warn!("a background worker panicked before shutdown");
|
if !running.is_empty() {
|
||||||
}
|
warn!(
|
||||||
|
running = running.join(","),
|
||||||
|
"background workers did not finish in time; shutting down around them"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The FUSE worker pool last, and *before* the caller drops the tokio
|
// The FUSE worker pool last, and *before* the caller drops the tokio
|
||||||
// runtime: a worker still inside a job when the runtime goes away panics on
|
// runtime: a worker still inside a job when the runtime goes away panics on
|
||||||
// its next timer with "A Tokio 1.x context was found, but it is being
|
// its next timer with "A Tokio 1.x context was found, but it is being
|
||||||
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||||
// network cannot hold the stop the user asked for.
|
// network cannot hold the stop the user asked for.
|
||||||
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
|
core.workers
|
||||||
|
.stop_and_join(deadline.saturating_duration_since(Instant::now()));
|
||||||
debug!("background workers stopped");
|
debug!("background workers stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Join `workers` until `deadline`, and name the ones still running then.
|
||||||
|
///
|
||||||
|
/// A stop used to wait for each one however long it took. The sync engine's
|
||||||
|
/// mirror pass waits on Drive with no deadline, and a stop that waited for it
|
||||||
|
/// was killed by systemd (`docs/BUGS.md` B165).
|
||||||
|
fn join_until(workers: Vec<std::thread::JoinHandle<()>>, deadline: Instant) -> Vec<String> {
|
||||||
|
let mut running = Vec::new();
|
||||||
|
for worker in workers {
|
||||||
|
while !worker.is_finished() && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
if !worker.is_finished() {
|
||||||
|
running.push(worker.thread().name().unwrap_or("unnamed").to_string());
|
||||||
|
} else if worker.join().is_err() {
|
||||||
|
warn!("a background worker panicked before shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
running
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
|
use super::{SecondaryInsertRejection, SecondaryMountRegistry, join_until, teardown_session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stop_names_the_workers_still_running_at_its_deadline() {
|
||||||
|
let (release, wait) = std::sync::mpsc::channel::<()>();
|
||||||
|
let done = std::thread::Builder::new()
|
||||||
|
.name("done".into())
|
||||||
|
.spawn(|| {})
|
||||||
|
.unwrap();
|
||||||
|
let stuck = std::thread::Builder::new()
|
||||||
|
.name("stuck".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let _ = wait.recv();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let running = join_until(
|
||||||
|
vec![done, stuck],
|
||||||
|
Instant::now() + Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
assert_eq!(running, ["stuck"]);
|
||||||
|
drop(release);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_is_unroutable_before_teardown_starts() {
|
fn session_is_unroutable_before_teardown_starts() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
//! 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
|
//! 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:
|
//! 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)
|
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
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// proposed local path. `device` names another registered device by uid;
|
/// proposed local path. `device` names another registered device by uid;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
//! The user's view of the pending-op queue: what has not reached the remote
|
//! The user's view of the pending-op queue: what has not reached the remote
|
||||||
//! yet, why, and a way to stop waiting out a backoff.
|
//! yet, why, and a way to stop waiting out a backoff.
|
||||||
//!
|
//!
|
||||||
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
//! Read-only apart from "retry now", which only moves a row's next-attempt time,
|
||||||
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
//! "export", which copies a staged blob out, and "discard". Only a discard the
|
||||||
//! user's write (see `docs/RECOVERY.md`).
|
//! user asks for drops a queued op: its staged blob is the only copy of the
|
||||||
|
//! user's write (see `docs/RECOVERY.md`), so it is the user's call, made after
|
||||||
|
//! an export or for a change with no content.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use pdfs_core::control::PendingOpInfo;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use pdfs_core::control::{PendingOpInfo, SyncIssue};
|
||||||
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
@@ -16,10 +20,15 @@ impl Core {
|
|||||||
.db
|
.db
|
||||||
.pending_ops()
|
.pending_ops()
|
||||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||||
|
let issues = self
|
||||||
|
.db
|
||||||
|
.op_issues()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing sync issues: {e}")))?;
|
||||||
Ok(ops
|
Ok(ops
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|op| {
|
.map(|op| {
|
||||||
let parked = op.next_attempt_at >= PARK_UNTIL;
|
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||||
|
let exportable = exportable(&op).is_some();
|
||||||
let path = self
|
let path = self
|
||||||
.db
|
.db
|
||||||
.node_path(&op.uid)
|
.node_path(&op.uid)
|
||||||
@@ -37,7 +46,9 @@ impl Core {
|
|||||||
queued_at: op.created_at / 1000,
|
queued_at: op.created_at / 1000,
|
||||||
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||||
parked,
|
parked,
|
||||||
failing: op.attempts >= FAILING_ATTEMPTS,
|
failing: op.attempts >= FAILING_ATTEMPTS || issues.contains_key(&op.id),
|
||||||
|
issue: issues.get(&op.id).map(|issue| SyncIssue::parse(issue)),
|
||||||
|
exportable,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -57,4 +68,235 @@ impl Core {
|
|||||||
}
|
}
|
||||||
Ok(moved)
|
Ok(moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy the content of queued op `id` to `dest`, which must not exist yet.
|
||||||
|
/// The op stays queued. Returns the bytes written.
|
||||||
|
pub(crate) fn export_pending_op(&self, id: i64, dest: &Path) -> CoreResult<u64> {
|
||||||
|
if !dest.is_absolute() {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"destination must be an absolute path: {}",
|
||||||
|
dest.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let ops = self
|
||||||
|
.db
|
||||||
|
.pending_ops()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||||
|
let op = ops
|
||||||
|
.iter()
|
||||||
|
.find(|op| op.id == id)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no queued change {id}")))?;
|
||||||
|
let blob = exportable(op).ok_or_else(|| {
|
||||||
|
CoreError::invalid(format!("queued change {id} carries no file content"))
|
||||||
|
})?;
|
||||||
|
let written = copy_to_new_file(blob, dest)?;
|
||||||
|
info!(id, dest = %dest.display(), written, "exported a queued change's content");
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop queued op `id` and undo the local change it stood for. Returns the
|
||||||
|
/// node's path.
|
||||||
|
///
|
||||||
|
/// A node only made here goes, as if it had been deleted before it was
|
||||||
|
/// sent. A node Drive has goes back to what Drive has: every change queued
|
||||||
|
/// on it goes, since each was made on top of the one before. What is queued
|
||||||
|
/// below it stays.
|
||||||
|
pub(crate) fn discard_pending_op(&self, id: i64) -> CoreResult<String> {
|
||||||
|
let ops = self
|
||||||
|
.db
|
||||||
|
.pending_ops()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||||
|
let op = ops
|
||||||
|
.into_iter()
|
||||||
|
.find(|op| op.id == id)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no queued change {id}")))?;
|
||||||
|
let uid = parse_node_uid(&op.uid)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("queued change {id} names {}", op.uid)))?;
|
||||||
|
let path = self
|
||||||
|
.db
|
||||||
|
.node_path(&op.uid)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.or(op.name)
|
||||||
|
.unwrap_or_else(|| op.uid.clone());
|
||||||
|
if self.is_open_anywhere(&uid) {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{path} is open; close it first"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if is_local_uid(&uid) {
|
||||||
|
self.discard_local_node(&uid, &path)?;
|
||||||
|
} else {
|
||||||
|
self.discard_remote_change(&uid, &path)?;
|
||||||
|
}
|
||||||
|
info!(id, %uid, path, "discarded a queued change");
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a node made here, and the ops that would have sent it.
|
||||||
|
fn discard_local_node(&self, uid: &NodeUid, path: &str) -> CoreResult<()> {
|
||||||
|
// Its children's ops go with its own, and their content with them.
|
||||||
|
if self
|
||||||
|
.db
|
||||||
|
.has_children(uid)
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing {path}: {e}")))?
|
||||||
|
{
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{path} is not empty; discard what is in it first"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.discard_queued_ops(uid)
|
||||||
|
.map_err(|_| CoreError::internal(format!("dropping the changes to {path}")))?;
|
||||||
|
self.for_each_mount(|st, notify| {
|
||||||
|
background::forget_and_notify(st, notify, uid);
|
||||||
|
});
|
||||||
|
self.cache.evict(uid);
|
||||||
|
self.evict_reader(uid);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a node Drive has back as Drive has it.
|
||||||
|
fn discard_remote_change(&self, uid: &NodeUid, path: &str) -> CoreResult<()> {
|
||||||
|
// Asked first: the change is only dropped once what replaces it is known.
|
||||||
|
let node = self
|
||||||
|
.fetch_node_remote(uid)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "reading the node from Drive"))?
|
||||||
|
.filter(|node| !node.trashed);
|
||||||
|
self.cancel_upload(uid);
|
||||||
|
let blobs = self
|
||||||
|
.db
|
||||||
|
.discard_ops_on_node(&uid.to_string())
|
||||||
|
.map_err(|e| CoreError::internal(format!("dropping the changes to {path}: {e}")))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CoreError::conflict(format!("{path} is being sent right now; try again"))
|
||||||
|
})?;
|
||||||
|
self.release_dropped_ops(uid, blobs);
|
||||||
|
self.hidden.lock().remove(uid);
|
||||||
|
match node {
|
||||||
|
Some(node) => self.for_each_mount(|st, notify| {
|
||||||
|
put_back(st, notify, node);
|
||||||
|
}),
|
||||||
|
None => self.for_each_mount(|st, notify| {
|
||||||
|
background::forget_and_notify(st, notify, uid);
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
self.cache.evict(uid);
|
||||||
|
self.evict_reader(uid);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show `node` as Drive has it, where Drive has it, under the inode it has.
|
||||||
|
fn put_back(st: &mut State, notify: &mut NotifyBatch, node: Node) {
|
||||||
|
let parent = node.parent_uid.as_ref().and_then(|parent| {
|
||||||
|
st.by_uid
|
||||||
|
.get(parent)
|
||||||
|
.copied()
|
||||||
|
.map(|ino| (ino, parent.clone()))
|
||||||
|
});
|
||||||
|
let here = st.by_uid.get(&node.uid).and_then(|ino| {
|
||||||
|
st.entries
|
||||||
|
.get(ino)
|
||||||
|
.map(|entry| (*ino, entry.parent, entry.node.name.clone()))
|
||||||
|
});
|
||||||
|
match (here, parent) {
|
||||||
|
(Some((ino, from, name)), Some((to, to_uid))) => {
|
||||||
|
st.relocate(ino, from, to, &to_uid, &node.name);
|
||||||
|
st.write_through(node.clone());
|
||||||
|
st.intern_mem(to, node);
|
||||||
|
notify.inval_entry(from, name);
|
||||||
|
notify.inval_inode(ino);
|
||||||
|
}
|
||||||
|
(here, parent) => {
|
||||||
|
if let Some((_, from, name)) = here {
|
||||||
|
st.forget_mem(&node.uid);
|
||||||
|
st.invalidate_listing(from);
|
||||||
|
notify.inval_entry(from, name);
|
||||||
|
}
|
||||||
|
if let Some((to, _)) = parent {
|
||||||
|
st.invalidate_listing(to);
|
||||||
|
notify.inval_entry(to, node.name.clone());
|
||||||
|
}
|
||||||
|
st.write_through(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `blob` to `dest`, which must not exist: never replace something the
|
||||||
|
/// user already has.
|
||||||
|
fn copy_to_new_file(blob: &Path, dest: &Path) -> CoreResult<u64> {
|
||||||
|
let mut source = File::open(blob)
|
||||||
|
.map_err(|e| CoreError::internal(format!("open {}: {e}", blob.display())))?;
|
||||||
|
let mut out = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(dest)
|
||||||
|
.map_err(|e| match e.kind() {
|
||||||
|
std::io::ErrorKind::AlreadyExists => {
|
||||||
|
CoreError::invalid(format!("{} already exists", dest.display()))
|
||||||
|
}
|
||||||
|
_ => CoreError::internal(format!("create {}: {e}", dest.display())),
|
||||||
|
})?;
|
||||||
|
std::io::copy(&mut source, &mut out)
|
||||||
|
.and_then(|written| out.flush().and(out.sync_all()).map(|()| written))
|
||||||
|
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The staged blob of an op that carries file content.
|
||||||
|
fn exportable(op: &PendingOp) -> Option<&Path> {
|
||||||
|
matches!(op.kind.as_str(), OP_CREATE | OP_REVISION)
|
||||||
|
.then_some(op.blob_path.as_deref())
|
||||||
|
.flatten()
|
||||||
|
.map(Path::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn op(kind: &str, blob: Option<&str>) -> PendingOp {
|
||||||
|
PendingOp {
|
||||||
|
id: 1,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
uid: "vol~a".to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: blob.map(str::to_string),
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 0,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_change_that_carries_file_content_can_be_exported() {
|
||||||
|
assert!(exportable(&op(OP_CREATE, Some("/staging/a"))).is_some());
|
||||||
|
assert!(exportable(&op(OP_REVISION, Some("/staging/a"))).is_some());
|
||||||
|
assert!(exportable(&op(OP_CREATE, None)).is_none());
|
||||||
|
assert!(exportable(&op(OP_RENAME, None)).is_none());
|
||||||
|
assert!(exportable(&op(OP_TRASH, None)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_export_copies_the_bytes_and_never_replaces_a_file() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-export-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let blob = dir.join("blob");
|
||||||
|
std::fs::write(&blob, b"queued bytes").unwrap();
|
||||||
|
|
||||||
|
let dest = dir.join("copy.txt");
|
||||||
|
assert_eq!(copy_to_new_file(&blob, &dest).unwrap(), 12);
|
||||||
|
assert_eq!(std::fs::read(&dest).unwrap(), b"queued bytes");
|
||||||
|
|
||||||
|
std::fs::write(&dest, b"the user's own").unwrap();
|
||||||
|
assert!(copy_to_new_file(&blob, &dest).is_err());
|
||||||
|
assert_eq!(std::fs::read(&dest).unwrap(), b"the user's own");
|
||||||
|
// The staged blob is the only copy of the change: an export leaves it.
|
||||||
|
assert_eq!(std::fs::read(&blob).unwrap(), b"queued bytes");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -464,7 +464,7 @@ impl Core {
|
|||||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||||
/// first read, because learning it means opening a `RevisionReader`, and
|
/// first read, because learning it means opening a `RevisionReader`, and
|
||||||
/// opening one per read is precisely the per-file key derivation the block
|
/// 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
|
/// 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
|
/// real sizes and clamps to what was asked for either way. It costs a
|
||||||
@@ -493,15 +493,20 @@ impl Core {
|
|||||||
) -> Result<Vec<u8>, Errno> {
|
) -> Result<Vec<u8>, Errno> {
|
||||||
// A queued write has not reached the remote yet, so the remote's current
|
// 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
|
// 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() {
|
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
||||||
return self.read_pending(&pending, offset, len);
|
return self.read_pending(&pending, offset, len);
|
||||||
}
|
}
|
||||||
// A node created offline and never written has no blob and no remote: it
|
// 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
|
// is an empty file, and asking the API about a `local~` uid would only
|
||||||
// earn a 404 (offline.md Phase 3b).
|
// 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) {
|
if is_local_uid(uid) {
|
||||||
return Ok(Vec::new());
|
return match self.landed_stand_in_uid(uid) {
|
||||||
|
Some(real) => self.read_range(&real, mtime, fsize, offset, len, cache_blocks),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
|
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
|
||||||
}
|
}
|
||||||
@@ -631,7 +636,7 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// A block that comes back shorter than the file's size says it must be is
|
/// 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
|
/// 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(
|
async fn read_block(
|
||||||
&self,
|
&self,
|
||||||
reader: &Arc<dyn RevisionRead>,
|
reader: &Arc<dyn RevisionRead>,
|
||||||
@@ -646,7 +651,7 @@ impl Core {
|
|||||||
// file's boundaries are wrong, and the failure is not confined to short
|
// 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
|
// 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
|
// 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 {
|
let bytes = if reader.size() != fsize {
|
||||||
warn!(
|
warn!(
|
||||||
%uid, bidx = span.idx, fsize,
|
%uid, bidx = span.idx, fsize,
|
||||||
@@ -659,7 +664,7 @@ impl Core {
|
|||||||
// Learned here rather than on a path of its own: this is where a
|
// 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
|
// reader is open anyway, and recording the real geometry is what
|
||||||
// lets the *next* read plan its blocks on the revision's own
|
// 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 {
|
if cache_blocks {
|
||||||
self.cache
|
self.cache
|
||||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||||
@@ -701,7 +706,7 @@ impl Core {
|
|||||||
/// The range path derives every block boundary from the revision's recorded
|
/// The range path derives every block boundary from the revision's recorded
|
||||||
/// block sizes. When those understate the file, reads come back short with
|
/// block sizes. When those understate the file, reads come back short with
|
||||||
/// no error anywhere, and the caller hands userspace a truncated file that
|
/// 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
|
/// arithmetic — it streams the block list — so it returns the whole file
|
||||||
/// where the range path cannot, which is why pinning was the workaround.
|
/// 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
|
/// This does the same thing on the user's behalf, once, and the resulting
|
||||||
@@ -913,7 +918,7 @@ impl Core {
|
|||||||
let end = offset.saturating_add(len).min(fsize);
|
let end = offset.saturating_add(len).min(fsize);
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
// On the revision's own boundaries where they are known, and on the
|
// 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 wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
||||||
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
@@ -1033,7 +1038,7 @@ impl Core {
|
|||||||
// — the cache validates it on read, the network path in `read_block`
|
// — the cache validates it on read, the network path in `read_block`
|
||||||
// — so this refuses rather than quietly contributing fewer bytes
|
// — so this refuses rather than quietly contributing fewer bytes
|
||||||
// than the range covers, which is what turned a bad block into a
|
// 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 {
|
if block.len() as u64 != span.len {
|
||||||
error!(
|
error!(
|
||||||
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
shared_by_unverified,
|
shared_by_unverified,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
//! 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
|
//! 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
|
//! 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
|
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
//! [`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
|
//! names, and an event log. Each simulated daemon reaches it through its own
|
||||||
@@ -33,7 +33,13 @@
|
|||||||
//!
|
//!
|
||||||
//! A test can also hold the next read of one node on its way to Drive
|
//! A test can also hold the next read of one node on its way to Drive
|
||||||
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
||||||
//! for the answer.
|
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
|
||||||
|
//! so the daemon gets an answer a change made meanwhile has overtaken. It can
|
||||||
|
//! also have a new file's next reads answered as Drive lists it before its
|
||||||
|
//! create is committed ([`FakeClient::answer_reads_unrevised`]; B163), and
|
||||||
|
//! hold a folder's listing on its way back with one of its files listed that
|
||||||
|
//! way ([`FakeClient::hold_answer_to_next_listing`]), or hold a new file's
|
||||||
|
//! create on its way to Drive ([`FakeClient::hold_next_create`]).
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
@@ -330,6 +336,18 @@ impl FakeDrive {
|
|||||||
online: AtomicBool::new(true),
|
online: AtomicBool::new(true),
|
||||||
requests: AtomicU64::new(0),
|
requests: AtomicU64::new(0),
|
||||||
hold: Mutex::new(None),
|
hold: Mutex::new(None),
|
||||||
|
hold_answer: Mutex::new(None),
|
||||||
|
hold_trash: Mutex::new(None),
|
||||||
|
hold_landing: Mutex::new(None),
|
||||||
|
drop_after_create: AtomicBool::new(false),
|
||||||
|
lose_create_reply: AtomicBool::new(false),
|
||||||
|
hold_create_reply: Mutex::new(None),
|
||||||
|
hold_create: Mutex::new(None),
|
||||||
|
lose_revision_reply: AtomicBool::new(false),
|
||||||
|
lose_revision_read_back: AtomicBool::new(false),
|
||||||
|
lose_read: Mutex::new(None),
|
||||||
|
unrevised_reads: Mutex::new(None),
|
||||||
|
hold_listing: Mutex::new(None),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +408,23 @@ impl FakeDrive {
|
|||||||
tree
|
tree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every name a node has had in the root, trashed or not, as far back as
|
||||||
|
/// Drive keeps its versions.
|
||||||
|
pub(crate) fn names_ever_in_root(&self) -> Vec<String> {
|
||||||
|
let server = self.server.lock();
|
||||||
|
let mut names: Vec<String> = server
|
||||||
|
.nodes
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|(_, remote)| remote.as_ref())
|
||||||
|
.filter(|remote| remote.parent.as_ref() == Some(&server.root))
|
||||||
|
.map(|remote| remote.name.clone())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
/// The uid at `path` below the root, as Drive has it now.
|
/// The uid at `path` below the root, as Drive has it now.
|
||||||
pub(crate) fn lookup(&self, path: &str) -> Option<NodeUid> {
|
pub(crate) fn lookup(&self, path: &str) -> Option<NodeUid> {
|
||||||
let server = self.server.lock();
|
let server = self.server.lock();
|
||||||
@@ -577,6 +612,32 @@ struct ClientInner {
|
|||||||
requests: AtomicU64,
|
requests: AtomicU64,
|
||||||
/// The read [`FakeClient::hold_next_read`] is waiting for.
|
/// The read [`FakeClient::hold_next_read`] is waiting for.
|
||||||
hold: Mutex<Option<Arc<HoldState>>>,
|
hold: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The read [`FakeClient::hold_answer_to_next_read`] is waiting for.
|
||||||
|
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
||||||
|
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||||
|
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// Set by [`FakeClient::drop_link_after_next_create`].
|
||||||
|
drop_after_create: AtomicBool,
|
||||||
|
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
||||||
|
lose_create_reply: AtomicBool,
|
||||||
|
/// The create [`FakeClient::hold_reply_to_next_create`] is waiting for.
|
||||||
|
hold_create_reply: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The create [`FakeClient::hold_next_create`] is waiting for.
|
||||||
|
hold_create: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// Set by [`FakeClient::lose_reply_to_next_revision`].
|
||||||
|
lose_revision_reply: AtomicBool,
|
||||||
|
/// Set by [`FakeClient::lose_read_back_of_next_revision`].
|
||||||
|
lose_revision_read_back: AtomicBool,
|
||||||
|
/// The file whose next read goes unanswered, once its revision is made.
|
||||||
|
lose_read: Mutex<Option<NodeUid>>,
|
||||||
|
/// Set by [`FakeClient::answer_reads_unrevised`]: the file and how many
|
||||||
|
/// more of its reads are answered without an active revision.
|
||||||
|
unrevised_reads: Mutex<Option<(NodeUid, usize)>>,
|
||||||
|
/// The listing [`FakeClient::hold_answer_to_next_listing`] is waiting
|
||||||
|
/// for, and the file it lists without an active revision.
|
||||||
|
hold_listing: Mutex<Option<(Arc<HoldState>, NodeUid)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HoldState {
|
struct HoldState {
|
||||||
@@ -585,8 +646,37 @@ struct HoldState {
|
|||||||
released: AtomicBool,
|
released: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A read held on its way to Drive, from [`FakeClient::hold_next_read`]. It
|
/// Wait while `state` holds a call.
|
||||||
/// goes on when this is dropped.
|
/// `node` as Drive lists a file whose create it has not committed yet: no
|
||||||
|
/// active revision, no size.
|
||||||
|
fn unrevise(node: &mut Node) {
|
||||||
|
if let NodeKind::File {
|
||||||
|
total_size_on_storage,
|
||||||
|
active_revision_state,
|
||||||
|
active_revision_id,
|
||||||
|
claimed_size,
|
||||||
|
content_sha1,
|
||||||
|
..
|
||||||
|
} = &mut node.kind
|
||||||
|
{
|
||||||
|
*total_size_on_storage = 0;
|
||||||
|
*active_revision_state = None;
|
||||||
|
*active_revision_id = None;
|
||||||
|
*claimed_size = None;
|
||||||
|
*content_sha1 = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_released(state: &HoldState) {
|
||||||
|
state.reached.store(true, Ordering::SeqCst);
|
||||||
|
while !state.released.load(Ordering::SeqCst) {
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call held on its way to or from Drive, from [`FakeClient::hold_next_read`]
|
||||||
|
/// or [`FakeClient::hold_reply_to_next_create`]. It goes on when this is
|
||||||
|
/// dropped.
|
||||||
pub(crate) struct Held(Arc<HoldState>);
|
pub(crate) struct Held(Arc<HoldState>);
|
||||||
|
|
||||||
impl Held {
|
impl Held {
|
||||||
@@ -616,6 +706,18 @@ impl FakeClient {
|
|||||||
*self.0.faults.lock() = faults;
|
*self.0.faults.lock() = faults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the link down as soon as the next file create has been answered,
|
||||||
|
/// so whatever the daemon asks next goes unanswered.
|
||||||
|
pub(crate) fn drop_link_after_next_create(&self) {
|
||||||
|
self.0.drop_after_create.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next file create on Drive and lose its answer, as a link that
|
||||||
|
/// drops it on the way back does.
|
||||||
|
pub(crate) fn lose_reply_to_next_create(&self) {
|
||||||
|
self.0.lose_create_reply.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
/// Hold the next read of `uid` alone, before it reaches Drive, until the
|
/// Hold the next read of `uid` alone, before it reaches Drive, until the
|
||||||
/// answer is dropped. Only one read is held at a time.
|
/// answer is dropped. Only one read is held at a time.
|
||||||
pub(crate) fn hold_next_read(&self, uid: &NodeUid) -> Held {
|
pub(crate) fn hold_next_read(&self, uid: &NodeUid) -> Held {
|
||||||
@@ -628,20 +730,115 @@ impl FakeClient {
|
|||||||
Held(state)
|
Held(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait here if `uids` is the read a [`Held`] is for.
|
/// Answer the next read of `uid` alone with what Drive holds now, then
|
||||||
async fn wait_if_held(&self, uids: &[NodeUid]) {
|
/// hold the answer until it is dropped, as a slow link does. Only one
|
||||||
|
/// answer is held at a time.
|
||||||
|
pub(crate) fn hold_answer_to_next_read(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_answer.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the next trash of `uid` alone, before it reaches Drive, until the
|
||||||
|
/// answer is dropped.
|
||||||
|
pub(crate) fn hold_next_trash(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_trash.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the drain once the next upload to `uid` has landed and its blob
|
||||||
|
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
||||||
|
/// the answer is dropped.
|
||||||
|
pub(crate) fn hold_next_landing(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_landing.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next file create on Drive, then hold its answer until the
|
||||||
|
/// answer is dropped, as a slow link does.
|
||||||
|
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: self.0.drive.root(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_create_reply.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the next file create before it reaches Drive, until the answer
|
||||||
|
/// is dropped.
|
||||||
|
pub(crate) fn hold_next_create(&self) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: self.0.drive.root(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_create.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next revision of a file on Drive and lose its answer.
|
||||||
|
pub(crate) fn lose_reply_to_next_revision(&self) {
|
||||||
|
self.0.lose_revision_reply.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next revision of a file on Drive and answer it, then lose the
|
||||||
|
/// answer to the next read of that file alone, as the read-back after an
|
||||||
|
/// upload meets on a link that drops it.
|
||||||
|
pub(crate) fn lose_read_back_of_next_revision(&self) {
|
||||||
|
self.0.lose_revision_read_back.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the next `reads` reads of file `uid` alone that find it as Drive
|
||||||
|
/// lists a file whose create it has not committed yet: no active revision,
|
||||||
|
/// no size.
|
||||||
|
pub(crate) fn answer_reads_unrevised(&self, uid: &NodeUid, reads: usize) {
|
||||||
|
*self.0.unrevised_reads.lock() = Some((uid.clone(), reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the next listing of `folder`'s nodes with what Drive holds now,
|
||||||
|
/// `unrevised` in it as Drive lists a file whose create it has not
|
||||||
|
/// committed yet, then hold the answer until it is dropped.
|
||||||
|
pub(crate) fn hold_answer_to_next_listing(
|
||||||
|
&self,
|
||||||
|
folder: &NodeUid,
|
||||||
|
unrevised: &NodeUid,
|
||||||
|
) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: folder.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_listing.lock() = Some((state.clone(), unrevised.clone()));
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait here if `uids` is the read a [`Held`] in `slot` is for.
|
||||||
|
async fn wait_if_held(&self, slot: &Mutex<Option<Arc<HoldState>>>, uids: &[NodeUid]) {
|
||||||
let state = {
|
let state = {
|
||||||
let mut hold = self.0.hold.lock();
|
let mut hold = slot.lock();
|
||||||
match hold.as_ref() {
|
match hold.as_ref() {
|
||||||
Some(state) if uids == std::slice::from_ref(&state.uid) => hold.take(),
|
Some(state) if uids == std::slice::from_ref(&state.uid) => hold.take(),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(state) = state else { return };
|
let Some(state) = state else { return };
|
||||||
state.reached.store(true, Ordering::SeqCst);
|
wait_until_released(&state).await;
|
||||||
while !state.released.load(Ordering::SeqCst) {
|
|
||||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests made through this client so far.
|
/// Requests made through this client so far.
|
||||||
@@ -868,7 +1065,17 @@ impl DriveApi for FakeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||||
self.wait_if_held(uids).await;
|
self.wait_if_held(&self.0.hold, uids).await;
|
||||||
|
{
|
||||||
|
let mut lose = self.0.lose_read.lock();
|
||||||
|
if lose
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|uid| uids == std::slice::from_ref(uid))
|
||||||
|
{
|
||||||
|
*lose = None;
|
||||||
|
return Err(no_answer());
|
||||||
|
}
|
||||||
|
}
|
||||||
let lag = self.lag();
|
let lag = self.lag();
|
||||||
let nodes: Vec<Node> = self
|
let nodes: Vec<Node> = self
|
||||||
.request(false, |server| {
|
.request(false, |server| {
|
||||||
@@ -879,20 +1086,49 @@ impl DriveApi for FakeClient {
|
|||||||
.collect())
|
.collect())
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
self.wait_if_held(&self.0.hold_answer, uids).await;
|
||||||
|
let mut nodes = nodes;
|
||||||
|
{
|
||||||
|
let mut unrevised = self.0.unrevised_reads.lock();
|
||||||
|
if let Some((uid, left)) = unrevised.as_mut()
|
||||||
|
&& uids == std::slice::from_ref(uid)
|
||||||
|
&& !nodes.is_empty()
|
||||||
|
{
|
||||||
|
*left -= 1;
|
||||||
|
if *left == 0 {
|
||||||
|
*unrevised = None;
|
||||||
|
}
|
||||||
|
nodes.iter_mut().for_each(unrevise);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.cache(&nodes);
|
self.cache(&nodes);
|
||||||
Ok(nodes)
|
Ok(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||||
let lag = self.lag();
|
let lag = self.lag();
|
||||||
self.request(false, |server| {
|
let mut nodes: Vec<Node> = self
|
||||||
let now = Instant::now();
|
.request(false, |server| {
|
||||||
Ok(uids
|
let now = Instant::now();
|
||||||
|
Ok(uids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|uid| Some(to_node(uid, &server.visible(uid, lag, now)?, true)))
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let held = self.0.hold_listing.lock().take_if(|(state, _)| {
|
||||||
|
nodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|uid| Some(to_node(uid, &server.visible(uid, lag, now)?, true)))
|
.any(|node| node.parent_uid.as_ref() == Some(&state.uid))
|
||||||
.collect())
|
});
|
||||||
})
|
if let Some((state, unrevised)) = held {
|
||||||
.await
|
nodes
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|node| node.uid == unrevised)
|
||||||
|
.for_each(unrevise);
|
||||||
|
wait_until_released(&state).await;
|
||||||
|
}
|
||||||
|
Ok(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_folder_children_node_uids(
|
async fn enumerate_folder_children_node_uids(
|
||||||
@@ -1038,16 +1274,34 @@ impl DriveApi for FakeClient {
|
|||||||
_aead: bool,
|
_aead: bool,
|
||||||
) -> Result<NodeUid> {
|
) -> Result<NodeUid> {
|
||||||
let content = read_all(reader)?;
|
let content = read_all(reader)?;
|
||||||
self.request(true, |server| {
|
let held = self.0.hold_create.lock().take();
|
||||||
server.new_file(
|
if let Some(state) = held {
|
||||||
parent_uid,
|
wait_until_released(&state).await;
|
||||||
name,
|
}
|
||||||
media_type,
|
let made = self
|
||||||
content,
|
.request(true, |server| {
|
||||||
last_modification_time,
|
server.new_file(
|
||||||
)
|
parent_uid,
|
||||||
})
|
name,
|
||||||
.await
|
media_type,
|
||||||
|
content,
|
||||||
|
last_modification_time,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if made.is_ok() && self.0.drop_after_create.swap(false, Ordering::SeqCst) {
|
||||||
|
self.set_online(false);
|
||||||
|
}
|
||||||
|
let held = self.0.hold_create_reply.lock().take();
|
||||||
|
if made.is_ok()
|
||||||
|
&& let Some(state) = held
|
||||||
|
{
|
||||||
|
wait_until_released(&state).await;
|
||||||
|
}
|
||||||
|
if made.is_ok() && self.0.lose_create_reply.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(no_answer());
|
||||||
|
}
|
||||||
|
made
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_file_replacing_draft_from_dyn(
|
async fn upload_file_replacing_draft_from_dyn(
|
||||||
@@ -1103,7 +1357,14 @@ impl DriveApi for FakeClient {
|
|||||||
server.put(file_uid, Some(remote));
|
server.put(file_uid, Some(remote));
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
if self.0.lose_revision_reply.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(no_answer());
|
||||||
|
}
|
||||||
|
if self.0.lose_revision_read_back.swap(false, Ordering::SeqCst) {
|
||||||
|
*self.0.lose_read.lock() = Some(file_uid.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||||
@@ -1204,6 +1465,7 @@ impl DriveApi for FakeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||||
|
self.wait_if_held(&self.0.hold_trash, uids).await;
|
||||||
self.batch(uids, trash_one).await
|
self.batch(uids, trash_one).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,6 +1481,21 @@ impl DriveApi for FakeClient {
|
|||||||
outcome_stream(self.batch(uids, delete_one))
|
outcome_stream(self.batch(uids, delete_one))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn landing(&self, uid: &NodeUid) {
|
||||||
|
let state = {
|
||||||
|
let mut hold = self.0.hold_landing.lock();
|
||||||
|
match hold.as_ref() {
|
||||||
|
Some(state) if state.uid == *uid => hold.take(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(state) = state else { return };
|
||||||
|
state.reached.store(true, Ordering::SeqCst);
|
||||||
|
while !state.released.load(Ordering::SeqCst) {
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn quota(&self) -> Result<Quota> {
|
async fn quota(&self) -> Result<Quota> {
|
||||||
self.request(false, |server| {
|
self.request(false, |server| {
|
||||||
Ok(Quota {
|
Ok(Quota {
|
||||||
@@ -1554,6 +1831,29 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_held_answer_is_what_drive_held_when_read() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let client = drive.client(1, Faults::none());
|
||||||
|
let root = drive.root();
|
||||||
|
let file = client.upload_file(&root, "a", "", b"1").await.unwrap();
|
||||||
|
let held = client.hold_answer_to_next_read(&file);
|
||||||
|
let read = tokio::spawn({
|
||||||
|
let client = client.clone();
|
||||||
|
let file = file.clone();
|
||||||
|
async move { client.enumerate_nodes(std::slice::from_ref(&file)).await }
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
assert!(held.reached());
|
||||||
|
assert!(!read.is_finished());
|
||||||
|
drive.device().trash("a");
|
||||||
|
drop(held);
|
||||||
|
let nodes = read.await.unwrap().unwrap();
|
||||||
|
assert!(!nodes[0].trashed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rename_lags_only_by_the_update_lag() {
|
fn a_rename_lags_only_by_the_update_lag() {
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
|
|||||||
@@ -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.
|
//! the faults the real one has, and the tools to drive daemons against it.
|
||||||
|
|
||||||
pub(crate) mod daemon;
|
pub(crate) mod daemon;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! The reference file system of the simulation (`docs/MILESTONE-3.0.0.md`
|
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||||
//! §8.2, property 4): what each syscall a simulated client makes should
|
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||||
//! answer, and the tree it should leave, on a POSIX file system.
|
//! 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
|
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||||
|
|||||||
+49
-403
@@ -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
|
//! 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
|
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||||
@@ -22,14 +22,9 @@
|
|||||||
//! one wrote down, but in-flight work is let finish rather than cut. A kill
|
//! one wrote down, but in-flight work is let finish rather than cut. A kill
|
||||||
//! at an arbitrary point needs the daemon in a process of its own.
|
//! at an arbitrary point needs the daemon in a process of its own.
|
||||||
//!
|
//!
|
||||||
//! A call that answers otherwise than the model says fails the run, unless it
|
//! Any of these fails the seed. No open bug is tolerated: the matchers that
|
||||||
//! is one of the [`KNOWN`] open bugs. Those are counted instead and the model
|
//! counted the ones still open instead (B125 to B134) went once a run on
|
||||||
//! takes the call back, or the seed ends there when the bug leaves the mount
|
//! 812eb3d passed every profile without them.
|
||||||
//! where the model cannot follow, so the runs stay a regression net until
|
|
||||||
//! they are fixed. A file gone, or a conflict copy the drain made of one of
|
|
||||||
//! the client's own, is counted too when a known bug explains it
|
|
||||||
//! (`Run::known_damage`), and so is a hang the stacks show is one of the
|
|
||||||
//! [`KNOWN_HANGS`]. `PDFS_SIM_KNOWN=fail` fails on all of them.
|
|
||||||
//!
|
//!
|
||||||
//! The runs are ignored by default because they mount FUSE; run them with
|
//! The runs are ignored by default because they mount FUSE; run them with
|
||||||
//! `cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1`.
|
//! `cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1`.
|
||||||
@@ -42,105 +37,11 @@ use std::io::{self, Read, Seek, SeekFrom, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
||||||
|
|
||||||
use super::daemon::{Daemon, scratch, take_logged, wait_until};
|
use super::daemon::{Daemon, scratch, take_logged, wait_until};
|
||||||
use super::fake_drive::{Entry, FakeDrive, Faults};
|
use super::fake_drive::{Entry, FakeDrive, Faults};
|
||||||
use super::model::{FsOp, Model, Tree, data, join, name_of, parent_of};
|
use super::model::{FsOp, Model, Tree, data, join};
|
||||||
use super::rng::Rng;
|
use super::rng::Rng;
|
||||||
use super::watchdog::{Hung, Watchdog};
|
use super::watchdog::Watchdog;
|
||||||
|
|
||||||
/// An open bug in `docs/BUGS.md` the runs tolerate.
|
|
||||||
struct Known {
|
|
||||||
bug: &'static str,
|
|
||||||
/// Whether a call that answered `errno` against the model's word, with
|
|
||||||
/// lines `logged` at `INFO` and above meanwhile and `before` it, is this
|
|
||||||
/// bug. `model` is from before the call.
|
|
||||||
is: fn(model: &Model, op: &FsOp, errno: i32, logged: &[String], before: &[String]) -> bool,
|
|
||||||
/// Whether it also left the mount where the model cannot follow, so the
|
|
||||||
/// seed ends there.
|
|
||||||
ends_seed: fn(model: &Model, op: &FsOp) -> bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const KNOWN: &[Known] = &[
|
|
||||||
// A replacing rename trashes what it replaces first, so Drive refuses the
|
|
||||||
// name only when it holds a node the mount has forgotten: the create of
|
|
||||||
// the replaced file landed after its op was dropped.
|
|
||||||
Known {
|
|
||||||
bug: "B129",
|
|
||||||
is: |model, op, errno, logged, _| {
|
|
||||||
let FsOp::Rename { to, .. } = op else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
errno == libc::EIO
|
|
||||||
&& model.tree().contains_key(to)
|
|
||||||
&& logged
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.starts_with("rename failed") && line.contains("AlreadyExists"))
|
|
||||||
},
|
|
||||||
// The model made the rename, and Drive holds a node the mount does
|
|
||||||
// not know.
|
|
||||||
ends_seed: |_, _| true,
|
|
||||||
},
|
|
||||||
// A folder made offline is listed on Drive once its listing has been
|
|
||||||
// invalidated, and Drive does not know it, or does not list it yet. Only
|
|
||||||
// the client writes its folder, so a folder of its own is never really
|
|
||||||
// gone.
|
|
||||||
Known {
|
|
||||||
bug: "B125",
|
|
||||||
is: |_, _, errno, logged, _| {
|
|
||||||
matches!(errno, libc::EIO | libc::ENOENT)
|
|
||||||
&& logged.iter().any(|line| {
|
|
||||||
line.starts_with("enumerate folder children failed")
|
|
||||||
&& line.contains("DoesNotExist")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// The folder keeps failing to list, and what is in it can come back
|
|
||||||
// as gone, so the mount no longer follows the model.
|
|
||||||
ends_seed: |_, _| true,
|
|
||||||
},
|
|
||||||
// A restart forgets the listings served offline, so the first lookup
|
|
||||||
// lists a folder on Drive, which does not hold what is still queued for
|
|
||||||
// it.
|
|
||||||
Known {
|
|
||||||
bug: "B132",
|
|
||||||
is: |_, _, errno, _, before| {
|
|
||||||
errno == libc::ENOENT
|
|
||||||
&& before
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.starts_with("lost the connection to Proton"))
|
|
||||||
&& before
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.starts_with("restored pending ops"))
|
|
||||||
},
|
|
||||||
// The listing stays without it until something relists the folder.
|
|
||||||
ends_seed: |_, _| true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// An open bug in `docs/BUGS.md` that hangs a call, which the runs tolerate.
|
|
||||||
/// The seed ends there, since the watchdog aborted the mounts.
|
|
||||||
struct KnownHang {
|
|
||||||
bug: &'static str,
|
|
||||||
/// Whether the stacks the watchdog kept show this bug.
|
|
||||||
is: fn(stacks: &str) -> bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const KNOWN_HANGS: &[KnownHang] = &[
|
|
||||||
// A runtime worker invalidating a file's pages waits for the page lock
|
|
||||||
// of a read, whose fetch waits for a timer that no worker drives.
|
|
||||||
KnownHang {
|
|
||||||
bug: "B130",
|
|
||||||
is: |stacks| {
|
|
||||||
stacks.split("\nthread ").any(|thread| {
|
|
||||||
thread.contains("\"tokio-rt-worker\", state D")
|
|
||||||
&& thread.contains("NotifyBatch>::flush")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// What a run looks like.
|
/// What a run looks like.
|
||||||
pub(crate) struct Profile {
|
pub(crate) struct Profile {
|
||||||
@@ -178,16 +79,6 @@ const LOG_TAIL: usize = 40;
|
|||||||
/// for the last run and the mount.
|
/// for the last run and the mount.
|
||||||
const STALL: Duration = Duration::from_secs(120);
|
const STALL: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
/// What a known bug left on Drive in place of a file, and how the model
|
|
||||||
/// takes it in.
|
|
||||||
struct KnownDamage {
|
|
||||||
bug: &'static str,
|
|
||||||
path: String,
|
|
||||||
/// The conflict copy it made, if it made one.
|
|
||||||
copy: Option<String>,
|
|
||||||
change: FsOp,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Client {
|
struct Client {
|
||||||
daemon: Option<Daemon>,
|
daemon: Option<Daemon>,
|
||||||
model: Model,
|
model: Model,
|
||||||
@@ -213,25 +104,14 @@ struct Run {
|
|||||||
clients: Vec<Client>,
|
clients: Vec<Client>,
|
||||||
log: Vec<String>,
|
log: Vec<String>,
|
||||||
started: Instant,
|
started: Instant,
|
||||||
/// How often each [`KNOWN`] bug turned up.
|
|
||||||
known: BTreeMap<&'static str, usize>,
|
|
||||||
/// The known bug that ended the seed early, if one did.
|
|
||||||
ended_by: Option<&'static str>,
|
|
||||||
/// What the daemons logged at `INFO` and above so far.
|
/// What the daemons logged at `INFO` and above so far.
|
||||||
logged: Vec<String>,
|
logged: Vec<String>,
|
||||||
/// Files deleted while the drain held a write to them.
|
|
||||||
held: Vec<NodeUid>,
|
|
||||||
/// Those of them made again before it went on.
|
|
||||||
remade: Vec<NodeUid>,
|
|
||||||
watchdog: Watchdog,
|
watchdog: Watchdog,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `profile` on `seed`. It answers how often each known bug turned up,
|
/// Run `profile` on `seed`. It answers a report of what went wrong, if
|
||||||
/// or a report of what went wrong.
|
/// anything did.
|
||||||
pub(crate) fn run(
|
pub(crate) fn run(seed: u64, profile: &'static Profile) -> Result<(), String> {
|
||||||
seed: u64,
|
|
||||||
profile: &'static Profile,
|
|
||||||
) -> Result<BTreeMap<&'static str, usize>, String> {
|
|
||||||
let dir = scratch(&format!("{}-{seed}", profile.name));
|
let dir = scratch(&format!("{}-{seed}", profile.name));
|
||||||
let mut rng = Rng::new(seed);
|
let mut rng = Rng::new(seed);
|
||||||
let drive = FakeDrive::new();
|
let drive = FakeDrive::new();
|
||||||
@@ -242,18 +122,14 @@ pub(crate) fn run(
|
|||||||
clients: Vec::new(),
|
clients: Vec::new(),
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
known: BTreeMap::new(),
|
|
||||||
ended_by: None,
|
|
||||||
logged: Vec::new(),
|
logged: Vec::new(),
|
||||||
held: Vec::new(),
|
|
||||||
remade: Vec::new(),
|
|
||||||
watchdog: Watchdog::new(&dir),
|
watchdog: Watchdog::new(&dir),
|
||||||
};
|
};
|
||||||
let outcome = run.execute(&dir, &mut rng);
|
let outcome = run.execute(&dir, &mut rng);
|
||||||
run.watchdog.stop();
|
run.watchdog.stop();
|
||||||
// A hang is what failed, whatever the aborted call answered.
|
// A hang is what failed, whatever the aborted call answered.
|
||||||
let outcome = match run.watchdog.fired() {
|
let outcome = match run.watchdog.fired() {
|
||||||
Some(hung) => run.known_hang(&hung),
|
Some(hung) => Err(format!("liveness: {}", hung.what)),
|
||||||
None => outcome,
|
None => outcome,
|
||||||
};
|
};
|
||||||
for client in &mut run.clients {
|
for client in &mut run.clients {
|
||||||
@@ -264,27 +140,13 @@ pub(crate) fn run(
|
|||||||
match outcome {
|
match outcome {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
Ok(run.known)
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(failure) => Err(run.report(&failure, &dir)),
|
Err(failure) => Err(run.report(&failure, &dir)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Run {
|
impl Run {
|
||||||
/// Count `hung` if it is one of the [`KNOWN_HANGS`], or fail on it.
|
|
||||||
fn known_hang(&mut self, hung: &Hung) -> Result<(), String> {
|
|
||||||
let known = KNOWN_HANGS.iter().find(|known| (known.is)(&hung.stacks));
|
|
||||||
match known {
|
|
||||||
Some(known) if tolerate_known() => {
|
|
||||||
*self.known.entry(known.bug).or_default() += 1;
|
|
||||||
self.note(format!("known bug {}; the seed ends here", known.bug));
|
|
||||||
self.ended_by = Some(known.bug);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => Err(format!("liveness: {}", hung.what)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
|
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
|
||||||
for index in 0..self.profile.clients {
|
for index in 0..self.profile.clients {
|
||||||
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
|
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
|
||||||
@@ -302,9 +164,6 @@ impl Run {
|
|||||||
for step in 0..self.profile.steps {
|
for step in 0..self.profile.steps {
|
||||||
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
|
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
|
||||||
self.step(rng)?;
|
self.step(rng)?;
|
||||||
if self.ended_by.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _watching = self.watchdog.arm(
|
let _watching = self.watchdog.arm(
|
||||||
self.profile.drain_budget + STALL,
|
self.profile.drain_budget + STALL,
|
||||||
@@ -363,7 +222,11 @@ impl Run {
|
|||||||
fn settle(&mut self, budget: Duration) -> Result<(), String> {
|
fn settle(&mut self, budget: Duration) -> Result<(), String> {
|
||||||
self.note("settle".into());
|
self.note("settle".into());
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
if !wait_until(budget, || self.landed()) {
|
// What the drain logs meanwhile is what explains a known bug's damage.
|
||||||
|
if !wait_until(budget, || {
|
||||||
|
self.logged.extend(take_logged());
|
||||||
|
self.landed()
|
||||||
|
}) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"liveness: the queues did not drain within {budget:?}\n{}",
|
"liveness: the queues did not drain within {budget:?}\n{}",
|
||||||
self.difference()
|
self.difference()
|
||||||
@@ -409,18 +272,11 @@ impl Run {
|
|||||||
data: bytes,
|
data: bytes,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
if self.ended_by.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if !wait_until(PICKUP_BUDGET, || held.reached()) {
|
if !wait_until(PICKUP_BUDGET, || held.reached()) {
|
||||||
self.note(format!("client {index}: nothing read {path} back"));
|
self.note(format!("client {index}: nothing read {path} back"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
|
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
|
||||||
if self.ended_by.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.held.push(uid.clone());
|
|
||||||
if rng.chance(0.5) {
|
if rng.chance(0.5) {
|
||||||
self.syscall(
|
self.syscall(
|
||||||
index,
|
index,
|
||||||
@@ -429,7 +285,6 @@ impl Run {
|
|||||||
data: data(rng),
|
data: data(rng),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
self.remade.push(uid);
|
|
||||||
}
|
}
|
||||||
let faults = (self.profile.faults)();
|
let faults = (self.profile.faults)();
|
||||||
let latency = Duration::from_millis(faults.latency_ms.1);
|
let latency = Duration::from_millis(faults.latency_ms.1);
|
||||||
@@ -444,11 +299,9 @@ impl Run {
|
|||||||
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
|
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
|
||||||
let client = &mut self.clients[index];
|
let client = &mut self.clients[index];
|
||||||
let root = client.root(index);
|
let root = client.root(index);
|
||||||
let model = client.model.clone();
|
|
||||||
let before = client.model.content(target(&op)).cloned();
|
let before = client.model.content(target(&op)).cloned();
|
||||||
let expected = client.model.apply(&op);
|
let expected = client.model.apply(&op);
|
||||||
self.logged.extend(take_logged());
|
self.logged.extend(take_logged());
|
||||||
let logged_before = self.logged.len();
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let actual = perform(&root, &op, before.as_deref());
|
let actual = perform(&root, &op, before.as_deref());
|
||||||
let took = start.elapsed();
|
let took = start.elapsed();
|
||||||
@@ -458,25 +311,7 @@ impl Run {
|
|||||||
outcome(&actual)
|
outcome(&actual)
|
||||||
));
|
));
|
||||||
let actual = actual?;
|
let actual = actual?;
|
||||||
let logged = take_logged();
|
self.logged.extend(take_logged());
|
||||||
self.logged.extend(logged.iter().cloned());
|
|
||||||
if actual != expected
|
|
||||||
&& let Err(errno) = actual
|
|
||||||
&& tolerate_known()
|
|
||||||
&& let Some(known) = KNOWN.iter().find(|known| {
|
|
||||||
(known.is)(&model, &op, errno, &logged, &self.logged[..logged_before])
|
|
||||||
})
|
|
||||||
{
|
|
||||||
*self.known.entry(known.bug).or_default() += 1;
|
|
||||||
if (known.ends_seed)(&model, &op) {
|
|
||||||
self.note(format!("known bug {}; the seed ends here", known.bug));
|
|
||||||
self.ended_by = Some(known.bug);
|
|
||||||
} else {
|
|
||||||
self.note(format!("known bug {}; taken back", known.bug));
|
|
||||||
}
|
|
||||||
self.clients[index].model = model;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if actual != expected {
|
if actual != expected {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"posix: client {index}: {} answered {}, expected {}",
|
"posix: client {index}: {} answered {}, expected {}",
|
||||||
@@ -513,61 +348,15 @@ impl Run {
|
|||||||
}
|
}
|
||||||
// Each client's folder is its own, so a file of it gone from Drive was
|
// Each client's folder is its own, so a file of it gone from Drive was
|
||||||
// deleted by the client.
|
// deleted by the client.
|
||||||
let own_deletes: Vec<String> = self
|
if let Some(line) = self.logged.iter().find(|line| is_own_delete_kept(line)) {
|
||||||
.logged
|
return Err(format!(
|
||||||
.iter()
|
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
||||||
.filter(|line| is_own_delete_kept(line))
|
));
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for line in own_deletes {
|
|
||||||
let bug = if self
|
|
||||||
.remade
|
|
||||||
.iter()
|
|
||||||
.any(|uid| line.contains(&format!(" uid={uid} ")))
|
|
||||||
{
|
|
||||||
// Made again while the drain held its write: the new file's
|
|
||||||
// write took the freed op id.
|
|
||||||
"B133"
|
|
||||||
} else if !self
|
|
||||||
.held
|
|
||||||
.iter()
|
|
||||||
.any(|uid| line.contains(&format!(" uid={uid} ")))
|
|
||||||
&& !line.contains(" name=\"recovered-")
|
|
||||||
{
|
|
||||||
// The tree still placed the file, so the drain looked before
|
|
||||||
// the unlink had dropped its write.
|
|
||||||
"B134"
|
|
||||||
} else {
|
|
||||||
return Err(format!(
|
|
||||||
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if !tolerate_known() {
|
|
||||||
return Err(format!(
|
|
||||||
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
*self.known.entry(bug).or_default() += 1;
|
|
||||||
self.note(format!("known bug {bug}: {line}"));
|
|
||||||
}
|
}
|
||||||
let drive = self.drive_tree();
|
let drive = self.drive_tree();
|
||||||
let mut explained = Vec::new();
|
|
||||||
for index in 0..self.clients.len() {
|
|
||||||
for damage in self.known_damage(index, &drive) {
|
|
||||||
*self.known.entry(damage.bug).or_default() += 1;
|
|
||||||
self.note(format!(
|
|
||||||
"known bug {}: client {index}'s {} landed as {:?}",
|
|
||||||
damage.bug, damage.path, damage.copy
|
|
||||||
));
|
|
||||||
let _ = self.clients[index].model.apply(&damage.change);
|
|
||||||
if let Some(copy) = damage.copy {
|
|
||||||
explained.push(join(&owned_folder(index), ©));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let conflicts: Vec<String> = drive
|
let conflicts: Vec<String> = drive
|
||||||
.into_keys()
|
.into_keys()
|
||||||
.filter(|path| is_conflict_copy(path) && !explained.contains(path))
|
.filter(|path| is_conflict_copy(path))
|
||||||
.collect();
|
.collect();
|
||||||
if !conflicts.is_empty() {
|
if !conflicts.is_empty() {
|
||||||
return Err(format!("no false conflicts: {conflicts:?}"));
|
return Err(format!("no false conflicts: {conflicts:?}"));
|
||||||
@@ -589,155 +378,18 @@ impl Run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether every client's queue is empty and Drive holds what its model
|
/// Whether every client's queue is empty and Drive holds what its model
|
||||||
/// says, but for what known bugs explain.
|
/// says.
|
||||||
fn landed(&self) -> bool {
|
fn landed(&self) -> bool {
|
||||||
let drive = self.drive_tree();
|
let drive = self.drive_tree();
|
||||||
self.clients.iter().enumerate().all(|(index, client)| {
|
self.clients.iter().enumerate().all(|(index, client)| {
|
||||||
let mut model = client.model.clone();
|
|
||||||
for damage in self.known_damage(index, &drive) {
|
|
||||||
let _ = model.apply(&damage.change);
|
|
||||||
}
|
|
||||||
client
|
client
|
||||||
.daemon()
|
.daemon()
|
||||||
.pending()
|
.pending()
|
||||||
.is_ok_and(|items| items.is_empty())
|
.is_ok_and(|items| items.is_empty())
|
||||||
&& subtree(&drive, &owned_folder(index)) == *model.tree()
|
&& subtree(&drive, &owned_folder(index)) == *client.model.tree()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The files of client `index` that a known bug left otherwise on Drive
|
|
||||||
/// than the model says. A conflict copy takes Drive holding it with the
|
|
||||||
/// model's bytes, and the drain saying it made that copy for that name:
|
|
||||||
///
|
|
||||||
/// - B126: the file's queued create ran into its name while a queued
|
|
||||||
/// rename was about to free it, so it is there only as the copy.
|
|
||||||
/// - B127: the file's queued create landed but its answer was lost, so
|
|
||||||
/// the retry found it and made the copy next to it.
|
|
||||||
///
|
|
||||||
/// A lost file takes the drain saying it adopted a node for that name
|
|
||||||
/// whose queued trash then landed:
|
|
||||||
///
|
|
||||||
/// - B128: the file's queued create ran into the name of the file it
|
|
||||||
/// replaced and took that over, trash and all.
|
|
||||||
///
|
|
||||||
/// A conflict copy of a file the client deleted takes the drain saying it
|
|
||||||
/// kept that file's write as the copy, because Drive had the file in the
|
|
||||||
/// trash while the tree still placed it:
|
|
||||||
///
|
|
||||||
/// - B134: the unlink trashed the file on Drive before it dropped the
|
|
||||||
/// write.
|
|
||||||
fn known_damage(&self, index: usize, drive: &Tree) -> Vec<KnownDamage> {
|
|
||||||
if !tolerate_known() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let drive = subtree(drive, &owned_folder(index));
|
|
||||||
let model = self.clients[index].model.tree();
|
|
||||||
let made_copy = |path: &str, copy: &str| {
|
|
||||||
let wanted = format!("wanted={:?}", name_of(path));
|
|
||||||
let name = format!("name={:?}", name_of(copy));
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("name is taken remotely; creating under a conflict name")
|
|
||||||
&& line.contains(&wanted)
|
|
||||||
&& line.contains(&name)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let adopted_trashed = |path: &str| {
|
|
||||||
let wanted = format!("wanted={:?}", name_of(path));
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("the name is held by our own unanswered create; adopting it")
|
|
||||||
&& line.contains(&wanted)
|
|
||||||
&& line.split(' ').any(|field| {
|
|
||||||
field.strip_prefix("twin=").is_some_and(|twin| {
|
|
||||||
let uid = format!("uid={twin}");
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("pending trash landed")
|
|
||||||
&& line.split(' ').any(|field| field == uid)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let kept_deleted = |copy: &str| {
|
|
||||||
let alt = format!(" alt={:?}", name_of(copy));
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("queued write landed as a conflict copy")
|
|
||||||
&& line.ends_with(&alt)
|
|
||||||
&& line.split(' ').any(|field| {
|
|
||||||
field.starts_with("uid=")
|
|
||||||
&& self.logged.iter().any(|kept| {
|
|
||||||
is_own_delete_kept(kept)
|
|
||||||
&& !kept.contains(" name=\"recovered-")
|
|
||||||
&& kept.split(' ').any(|other| other == field)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let mut damage = Vec::new();
|
|
||||||
for (copy, content) in &drive {
|
|
||||||
let Some(content) = content else { continue };
|
|
||||||
if !is_conflict_copy(copy) || model.contains_key(copy) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !kept_deleted(copy) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
damage.push(KnownDamage {
|
|
||||||
bug: "B134",
|
|
||||||
path: copy.clone(),
|
|
||||||
copy: Some(copy.clone()),
|
|
||||||
change: FsOp::Create {
|
|
||||||
path: copy.clone(),
|
|
||||||
data: content.clone(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (path, content) in model {
|
|
||||||
let Some(content) = content else { continue };
|
|
||||||
let copy = drive.iter().find(|(copy, other)| {
|
|
||||||
is_conflict_copy(copy)
|
|
||||||
&& !model.contains_key(*copy)
|
|
||||||
&& parent_of(copy) == parent_of(path)
|
|
||||||
&& other.as_ref() == Some(content)
|
|
||||||
&& made_copy(path, copy)
|
|
||||||
});
|
|
||||||
let Some((copy, _)) = copy else {
|
|
||||||
if !drive.contains_key(path) && adopted_trashed(path) {
|
|
||||||
damage.push(KnownDamage {
|
|
||||||
bug: "B128",
|
|
||||||
path: path.clone(),
|
|
||||||
copy: None,
|
|
||||||
change: FsOp::Unlink { path: path.clone() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let (bug, change) = match drive.get(path) {
|
|
||||||
None => (
|
|
||||||
"B126",
|
|
||||||
FsOp::Rename {
|
|
||||||
from: path.clone(),
|
|
||||||
to: copy.clone(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Some(Some(landed)) if landed == content => (
|
|
||||||
"B127",
|
|
||||||
FsOp::Create {
|
|
||||||
path: copy.clone(),
|
|
||||||
data: content.clone(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Some(_) => continue,
|
|
||||||
};
|
|
||||||
damage.push(KnownDamage {
|
|
||||||
bug,
|
|
||||||
path: path.clone(),
|
|
||||||
copy: Some(copy.clone()),
|
|
||||||
change,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
damage
|
|
||||||
}
|
|
||||||
|
|
||||||
fn difference(&self) -> String {
|
fn difference(&self) -> String {
|
||||||
let drive = self.drive_tree();
|
let drive = self.drive_tree();
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@@ -890,11 +542,6 @@ fn subtree(tree: &Tree, folder: &str) -> Tree {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a known bug is counted rather than failing the run.
|
|
||||||
fn tolerate_known() -> bool {
|
|
||||||
std::env::var("PDFS_SIM_KNOWN").as_deref() != Ok("fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn owned_folder(index: usize) -> String {
|
fn owned_folder(index: usize) -> String {
|
||||||
format!("c{index}")
|
format!("c{index}")
|
||||||
}
|
}
|
||||||
@@ -970,21 +617,10 @@ pub(crate) fn run_seeds(profile: &'static Profile, default_seeds: u64) {
|
|||||||
(1..=count).collect()
|
(1..=count).collect()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut failures = Vec::new();
|
let failures: Vec<String> = seeds
|
||||||
let mut known = BTreeMap::<&str, usize>::new();
|
.into_iter()
|
||||||
for seed in seeds {
|
.filter_map(|seed| run(seed, profile).err())
|
||||||
match run(seed, profile) {
|
.collect();
|
||||||
Ok(hits) => {
|
|
||||||
for (bug, count) in hits {
|
|
||||||
*known.entry(bug).or_default() += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(failure) => failures.push(failure),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !known.is_empty() {
|
|
||||||
eprintln!("{}: known bugs turned up: {known:?}", profile.name);
|
|
||||||
}
|
|
||||||
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
|
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +680,21 @@ mod tests {
|
|||||||
drain_budget: Duration::from_secs(120),
|
drain_budget: Duration::from_secs(120),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Wi-Fi's latency, lag and lost replies without the outages: the queue
|
||||||
|
/// has to drain within a settle, not merely by the end.
|
||||||
|
static ONE_CLIENT_WIFI: Profile = Profile {
|
||||||
|
name: "one-client-wifi",
|
||||||
|
clients: 1,
|
||||||
|
steps: 80,
|
||||||
|
faults: Faults::wifi,
|
||||||
|
link_flaps: false,
|
||||||
|
restarts: false,
|
||||||
|
deletes_mid_upload: false,
|
||||||
|
settle_budget: Some(Duration::from_secs(30)),
|
||||||
|
syscall_budget: Duration::from_secs(10),
|
||||||
|
drain_budget: Duration::from_secs(120),
|
||||||
|
};
|
||||||
|
|
||||||
static THREE_CLIENTS: Profile = Profile {
|
static THREE_CLIENTS: Profile = Profile {
|
||||||
name: "three-clients",
|
name: "three-clients",
|
||||||
clients: 3,
|
clients: 3,
|
||||||
@@ -1081,6 +732,12 @@ mod tests {
|
|||||||
run_seeds(&ONE_CLIENT_FLAKY, 4);
|
run_seeds(&ONE_CLIENT_FLAKY, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||||
|
fn one_client_on_a_slow_link() {
|
||||||
|
run_seeds(&ONE_CLIENT_WIFI, 4);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||||
fn three_clients_one_writer_each() {
|
fn three_clients_one_writer_each() {
|
||||||
@@ -1106,15 +763,4 @@ mod tests {
|
|||||||
&kept.replace("the file was trashed remotely", "revision changed")
|
&kept.replace("the file was trashed remotely", "revision changed")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_invalidation_stuck_on_a_page_lock_is_b130() {
|
|
||||||
let b130 = |stacks: &str| KNOWN_HANGS.iter().any(|known| (known.is)(stacks));
|
|
||||||
let stuck = "thread 1 \"fuser-0\", state S, waiting in fuse_dev_do_read\n\
|
|
||||||
\nthread 2 \"tokio-rt-worker\", state D, waiting in __folio_lock\n\
|
|
||||||
\x20 11: <pdfs_fuse::NotifyBatch>::flush\n";
|
|
||||||
assert!(b130(stuck));
|
|
||||||
assert!(!b130(&stuck.replace("state D", "state S")));
|
|
||||||
assert!(!b130(&stuck.replace("NotifyBatch", "Other")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
/// 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
|
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||||
@@ -212,6 +212,12 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
|
|||||||
lid.unsigned_abs() + crate::ROOT_INO
|
lid.unsigned_abs() + crate::ROOT_INO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
||||||
|
/// `None` for the root and a fallback inode.
|
||||||
|
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
||||||
|
(ino > crate::ROOT_INO && ino < FALLBACK_INO).then(|| (ino - crate::ROOT_INO) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||||
/// `Filesystem` trait through `&self`.
|
/// `Filesystem` trait through `&self`.
|
||||||
///
|
///
|
||||||
@@ -247,7 +253,7 @@ pub(crate) struct State {
|
|||||||
/// two pages shifted every later entry, and the caller silently skipped or
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
/// repeated whatever crossed the page boundary. Each entry is
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
/// `(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>>,
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
@@ -878,6 +884,11 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write `node` through as it is, in order with the tree's other changes.
|
||||||
|
pub(crate) fn write_through(&mut self, node: Node) {
|
||||||
|
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
|
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
|
||||||
self.keep_revision_mtime(&mut node);
|
self.keep_revision_mtime(&mut node);
|
||||||
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||||
@@ -1187,7 +1198,7 @@ impl State {
|
|||||||
/// re-enumerate, which is the cheaper way to stay honest about what the
|
/// 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
|
/// 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
|
/// 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(
|
pub(crate) fn rename_in_place(
|
||||||
&mut self,
|
&mut self,
|
||||||
ino: u64,
|
ino: u64,
|
||||||
@@ -1306,7 +1317,7 @@ impl State {
|
|||||||
/// created offline, nothing at all), so this row is the only record that the
|
/// 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
|
/// 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
|
/// 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
|
/// 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:
|
/// the one mutation here whose write-through the caller must do itself:
|
||||||
/// failure has to be reported, not logged. Dropping the row silently means
|
/// failure has to be reported, not logged. Dropping the row silently means
|
||||||
@@ -1325,6 +1336,13 @@ mod tests {
|
|||||||
use proton_drive_rs::ShareMembership;
|
use proton_drive_rs::ShareMembership;
|
||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inode_gives_back_the_lid_it_was_made_from() {
|
||||||
|
assert_eq!(ino_lid(lid_ino(7)), Some(7));
|
||||||
|
assert_eq!(ino_lid(crate::ROOT_INO), None);
|
||||||
|
assert_eq!(ino_lid(FALLBACK_INO + 3), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn uid(link: &str) -> NodeUid {
|
fn uid(link: &str) -> NodeUid {
|
||||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ impl Core {
|
|||||||
/// docs for the identical/divergent policy.
|
/// docs for the identical/divergent policy.
|
||||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
// Both waits are interruptible, so teardown does not have to sit out a
|
// 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) {
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||||
return;
|
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
|
//! 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
|
//! and a folder under this machine's Proton Drive device. A local filesystem
|
||||||
@@ -102,6 +102,9 @@ impl Core {
|
|||||||
|
|
||||||
/// One reconcile pass over a mirror folder, updating its `state` column.
|
/// One reconcile pass over a mirror folder, updating its `state` column.
|
||||||
fn reconcile_pass(&self, folder: &StoredSyncFolder) {
|
fn reconcile_pass(&self, folder: &StoredSyncFolder) {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Hold the folder's lock for the whole pass so a mode switch can't evict the
|
// Hold the folder's lock for the whole pass so a mode switch can't evict the
|
||||||
// local tree (and mount FUSE over it) while we walk and upload it.
|
// local tree (and mount FUSE over it) while we walk and upload it.
|
||||||
let lock = self.sync_lock(folder.id);
|
let lock = self.sync_lock(folder.id);
|
||||||
@@ -148,8 +151,6 @@ impl Core {
|
|||||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||||
} else {
|
} else {
|
||||||
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
||||||
Ok(outcome) => Ok(outcome),
|
|
||||||
Err(PassAbort::Failed(e)) => Err(e),
|
|
||||||
// The user asked for on-demand while this pass was running. Rather
|
// The user asked for on-demand while this pass was running. Rather
|
||||||
// than make them wait out a walk-and-download whose results are
|
// than make them wait out a walk-and-download whose results are
|
||||||
// about to be deleted, drop it here and do the only part that still
|
// about to be deleted, drop it here and do the only part that still
|
||||||
@@ -164,6 +165,7 @@ impl Core {
|
|||||||
self.progress_begin(folder.id);
|
self.progress_begin(folder.id);
|
||||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||||
}
|
}
|
||||||
|
result => result,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.progress_end(folder.id);
|
self.progress_end(folder.id);
|
||||||
@@ -216,7 +218,15 @@ impl Core {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
// A push pass is never interrupted, so only the stop gets here: the
|
||||||
|
// folder keeps the state it had, which the next start's pass updates.
|
||||||
|
Err(PassAbort::Stopped | PassAbort::Interrupted) => {
|
||||||
|
info!(id = folder.id, "sync: pass cut short by the stop");
|
||||||
|
let _ = self
|
||||||
|
.db
|
||||||
|
.sync_folder_set_state(folder.id, ¤t.state, current.last_sync);
|
||||||
|
}
|
||||||
|
Err(PassAbort::Failed(e)) => {
|
||||||
warn!(id = folder.id, error = %e, "sync: reconcile failed");
|
warn!(id = folder.id, error = %e, "sync: reconcile failed");
|
||||||
let _ = self
|
let _ = self
|
||||||
.db
|
.db
|
||||||
@@ -294,7 +304,7 @@ impl Core {
|
|||||||
local_root: &Path,
|
local_root: &Path,
|
||||||
remote_root: &NodeUid,
|
remote_root: &NodeUid,
|
||||||
rules: &IgnoreRules,
|
rules: &IgnoreRules,
|
||||||
) -> Result<Outcome, String> {
|
) -> Result<Outcome, PassAbort> {
|
||||||
let baseline = self
|
let baseline = self
|
||||||
.db
|
.db
|
||||||
.sync_entries(folder_id)
|
.sync_entries(folder_id)
|
||||||
@@ -400,7 +410,7 @@ impl Core {
|
|||||||
&mut remote_dirs,
|
&mut remote_dirs,
|
||||||
std::mem::take(&mut batch),
|
std::mem::take(&mut batch),
|
||||||
&mut outcome,
|
&mut outcome,
|
||||||
);
|
)?;
|
||||||
batch_depth = depth;
|
batch_depth = depth;
|
||||||
}
|
}
|
||||||
let item = &local[rel];
|
let item = &local[rel];
|
||||||
@@ -498,7 +508,7 @@ impl Core {
|
|||||||
&mut remote_dirs,
|
&mut remote_dirs,
|
||||||
std::mem::take(&mut batch),
|
std::mem::take(&mut batch),
|
||||||
&mut outcome,
|
&mut outcome,
|
||||||
);
|
)?;
|
||||||
|
|
||||||
// Paths the baseline knows that are no longer on disk: the user deleted them
|
// Paths the baseline knows that are no longer on disk: the user deleted them
|
||||||
// locally, and the deletion has to reach Drive before the mount starts
|
// locally, and the deletion has to reach Drive before the mount starts
|
||||||
@@ -625,7 +635,7 @@ impl Core {
|
|||||||
&mut remote_dirs,
|
&mut remote_dirs,
|
||||||
std::mem::take(&mut batch),
|
std::mem::take(&mut batch),
|
||||||
&mut outcome,
|
&mut outcome,
|
||||||
);
|
)?;
|
||||||
batch_depth = depth;
|
batch_depth = depth;
|
||||||
// A depth boundary is the pass's natural checkpoint — everything
|
// A depth boundary is the pass's natural checkpoint — everything
|
||||||
// queued has been applied, so stopping here leaves nothing half done.
|
// queued has been applied, so stopping here leaves nothing half done.
|
||||||
@@ -813,7 +823,7 @@ impl Core {
|
|||||||
&mut remote_dirs,
|
&mut remote_dirs,
|
||||||
std::mem::take(&mut batch),
|
std::mem::take(&mut batch),
|
||||||
&mut outcome,
|
&mut outcome,
|
||||||
);
|
)?;
|
||||||
|
|
||||||
// Deferred folder deletions, deepest first.
|
// Deferred folder deletions, deepest first.
|
||||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||||
@@ -941,6 +951,9 @@ impl Core {
|
|||||||
// is safe precisely because the walk has applied nothing — a *partial*
|
// is safe precisely because the walk has applied nothing — a *partial*
|
||||||
// remote map, on the other hand, would be read as "the remote deleted
|
// remote map, on the other hand, would be read as "the remote deleted
|
||||||
// everything we haven't reached yet", so it must never reach the diff.
|
// everything we haven't reached yet", so it must never reach the diff.
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
return Err(PassAbort::Stopped);
|
||||||
|
}
|
||||||
if self.ondemand_queued(folder_id) {
|
if self.ondemand_queued(folder_id) {
|
||||||
return Err(PassAbort::Interrupted);
|
return Err(PassAbort::Interrupted);
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1064,9 @@ impl Core {
|
|||||||
/// `remote_dirs` (so deeper paths resolve their parent), and what each op did
|
/// `remote_dirs` (so deeper paths resolve their parent), and what each op did
|
||||||
/// is tallied into `outcome`. Driven by `block_on` from the (non-runtime) sync
|
/// is tallied into `outcome`. Driven by `block_on` from the (non-runtime) sync
|
||||||
/// engine thread, spawning the tasks onto the shared runtime.
|
/// engine thread, spawning the tasks onto the shared runtime.
|
||||||
|
///
|
||||||
|
/// Fails with [`PassAbort::Stopped`] once the daemon is stopping, after the
|
||||||
|
/// ops already sending have finished.
|
||||||
fn flush_batch(
|
fn flush_batch(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
@@ -1058,9 +1074,9 @@ impl Core {
|
|||||||
remote_dirs: &mut HashMap<String, NodeUid>,
|
remote_dirs: &mut HashMap<String, NodeUid>,
|
||||||
batch: Vec<Pending>,
|
batch: Vec<Pending>,
|
||||||
outcome: &mut Outcome,
|
outcome: &mut Outcome,
|
||||||
) {
|
) -> Result<(), PassAbort> {
|
||||||
if batch.is_empty() {
|
if batch.is_empty() {
|
||||||
return;
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.progress_queued(folder_id, batch.len());
|
self.progress_queued(folder_id, batch.len());
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
@@ -1074,14 +1090,22 @@ impl Core {
|
|||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
let _permit = sem.acquire_owned().await.expect("semaphore not closed");
|
let _permit = sem.acquire_owned().await.expect("semaphore not closed");
|
||||||
core.apply_pending(folder_id, &root, op).await
|
// An op still waiting for its turn when the stop comes is
|
||||||
|
// not started; the ones already sending finish.
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(core.apply_pending(folder_id, &root, op).await)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut join_errors = 0usize;
|
let mut join_errors = 0usize;
|
||||||
while let Some(joined) = set.join_next().await {
|
while let Some(joined) = set.join_next().await {
|
||||||
match joined {
|
match joined {
|
||||||
Ok(result) => out.push(result),
|
Ok(Some(result)) => out.push(result),
|
||||||
|
Ok(None) => {}
|
||||||
|
// The runtime ends with the daemon and cancels what is left.
|
||||||
|
Err(e) if e.is_cancelled() => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
join_errors += 1;
|
join_errors += 1;
|
||||||
warn!(error = %e, "sync: task panicked");
|
warn!(error = %e, "sync: task panicked");
|
||||||
@@ -1106,6 +1130,13 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The pass goes no further: the next depth's paths may need a folder
|
||||||
|
// this batch did not get to make, and went on to fail one by one
|
||||||
|
// (`docs/BUGS.md` B169).
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
return Err(PassAbort::Stopped);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one [`Pending`] op (async, so it can run concurrently in a batch),
|
/// Apply one [`Pending`] op (async, so it can run concurrently in a batch),
|
||||||
@@ -1311,7 +1342,7 @@ impl Core {
|
|||||||
/// rename below is the destructive step, and the download in between can run
|
/// 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
|
/// 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
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
&self,
|
&self,
|
||||||
@@ -1410,7 +1441,7 @@ impl Core {
|
|||||||
/// the pre-upload identity is what makes the next pass see the path as
|
/// 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
|
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||||
/// post-upload stat instead declared the torn revision synchronized and left
|
/// 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(
|
async fn record_file_baseline(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
@@ -1474,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
|
/// 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
|
/// 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
|
/// 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) {
|
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||||
let now = local_sig(path);
|
let now = local_sig(path);
|
||||||
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||||
@@ -1506,7 +1537,7 @@ fn local_sig(path: &Path) -> Option<LocalSig> {
|
|||||||
/// transfer started, and a download of a large file can run for minutes. Without
|
/// 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
|
/// 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
|
/// 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
|
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||||
/// conflict already gets.
|
/// conflict already gets.
|
||||||
///
|
///
|
||||||
@@ -1542,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
|
/// 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
|
/// 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
|
/// 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<()> {
|
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||||
match result {
|
match result {
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
@@ -1799,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
|
/// 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
|
/// 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
|
/// cannot answer, which falls the comparison back to seconds rather than
|
||||||
/// asserting an mtime of zero.
|
/// asserting an mtime of zero.
|
||||||
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||||
@@ -1814,7 +1845,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A removal of something already gone has produced the state the sync
|
/// A removal of something already gone has produced the state the sync
|
||||||
/// engine wanted, so its baseline is dropped rather than retried forever.
|
/// 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]
|
#[test]
|
||||||
fn an_already_absent_path_counts_as_removed() {
|
fn an_already_absent_path_counts_as_removed() {
|
||||||
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||||
@@ -1831,7 +1862,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The download guard: a destination that still matches the plan is
|
/// 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]
|
#[test]
|
||||||
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
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()));
|
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||||
@@ -2146,7 +2177,7 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&root).unwrap();
|
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
|
/// an empty local directory and a full remote. That must reconcile as
|
||||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||||
/// every restore would wedge its folder on the first pass.
|
/// 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).
|
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||||
Rewatch,
|
Rewatch,
|
||||||
/// The daemon is tearing down: finish the current pass and end the engine
|
/// 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,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
|||||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
/// falls back to whole seconds, which is what a baseline row written before
|
/// 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
|
/// 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 {
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
if self.size != other.size {
|
if self.size != other.size {
|
||||||
return false;
|
return false;
|
||||||
@@ -161,7 +161,7 @@ pub(super) enum Pending {
|
|||||||
/// was there. A download can run for minutes, so the apply step revalidates
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
/// against it immediately before it renames over the destination: a writer
|
/// against it immediately before it renames over the destination: a writer
|
||||||
/// that finished an edit in the meantime must not be overwritten without a
|
/// 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 {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
@@ -227,6 +227,10 @@ pub(super) enum PassAbort {
|
|||||||
Interrupted,
|
Interrupted,
|
||||||
/// The pass could not establish its diff (a walk or the baseline load failed).
|
/// The pass could not establish its diff (a walk or the baseline load failed).
|
||||||
Failed(String),
|
Failed(String),
|
||||||
|
/// The daemon is stopping. What the pass sent before the stop has landed or
|
||||||
|
/// will be cancelled with the runtime; nothing more is sent, and the next
|
||||||
|
/// start's pass picks up from the baseline.
|
||||||
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for PassAbort {
|
impl From<String> for PassAbort {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ pub(super) fn plan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_changed = local.is_some_and(|item| {
|
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
|
// 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.
|
// left the file the same length, so it was never uploaded.
|
||||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
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
|
/// 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
|
/// 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]
|
#[test]
|
||||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||||
let mut base = baseline(10, 20, 30, 40);
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ fn open_archive(
|
|||||||
|
|
||||||
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||||
/// photo duplicate detection is keyed on.
|
/// photo duplicate detection is keyed on.
|
||||||
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||||
let mut hasher = Sha1::new();
|
let mut hasher = Sha1::new();
|
||||||
let mut buffer = vec![0u8; 256 * 1024];
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
.badge-pinned { color: var(--success-color); }
|
.badge-pinned { color: var(--success-color); }
|
||||||
.badge-cached { color: var(--success-color); }
|
.badge-cached { color: var(--success-color); }
|
||||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
|
.badge-issue { color: var(--error-color); }
|
||||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ use pdfs_core::control::{
|
|||||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection,
|
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
||||||
TransferItem, send,
|
TransferDirection, TransferItem, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
|||||||
@@ -341,6 +341,59 @@ fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
|||||||
&[("name", alt)],
|
&[("name", alt)],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some((reason, copy)) = detail.split_once("; local changes uploaded as ") {
|
||||||
|
let args = [("name", copy)];
|
||||||
|
return Some(match edit_conflict(reason) {
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Gone => gettext_f(
|
||||||
|
"It no longer exists on Proton Drive, so your changes were uploaded as {name}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Trashed => gettext_f(
|
||||||
|
"It was moved to the Trash on Proton Drive, so your changes were uploaded as {name}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Changed => gettext_f(
|
||||||
|
"It was changed on Proton Drive, so your changes were uploaded as {name}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Other => gettext_f("Your changes were uploaded as {name}", &args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some((reason, path)) = detail.split_once("; local changes kept at ") {
|
||||||
|
let path = tilde_path(path);
|
||||||
|
let args = [("path", path.as_str())];
|
||||||
|
return Some(match edit_conflict(reason) {
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Gone => gettext_f(
|
||||||
|
"It no longer exists on Proton Drive, so your changes were kept in {path}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Trashed => gettext_f(
|
||||||
|
"It was moved to the Trash on Proton Drive, so your changes were kept in {path}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Changed => gettext_f(
|
||||||
|
"It was changed on Proton Drive, so your changes were kept in {path}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Other => gettext_f("Your changes were kept in {path}", &args),
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(base) = detail.strip_prefix("differs from ") {
|
if let Some(base) = detail.strip_prefix("differs from ") {
|
||||||
// Translators: {name} is the file the conflicting copy was made from.
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
||||||
@@ -388,6 +441,26 @@ fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
|||||||
Some(capitalize(detail))
|
Some(capitalize(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a queued edit could not land on its file, from the reason the daemon
|
||||||
|
/// gives before "; local changes uploaded as" or "; local changes kept at".
|
||||||
|
enum EditConflict {
|
||||||
|
Gone,
|
||||||
|
Trashed,
|
||||||
|
Changed,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_conflict(reason: &str) -> EditConflict {
|
||||||
|
match reason {
|
||||||
|
"the file no longer exists remotely" => EditConflict::Gone,
|
||||||
|
"the file was trashed remotely" => EditConflict::Trashed,
|
||||||
|
r if r.starts_with("the remote revision changed under the queued write") => {
|
||||||
|
EditConflict::Changed
|
||||||
|
}
|
||||||
|
_ => EditConflict::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
||||||
/// list of counts. `None` when a part is not one the daemon writes.
|
/// list of counts. `None` when a part is not one the daemon writes.
|
||||||
fn sync_summary(detail: &str) -> Option<String> {
|
fn sync_summary(detail: &str) -> Option<String> {
|
||||||
@@ -596,6 +669,38 @@ mod tests {
|
|||||||
assert_eq!(d.details, vec!["1 person can view"]);
|
assert_eq!(d.details, vec!["1 person can view"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_kept_as_a_copy_says_why_in_words() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"notes.txt",
|
||||||
|
"the remote revision changed under the queued write \
|
||||||
|
(based on revision r1, remote now at r2); \
|
||||||
|
local changes uploaded as notes (sync-conflict 1700000000).txt",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
d.details,
|
||||||
|
vec![
|
||||||
|
"It was changed on Proton Drive, so your changes were uploaded as \
|
||||||
|
notes (sync-conflict 1700000000).txt"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"notes.txt",
|
||||||
|
"the file was trashed remotely; local changes kept at /var/staging/7",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
d.details,
|
||||||
|
vec![
|
||||||
|
"It was moved to the Trash on Proton Drive, so your changes were kept in \
|
||||||
|
/var/staging/7"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_details_show_as_written() {
|
fn unknown_details_show_as_written() {
|
||||||
let d = describe(&entry(
|
let d = describe(&entry(
|
||||||
|
|||||||
@@ -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,
|
/// The menu for one entry, in a fixed order: open, offline, sharing,
|
||||||
/// offline, sharing, organising, and the destructive item last on its own.
|
/// organising, and the destructive item last on its own.
|
||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
@@ -2495,9 +2495,13 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
||||||
/// (which carry no per-file cache state). Pinned (kept offline) ranks above merely
|
/// (which carry no per-file cache state). A sync issue ranks first, on folders
|
||||||
|
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
|
||||||
/// cached (downloaded, evictable); everything else is online-only.
|
/// cached (downloaded, evictable); everything else is online-only.
|
||||||
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
||||||
|
if entry.issue.is_some() {
|
||||||
|
return Some(("dialog-warning-symbolic", "badge-issue"));
|
||||||
|
}
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -2513,9 +2517,11 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
|
|||||||
/// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any
|
/// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any
|
||||||
/// prior colour class first, since list factories recycle cells.
|
/// prior colour class first, since list factories recycle cells.
|
||||||
pub(crate) fn apply_badge(badge: >k4::Image, entry: &DirEntry) {
|
pub(crate) fn apply_badge(badge: >k4::Image, entry: &DirEntry) {
|
||||||
for class in ["badge-pinned", "badge-cached", "badge-cloud"] {
|
for class in ["badge-pinned", "badge-cached", "badge-cloud", "badge-issue"] {
|
||||||
badge.remove_css_class(class);
|
badge.remove_css_class(class);
|
||||||
}
|
}
|
||||||
|
let issue = entry.issue.map(super::locations::issue_text);
|
||||||
|
badge.set_tooltip_text(issue.as_deref());
|
||||||
match badge_for(entry) {
|
match badge_for(entry) {
|
||||||
Some((icon, class)) => {
|
Some((icon, class)) => {
|
||||||
badge.set_icon_name(Some(icon));
|
badge.set_icon_name(Some(icon));
|
||||||
@@ -2915,6 +2921,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
sort_entries(&mut entries, ui.browser.view.get());
|
sort_entries(&mut entries, ui.browser.view.get());
|
||||||
@@ -2952,11 +2959,11 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
MoveTarget, hit_location, listing_summary, move_target_allowed,
|
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
||||||
show_thumbnail_build_progress, sort_entries,
|
show_thumbnail_build_progress, sort_entries,
|
||||||
};
|
};
|
||||||
use pdfs_core::config::{FileSort, FilesView};
|
use pdfs_core::config::{FileSort, FilesView};
|
||||||
use pdfs_core::control::{DirEntry, ThumbnailBuildStatus};
|
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
||||||
|
|
||||||
fn entry(name: &str, is_dir: bool, size: u64, modified: i64) -> DirEntry {
|
fn entry(name: &str, is_dir: bool, size: u64, modified: i64) -> DirEntry {
|
||||||
DirEntry {
|
DirEntry {
|
||||||
@@ -2974,6 +2981,7 @@ mod tests {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2981,6 +2989,20 @@ mod tests {
|
|||||||
entries.iter().map(|e| e.name.as_str()).collect()
|
entries.iter().map(|e| e.name.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sync_issue_shows_on_the_node_before_its_cache_state() {
|
||||||
|
let mut file = entry("a.txt", false, 1, 0);
|
||||||
|
file.pinned = true;
|
||||||
|
assert_eq!(badge_for(&file).unwrap().1, "badge-pinned");
|
||||||
|
file.issue = Some(SyncIssue::Quota);
|
||||||
|
assert_eq!(badge_for(&file).unwrap().1, "badge-issue");
|
||||||
|
|
||||||
|
let mut folder = entry("d", true, 0, 0);
|
||||||
|
assert_eq!(badge_for(&folder), None);
|
||||||
|
folder.issue = Some(SyncIssue::Missing);
|
||||||
|
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_search_hit_location_is_its_parent_folder() {
|
fn a_search_hit_location_is_its_parent_folder() {
|
||||||
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
|
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
//! `~/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
|
//! 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
|
//! *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:
|
//! 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
|
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||||
@@ -70,8 +70,9 @@ pub(crate) struct LocationRow {
|
|||||||
progress: gtk4::ProgressBar,
|
progress: gtk4::ProgressBar,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a queue row shows that can change: id, attempts, next attempt, parked.
|
/// What a queue row shows that can change: id, attempts, next attempt, parked,
|
||||||
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
|
/// issue.
|
||||||
|
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool, Option<SyncIssue>);
|
||||||
|
|
||||||
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
||||||
pub(crate) struct QueueState {
|
pub(crate) struct QueueState {
|
||||||
@@ -591,7 +592,7 @@ fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
|||||||
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
||||||
let key: Vec<QueueKey> = items
|
let key: Vec<QueueKey> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked))
|
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked, op.issue))
|
||||||
.collect();
|
.collect();
|
||||||
if *queue.painted.borrow() == key {
|
if *queue.painted.borrow() == key {
|
||||||
return;
|
return;
|
||||||
@@ -648,6 +649,10 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
|||||||
"{action} · {state}",
|
"{action} · {state}",
|
||||||
&[("action", &action), ("state", &state)],
|
&[("action", &action), ("state", &state)],
|
||||||
);
|
);
|
||||||
|
if let Some(issue) = op.issue {
|
||||||
|
subtitle.push('\n');
|
||||||
|
subtitle.push_str(&issue_text(issue));
|
||||||
|
}
|
||||||
if let Some(error) = &op.last_error {
|
if let Some(error) = &op.last_error {
|
||||||
subtitle.push('\n');
|
subtitle.push('\n');
|
||||||
// Translators: {n} is how many attempts failed, {error} the last error message.
|
// Translators: {n} is how many attempts failed, {error} the last error message.
|
||||||
@@ -662,7 +667,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
|||||||
.title(glib::markup_escape_text(&op.path).as_str())
|
.title(glib::markup_escape_text(&op.path).as_str())
|
||||||
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
||||||
.title_lines(1)
|
.title_lines(1)
|
||||||
.subtitle_lines(3)
|
.subtitle_lines(if op.issue.is_some() { 5 } else { 3 })
|
||||||
.tooltip_text(&op.path)
|
.tooltip_text(&op.path)
|
||||||
.build();
|
.build();
|
||||||
let image = gtk4::Image::from_icon_name(if op.failing {
|
let image = gtk4::Image::from_icon_name(if op.failing {
|
||||||
@@ -687,9 +692,157 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
|||||||
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
||||||
row.add_suffix(&retry);
|
row.add_suffix(&retry);
|
||||||
}
|
}
|
||||||
|
if op.failing && op.exportable {
|
||||||
|
let export = gtk4::Button::builder()
|
||||||
|
.label(gettext("Export"))
|
||||||
|
.tooltip_text(gettext("Save a copy of this file's content elsewhere"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
export.add_css_class("flat");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let id = op.id;
|
||||||
|
let name = Path::new(&op.path)
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
export.connect_clicked(move |_| prompt_export_queued(&ui, id, &name));
|
||||||
|
row.add_suffix(&export);
|
||||||
|
}
|
||||||
|
if op.failing {
|
||||||
|
let discard = gtk4::Button::builder()
|
||||||
|
.label(gettext("Discard"))
|
||||||
|
.tooltip_text(gettext("Undo this change here instead of sending it"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
discard.add_css_class("flat");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let id = op.id;
|
||||||
|
let path = op.path.clone();
|
||||||
|
let exportable = op.exportable;
|
||||||
|
discard.connect_clicked(move |_| prompt_discard_queued(&ui, id, &path, exportable));
|
||||||
|
row.add_suffix(&discard);
|
||||||
|
}
|
||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a sync issue means and what the user can do about it. The change stays
|
||||||
|
/// queued either way.
|
||||||
|
pub(crate) fn issue_text(issue: SyncIssue) -> String {
|
||||||
|
match issue {
|
||||||
|
SyncIssue::Quota => gettext(
|
||||||
|
"Your Proton Drive storage is full. Free up space or upgrade your plan, and this change uploads on its own.",
|
||||||
|
),
|
||||||
|
SyncIssue::Access => gettext(
|
||||||
|
"You can no longer change this on Proton Drive. Ask the owner for edit access, or export the file to keep your version.",
|
||||||
|
),
|
||||||
|
SyncIssue::Missing => gettext(
|
||||||
|
"The folder this change belongs in is gone from Proton Drive. Export the file to keep your version.",
|
||||||
|
),
|
||||||
|
SyncIssue::Limit => gettext(
|
||||||
|
"Proton Drive's limit on items in a folder or on folder depth is reached. Make room in the folder, then retry.",
|
||||||
|
),
|
||||||
|
SyncIssue::Rejected | SyncIssue::Unknown => {
|
||||||
|
gettext("Proton Drive refused this change. It stays queued and is tried again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask where to save a copy of a queued file's content, then have the daemon
|
||||||
|
/// write it there. The change itself stays queued.
|
||||||
|
fn prompt_export_queued(ui: &Rc<Ui>, id: i64, name: &str) {
|
||||||
|
let window = ui_window(ui);
|
||||||
|
let dialog = gtk4::FileDialog::builder()
|
||||||
|
.title(gettext("Export File"))
|
||||||
|
.initial_name(name)
|
||||||
|
.build();
|
||||||
|
let ui = ui.clone();
|
||||||
|
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
|
let Ok(file) = res else { return };
|
||||||
|
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't export the file"),
|
||||||
|
&gettext("That location isn't a local file."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ExportPendingOp { id, dest },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("File exported")),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't export the file"), &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't export the file"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then have the daemon drop a queued change and undo it here.
|
||||||
|
fn prompt_discard_queued(ui: &Rc<Ui>, id: i64, path: &str, exportable: bool) {
|
||||||
|
let body = if exportable {
|
||||||
|
// Translators: {path} is the file whose queued change would be dropped.
|
||||||
|
gettext_f(
|
||||||
|
"Your changes to {path} are undone here and never reach Proton Drive. Export the file first to keep your version.",
|
||||||
|
&[("path", path)],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Translators: {path} is the file or folder whose queued change would be dropped.
|
||||||
|
gettext_f(
|
||||||
|
"The change to {path} is undone here and never reaches Proton Drive.",
|
||||||
|
&[("path", path)],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Discard Change?"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("discard", &gettext("Discard"));
|
||||||
|
dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let window = ui_window(ui);
|
||||||
|
let ui = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp == "discard" {
|
||||||
|
discard_queued(&ui, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(window.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discard_queued(ui: &Rc<Ui>, id: i64) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::DiscardPendingOp { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
toast(&ui, &gettext("Change discarded"));
|
||||||
|
ui.locations.queue.painted.borrow_mut().clear();
|
||||||
|
refresh_queue(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't discard the change"), &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't discard the change"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
||||||
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
||||||
|
|||||||
@@ -618,6 +618,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -533,6 +533,7 @@ mod tests {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at,
|
trashed_at,
|
||||||
trashed_from: from.map(str::to_string),
|
trashed_from: from.map(str::to_string),
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ impl Drop for Busy {
|
|||||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
/// 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
|
/// 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
|
/// 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
|
/// The two are separate request variants rather than one request with an
|
||||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||||
|
|||||||
+521
-468
File diff suppressed because it is too large.
Load diff
+4934
-3158
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,161 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.0.0] — 2026-10-05
|
||||||
|
|
||||||
|
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
|
||||||
|
are kept. Queued changes get a column for the reason Drive refused them, and two for the
|
||||||
|
content a new file was sent with and where, and name their file and folder by local id as well
|
||||||
|
as by Drive's id. Stored files and folders name their parent
|
||||||
|
folder by local id too. A file or folder made on this machine that Drive does not have yet goes
|
||||||
|
by its local id, in the queue, the stored tree and pins alike.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Sync issues.** A change Drive refuses because the storage is full, access was taken away,
|
||||||
|
its folder is gone, a folder limit is reached or Drive rejected it shows up at once as needing
|
||||||
|
attention, with what it means and what to do, instead of after six failed attempts. The
|
||||||
|
change stays queued and is still retried.
|
||||||
|
- **Export a queued file.** The Sync page's Export button and `pdfs sync export <id> <dest>`
|
||||||
|
save a copy of a queued upload's content. The upload stays queued.
|
||||||
|
- **`pdfs sync issues`** lists the changes that need attention; `pdfs sync queue` shows the
|
||||||
|
issue next to each change.
|
||||||
|
- **Discard a queued change.** The Sync page's Discard button and `pdfs sync discard <id>` drop
|
||||||
|
a change Drive will not take and undo it here: a new file or folder goes, anything else goes
|
||||||
|
back to what Drive has. Export a file first to keep its content.
|
||||||
|
- **Sync issues show on the file.** A file or folder with a change Drive refused carries a
|
||||||
|
warning badge in the file browser, and its tooltip says what it means and what to do.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Making, renaming and deleting through the mount no longer waits for Drive.** A new folder,
|
||||||
|
a new file, a rename or a delete is recorded locally and the call returns at once; the queue
|
||||||
|
sends it, as it does offline. `"local_first": false` in `config.json` brings back the old way
|
||||||
|
for this release.
|
||||||
|
- **The queue sends up to 16 changes at once instead of 3.** A thousand new files in a hundred
|
||||||
|
folders reach Drive about three times as fast on a LAN, and four times as fast on Wi-Fi.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A file made under the name of a file whose upload is still going out keeps its name.** It
|
||||||
|
no longer lands as a conflict copy, and the mount no longer shows that copy's name (B154).
|
||||||
|
- **A change to a file no longer lands as a conflict copy of itself when the link loses Drive's
|
||||||
|
answer to its upload** (B155).
|
||||||
|
- **A file written again while its upload was being retried no longer lands as a conflict copy
|
||||||
|
of itself when the link lost Drive's answer to the first try** (B156).
|
||||||
|
- **Writing to a file twice in a row no longer fails with "Input/output error" when the link
|
||||||
|
lost Drive's answer to reading back the file's last upload** (B157).
|
||||||
|
- **A file renamed twice in a row, while the first name was still taken, keeps the second name**
|
||||||
|
(B158).
|
||||||
|
- **Opening a file right after the daemon restarts no longer fails with "Resource temporarily
|
||||||
|
unavailable"** (B159).
|
||||||
|
- **A new folder, or a new empty file, reaches Drive at once**, not up to half a minute later
|
||||||
|
(B160, B192).
|
||||||
|
- **A file moved while it was still being uploaded keeps its new name in the mount** on a slow
|
||||||
|
link (B161).
|
||||||
|
- **A folder removed and made again at once keeps what goes into it.** It used to be created
|
||||||
|
inside the removed folder and was trashed along with it (B162).
|
||||||
|
- **A new file no longer reads as empty just after it is uploaded.** Drive can list it a moment
|
||||||
|
before its contents are committed, and the mount took that answer (B163).
|
||||||
|
- **A folder no longer lists a new file twice while it is uploaded.** A listing taken as the
|
||||||
|
upload landed showed it both as Drive had it and as the queue did (B164).
|
||||||
|
- **Stopping the daemon no longer hangs until systemd kills it.** A stop waits up to 10 s for
|
||||||
|
work in progress, then exits and logs what was still running (B165).
|
||||||
|
- **A folder no longer drops or empties a new file while others are uploaded.** A file whose
|
||||||
|
upload landed while the folder was being listed was missing from that listing, or listed as
|
||||||
|
empty (B166).
|
||||||
|
- **The mount comes up faster on a large Drive.** Rebuilding the folder listings from the cache
|
||||||
|
took close to 2 s with 37,000 files at every start (B167).
|
||||||
|
- **Files deleted with their folder while uploading stay deleted.** The mount no longer tries
|
||||||
|
to upload them to the top of your Drive instead (B168).
|
||||||
|
- **Stopping the daemon during a folder sync no longer waits for the sync.** The uploads in
|
||||||
|
progress finish, and the rest waits for the next start (B169).
|
||||||
|
- **Writing to a file again just as its upload finishes no longer fails.** A write right after
|
||||||
|
it failed with "Input/output error" (B170).
|
||||||
|
- **A file deleted while its upload was retrying is removed from Drive right away.** It no
|
||||||
|
longer waits for the upload's next retry (B171).
|
||||||
|
- **A file deleted just as it reached Drive is removed from Drive too.** It no longer stayed
|
||||||
|
there, and the next file with that name no longer became a conflict copy (B173).
|
||||||
|
- **Renaming a file just as it reaches Drive no longer fails with "Permission denied"**
|
||||||
|
(B174).
|
||||||
|
- **A file replaced by a rename just as it reached Drive is removed from Drive too.** It no
|
||||||
|
longer stayed there, and the next rename of its name no longer moved it instead (B175).
|
||||||
|
- **A write closed just as its file reached Drive is no longer lost** (B176).
|
||||||
|
- **Opening a folder just as it reaches Drive no longer fails with "Input/output error"**
|
||||||
|
(B177).
|
||||||
|
- **Deleting a folder while sync is paused no longer leaves a queued change behind** for a
|
||||||
|
file in it that was never uploaded (B178).
|
||||||
|
- **A file read just as it reaches Drive no longer reads empty** (B179).
|
||||||
|
- **Stopping the daemon no longer takes 10 seconds more than it needs** when an idle upload
|
||||||
|
worker missed the stop (B180).
|
||||||
|
- **A folder made while offline or with sync paused no longer fails with "Input/output
|
||||||
|
error"** once it is refreshed or the connection comes back (B125).
|
||||||
|
- **The daemon no longer hangs when a file being read changes on Drive** (B130).
|
||||||
|
- **The Activity page says in your language why an edit was kept as a conflicting copy**,
|
||||||
|
instead of the daemon's English (B181).
|
||||||
|
- **A file written after a restart no longer lands as a conflict copy of itself** when the
|
||||||
|
connection dropped just as it first reached Drive (B182).
|
||||||
|
- **A file deleted after a restart is removed from Drive too** when the connection lost Drive's
|
||||||
|
answer to its upload and the file was written again meanwhile. It no longer stayed there and
|
||||||
|
turned the next file under its name into a conflict copy (B183).
|
||||||
|
- **A file renamed while the connection lost Drive's answer to its upload lands once.** It no
|
||||||
|
longer stayed on Drive under its old name too, or, when deleted, under its old name alone
|
||||||
|
(B184).
|
||||||
|
- **A new file written again just as it starts uploading no longer waits out a retry** before
|
||||||
|
it reaches Drive (B185).
|
||||||
|
- **A file deleted while open can still be read and written until it is closed**, offline too.
|
||||||
|
A write into it no longer failed with "Input/output error" (B186).
|
||||||
|
- **An uploaded file no longer leaves a copy of itself in the cache** until the next start
|
||||||
|
(B187).
|
||||||
|
- **Changes that failed during a short outage go out soon after the connection is back**, not
|
||||||
|
after a retry delay of up to five minutes (B189).
|
||||||
|
- **A file saved just before the daemon stops reaches Drive** with what was written. It could
|
||||||
|
keep its old content, or stay empty (B190).
|
||||||
|
- **A file renamed just as its upload finished keeps its new name.** The mount no longer showed
|
||||||
|
the old name, and a second rename no longer failed with "No such file or directory" (B152,
|
||||||
|
B172).
|
||||||
|
- **A change that waits for a name to be freed no longer waits seconds after it is.** A rename or
|
||||||
|
upload over a file being deleted goes out as soon as the delete lands (B153).
|
||||||
|
- **A file deleted right after it was made no longer stays on Drive when the link lost Drive's
|
||||||
|
answer.** The node Drive made is trashed (B151).
|
||||||
|
- **A new file no longer lands twice when the link loses Drive's answer to its upload.** The
|
||||||
|
file Drive made is taken as it is, instead of a second upload as a conflict copy (B127).
|
||||||
|
- **A change to a file deleted elsewhere, together with its folder, no longer retries forever.**
|
||||||
|
The conflict copy that keeps it now lands in the location's root (B150).
|
||||||
|
- **`pdfs rename` and `pdfs move` right after a change through the mount are no longer undone.**
|
||||||
|
They are queued behind it, and they work on a file that has not reached Drive yet (B149).
|
||||||
|
- **A folder kept offline before it reached Drive stays kept offline.** Its pin was lost when the
|
||||||
|
folder landed (B148).
|
||||||
|
- **A queued change no longer holds up the queue forever on a link that stops answering.**
|
||||||
|
Sending a new folder, a delete or a file waits at most 20 seconds for Drive, plus time for
|
||||||
|
the file's size, and then the queue treats the link as down and tries again.
|
||||||
|
- **A file deleted while its first upload is on the wire no longer stays on Drive.** The node
|
||||||
|
the upload made is trashed as soon as it lands, so its name is free again (B129).
|
||||||
|
- **A delete queued while a rename is sent is no longer lost.** The rename landing could remove
|
||||||
|
the delete from the queue, leaving the file on Drive (B137).
|
||||||
|
- **A file made offline over one deleted offline is no longer lost.** Its upload could take
|
||||||
|
over the deleted file, and the delete then took the new bytes along with it (B128).
|
||||||
|
- **A file deleted while its upload is on the wire no longer comes back as a `recovered-…`
|
||||||
|
copy** when another file is made right after (B133).
|
||||||
|
- **A file made where another one is leaving no longer lands as a conflict copy.** Its upload
|
||||||
|
waits for the rename or trash that frees the name (B126, B138).
|
||||||
|
- **A file closed just as its first upload lands keeps its last write.** The write was refused
|
||||||
|
and left in staging (B139).
|
||||||
|
- **Files made in a new folder are still there after a restart.** A folder that landed as the
|
||||||
|
daemon stopped, or one listed from Drive while files in it were queued, came back empty
|
||||||
|
(B132, B140).
|
||||||
|
- **A file renamed twice in quick succession ends up under the second name.** The first rename
|
||||||
|
could land after the second and undo it (B141).
|
||||||
|
- **Renaming a new file just as its upload lands no longer fails with "Device or resource
|
||||||
|
busy".** The rename goes to the file Drive made (B142).
|
||||||
|
- **A file whose upload landed just as the connection dropped no longer gets a conflict copy at
|
||||||
|
its next save.** The daemon reads the file back once the connection returns (B143).
|
||||||
|
- **A rename whose target name was just freed no longer lands as a conflict copy.** The rename
|
||||||
|
is sent once more under the name it asked for (B144).
|
||||||
|
- **Saving a download under its final name no longer fails with "Device or resource busy".**
|
||||||
|
A file made a moment before was taken for deleted and dropped from the queue (B145).
|
||||||
|
- **A file renamed just as its upload lands keeps its new name.** The tree could take back the
|
||||||
|
name Drive still had (B146).
|
||||||
|
- **A new file made under a name another file just left no longer takes that file's place on
|
||||||
|
Drive.** Both could land on one node, and one file's content was lost (B147).
|
||||||
|
|
||||||
## [2.10.0] — 2026-10-03
|
## [2.10.0] — 2026-10-03
|
||||||
|
|
||||||
Schema: **36**. Every node row gets a local id that never changes; existing rows keep their
|
Schema: **36**. Every node row gets a local id that never changes; existing rows keep their
|
||||||
@@ -1444,6 +1599,8 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
||||||
|
|
||||||
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
||||||
[2.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.9.0
|
[2.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.9.0
|
||||||
[2.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.2
|
[2.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.2
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
||||||
| `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 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 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. `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 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). |
|
| `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). |
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -38,7 +38,7 @@ Inside `content/`:
|
|||||||
> not reached Proton Drive and exists nowhere else.
|
> not reached Proton Drive and exists nowhere else.
|
||||||
|
|
||||||
Everything in these directories except the keyring is **decrypted**. See
|
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`
|
## `config.json`
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ fix it.
|
|||||||
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
||||||
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" |
|
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" |
|
||||||
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
||||||
|
| `local_first` | bool | `true` | — | See [`local_first`](#local_first) |
|
||||||
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
||||||
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
|
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
|
||||||
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
||||||
@@ -116,6 +117,15 @@ were copied from.
|
|||||||
|
|
||||||
`PDFS_CONFLICT_SWEEP` overrides the setting for one run of the service.
|
`PDFS_CONFLICT_SWEEP` overrides the setting for one run of the service.
|
||||||
|
|
||||||
|
### `local_first`
|
||||||
|
|
||||||
|
A new folder, a new file, a rename or a delete made through the mount is recorded locally and the
|
||||||
|
call returns at once. The change goes to Drive from the queue, the same way it does offline, and
|
||||||
|
`pdfs status` counts it until it lands.
|
||||||
|
|
||||||
|
Set it to `false` to have these calls wait for Drive again, as before 3.0.0. The switch is there
|
||||||
|
for one release, in case the queued path misbehaves for you; please report it if it does.
|
||||||
|
|
||||||
### `open_with`
|
### `open_with`
|
||||||
|
|
||||||
Decides how files picked in the search launcher or the app are opened. Rules are tried in order;
|
Decides how files picked in the search launcher or the app are opened. Rules are tried in order;
|
||||||
@@ -180,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_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) |
|
| `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
|
## The systemd service
|
||||||
|
|
||||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
`proton-drive.service` is a user unit started with your graphical session
|
||||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
(`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
|
||||||
the unit without editing the packaged file, use a drop-in:
|
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
|
```bash
|
||||||
systemctl --user edit proton-drive.service
|
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 |
|
| 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-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-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/` |
|
| `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 |
|
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
||||||
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
| `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 |
|
| `docs/` | This documentation |
|
||||||
|
|
||||||
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
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
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
For changes to the FUSE layer, the queue or the sync engine, also run the
|
||||||
cases described in [TESTING.md](TESTING.md).
|
[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:
|
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
|
`#[serde(default)]`, so older files still load, and document it in
|
||||||
[CONFIGURATION.md](CONFIGURATION.md).
|
[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
|
## Releasing
|
||||||
|
|
||||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
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
|
git push origin main vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
Before a release, work through [Before a release](TESTING.md#before-a-release). Open
|
||||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
|
||||||
+4
-5
@@ -5,7 +5,7 @@
|
|||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
| [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` |
|
| [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 |
|
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||||
@@ -16,13 +16,12 @@
|
|||||||
|
|
||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
|
||||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
| [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 and the FUSE acceptance suite |
|
| [Testing](TESTING.md) | Unit tests, simulation runs and the FUSE acceptance suite |
|
||||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
| [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
|
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||||
root.
|
root.
|
||||||
+60
-25
@@ -1,24 +1,54 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Planned work and open verification, as of 2.5.0. Individual defects and their status live in
|
Planned work and open verification after 3.0.0. Individual defects and their status are in
|
||||||
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme.
|
[BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
|
||||||
Nothing here is a promise of a date.
|
|
||||||
|
|
||||||
|
- [Sync engine](#sync-engine)
|
||||||
- [Data safety and recovery](#data-safety-and-recovery)
|
- [Data safety and recovery](#data-safety-and-recovery)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Performance and correctness](#performance-and-correctness)
|
- [Performance and correctness](#performance-and-correctness)
|
||||||
- [Verification still owed](#verification-still-owed)
|
- [Verification still owed](#verification-still-owed)
|
||||||
- [Release assurance](#release-assurance)
|
- [Release assurance](#release-assurance)
|
||||||
|
|
||||||
|
## Sync engine
|
||||||
|
|
||||||
|
3.0.0 records changes made through the mount locally and sends them from the queue
|
||||||
|
([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 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 many nodes per call,
|
||||||
|
but the queue sends one node per call. It was left out of 3.0.0 on purpose:
|
||||||
|
|
||||||
|
- 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 a trashed folder's contents: trashing the folder takes
|
||||||
|
them along.
|
||||||
|
|
||||||
## Data safety and recovery
|
## Data safety and recovery
|
||||||
|
|
||||||
| Item | State | Notes |
|
| 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) |
|
| 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; `sync restore` does not apply them yet |
|
| 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 UI (B68) |
|
| 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 | Surface and retry keyring write failures so a rotated single-use refresh token is not lost (B61) |
|
| Rotated credentials survive a keyring failure | Open | Report 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) |
|
| 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
|
## Features
|
||||||
|
|
||||||
@@ -26,9 +56,10 @@ Nothing here is a promise of a date.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| Photo name search | Not started | Photos search by date and filters only |
|
||||||
| Automatic photo upload from a folder | Not started | |
|
| Automatic photo upload from a folder | Not started | |
|
||||||
@@ -39,34 +70,38 @@ Nothing here is a promise of a date.
|
|||||||
|
|
||||||
| Item | Notes |
|
| 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) |
|
| 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) |
|
| 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, deliberately (B36) |
|
| Unicode normalization | NFC and NFD names are distinct, on purpose (B36) |
|
||||||
| Pipelined node enumeration | Faster cold listings of large folders |
|
| Pipelined node enumeration | Faster first listings of large folders |
|
||||||
|
|
||||||
## Verification still owed
|
## Verification still owed
|
||||||
|
|
||||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been
|
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
|
||||||
driven against a live account. The larger outstanding runs:
|
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
|
- **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).
|
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
|
- **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.
|
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
|
- **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
|
||||||
across the write publication path (after each fsync, rename and queue commit).
|
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).
|
- **CAPTCHA sign-in** against a real verification request (B8).
|
||||||
- **Managed live mode matrix** before each release:
|
- **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
|
||||||
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md).
|
catch the next one.
|
||||||
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
|
|
||||||
catch the next occurrence.
|
|
||||||
|
|
||||||
## Release assurance
|
## Release assurance
|
||||||
|
|
||||||
| Item | State |
|
| 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) |
|
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
||||||
| Protocol version identity between front ends and daemon | Open (B63) |
|
| Protocol version identity between front ends and daemon | Open (B63) |
|
||||||
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
||||||
+207
-451
@@ -1,509 +1,265 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
The normal Rust suite exercises the metadata database, sync planner, offline
|
Three layers check the client, from fast and offline to slow and against a real account:
|
||||||
queue, write staging, cache, and FUSE handler state without requiring a Proton
|
|
||||||
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 test --workspace
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
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
|
To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
|
||||||
temporary local directory. This validates the runner and covers creation/open
|
ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
|
||||||
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:
|
|
||||||
|
|
||||||
```console
|
```bash
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
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
|
## Simulation runs
|
||||||
arguments, the script tests the account the running service is logged in to:
|
|
||||||
|
|
||||||
```console
|
The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
|
||||||
scripts/fuse-acceptance.sh # every case, every mount kind
|
an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
|
||||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four
|
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.
|
A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
|
||||||
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and
|
several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
|
||||||
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.
|
|
||||||
|
|
||||||
Cleanup identifies remote folders by uid, never by name alone, so nothing of
|
## Acceptance suite
|
||||||
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).
|
|
||||||
|
|
||||||
Ops that were queued before a target starts are not the run's. The run prints
|
`scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
|
||||||
one `NOTE` line naming them, and the queue waits ignore them. They are told
|
flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
|
||||||
apart by id and queue time together, since SQLite can give a new op the id of
|
`copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
|
||||||
one that has drained.
|
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
|
> [!CAUTION]
|
||||||
without waiting for that. A case that reads `pdfs sync queue` right after
|
> Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
|
||||||
writing therefore waits for the ops it expects (`Daemon.queued`) instead of
|
> account with nothing you cannot lose.
|
||||||
reading once. "regression B102" saw one revision of two before it did.
|
|
||||||
|
|
||||||
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs,
|
### Modes
|
||||||
two more interrupts are ignored; a third abandons it.
|
|
||||||
|
|
||||||
Each run records what it is about to create in
|
```bash
|
||||||
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and
|
scripts/fuse-acceptance.sh --offline-only # the reference run; no account
|
||||||
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a
|
scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
|
||||||
hard timeout, a power cut), the next run finds the unlocked manifest and
|
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
|
||||||
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>`
|
scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
|
||||||
folders that no live run claims: in My files (older than
|
scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
|
||||||
`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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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
|
### Reading the output
|
||||||
|
|
||||||
A run opens with what it is about to test: the mode, the `pdfs` binary and its
|
A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
|
||||||
version, Python and the kernel, the per-case limit, the options and the start
|
and the options. Each target then names its root, the storage behind it, the daemon and the
|
||||||
time. Each target then names its root, the storage behind it, the daemon it
|
number of cases, followed by one line per case:
|
||||||
talks to and how many cases it runs, followed by one line per case:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
==> live FUSE /mnt/testmount
|
==> live FUSE /mnt/testmount
|
||||||
-> root /mnt/testmount/pdfs-acceptance-…
|
-> root /mnt/testmount/pdfs-acceptance-…
|
||||||
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
-> 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
|
-> cases 45 of 45
|
||||||
[ 1/45] creation and open flags .................................. ok 0.41s
|
[ 1/45] creation and open flags .................................. ok 0.41s
|
||||||
…
|
…
|
||||||
[21/45] throughput floors ........................................ ok 48.20s
|
[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
|
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
|
-> reference matches the ordinary filesystem in 412 compared observations
|
||||||
-> result 44 passed, 1 skipped in 11m02s
|
-> result 44 passed, 1 skipped in 11m02s
|
||||||
```
|
```
|
||||||
|
|
||||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` when it does not apply to the
|
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
|
||||||
target (no daemon, no share at that role), or `known` when an `open B<n>` case
|
`open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
|
||||||
reproduced a bug that is still open (see below). The dimmed lines under a case
|
A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
|
||||||
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.
|
|
||||||
|
|
||||||
The run ends with a table of every target's counts and time, the report paths,
|
The run ends with a table of every target, the report paths, the slowest cases, cases over
|
||||||
the slowest cases, the cases over `--budget`, the known issues that still
|
`--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
|
||||||
reproduce, any errors the daemon logged, and each failure with its target. The
|
last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
|
||||||
last line is `PASS:` or `FAIL:` and the totals. Colour is used only on a
|
`NO_COLOR` set.
|
||||||
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,
|
- **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
|
||||||
including every size next to a boundary, then overwrites that grow, shrink
|
boundary, and overwrites that grow, shrink and reshape them.
|
||||||
and reshape them.
|
- **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
|
||||||
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text,
|
characters Windows forbids, leading and trailing spaces, a case-only rename.
|
||||||
newline, tab, emoji, characters Windows forbids, leading and trailing
|
- **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
|
||||||
spaces, and a case-only rename.
|
concurrently, unlinked concurrently, then `rm -rf`.
|
||||||
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the
|
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
|
||||||
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`.
|
`EISDIR`, `ENOTDIR` and `ENOENT` refusals.
|
||||||
Online, each create is a round trip to the server, and the kernel runs
|
- **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
|
||||||
creates in one directory one at a time. So the wide case gets twice the
|
generations, a truncate under a reader.
|
||||||
per-case timeout and notes the time per create as `wide.create_ms`.
|
- **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
|
||||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an
|
fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
|
||||||
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out
|
operations per second, 3 on a FUSE mount). `0` turns a floor off.
|
||||||
and back.
|
- **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
|
||||||
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND`
|
probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
|
||||||
writers, 30 rewrite generations, and a truncate under a reader.
|
work or be refused cleanly: a known errno, never `EIO`, never a hang.
|
||||||
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes,
|
- **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
|
||||||
and metadata operations on 100 files. The rates are printed. The case fails
|
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||||
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS`
|
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||||
(default 20 operations per second, or 3 on a FUSE mount, where each create
|
mirrored source holding something Drive lacks, a file still open for writing.
|
||||||
and unlink waits for Drive before it returns).
|
- **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
|
A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
|
||||||
records the facts it established, and a live run is compared against those
|
need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
|
||||||
recordings, so a case fails when the mount *differs* from an ordinary
|
and the targets it runs on.
|
||||||
filesystem — not only when it raises. The target's `reference` line names the
|
|
||||||
exact key:
|
|
||||||
|
|
||||||
```
|
A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
|
||||||
-> reference 1 observation of 412 differs from the ordinary filesystem:
|
through the mount proves nothing: the local caches return the bytes just written whether or not
|
||||||
! creation and open flags.truncated.size: live FUSE /mnt/testmount=6 reference=0
|
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.
|
Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
|
||||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
|
||||||
reported mode bits, and which optional syscalls exist at all. Listed under
|
synced folder.
|
||||||
the target's `accepted` line, never a failure.
|
|
||||||
|
|
||||||
Because the choice is made at each call site, the set of accepted divergences
|
### Timeouts, reports and hung mounts
|
||||||
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.
|
|
||||||
|
|
||||||
### 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,
|
| Option | Effect |
|
||||||
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
|
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||||
one that is dirty breaks software that never asked for the feature.
|
| `--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 |
|
||||||
### Regressions from the bug ledger
|
| `--fail-fast` | Stops at the first failure |
|
||||||
|
| `--list` | Prints every case and its targets |
|
||||||
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.
|
|
||||||
|
|
||||||
### Selecting cases
|
### Selecting cases
|
||||||
|
|
||||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
`PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
|
||||||
contains the supplied text, ignoring case (for example
|
with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
|
||||||
`PDFS_ACCEPTANCE_ONLY=namespace`). Separate several texts with commas to run
|
typo it is. A release run leaves it unset.
|
||||||
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.
|
|
||||||
|
|
||||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
| Variable | Effect |
|
||||||
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.
|
| `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.
|
## Before a release
|
||||||
The first path must be the FUSE mount under test; later paths may be another
|
|
||||||
FUSE mount or a normal local mirror filesystem:
|
|
||||||
|
|
||||||
```console
|
1. The quality gates and the simulation runs pass in CI.
|
||||||
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
|
2. A full account run with `--journal-check` passes on a test account:
|
||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount
|
`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
|
What no automated run covers yet is listed under
|
||||||
additional byte-for-byte convergence check explicitly:
|
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||||
|
|
||||||
```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.
|
|
||||||
+24
-6
@@ -113,22 +113,40 @@ pdfs refresh Documents
|
|||||||
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
**"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`.
|
`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
|
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||||
links, device files or FIFOs. The error is intentional. Use a synced folder for
|
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||||
projects that need them, or exclude those paths.
|
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||||
|
|
||||||
## Uploads and sync
|
## 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
|
```bash
|
||||||
pdfs sync queue
|
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 # every failed operation
|
||||||
pdfs sync retry 42 # one operation
|
pdfs sync retry 42 # one operation
|
||||||
```
|
```
|
||||||
|
|
||||||
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its
|
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||||
error there.
|
`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:
|
**A file in a synced folder does not upload** — check that:
|
||||||
|
|
||||||
|
|||||||
@@ -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
-5
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
|
|||||||
- [My files](#my-files)
|
- [My files](#my-files)
|
||||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||||
- [Syncing local folders](#syncing-local-folders)
|
- [Syncing local folders](#syncing-local-folders)
|
||||||
|
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||||
- [Conflicts](#conflicts)
|
- [Conflicts](#conflicts)
|
||||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||||
- [Photos](#photos)
|
- [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,
|
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
|
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.
|
lists exactly what is stored.
|
||||||
|
|
||||||
The app uses these words consistently, and so does this guide:
|
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
|
- **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.
|
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
|
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||||
content on disk and queues the upload. The queue survives network loss, restarts and crashes.
|
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||||
`pdfs sync queue` lists what has not reached Proton Drive yet.
|
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
|
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
||||||
evicted oldest first.
|
evicted oldest first.
|
||||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
- **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
|
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.
|
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
|
## Conflicts
|
||||||
|
|
||||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||||
@@ -166,7 +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.
|
reading files keeps working. `pdfs sync resume` continues.
|
||||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||||
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
|
|
||||||
|
|
||||||
## Photos
|
## 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
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=2.10.0
|
pkgver=3.0.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 2.8.2
|
pkgver = 3.0.0
|
||||||
pkgrel = 2
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
license = MIT
|
||||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.8.2
|
provides = proton-drive-for-linux=3.0.0
|
||||||
provides = proton-drive-linux=2.8.2
|
provides = proton-drive-linux=3.0.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-2.8.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.8.2/proton-drive-linux-2.8.2-x86_64.tar.gz
|
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-2.8.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/io.narl.proton-drive-linux.desktop
|
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-2.8.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/io.narl.proton-drive-linux-tray.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-2.8.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/io.narl.proton-drive-linux.svg
|
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-2.8.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/proton-drive.service
|
source = proton-drive-3.0.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/proton-drive.service
|
||||||
source = LICENSE-2.8.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/LICENSE
|
source = LICENSE-3.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/LICENSE
|
||||||
sha256sums = c0af4ff57dc6f587b1b8431f3180b00d45211389f3dc675569af45f64e89cb36
|
sha256sums = 4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=2.8.2
|
pkgver=3.0.0
|
||||||
pkgrel=2
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://proton-drive.narl.io"
|
url="https://proton-drive.narl.io"
|
||||||
@@ -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"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('c0af4ff57dc6f587b1b8431f3180b00d45211389f3dc675569af45f64e89cb36'
|
sha256sums=('4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 2.8.2.r0.g50483bf
|
pkgver = 3.0.0.r0.g2177383
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.8.2.r0.g50483bf
|
provides = proton-drive-for-linux=3.0.0.r0.g2177383
|
||||||
provides = proton-drive-linux=2.8.2.r0.g50483bf
|
provides = proton-drive-linux=3.0.0.r0.g2177383
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=2.8.2.r0.g50483bf
|
pkgver=3.0.0.r0.g2177383
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 2.8.2
|
pkgver = 3.0.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=2.8.2
|
provides = proton-drive-linux=3.0.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-2.8.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.8.2.tar.gz
|
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 = 7f2df1d2748c3d8daaa4c05d242fcd293cb1983572e44bb61a35188dbe5f342c
|
sha256sums = 3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=2.8.2
|
pkgver=3.0.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
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.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('7f2df1d2748c3d8daaa4c05d242fcd293cb1983572e44bb61a35188dbe5f342c')
|
sha256sums=('3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
+320
-165
File diff suppressed because it is too large.
Load diff
+304
-166
File diff suppressed because it is too large.
Load diff
+327
-165
File diff suppressed because it is too large.
Load diff
+59
-19
@@ -1410,10 +1410,11 @@ def test_throughput(ctx: Context) -> None:
|
|||||||
on the network. PDFS_ACCEPTANCE_MIN_MIBPS and PDFS_ACCEPTANCE_MIN_OPS move
|
on the network. PDFS_ACCEPTANCE_MIN_MIBPS and PDFS_ACCEPTANCE_MIN_OPS move
|
||||||
the floors; 0 turns one off.
|
the floors; 0 turns one off.
|
||||||
|
|
||||||
Metadata is the exception. On a Drive mount each create and each unlink
|
Metadata has a lower default floor on a FUSE target: 3 operations a
|
||||||
waits for Drive before it returns (about 350 ms for a create), so 20
|
second. A local-first mount records each create and unlink locally and
|
||||||
operations a second is out of reach there by design. The default floor for
|
clears it easily, but with `local_first` off each one waits for Drive
|
||||||
a FUSE target is 3, which still catches a second round trip per operation.
|
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"))
|
min_rate = float(os.environ.get("PDFS_ACCEPTANCE_MIN_MIBPS", "10"))
|
||||||
default_ops = "3" if is_fuse(ctx.root) else "20"
|
default_ops = "3" if is_fuse(ctx.root) else "20"
|
||||||
@@ -1658,6 +1659,10 @@ def test_regression_b113_move_before_upload(ctx: Context) -> None:
|
|||||||
to an empty original. Pausing sync holds the bytes back while the files
|
to an empty original. Pausing sync holds the bytes back while the files
|
||||||
move, which is the order the bug needs. The names must not be transient
|
move, which is the order the bug needs. The names must not be transient
|
||||||
ones like `*.part`: those mint nothing and queue their create instead (B70).
|
ones like `*.part`: those mint nothing and queue their create instead (B70).
|
||||||
|
|
||||||
|
Since changes through the mount are queued (local-first), the create is
|
||||||
|
queued as well and the moves rewrite it before anything reaches Drive. The
|
||||||
|
files must land where they were moved, without conflict copies, either way.
|
||||||
"""
|
"""
|
||||||
daemon = ctx.require_daemon()
|
daemon = ctx.require_daemon()
|
||||||
if not is_fuse(ctx.root):
|
if not is_fuse(ctx.root):
|
||||||
@@ -1685,9 +1690,8 @@ def test_regression_b113_move_before_upload(ctx: Context) -> None:
|
|||||||
os.rename(root / "both.bin", moved / "both moved.bin")
|
os.rename(root / "both.bin", moved / "both moved.bin")
|
||||||
held = [item for item in daemon.queue() if not preexisting(item)]
|
held = [item for item in daemon.queue() if not preexisting(item)]
|
||||||
check(bool(held), "nothing was queued while sync was paused, so the moves raced nothing")
|
check(bool(held), "nothing was queued while sync was paused, so the moves raced nothing")
|
||||||
creates = [item["path"] for item in held if item["kind"] == "create"]
|
|
||||||
check(not creates, f"creates were queued, not minted on Drive, so B113 was not replayed: {creates}")
|
|
||||||
ctx.note("held_ops", len(held))
|
ctx.note("held_ops", len(held))
|
||||||
|
ctx.note("held_creates", sum(item["kind"] == "create" for item in held))
|
||||||
finally:
|
finally:
|
||||||
daemon.command("sync", "resume")
|
daemon.command("sync", "resume")
|
||||||
daemon.wait_for_queue()
|
daemon.wait_for_queue()
|
||||||
@@ -2341,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
|
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
|
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]
|
entries = [e for e in _shared_with_me(daemon) if e.get("role") == role]
|
||||||
if not entries:
|
if not entries:
|
||||||
@@ -3297,8 +3301,15 @@ def remember_preexisting_ops(queue: list[dict]) -> None:
|
|||||||
print(f"NOTE: {plural(len(new), 'queued op')} predate this target, for {shown}; queue waits ignore them")
|
print(f"NOTE: {plural(len(new), 'queued op')} predate this target, for {shown}; queue waits ignore them")
|
||||||
|
|
||||||
|
|
||||||
def queue_settled(mount: dict, queue) -> bool:
|
def queue_settled(mount: dict | None, queue) -> bool:
|
||||||
"""Whether everything this run queued has drained. `queue` lists the ops."""
|
"""Whether everything this run queued has drained. `queue` lists the ops.
|
||||||
|
|
||||||
|
`mount` is `pdfs --json status`'s "mount", None while no daemon answers:
|
||||||
|
just after a restart, before the new one listens. That is not a drained
|
||||||
|
queue, only an unread one.
|
||||||
|
"""
|
||||||
|
if mount is None:
|
||||||
|
return False
|
||||||
if mount.get("pending_uploads", 0) == 0 and mount.get("pending_changes", 0) == 0:
|
if mount.get("pending_uploads", 0) == 0 and mount.get("pending_changes", 0) == 0:
|
||||||
return True
|
return True
|
||||||
if not PREEXISTING_OPS:
|
if not PREEXISTING_OPS:
|
||||||
@@ -3353,10 +3364,10 @@ class Daemon:
|
|||||||
deadline = time.monotonic() + self.timeout
|
deadline = time.monotonic() + self.timeout
|
||||||
last = None
|
last = None
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
last = self.status().get("mount") or {}
|
last = self.status().get("mount")
|
||||||
if queue_settled(last, self.queue):
|
if queue_settled(last, self.queue):
|
||||||
return
|
return
|
||||||
if last.get("parked_uploads"):
|
if last and last.get("parked_uploads"):
|
||||||
# A transient name's create waits for a rename, not for the
|
# A transient name's create waits for a rename, not for the
|
||||||
# drain, so waiting out the timeout would only hide which file.
|
# drain, so waiting out the timeout would only hide which file.
|
||||||
parked = [
|
parked = [
|
||||||
@@ -3846,7 +3857,7 @@ class ManagedSyncPair:
|
|||||||
last = None
|
last = None
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
value = json.loads(self.command("status", json_output=True))
|
value = json.loads(self.command("status", json_output=True))
|
||||||
last = value.get("mount") or {}
|
last = value.get("mount")
|
||||||
if queue_settled(
|
if queue_settled(
|
||||||
last,
|
last,
|
||||||
lambda: json.loads(self.command("sync", "queue", json_output=True))["items"],
|
lambda: json.loads(self.command("sync", "queue", json_output=True))["items"],
|
||||||
@@ -4161,12 +4172,28 @@ class MoveContext:
|
|||||||
self.pair = first.pair
|
self.pair = first.pair
|
||||||
|
|
||||||
def move(self, *paths: Path) -> subprocess.CompletedProcess:
|
def move(self, *paths: Path) -> subprocess.CompletedProcess:
|
||||||
"""`pdfs move`, bounded: a wedged mount must not hang the harness."""
|
"""`pdfs move`, bounded: a wedged mount must not hang the harness.
|
||||||
command = [self.pair.pdfs, "move", *(str(path) for path in paths)]
|
|
||||||
result = subprocess.run(
|
A mirror's sync pass holds the folder, and the daemon refuses a move
|
||||||
command, text=True, capture_output=True, timeout=self.pair.timeout
|
that waited 5 s for it. A periodic pass over a folder earlier runs
|
||||||
)
|
filled can take longer, so the move is tried again until it ends.
|
||||||
detail = result.stderr.strip() or result.stdout.strip()
|
With several sources, the ones an attempt already moved are gone, and
|
||||||
|
only the rest are tried again."""
|
||||||
|
*sources, dest = paths
|
||||||
|
deadline = time.monotonic() + self.pair.timeout
|
||||||
|
while True:
|
||||||
|
command = [self.pair.pdfs, "move", *(str(path) for path in (*sources, dest))]
|
||||||
|
result = subprocess.run(
|
||||||
|
command, text=True, capture_output=True, timeout=self.pair.timeout
|
||||||
|
)
|
||||||
|
detail = result.stderr.strip() or result.stdout.strip()
|
||||||
|
if not (result.returncode and "busy syncing" in detail) or time.monotonic() > deadline:
|
||||||
|
break
|
||||||
|
left = [source for source in sources if os.path.lexists(source)]
|
||||||
|
if not left:
|
||||||
|
break
|
||||||
|
sources = left
|
||||||
|
time.sleep(2)
|
||||||
if result.returncode and "different Proton Drive volumes" in detail:
|
if result.returncode and "different Proton Drive volumes" in detail:
|
||||||
raise Skip(f"the locations are on different volumes: {detail}")
|
raise Skip(f"the locations are on different volumes: {detail}")
|
||||||
return result
|
return result
|
||||||
@@ -4362,6 +4389,19 @@ def test_move_refuses_a_mirror_copy_drive_lacks(mc: MoveContext) -> None:
|
|||||||
# copy after a move would lose it.
|
# copy after a move would lose it.
|
||||||
os.symlink("data.txt", source.root / name / "link")
|
os.symlink("data.txt", source.root / name / "link")
|
||||||
source.settle()
|
source.settle()
|
||||||
|
if dest.mode == "mirror" and os.stat(source.folder).st_dev == os.stat(dest.folder).st_dev:
|
||||||
|
# Between two mirrors on one filesystem the local copy is renamed
|
||||||
|
# along, so the move loses nothing and goes ahead.
|
||||||
|
mc.move_ok(source.root / name, dest.root)
|
||||||
|
check((dest.root / name / "link").is_symlink(), "the symlink did not move along")
|
||||||
|
dest.settle()
|
||||||
|
check_bytes(
|
||||||
|
read(dest.root / name / "data.txt"),
|
||||||
|
files[Path(name) / "data.txt"],
|
||||||
|
f"{dest.name}: data.txt after the move",
|
||||||
|
)
|
||||||
|
check(not (source.root / name).exists(), f"{source.name}: {name} is still there")
|
||||||
|
return
|
||||||
detail = mc.move_refused(source.root / name, dest.root)
|
detail = mc.move_refused(source.root / name, dest.root)
|
||||||
check("not fully synced" in detail, f"unexpected refusal: {detail}")
|
check("not fully synced" in detail, f"unexpected refusal: {detail}")
|
||||||
check((source.root / name / "link").is_symlink(), "the symlink was lost by a refused move")
|
check((source.root / name / "link").is_symlink(), "the symlink was lost by a refused move")
|
||||||
@@ -4405,7 +4445,7 @@ MOVE_CASES = [
|
|||||||
Case("copy a tree out of My files", test_copy_tree_out_of_my_files, (LIVE,)),
|
Case("copy a tree out of My files", test_copy_tree_out_of_my_files, (LIVE,)),
|
||||||
Case("move refuses a name taken at the destination", test_move_refuses_a_name_taken_at_the_destination, (LIVE,)),
|
Case("move refuses a name taken at the destination", test_move_refuses_a_name_taken_at_the_destination, (LIVE,)),
|
||||||
Case("move refuses a folder into itself", test_move_refuses_a_folder_into_itself, (LIVE,)),
|
Case("move refuses a folder into itself", test_move_refuses_a_folder_into_itself, (LIVE,)),
|
||||||
Case("move refuses a mirror copy Drive lacks", test_move_refuses_a_mirror_copy_drive_lacks, (LIVE,)),
|
Case("move never loses a mirror copy Drive lacks", test_move_refuses_a_mirror_copy_drive_lacks, (LIVE,)),
|
||||||
Case("move refuses a file still being written", test_move_refuses_a_file_still_being_written, (LIVE,)),
|
Case("move refuses a file still being written", test_move_refuses_a_file_still_being_written, (LIVE,)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user