mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
942cb9f583 | ||
|
|
8470662c54 | ||
|
|
a091c2f34f | ||
|
|
bca0fe34bc |
No files matched your search
Generated
+55
-55
@@ -378,7 +378,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -663,9 +663,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.1"
|
version = "1.25.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
@@ -747,9 +747,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -856,9 +856,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.2"
|
version = "4.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -878,14 +878,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1479,9 +1479,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
@@ -1568,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2359,9 +2359,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2559,9 +2559,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ignore"
|
name = "ignore"
|
||||||
version = "0.4.30"
|
version = "0.4.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7"
|
checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-deque",
|
"crossbeam-deque",
|
||||||
"globset",
|
"globset",
|
||||||
@@ -2916,9 +2916,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libdbus-sys"
|
name = "libdbus-sys"
|
||||||
@@ -3203,7 +3203,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3518,10 +3518,10 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.2",
|
"clap 4.6.4",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
"pdfs-fuse",
|
"pdfs-fuse",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"image",
|
"image",
|
||||||
@@ -3569,7 +3569,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3830,9 +3830,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.1.11"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a76197ffe7c9588c0bc1fa38a51e8aa8642b10e81ec16272d4962bd60525920"
|
checksum = "692610fbead83735a0415d9d068c1e9b467b12cce013ea19b3063f05206ce3d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3857,9 +3857,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.1.11"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "805df5ff2805e0e935ac7f5a237482231208a706d8c6ab3dd91157ae2e95c9f6"
|
checksum = "6b3f2ad9cd1ba69b67cbafcaf7c159b3a200162a3c518b1bc7e25df837343b41"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4214,7 +4214,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4401,7 +4401,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4432,9 +4432,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.0"
|
version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -4458,7 +4458,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4599,14 +4599,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.150"
|
version = "1.0.151"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -4623,7 +4623,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4784,18 +4784,18 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snafu"
|
name = "snafu"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522"
|
checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"snafu-derive",
|
"snafu-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snafu-derive"
|
name = "snafu-derive"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
|
checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4898,9 +4898,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.0"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4962,7 +4962,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5011,7 +5011,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5064,9 +5064,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.0"
|
version = "1.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5101,9 +5101,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5628,7 +5628,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6138,18 +6138,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+3
-3
@@ -3,15 +3,15 @@ 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 = "1.0.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.1" }
|
proton-sdk = { version = "0.2.2" }
|
||||||
proton-drive-rs = { version = "0.1" }
|
proton-drive-rs = { version = "0.2.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -1002,6 +1002,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
|
|||||||
Share => "share",
|
Share => "share",
|
||||||
PublicLink => "link",
|
PublicLink => "link",
|
||||||
Unshare => "unshare",
|
Unshare => "unshare",
|
||||||
|
Conflict => "conflict",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1290,7 +1291,10 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
cache,
|
cache,
|
||||||
&control_socket,
|
&control_socket,
|
||||||
db,
|
db,
|
||||||
username,
|
pdfs_fuse::MountOptions {
|
||||||
|
username,
|
||||||
|
sweep_mode: config.resolved_conflict_sweep(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean unmount check (as a best-effort final backup).
|
// Clean unmount check (as a best-effort final backup).
|
||||||
|
|||||||
@@ -150,15 +150,23 @@ pub struct StagedWrite {
|
|||||||
pub based_on: Option<Baseline>,
|
pub based_on: Option<Baseline>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The identity of a remote revision, as far as we can observe it: a file whose
|
/// The identity of a remote revision, as far as we can observe it.
|
||||||
/// size and mtime both still match the ones a queued change was made against has
|
///
|
||||||
/// not been rewritten by anyone else in the meantime.
|
/// `revision_id` is the authoritative identity when present: the server assigns a
|
||||||
|
/// fresh id to every sealed revision, so an unchanged id proves no one rewrote
|
||||||
|
/// the file — even when the server re-stamped its `mtime` on the *same* revision,
|
||||||
|
/// which is the drift that used to cut spurious conflict copies (B16/B25).
|
||||||
|
/// `mtime`/`size` remain the fallback for a baseline recorded before the id was
|
||||||
|
/// observable, or a node read through a surface that does not carry it.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Baseline {
|
pub struct Baseline {
|
||||||
pub mtime: i64,
|
pub mtime: i64,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hash: Option<String>,
|
pub hash: Option<String>,
|
||||||
|
/// Server revision id this change was made against (`None` if unknown).
|
||||||
|
#[serde(default)]
|
||||||
|
pub revision_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content cache rooted at a directory, with a sibling pin-registry file.
|
/// Content cache rooted at a directory, with a sibling pin-registry file.
|
||||||
@@ -1873,6 +1881,7 @@ mod tests {
|
|||||||
mtime: 100,
|
mtime: 100,
|
||||||
size: 40,
|
size: 40,
|
||||||
hash: None,
|
hash: None,
|
||||||
|
revision_id: None,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let staged = c.stage_write(&meta, &scratch).unwrap();
|
let staged = c.stage_write(&meta, &scratch).unwrap();
|
||||||
|
|||||||
@@ -20,6 +20,59 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
|||||||
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
||||||
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Environment override for [`AppConfig::resolved_conflict_sweep`]. Exists so a
|
||||||
|
/// support case can disable the sweep without editing the config file or
|
||||||
|
/// reinstalling — it wins over the stored setting.
|
||||||
|
pub const CONFLICT_SWEEP_ENV: &str = "PDFS_CONFLICT_SWEEP";
|
||||||
|
|
||||||
|
/// What the background conflict sweep (`pdfs-fuse`'s `pdfs-conflict-sweep`
|
||||||
|
/// thread) is allowed to do about a `(sync-conflict …)` copy it believes is a
|
||||||
|
/// redundant duplicate.
|
||||||
|
///
|
||||||
|
/// Defaults to [`SweepMode::Report`]: the sweep's whole reason to exist is that
|
||||||
|
/// the *previous* revision-identity check was wrong (B69), so the replacement is
|
||||||
|
/// not trusted with deletion until it has a release cycle of field evidence
|
||||||
|
/// behind it. See `docs/BUGS.md` B71.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SweepMode {
|
||||||
|
/// Do not run the sweep at all — the thread is never spawned.
|
||||||
|
Off,
|
||||||
|
/// Run, and log what it *would* remove, but never trash anything. Divergent
|
||||||
|
/// and orphaned copies are still surfaced to the activity feed as usual.
|
||||||
|
#[default]
|
||||||
|
Report,
|
||||||
|
/// Trash copies proven identical to their live sibling.
|
||||||
|
Enforce,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SweepMode {
|
||||||
|
/// Parse an operator-supplied value ([`CONFLICT_SWEEP_ENV`] or a CLI flag).
|
||||||
|
/// `None` for anything unrecognised, so a typo falls back to the stored
|
||||||
|
/// setting rather than silently picking a mode the operator did not ask for.
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"off" | "no" | "false" | "0" | "disabled" => Some(Self::Off),
|
||||||
|
"report" | "dry-run" | "dryrun" | "observe" => Some(Self::Report),
|
||||||
|
"enforce" | "on" | "yes" | "true" | "1" => Some(Self::Enforce),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this mode is allowed to trash a duplicate.
|
||||||
|
pub fn is_enforcing(self) -> bool {
|
||||||
|
matches!(self, Self::Enforce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Precedence for [`AppConfig::resolved_conflict_sweep`], split out from the
|
||||||
|
/// environment read so it can be unit-tested without mutating process state.
|
||||||
|
fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode {
|
||||||
|
env.and_then(SweepMode::parse)
|
||||||
|
.or(stored)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration structure allowing the user to customize client identification headers.
|
/// Configuration structure allowing the user to customize client identification headers.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@@ -48,6 +101,11 @@ pub struct AppConfig {
|
|||||||
/// by hostname". Defaulted for configs predating the field.
|
/// by hostname". Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub device_uid: Option<String>,
|
pub device_uid: Option<String>,
|
||||||
|
/// What the background conflict sweep may do. `None` means "use
|
||||||
|
/// [`SweepMode::default`]" (report-only). Defaulted for configs predating
|
||||||
|
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub conflict_sweep: Option<SweepMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -59,6 +117,7 @@ impl Default for AppConfig {
|
|||||||
mountpoint: None,
|
mountpoint: None,
|
||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
|
conflict_sweep: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +129,20 @@ impl AppConfig {
|
|||||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
||||||
|
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
||||||
|
///
|
||||||
|
/// The environment wins so a support case can stop a destructive background
|
||||||
|
/// loop without editing config or reinstalling (`docs/BUGS.md` B71). An
|
||||||
|
/// unparseable value is ignored rather than treated as "off", so a typo
|
||||||
|
/// cannot silently change behaviour in either direction.
|
||||||
|
pub fn resolved_conflict_sweep(&self) -> SweepMode {
|
||||||
|
resolve_sweep_mode(
|
||||||
|
std::env::var(CONFLICT_SWEEP_ENV).ok().as_deref(),
|
||||||
|
self.conflict_sweep,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective global sync ignore patterns: the user's explicit list, or
|
/// The effective global sync ignore patterns: the user's explicit list, or
|
||||||
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
|
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
|
||||||
/// honoured as "no global patterns", which is why this is not a
|
/// honoured as "no global patterns", which is why this is not a
|
||||||
@@ -374,6 +447,7 @@ mod tests {
|
|||||||
mountpoint: Some("/tmp/x".to_string()),
|
mountpoint: Some("/tmp/x".to_string()),
|
||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
};
|
};
|
||||||
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();
|
||||||
@@ -388,6 +462,62 @@ mod tests {
|
|||||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_defaults_to_report_only() {
|
||||||
|
// The sweep trashes user files, so an installed config that has never
|
||||||
|
// heard of the setting must not get the enforcing behaviour.
|
||||||
|
assert_eq!(SweepMode::default(), SweepMode::Report);
|
||||||
|
assert!(!SweepMode::default().is_enforcing());
|
||||||
|
assert_eq!(resolve_sweep_mode(None, None), SweepMode::Report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_parses_operator_spellings() {
|
||||||
|
for s in ["off", "OFF", " no ", "false", "0", "disabled"] {
|
||||||
|
assert_eq!(SweepMode::parse(s), Some(SweepMode::Off), "{s}");
|
||||||
|
}
|
||||||
|
for s in ["report", "dry-run", "dryrun", "observe"] {
|
||||||
|
assert_eq!(SweepMode::parse(s), Some(SweepMode::Report), "{s}");
|
||||||
|
}
|
||||||
|
for s in ["enforce", "on", "yes", "true", "1"] {
|
||||||
|
assert_eq!(SweepMode::parse(s), Some(SweepMode::Enforce), "{s}");
|
||||||
|
}
|
||||||
|
// A typo must not resolve to anything, so it falls through to the config.
|
||||||
|
assert_eq!(SweepMode::parse("offf"), None);
|
||||||
|
assert_eq!(SweepMode::parse(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_env_overrides_config_but_a_typo_does_not() {
|
||||||
|
let stored = Some(SweepMode::Enforce);
|
||||||
|
// The point of the override: kill a destructive loop without editing config.
|
||||||
|
assert_eq!(resolve_sweep_mode(Some("off"), stored), SweepMode::Off);
|
||||||
|
// Unrecognised value falls back to the stored choice rather than guessing.
|
||||||
|
assert_eq!(resolve_sweep_mode(Some("nope"), stored), SweepMode::Enforce);
|
||||||
|
assert_eq!(resolve_sweep_mode(None, stored), SweepMode::Enforce);
|
||||||
|
// No env, no stored value: report-only.
|
||||||
|
assert_eq!(resolve_sweep_mode(Some("nope"), None), SweepMode::Report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_predating_conflict_sweep_still_loads() {
|
||||||
|
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||||
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(decoded.conflict_sweep.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_round_trips_through_the_config_file() {
|
||||||
|
let config = AppConfig {
|
||||||
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
assert!(json.contains(r#""conflict_sweep":"enforce""#), "{json}");
|
||||||
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_config_predating_ignore_patterns_still_loads() {
|
fn a_config_predating_ignore_patterns_still_loads() {
|
||||||
// Every installed config on disk looks like this; the field must not be
|
// Every installed config on disk looks like this; the field must not be
|
||||||
|
|||||||
@@ -573,6 +573,11 @@ pub enum ActivityKind {
|
|||||||
Share,
|
Share,
|
||||||
PublicLink,
|
PublicLink,
|
||||||
Unshare,
|
Unshare,
|
||||||
|
/// A `(sync-conflict …)` copy that needs the user's attention: it diverges
|
||||||
|
/// from the live file (or has no sibling), so the auto-sweep left it in place
|
||||||
|
/// rather than removing it. An auto-*removed* identical copy is logged as a
|
||||||
|
/// [`ActivityKind::Trash`] instead, since that is what happened to it.
|
||||||
|
Conflict,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub use local::LocalFileHit;
|
|||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{SearchHit, StoredNode};
|
pub use nodes::{SearchHit, StoredNode};
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||||
PendingCounts, PendingOp, op_supersedes,
|
PendingCounts, PendingOp, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ pub fn op_supersedes(kind: &str) -> bool {
|
|||||||
/// drain replaces it with the uid the server assigns.
|
/// drain replaces it with the uid the server assigns.
|
||||||
pub const LOCAL_VOLUME: &str = "local";
|
pub const LOCAL_VOLUME: &str = "local";
|
||||||
|
|
||||||
|
/// A `next_attempt_at` far enough in the future that [`Db::next_due_op`] never
|
||||||
|
/// selects the op (ms since epoch, ≈ year 2223). Used to *park* a queued create
|
||||||
|
/// for a transient file — a browser's in-flight `*.crdownload`/`*.part`, an
|
||||||
|
/// editor's `*.swp` — so its bytes never upload while it wears that name. A
|
||||||
|
/// rename to the finished name un-parks it (see [`Db::set_create_hold`]), which
|
||||||
|
/// is the only moment the completed file is meant to reach Drive. Without the
|
||||||
|
/// park, every partial and every abandoned temp would upload and, on a
|
||||||
|
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||||
|
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
/// A mutation that has been accepted locally but not yet performed against the
|
/// A mutation that has been accepted locally but not yet performed against the
|
||||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||||
///
|
///
|
||||||
@@ -183,11 +193,18 @@ impl Db {
|
|||||||
let Some((id, superseded)) = existing else {
|
let Some((id, superseded)) = existing else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
// Clearing the backoff lets the fresh bytes be tried promptly — except
|
||||||
|
// when the create is *parked* (a transient file, next_attempt_at at the
|
||||||
|
// PARK_UNTIL sentinel). Attaching a new revision to a still-growing
|
||||||
|
// download must not wake it; only the finalize rename does. So preserve a
|
||||||
|
// park and reset an ordinary backoff.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pending_op
|
"UPDATE pending_op
|
||||||
SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0
|
SET blob_path = ?2, meta_json = ?3, attempts = 0,
|
||||||
|
next_attempt_at = CASE WHEN next_attempt_at >= ?4
|
||||||
|
THEN next_attempt_at ELSE 0 END
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![id, blob_path, meta_json],
|
params![id, blob_path, meta_json, PARK_UNTIL],
|
||||||
)?;
|
)?;
|
||||||
Ok(Some(AttachedBlob { id, superseded }))
|
Ok(Some(AttachedBlob { id, superseded }))
|
||||||
}
|
}
|
||||||
@@ -233,6 +250,25 @@ impl Db {
|
|||||||
Ok(n > 0)
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Park or un-park a queued create/mkdir for `uid`, returning whether a row
|
||||||
|
/// was touched.
|
||||||
|
///
|
||||||
|
/// Parking sets `next_attempt_at` to [`PARK_UNTIL`] so [`Db::next_due_op`]
|
||||||
|
/// skips it indefinitely; un-parking sets it due now. Used to keep a
|
||||||
|
/// transient file's bytes off Drive until it is renamed to its finished name
|
||||||
|
/// (docs/BUGS.md B70). Un-parking a create that was never parked is harmless:
|
||||||
|
/// it just makes the create due immediately, which for a normal local node it
|
||||||
|
/// already is.
|
||||||
|
pub fn set_create_hold(&self, uid: &str, held: bool) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let n = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?2
|
||||||
|
WHERE uid = ?1 AND kind IN (?3, ?4)",
|
||||||
|
params![uid, if held { PARK_UNTIL } else { 0 }, OP_CREATE, OP_MKDIR],
|
||||||
|
)?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a queued create or mkdir op exists for `uid`.
|
/// Check if a queued create or mkdir op exists for `uid`.
|
||||||
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -244,6 +280,22 @@ impl Db {
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether *any* op of any kind is queued against `uid`.
|
||||||
|
///
|
||||||
|
/// Used as a safety interlock by callers that are about to remove a node
|
||||||
|
/// they only know about from a stale snapshot: a queued op means the user's
|
||||||
|
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||||
|
/// blob that holds them (`docs/BUGS.md` B71).
|
||||||
|
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop every op targeting a node **or anything queued beneath it**,
|
/// Drop every op targeting a node **or anything queued beneath it**,
|
||||||
/// returning the staged blobs they held so the caller can delete them.
|
/// returning the staged blobs they held so the caller can delete them.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1615,6 +1615,58 @@ fn next_due_op_skips_ops_blocked_on_a_local_parent() {
|
|||||||
assert_eq!(db.next_due_op(10).unwrap().unwrap().uid, "vol~link3");
|
assert_eq!(db.next_due_op(10).unwrap().unwrap().uid, "vol~link3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B70: a transient file's create is parked so its bytes never upload while it
|
||||||
|
/// wears that name, the park survives a write attaching its blob, and the
|
||||||
|
/// finalize rename (un-park) is what finally makes the completed file due.
|
||||||
|
#[test]
|
||||||
|
fn a_parked_transient_create_stays_off_the_drain_until_finalized() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let node = uid("dl").to_string();
|
||||||
|
let parent = uid("root").to_string();
|
||||||
|
db.enqueue_op(&PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_CREATE.to_string(),
|
||||||
|
uid: node.clone(),
|
||||||
|
parent_uid: Some(parent),
|
||||||
|
name: Some("movie.mkv.part".to_string()),
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: PARK_UNTIL,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Parked: never selected, however far the clock advances.
|
||||||
|
assert!(
|
||||||
|
db.next_due_op(PARK_UNTIL - 1).unwrap().is_none(),
|
||||||
|
"a parked create is never due"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A write attaches its blob to the create but must not wake it.
|
||||||
|
let attached = db
|
||||||
|
.attach_blob_to_create(&node, "/staging/blob1", "{}")
|
||||||
|
.unwrap();
|
||||||
|
assert!(attached.is_some(), "the blob attached to the create");
|
||||||
|
assert!(
|
||||||
|
db.next_due_op(PARK_UNTIL - 1).unwrap().is_none(),
|
||||||
|
"still parked after a write attaches"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Finalize: un-park, and the completed file is due to upload with its bytes.
|
||||||
|
assert!(
|
||||||
|
db.set_create_hold(&node, false).unwrap(),
|
||||||
|
"a create row was un-parked"
|
||||||
|
);
|
||||||
|
let due = db
|
||||||
|
.next_due_op(1_000)
|
||||||
|
.unwrap()
|
||||||
|
.expect("the un-parked create is due");
|
||||||
|
assert_eq!(due.uid, node);
|
||||||
|
assert_eq!(due.blob_path.as_deref(), Some("/staging/blob1"));
|
||||||
|
}
|
||||||
|
|
||||||
/// Backoff still gates: nothing is returned before an op is due.
|
/// Backoff still gates: nothing is returned before an op is due.
|
||||||
#[test]
|
#[test]
|
||||||
fn next_due_op_respects_backoff() {
|
fn next_due_op_respects_backoff() {
|
||||||
@@ -1806,6 +1858,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
media_type: "text/plain".into(),
|
media_type: "text/plain".into(),
|
||||||
total_size_on_storage: 10,
|
total_size_on_storage: 10,
|
||||||
active_revision_state: None,
|
active_revision_state: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
content_sha1: None,
|
||||||
claimed_size: Some(10),
|
claimed_size: Some(10),
|
||||||
claimed_modification_time: None,
|
claimed_modification_time: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,45 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
|||||||
"Thumbs.db",
|
"Thumbs.db",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
||||||
|
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
||||||
|
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
||||||
|
/// revision — the app renames the finished file to its real name, and only that
|
||||||
|
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
||||||
|
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||||
|
".crdownload", // Chromium/Brave/Edge
|
||||||
|
".part", // Firefox, wget, aria2c
|
||||||
|
".partial", // some downloaders / older IE
|
||||||
|
".download", // Safari / macOS
|
||||||
|
".tmp", // generic scratch
|
||||||
|
".temp", // generic scratch
|
||||||
|
".swp", // vim swap
|
||||||
|
".swx", // vim swap (secondary)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
|
||||||
|
/// be uploaded on an on-demand mount until it is renamed to its finished name.
|
||||||
|
///
|
||||||
|
/// Recognises browser download temps, editor swap/backup files, and lock files.
|
||||||
|
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
|
||||||
|
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
|
||||||
|
/// mirror-folder reconcile); keep the two conceptually in step.
|
||||||
|
pub fn is_transient_name(name: &str) -> bool {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Emacs/gedit/kate backup copies.
|
||||||
|
if name.ends_with('~') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
|
||||||
|
if name.starts_with(".~lock.") || name.starts_with("~$") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||||
|
|
||||||
@@ -272,4 +311,37 @@ mod tests {
|
|||||||
// have nothing to reconcile against.
|
// have nothing to reconcile against.
|
||||||
assert!(!rules.is_ignored("", true));
|
assert!(!rules.is_ignored("", true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_download_and_editor_names_are_recognised() {
|
||||||
|
for name in [
|
||||||
|
"Unconfirmed 540477.crdownload",
|
||||||
|
"movie.mkv.part",
|
||||||
|
"big.iso.partial",
|
||||||
|
"photo.jpg.download",
|
||||||
|
"export.xml.tmp",
|
||||||
|
"notes.TEMP",
|
||||||
|
".notes.txt.swp",
|
||||||
|
"doc.txt~",
|
||||||
|
".~lock.report.odt#",
|
||||||
|
"~$budget.xlsx",
|
||||||
|
"FOO.CRDOWNLOAD",
|
||||||
|
] {
|
||||||
|
assert!(is_transient_name(name), "{name} should be transient");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finished_names_are_not_transient() {
|
||||||
|
for name in [
|
||||||
|
"teamspeak.tar.gz",
|
||||||
|
"nils.zip",
|
||||||
|
"report.xml",
|
||||||
|
"archive.partly.zip", // ".partly" is not ".part"
|
||||||
|
"template.dotx",
|
||||||
|
"readme",
|
||||||
|
] {
|
||||||
|
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,19 +2,48 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||||
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||||
|
/// which event carried them.
|
||||||
|
///
|
||||||
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
|
/// its content blob once if *any* mount did.
|
||||||
|
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
||||||
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
|
let child = st.by_uid.get(uid).copied();
|
||||||
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
match child {
|
||||||
|
Some(child) => {
|
||||||
|
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply one remote event to the local cache and notify the kernel so it drops
|
/// Apply one remote event to the local cache and notify the kernel so it drops
|
||||||
/// any stale cached metadata/data for the affected inodes.
|
/// any stale cached metadata/data for the affected inodes.
|
||||||
///
|
///
|
||||||
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||||
fn apply_event(
|
///
|
||||||
state: &Mutex<State>,
|
/// Applied to **every** mounted inode space, not just the primary one. This task
|
||||||
content: &ContentCache,
|
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
||||||
pending: &Mutex<HashMap<NodeUid, PendingRevision>>,
|
/// not a mount — so a file trashed from another device has to be withdrawn from
|
||||||
notifier: &Notifier,
|
/// whichever of our sessions is showing it, which for anything under a sync
|
||||||
event: &DriveEvent,
|
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
||||||
) {
|
/// the primary state alone left forks serving a deleted file indefinitely, the
|
||||||
|
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
||||||
|
/// mount must be notified through its **own** channel; that pairing is what
|
||||||
|
/// [`Core::for_each_mount`] exists to preserve.
|
||||||
|
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
|
||||||
match event {
|
match event {
|
||||||
DriveEvent::NodeUpdated {
|
DriveEvent::NodeUpdated {
|
||||||
node_uid,
|
node_uid,
|
||||||
@@ -22,74 +51,78 @@ fn apply_event(
|
|||||||
is_trashed,
|
is_trashed,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut st = state.lock();
|
// A node we owe an upload for is *ahead* of the remote, not behind
|
||||||
if *is_trashed {
|
// it: this event is almost always the echo of our own empty-file
|
||||||
// Trashing makes a node vanish from its parent listing.
|
// create, and re-fetching would replace the size and mtime of the
|
||||||
let child = st.by_uid.get(node_uid).copied();
|
// write we just accepted with the stale revision's — making a file
|
||||||
if let Some((parent, name)) = st.forget(node_uid) {
|
// that was copied in seconds ago read as empty until its upload
|
||||||
content.evict(node_uid);
|
// lands (offline.md Phase 3).
|
||||||
match child {
|
//
|
||||||
Some(child) => {
|
// A write that has already drained is the same story one step later:
|
||||||
let _ =
|
// the drain brought the tree level with the revision it sealed, so
|
||||||
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
// the feed's report of that revision has nothing to add, and acting
|
||||||
}
|
// on it evicts the very bytes we uploaded. Claimed unconditionally,
|
||||||
None => {
|
// even for a trash, so an echo we no longer care about does not
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
// linger to suppress a later foreign change.
|
||||||
}
|
let echo = core.take_self_change(node_uid);
|
||||||
|
let ours = !*is_trashed && (echo || core.pending.lock().contains_key(node_uid));
|
||||||
|
if ours {
|
||||||
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
|
}
|
||||||
|
let mut had_node = false;
|
||||||
|
core.for_each_mount(|st, notifier| {
|
||||||
|
if *is_trashed {
|
||||||
|
// Trashing makes a node vanish from its parent listing.
|
||||||
|
had_node |= forget_and_notify(st, notifier, node_uid);
|
||||||
|
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||||
|
// Known node changed: drop its cached attrs/data (and
|
||||||
|
// listing if it is a directory) so the next access
|
||||||
|
// re-fetches. Its content blob may now be stale too.
|
||||||
|
had_node = true;
|
||||||
|
st.invalidate_listing(ino);
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if pending.lock().contains_key(node_uid) {
|
});
|
||||||
// A node we owe an upload for is *ahead* of the remote, not
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
// behind it: this event is almost always the echo of our own
|
if had_node {
|
||||||
// empty-file create, and re-fetching would replace the size and
|
core.cache.evict(node_uid);
|
||||||
// mtime of the write we just accepted with the stale revision's
|
|
||||||
// — making a file that was copied in seconds ago read as empty
|
|
||||||
// until its upload lands (offline.md Phase 3).
|
|
||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
|
||||||
} else if let Some(&ino) = st.by_uid.get(node_uid) {
|
|
||||||
// Known node changed: drop its cached attrs/data (and listing if
|
|
||||||
// it is a directory) so the next access re-fetches. Its content
|
|
||||||
// blob may now be stale, so evict it too.
|
|
||||||
st.invalidate_listing(ino);
|
|
||||||
content.evict(node_uid);
|
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
}
|
||||||
// A create (or move-in) shows up as a change to the parent listing;
|
// A create, rename or move-in shows up as a change to the parent
|
||||||
// drop it so the new child is picked up on the next readdir.
|
// listing too, which is recorded for the end of the batch rather
|
||||||
if let Some(parent_uid) = parent_node_uid
|
// than acted on here — see [`DirtyParents`].
|
||||||
&& let Some(&parent) = st.by_uid.get(parent_uid)
|
if let Some(parent_uid) = parent_node_uid {
|
||||||
{
|
match ours {
|
||||||
st.invalidate_listing(parent);
|
true => dirty
|
||||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
.ours
|
||||||
|
.entry(parent_uid.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(node_uid.clone()),
|
||||||
|
false => dirty.foreign.insert(parent_uid.clone()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
let mut st = state.lock();
|
core.cache.evict(node_uid);
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
core.for_each_mount(|st, notifier| {
|
||||||
let child = st.by_uid.get(node_uid).copied();
|
forget_and_notify(st, notifier, node_uid);
|
||||||
content.evict(node_uid);
|
});
|
||||||
if let Some((parent, name)) = st.forget(node_uid) {
|
|
||||||
match child {
|
|
||||||
Some(child) => {
|
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Continuity or scope was lost: our cached listings may be arbitrarily
|
// Continuity or scope was lost: our cached listings may be arbitrarily
|
||||||
// stale, so drop every listing and tell the kernel to forget all
|
// stale, so drop every listing and tell the kernel to forget all
|
||||||
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
||||||
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||||
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
||||||
let mut st = state.lock();
|
core.for_each_mount(|st, notifier| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
for &ino in &dirs {
|
for &ino in &dirs {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
if let Some(notifier) = notifier {
|
||||||
}
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// No substantive local change; the cursor advance is handled by the
|
// No substantive local change; the cursor advance is handled by the
|
||||||
// caller persisting the event id.
|
// caller persisting the event id.
|
||||||
@@ -97,20 +130,103 @@ fn apply_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The folders one batch of events changed, collected so the listings are
|
||||||
|
/// dropped once at the end of the batch rather than once per event.
|
||||||
|
///
|
||||||
|
/// Both halves of that matter. A burst of uploads produces one event per file
|
||||||
|
/// all naming the *same* folder, and dropping the listing per event made every
|
||||||
|
/// interleaved lookup re-enumerate that folder from the API — quadratic in its
|
||||||
|
/// size, and slow enough to stall a busy mount. Splitting foreign changes from
|
||||||
|
/// our own then removes the re-enumeration entirely for the common case: a file
|
||||||
|
/// this daemon created is already in the tree under the right parent, so there
|
||||||
|
/// is nothing for a re-enumeration to discover.
|
||||||
|
///
|
||||||
|
/// Neither is a weakening. The state after the batch is what it always was.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DirtyParents {
|
||||||
|
/// Folders changed by someone else. Their listings must go: an event names
|
||||||
|
/// no name, so a rename inside a folder is indistinguishable from a write to
|
||||||
|
/// a file in it, and only re-enumerating can tell us which happened.
|
||||||
|
foreign: HashSet<NodeUid>,
|
||||||
|
/// Folders changed by this daemon, and the children the changes were about.
|
||||||
|
/// Checked per mount — a mount that already lists every one of those
|
||||||
|
/// children under that parent keeps its listing.
|
||||||
|
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
||||||
|
/// `child_uid`, so a change we made to that child leaves nothing to re-read.
|
||||||
|
///
|
||||||
|
/// "Nothing cached" counts as current: an absent listing is re-enumerated on the
|
||||||
|
/// next `readdir` regardless, so there is nothing to drop.
|
||||||
|
fn listing_accounts_for(st: &State, parent_uid: &NodeUid, child_uid: &NodeUid) -> bool {
|
||||||
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Some(children) = st.children.get(&parent) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Some(&child) = st.by_uid.get(child_uid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
children.contains(&child) && st.entries.get(&child).is_some_and(|e| e.parent == parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached listing of every folder a batch of events changed, in every
|
||||||
|
/// mount that holds one, so the next `readdir`/`lookup` re-enumerates it.
|
||||||
|
///
|
||||||
|
/// Runs strictly after the whole batch is applied: doing it per event would
|
||||||
|
/// leave a window where a concurrent `readdir` re-populates a listing that a
|
||||||
|
/// later event in the same batch then has to drop again.
|
||||||
|
fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
||||||
|
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
core.for_each_mount(|st, notifier| {
|
||||||
|
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
||||||
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
st.invalidate_listing(parent);
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for parent_uid in &dirty.foreign {
|
||||||
|
drop_listing(st, parent_uid);
|
||||||
|
}
|
||||||
|
for (parent_uid, children) in &dirty.ours {
|
||||||
|
if dirty.foreign.contains(parent_uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// One child this mount has not placed is enough: the listing it is
|
||||||
|
// serving is missing something the user just made.
|
||||||
|
if children
|
||||||
|
.iter()
|
||||||
|
.all(|child| listing_accounts_for(st, parent_uid, child))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drop_listing(st, parent_uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll the remote event cursor forever, applying each batch to the shared
|
/// Poll the remote event cursor forever, applying each batch to the shared
|
||||||
/// state. Resumes from the cursor persisted in the DB so changes made while
|
/// state. Resumes from the cursor persisted in the DB so changes made while
|
||||||
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
||||||
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
||||||
/// only on fatal error.
|
/// only on fatal error.
|
||||||
|
///
|
||||||
|
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
||||||
|
/// to be applied to every mounted inode space, and the registry that enumerates
|
||||||
|
/// them lives on the `Core` (see [`apply_event`]).
|
||||||
pub(super) async fn run_event_sync(
|
pub(super) async fn run_event_sync(
|
||||||
client: ProtonDriveClient,
|
client: ProtonDriveClient,
|
||||||
scope: DriveEventScopeId,
|
scope: DriveEventScopeId,
|
||||||
state: Arc<Mutex<State>>,
|
core: Core,
|
||||||
content: Arc<ContentCache>,
|
|
||||||
db: Arc<Db>,
|
|
||||||
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
|
|
||||||
notifier: Notifier,
|
|
||||||
) {
|
) {
|
||||||
|
let db = core.db.clone();
|
||||||
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
||||||
// Resume: pick up exactly where the last run left off.
|
// Resume: pick up exactly where the last run left off.
|
||||||
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
||||||
@@ -165,6 +281,11 @@ pub(super) async fn run_event_sync(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
debug!(count = events.len(), "applying remote events");
|
debug!(count = events.len(), "applying remote events");
|
||||||
|
// Folders whose listing this batch invalidates, applied once at the end
|
||||||
|
// rather than per event — see [`DirtyParents`]. Every `break` below falls
|
||||||
|
// through to that flush, so an interrupted batch still publishes what it
|
||||||
|
// did apply.
|
||||||
|
let mut dirty = DirtyParents::default();
|
||||||
for event in &events {
|
for event in &events {
|
||||||
// Converge the SDK's own caches (folder keys, entity cache) on the
|
// Converge the SDK's own caches (folder keys, entity cache) on the
|
||||||
// server before applying the event to our tree. Without this, a node
|
// server before applying the event to our tree. Without this, a node
|
||||||
@@ -175,7 +296,7 @@ pub(super) async fn run_event_sync(
|
|||||||
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
apply_event(&state, &content, &pending, ¬ifier, event);
|
apply_event(&core, event, &mut dirty);
|
||||||
let applied = event.id().clone();
|
let applied = event.id().clone();
|
||||||
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
||||||
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
||||||
@@ -186,6 +307,7 @@ pub(super) async fn run_event_sync(
|
|||||||
// that was not already made visible locally.
|
// that was not already made visible locally.
|
||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
|
flush_dirty_parents(&core, &dirty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -505,6 +505,11 @@ impl Core {
|
|||||||
fork.notifier = Arc::new(OnceLock::new());
|
fork.notifier = Arc::new(OnceLock::new());
|
||||||
// Size upgrades are keyed by inode, which is per-fork too.
|
// Size upgrades are keyed by inode, which is per-fork too.
|
||||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
// The daemon's single drain thread belongs to the primary mount, but it
|
||||||
|
// drains ops for nodes that live *here*. Publishing this inode space is
|
||||||
|
// what lets it find them; without it a queued create's node keeps its
|
||||||
|
// `local~` uid after the upload lands and reads it as empty (B74).
|
||||||
|
fork.register_state();
|
||||||
fork
|
fork
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+254
-52
@@ -36,8 +36,8 @@ use super::state::Intervals;
|
|||||||
use super::transfers::CountingReader;
|
use super::transfers::CountingReader;
|
||||||
use super::{
|
use super::{
|
||||||
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
|
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
|
||||||
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_size, now_millis, now_secs,
|
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size,
|
||||||
parse_node_uid,
|
now_millis, now_secs, parse_node_uid,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
@@ -229,6 +229,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
|
self.note_self_change(&uid);
|
||||||
info!(%uid, name = %landed, "pending rename landed");
|
info!(%uid, name = %landed, "pending rename landed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -252,6 +253,7 @@ impl Core {
|
|||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
|
self.own_sealed_revs.lock().remove(&uid);
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
||||||
info!(%uid, name, "pending trash landed");
|
info!(%uid, name, "pending trash landed");
|
||||||
@@ -262,18 +264,19 @@ impl Core {
|
|||||||
/// it away from the one the user asked for. Best effort: the event sync
|
/// it away from the one the user asked for. Best effort: the event sync
|
||||||
/// would correct the tree anyway, but not before the user has looked at it.
|
/// would correct the tree anyway, but not before the user has looked at it.
|
||||||
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
|
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
let Some(&ino) = st.by_uid.get(uid) else {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(entry) = st.entries.get_mut(&ino) else {
|
let Some(entry) = st.entries.get_mut(&ino) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
entry.node.name = name.to_string();
|
entry.node.name = name.to_string();
|
||||||
let node = entry.node.clone();
|
let node = entry.node.clone();
|
||||||
if let Err(e) = st.db.upsert_node(&node) {
|
if let Err(e) = st.db.upsert_node(&node) {
|
||||||
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make a node that so far exists only on this machine real, and adopt the
|
/// Make a node that so far exists only on this machine real, and adopt the
|
||||||
@@ -342,6 +345,9 @@ impl Core {
|
|||||||
// sync, whereas a surviving op would create the file a second time.
|
// sync, whereas a surviving op would create the file a second time.
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
self.adopt_real_uid(&local, &real)?;
|
self.adopt_real_uid(&local, &real)?;
|
||||||
|
// The feed will report this create back to us; the tree already has it
|
||||||
|
// under its real uid, so that event is ours to ignore (`Core::self_changes`).
|
||||||
|
self.note_self_change(&real);
|
||||||
if let Some(blob) = op.blob_path.as_deref() {
|
if let Some(blob) = op.blob_path.as_deref() {
|
||||||
self.cache.discard_staged(Path::new(blob));
|
self.cache.discard_staged(Path::new(blob));
|
||||||
}
|
}
|
||||||
@@ -418,6 +424,14 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// The inode is deliberately kept, so anything already holding the file open
|
/// The inode is deliberately kept, so anything already holding the file open
|
||||||
/// keeps working across the drain.
|
/// keeps working across the drain.
|
||||||
|
///
|
||||||
|
/// Applied to **every** live inode space, not just this Core's. A node
|
||||||
|
/// created inside an on-demand sync folder lives in that fork's `state`,
|
||||||
|
/// while the drain that lands its create belongs to the primary mount — so
|
||||||
|
/// reaching for `self.state` here found nothing and left the fork's entry on
|
||||||
|
/// its `local~` uid, which reads as an empty file for as long as the daemon
|
||||||
|
/// runs (`docs/BUGS.md` B74). A uid is unique across mounts, so at most one
|
||||||
|
/// of them matches and the others are no-ops.
|
||||||
pub(crate) fn adopt_real_uid(
|
pub(crate) fn adopt_real_uid(
|
||||||
&self,
|
&self,
|
||||||
local: &NodeUid,
|
local: &NodeUid,
|
||||||
@@ -430,8 +444,10 @@ impl Core {
|
|||||||
self.db
|
self.db
|
||||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||||
|
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
if let Some(ino) = st.by_uid.remove(local) {
|
let Some(ino) = st.by_uid.remove(local) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
st.by_uid.insert(real.clone(), ino);
|
st.by_uid.insert(real.clone(), ino);
|
||||||
if let Some(e) = st.entries.get_mut(&ino) {
|
if let Some(e) = st.entries.get_mut(&ino) {
|
||||||
e.uid = real.clone();
|
e.uid = real.clone();
|
||||||
@@ -459,8 +475,7 @@ impl Core {
|
|||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
drop(st);
|
|
||||||
// Write the real node through, now that nothing points at the old uid.
|
// Write the real node through, now that nothing points at the old uid.
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
if let Err(e) = self.db.upsert_node(&node) {
|
||||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||||
@@ -503,15 +518,23 @@ impl Core {
|
|||||||
if node.trashed {
|
if node.trashed {
|
||||||
return Ok(Some("the file was trashed remotely".into()));
|
return Ok(Some("the file was trashed remotely".into()));
|
||||||
}
|
}
|
||||||
let (mtime, size) = (node.modification_time, node_size(&node));
|
let Some(reason) = revision_changed(base, &node) else {
|
||||||
if mtime != base.mtime || size != base.size {
|
return Ok(None);
|
||||||
return Ok(Some(format!(
|
};
|
||||||
"the remote revision changed under the queued write \
|
// The remote moved on from the baseline — normally a conflict. But if it
|
||||||
(expected {} bytes at mtime {}, found {size} at {mtime})",
|
// sits at a revision *this daemon* sealed, no other device changed the
|
||||||
base.size, base.mtime
|
// file: it is a single-writer stall→resume (a browser that closed and
|
||||||
)));
|
// reopened its download fd; B70 layer B). Chain onto it — supersede
|
||||||
|
// instead of forking — so the finished bytes win the name rather than
|
||||||
|
// being exiled to a `(sync-conflict)` copy.
|
||||||
|
let remote_rev = node_revision_id(&node);
|
||||||
|
let own_rev = self.own_sealed_revs.lock().get(uid).cloned();
|
||||||
|
if is_own_self_supersede(meta.complete, remote_rev.as_deref(), own_rev.as_deref()) {
|
||||||
|
debug!(%uid, ?remote_rev,
|
||||||
|
"queued write chains onto our own sealed revision; not a conflict");
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(Some(reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a queued write that can no longer be applied to its own node as a
|
/// Land a queued write that can no longer be applied to its own node as a
|
||||||
@@ -595,11 +618,11 @@ impl Core {
|
|||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
// The conflict copy is a node the tree has never seen.
|
// The conflict copy is a node the tree has never seen.
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
}
|
}
|
||||||
drop(st);
|
});
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Upload,
|
ActivityKind::Upload,
|
||||||
&name,
|
&name,
|
||||||
@@ -653,13 +676,25 @@ impl Core {
|
|||||||
|
|
||||||
/// A node's parent uid and name, as the tree currently has them.
|
/// A node's parent uid and name, as the tree currently has them.
|
||||||
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
||||||
{
|
// Across every mount: the drain runs on the primary Core, but a node
|
||||||
let st = self.state.lock();
|
// written inside a sync folder is interned in that fork's inode space,
|
||||||
|
// so asking `self.state` alone always missed it and fell through to the
|
||||||
|
// DB. That still gave the right answer — the fallback below exists for
|
||||||
|
// exactly this shape of miss — but it went to disk for something already
|
||||||
|
// in memory, and only *because* of the fallback was it not a bug.
|
||||||
|
let mut found = None;
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
if found.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
||||||
&& let Some(parent) = entry.node.parent_uid.clone()
|
&& let Some(parent) = entry.node.parent_uid.clone()
|
||||||
{
|
{
|
||||||
return Some((parent, entry.node.name.clone()));
|
found = Some((parent, entry.node.name.clone()));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
if found.is_some() {
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
// The in-memory tree only holds what has been walked to since the daemon
|
// The in-memory tree only holds what has been walked to since the daemon
|
||||||
// started, and the drain routinely runs before anything has walked to
|
// started, and the drain routinely runs before anything has walked to
|
||||||
@@ -814,6 +849,11 @@ impl Core {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// This function's whole job is to bring the tree level with the revision
|
||||||
|
// we just sealed, so the feed's report of that revision has nothing left
|
||||||
|
// to tell us. Claimed here rather than at the upload call so it is only
|
||||||
|
// recorded when the node was actually re-read (`Core::self_changes`).
|
||||||
|
self.note_self_change(uid);
|
||||||
// Rebase any *open* write handles targeting this node. A handle opened
|
// Rebase any *open* write handles targeting this node. A handle opened
|
||||||
// before this upload carries a stale base — its `base_mtime`/`base_size`
|
// before this upload carries a stale base — its `base_mtime`/`base_size`
|
||||||
// name the revision we just replaced. Without this update,
|
// name the revision we just replaced. Without this update,
|
||||||
@@ -826,15 +866,25 @@ impl Core {
|
|||||||
{
|
{
|
||||||
let sealed_mtime = node.modification_time;
|
let sealed_mtime = node.modification_time;
|
||||||
let sealed_size = node_size(&node);
|
let sealed_size = node_size(&node);
|
||||||
let mut st = self.state.lock();
|
let sealed_rev = node_revision_id(&node);
|
||||||
for aw in st.active_writes.values_mut() {
|
// Remember it as ours, so a queued write that opened over an earlier
|
||||||
if aw.uid == *uid {
|
// revision recognises this as a self-supersede rather than a foreign
|
||||||
aw.base_mtime = sealed_mtime;
|
// change and does not fork (B70 layer B; consulted in
|
||||||
aw.base_size = sealed_size;
|
// `revision_conflict`).
|
||||||
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
|
if let Some(rev) = sealed_rev.clone() {
|
||||||
"rebased open write handle onto the revision just uploaded");
|
self.own_sealed_revs.lock().insert(uid.clone(), rev);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
for aw in st.active_writes.values_mut() {
|
||||||
|
if aw.uid == *uid {
|
||||||
|
aw.base_mtime = sealed_mtime;
|
||||||
|
aw.base_size = sealed_size;
|
||||||
|
aw.base_revision_id = sealed_rev.clone();
|
||||||
|
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
|
||||||
|
"rebased open write handle onto the revision just uploaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Ordered so that a write queued *during* the fetch above is still
|
// Ordered so that a write queued *during* the fetch above is still
|
||||||
// caught: it took its baseline from the node's optimistic stamp, and
|
// caught: it took its baseline from the node's optimistic stamp, and
|
||||||
@@ -842,16 +892,17 @@ impl Core {
|
|||||||
if self.rebaseline_pending(uid, &node) {
|
if self.rebaseline_pending(uid, &node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
let Some(parent) = st
|
let Some(parent) = st
|
||||||
.by_uid
|
.by_uid
|
||||||
.get(uid)
|
.get(uid)
|
||||||
.and_then(|ino| st.entries.get(ino))
|
.and_then(|ino| st.entries.get(ino))
|
||||||
.map(|e| e.parent)
|
.map(|e| e.parent)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
st.intern(parent, node);
|
st.intern(parent, node.clone());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point a still-queued write at the revision this upload just sealed, and
|
/// Point a still-queued write at the revision this upload just sealed, and
|
||||||
@@ -875,6 +926,7 @@ impl Core {
|
|||||||
mtime: sealed.modification_time,
|
mtime: sealed.modification_time,
|
||||||
size: node_size(sealed),
|
size: node_size(sealed),
|
||||||
hash: None,
|
hash: None,
|
||||||
|
revision_id: node_revision_id(sealed),
|
||||||
};
|
};
|
||||||
let mut pending = self.pending.lock();
|
let mut pending = self.pending.lock();
|
||||||
let Some(p) = pending.get_mut(uid) else {
|
let Some(p) = pending.get_mut(uid) else {
|
||||||
@@ -896,3 +948,153 @@ impl Core {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the remote `node` moved on from the revision a queued write was
|
||||||
|
/// `base`d on — the reason string when it did, `None` when the write can still be
|
||||||
|
/// applied. Pure so it is testable without a live remote (the trashed/gone checks
|
||||||
|
/// stay in [`Core::revision_conflict`], which does the fetch).
|
||||||
|
///
|
||||||
|
/// The revision id is the authoritative identity: it advances iff a *new* revision
|
||||||
|
/// was sealed. When both sides carry one, it alone decides — the server re-stamps
|
||||||
|
/// the *same* revision's mtime, and reading that drift as a change is exactly what
|
||||||
|
/// diverted a write into a conflict copy of its own base (B16/B25). Only when
|
||||||
|
/// there is no id to compare (an old sidecar, or a surface that omits it) does it
|
||||||
|
/// fall back to the observable `(mtime, size)` tuple.
|
||||||
|
fn revision_changed(base: &Baseline, node: &Node) -> Option<String> {
|
||||||
|
if let (Some(base_rev), Some(remote_rev)) = (&base.revision_id, node_revision_id(node)) {
|
||||||
|
return (*base_rev != remote_rev).then(|| {
|
||||||
|
format!(
|
||||||
|
"the remote revision changed under the queued write \
|
||||||
|
(based on revision {base_rev}, remote now at {remote_rev})"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let (mtime, size) = (node.modification_time, node_size(node));
|
||||||
|
(mtime != base.mtime || size != base.size).then(|| {
|
||||||
|
format!(
|
||||||
|
"the remote revision changed under the queued write \
|
||||||
|
(expected {} bytes at mtime {}, found {size} at {mtime})",
|
||||||
|
base.size, base.mtime
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a queued write that found the remote moved on ([`revision_changed`]
|
||||||
|
/// fired) is nonetheless a single-writer self-supersede rather than a foreign
|
||||||
|
/// conflict — the remote sits at a revision this daemon sealed itself, so
|
||||||
|
/// chaining onto it is safe and forking would be wrong (B70 layer B).
|
||||||
|
///
|
||||||
|
/// `remote_rev` is the id the remote currently holds, `own_rev` this node's
|
||||||
|
/// latest own-sealed id. Requires a `complete` blob: an incomplete one's gaps
|
||||||
|
/// still refer to the stale base, so it must take the non-destructive
|
||||||
|
/// conflict-copy path instead of overwriting. A `None` remote id never matches
|
||||||
|
/// (there is nothing to prove it was ours), so absence falls through to a fork.
|
||||||
|
fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Option<&str>) -> bool {
|
||||||
|
complete && remote_rev.is_some() && remote_rev == own_rev
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
|
||||||
|
fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node {
|
||||||
|
Node {
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("l")),
|
||||||
|
parent_uid: None,
|
||||||
|
kind: NodeKind::File {
|
||||||
|
media_type: "text/plain".into(),
|
||||||
|
total_size_on_storage: size,
|
||||||
|
active_revision_state: None,
|
||||||
|
active_revision_id: rev.map(String::from),
|
||||||
|
claimed_size: Some(size),
|
||||||
|
claimed_modification_time: None,
|
||||||
|
content_sha1: None,
|
||||||
|
},
|
||||||
|
name: "f.txt".into(),
|
||||||
|
creation_time: 0,
|
||||||
|
modification_time: mtime,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline(mtime: i64, size: u64, rev: Option<&str>) -> Baseline {
|
||||||
|
Baseline {
|
||||||
|
mtime,
|
||||||
|
size,
|
||||||
|
hash: None,
|
||||||
|
revision_id: rev.map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_revision_id_is_not_a_conflict_despite_mtime_drift() {
|
||||||
|
// The exact shape of the reported bug: same revision, server re-stamped
|
||||||
|
// its mtime (1784897777 -> 1784897780), same size. Must NOT conflict.
|
||||||
|
let base = baseline(1_784_897_777, 87_438, Some("rev-A"));
|
||||||
|
let remote = file_node(1_784_897_780, 87_438, Some("rev-A"));
|
||||||
|
assert_eq!(revision_changed(&base, &remote), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_revision_id_is_a_conflict_even_at_the_same_size() {
|
||||||
|
// A same-size edit by another device advances the revision id: this is a
|
||||||
|
// real conflict the id catches where (mtime, size) alone could miss it.
|
||||||
|
let base = baseline(100, 40, Some("rev-A"));
|
||||||
|
let remote = file_node(100, 40, Some("rev-B"));
|
||||||
|
assert!(revision_changed(&base, &remote).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_ids_it_falls_back_to_mtime_and_size() {
|
||||||
|
// Old sidecar (no revision id): the (mtime, size) tuple still governs.
|
||||||
|
let base = baseline(100, 40, None);
|
||||||
|
assert_eq!(revision_changed(&base, &file_node(100, 40, None)), None);
|
||||||
|
assert!(revision_changed(&base, &file_node(101, 40, None)).is_some());
|
||||||
|
assert!(revision_changed(&base, &file_node(100, 41, None)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_write_over_our_own_sealed_revision_chains_not_forks() {
|
||||||
|
// B70 layer B: the partial we sealed ourselves (rev-mine) is what the
|
||||||
|
// remote holds; the resume rewrote the whole file. Not a foreign change.
|
||||||
|
assert!(is_own_self_supersede(
|
||||||
|
true,
|
||||||
|
Some("rev-mine"),
|
||||||
|
Some("rev-mine")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_move_to_a_revision_we_did_not_seal_still_forks() {
|
||||||
|
// Another device wrote rev-theirs; we only ever sealed rev-mine. Fork.
|
||||||
|
assert!(!is_own_self_supersede(
|
||||||
|
true,
|
||||||
|
Some("rev-theirs"),
|
||||||
|
Some("rev-mine")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_incomplete_blob_never_self_supersedes() {
|
||||||
|
// Its gaps still refer to the stale base; overwriting would mix
|
||||||
|
// revisions, so it must take the conflict-copy path even over our seal.
|
||||||
|
assert!(!is_own_self_supersede(
|
||||||
|
false,
|
||||||
|
Some("rev-mine"),
|
||||||
|
Some("rev-mine")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_remote_id_never_self_supersedes() {
|
||||||
|
// No id to prove the revision was ours: fall through to a fork rather
|
||||||
|
// than matching None==None.
|
||||||
|
assert!(!is_own_self_supersede(true, None, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
+769
-503
File diff suppressed because it is too large.
Load diff
+423
-32
@@ -47,24 +47,26 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use fuser::ReplyXattr;
|
use fuser::ReplyXattr;
|
||||||
use fuser::{
|
use fuser::{
|
||||||
BackgroundSession, BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem,
|
BackgroundSession, BsdFileFlags, Config, CopyFileRangeFlags, Errno, FileAttr, FileHandle,
|
||||||
FopenFlags, Generation, INodeNo, IoctlFlags, LockOwner, MountOption, Notifier, OpenAccMode,
|
FileType, Filesystem, FopenFlags, Generation, INodeNo, IoctlFlags, LockOwner, MountOption,
|
||||||
OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty,
|
Notifier, OpenAccMode, OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData,
|
||||||
ReplyEntry, ReplyIoctl, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyLseek, ReplyOpen, ReplyWrite, Request,
|
||||||
|
Session, TimeOrNow, WriteFlags,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::{AppDirs, SweepMode};
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||||
TransferDirection,
|
TransferDirection,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{
|
use pdfs_core::db::{
|
||||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode,
|
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||||
StoredSyncFolder, StoredTrash,
|
StoredNode, StoredSyncFolder, StoredTrash,
|
||||||
};
|
};
|
||||||
use pdfs_core::localindex;
|
use pdfs_core::localindex;
|
||||||
use pdfs_core::search::relevance_score;
|
use pdfs_core::search::relevance_score;
|
||||||
|
use pdfs_core::syncignore::is_transient_name;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||||
@@ -87,13 +89,14 @@ mod profile;
|
|||||||
mod reads;
|
mod reads;
|
||||||
mod sharing;
|
mod sharing;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod sweep;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod transfers;
|
mod transfers;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod workers;
|
mod workers;
|
||||||
use background::{run_event_sync, run_local_index};
|
use background::{run_event_sync, run_local_index};
|
||||||
pub(crate) use mount::is_stale_mount;
|
pub(crate) use mount::is_stale_mount;
|
||||||
pub use mount::{MountOutcome, mount};
|
pub use mount::{MountOptions, MountOutcome, mount};
|
||||||
use mount::{SecondaryMount, clear_stale_mount, fuse_connection_id, umount_session_unblocked};
|
use mount::{SecondaryMount, clear_stale_mount, fuse_connection_id, umount_session_unblocked};
|
||||||
use reads::{ReaderSlot, STREAM_BYPASS_MIN, StreamRing};
|
use reads::{ReaderSlot, STREAM_BYPASS_MIN, StreamRing};
|
||||||
use state::{Entry, Intervals, PendingRevision, State, WriteHandle};
|
use state::{Entry, Intervals, PendingRevision, State, WriteHandle};
|
||||||
@@ -237,6 +240,27 @@ struct Core {
|
|||||||
/// off one refresh rather than one per request.
|
/// off one refresh rather than one per request.
|
||||||
timeline_refreshing: Arc<AtomicBool>,
|
timeline_refreshing: Arc<AtomicBool>,
|
||||||
trash_refreshing: Arc<AtomicBool>,
|
trash_refreshing: Arc<AtomicBool>,
|
||||||
|
/// Conflict copies the sweep has already flagged as needing attention this
|
||||||
|
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
||||||
|
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
||||||
|
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
|
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||||
|
/// only report them. Resolved once at mount from config + environment
|
||||||
|
/// ([`AppConfig::resolved_conflict_sweep`]); [`SweepMode::Off`] means the
|
||||||
|
/// sweep thread is never spawned at all. See `docs/BUGS.md` B71.
|
||||||
|
sweep_mode: SweepMode,
|
||||||
|
/// The latest server revision id this daemon has itself sealed, keyed by
|
||||||
|
/// node. A queued write whose baseline names an *earlier* revision would
|
||||||
|
/// normally fork into a `(sync-conflict)` copy when the drain finds the
|
||||||
|
/// remote already moved on — but if the remote sits at a revision *we*
|
||||||
|
/// sealed, no other device touched the file: it is a single-writer
|
||||||
|
/// stall→resume (a browser download that closed and reopened its fd, then
|
||||||
|
/// rewrote the whole file). [`Core::revision_conflict`] consults this to
|
||||||
|
/// adopt our own revision as the base and supersede instead of forking
|
||||||
|
/// (docs/BUGS.md B70, layer B). One entry per written node, overwritten as
|
||||||
|
/// the node advances and dropped when it is trashed; lost on restart, which
|
||||||
|
/// only widens the (already narrow) fork window across a daemon bounce.
|
||||||
|
own_sealed_revs: Arc<Mutex<HashMap<NodeUid, String>>>,
|
||||||
/// Photos whose missing thumbnail is being generated right now. A tile that is
|
/// Photos whose missing thumbnail is being generated right now. A tile that is
|
||||||
/// still on screen asks for its thumbnail again every few seconds, and each of
|
/// still on screen asks for its thumbnail again every few seconds, and each of
|
||||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||||
@@ -296,6 +320,147 @@ struct Core {
|
|||||||
/// uploading that same tree — the engine would upload files as they vanish
|
/// uploading that same tree — the engine would upload files as they vanish
|
||||||
/// and then walk the FUSE mount as if it were local.
|
/// and then walk the FUSE mount as if it were local.
|
||||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||||
|
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
||||||
|
/// those changes the event feed still owes us.
|
||||||
|
///
|
||||||
|
/// The feed replays our own writes back at us seconds later, and a
|
||||||
|
/// `NodeUpdated` carries no revision id — nothing in it distinguishes "another
|
||||||
|
/// device changed this file" from "you changed this file". Treating the echo
|
||||||
|
/// as foreign is not free: it evicts the content blob we just uploaded, so the
|
||||||
|
/// next read re-downloads a revision the API may not be serving yet. That is
|
||||||
|
/// how a SQLite database written on the mount came back `malformed`, and how a
|
||||||
|
/// mount doing bulk uploads spent its time re-fetching its own bytes.
|
||||||
|
///
|
||||||
|
/// So the first event for a node we changed ourselves, within
|
||||||
|
/// [`SELF_CHANGE_TTL_MS`], is consumed rather than applied: the tree already
|
||||||
|
/// holds what that event describes, because [`Core::refresh_after_upload`] put
|
||||||
|
/// it there. Anything beyond that applies normally, which bounds the staleness
|
||||||
|
/// a genuine remote change can suffer to one event.
|
||||||
|
///
|
||||||
|
/// Shared across forks like `pending`: the drain that records the change and
|
||||||
|
/// the event task that consumes the echo are both per-daemon.
|
||||||
|
self_changes: Arc<Mutex<HashMap<NodeUid, SelfChange>>>,
|
||||||
|
/// Every live inode space: this mount's own `state` plus one per on-demand
|
||||||
|
/// fork ([`Core::fork_state`]). Shared by every fork, unlike `state` itself.
|
||||||
|
///
|
||||||
|
/// There is exactly **one** drain thread per daemon, owned by the primary
|
||||||
|
/// mount, and it serves the whole `pending_op` table — including ops queued
|
||||||
|
/// by a forked mount, which keeps its nodes in its own `state`. So a drain
|
||||||
|
/// that reaches for `self.state` looks in the wrong inode space and silently
|
||||||
|
/// finds nothing: the fork's entry keeps its `local~` placeholder uid
|
||||||
|
/// forever, and every read of it short-circuits to empty because
|
||||||
|
/// [`Core::read_range`] refuses to ask the API about a `local~` uid. That
|
||||||
|
/// was `docs/BUGS.md` B74. Background work that rewrites node identity must
|
||||||
|
/// walk this instead ([`Core::for_each_state`]).
|
||||||
|
///
|
||||||
|
/// `Weak`, so an unmounted fork's state is dropped rather than pinned here.
|
||||||
|
states: Arc<StateRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a change this daemon made stays recognisable as its own. Generous
|
||||||
|
/// against a feed that can lag, but finite: past it, an event for the node is
|
||||||
|
/// applied like any other. See [`Core::self_changes`].
|
||||||
|
const SELF_CHANGE_TTL_MS: i64 = 120_000;
|
||||||
|
|
||||||
|
/// A remote change this daemon made, awaiting its echo from the event feed.
|
||||||
|
struct SelfChange {
|
||||||
|
/// When we made it, in the same epoch millis as [`now_millis`].
|
||||||
|
at_ms: i64,
|
||||||
|
/// Echoes not yet seen. More than one because a single file can be created
|
||||||
|
/// and then have a revision uploaded, and the feed reports both.
|
||||||
|
echoes: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one remote change this daemon made. Split from [`Core`] so the
|
||||||
|
/// expiry and echo-counting rules can be tested against a clock the test owns.
|
||||||
|
fn note_self_change(changes: &mut HashMap<NodeUid, SelfChange>, uid: &NodeUid, now_ms: i64) {
|
||||||
|
// Echoes that never arrived — a node trashed before its event came round, a
|
||||||
|
// feed that skipped it — would otherwise accumulate for the life of the
|
||||||
|
// daemon. Pruning on write keeps the map the size of the recent queue.
|
||||||
|
changes.retain(|_, c| now_ms - c.at_ms < SELF_CHANGE_TTL_MS);
|
||||||
|
let change = changes.entry(uid.clone()).or_insert(SelfChange {
|
||||||
|
at_ms: now_ms,
|
||||||
|
echoes: 0,
|
||||||
|
});
|
||||||
|
change.at_ms = now_ms;
|
||||||
|
change.echoes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim one echo, per [`Core::take_self_change`].
|
||||||
|
fn take_self_change(
|
||||||
|
changes: &mut HashMap<NodeUid, SelfChange>,
|
||||||
|
uid: &NodeUid,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> bool {
|
||||||
|
let Some(change) = changes.get_mut(uid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Too old to attribute: the feed is far enough behind that this is as likely
|
||||||
|
// to be someone else's change as ours, and guessing wrong here costs the user
|
||||||
|
// a stale file.
|
||||||
|
if now_ms - change.at_ms >= SELF_CHANGE_TTL_MS {
|
||||||
|
changes.remove(uid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
change.echoes -= 1;
|
||||||
|
if change.echoes == 0 {
|
||||||
|
changes.remove(uid);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One live inode space, as published to the shared registry.
|
||||||
|
///
|
||||||
|
/// The notifier travels with the state because the two are only meaningful
|
||||||
|
/// together: an inode number names a different node in every mount, so telling
|
||||||
|
/// the kernel to drop inode 42 is only correct on the session that minted it.
|
||||||
|
/// Background work that invalidates as well as mutates needs the matching pair,
|
||||||
|
/// which is what [`Core::for_each_mount`] hands it.
|
||||||
|
type LiveMount = (Arc<Mutex<State>>, Arc<OnceLock<Notifier>>);
|
||||||
|
|
||||||
|
struct MountedState {
|
||||||
|
/// `Weak`, so an unmounted fork's state is dropped rather than pinned here;
|
||||||
|
/// a dead entry is reaped on the next registry walk.
|
||||||
|
state: std::sync::Weak<Mutex<State>>,
|
||||||
|
/// This mount's kernel notification channel — the same cell as
|
||||||
|
/// [`Core::notifier`], still empty until its session is spawned.
|
||||||
|
notifier: Arc<OnceLock<Notifier>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every mounted inode space in the daemon, shared by the primary `Core` and
|
||||||
|
/// every [`Core::fork_state`] clone of it.
|
||||||
|
///
|
||||||
|
/// Split out from `Core` so the reap-and-walk rule can be tested on its own: a
|
||||||
|
/// fork that fails to appear here, or one that lingers after unmount, is exactly
|
||||||
|
/// the failure `docs/BUGS.md` B74 was.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct StateRegistry(Mutex<Vec<MountedState>>);
|
||||||
|
|
||||||
|
impl StateRegistry {
|
||||||
|
/// Publish an inode space and the channel to the session that owns it.
|
||||||
|
///
|
||||||
|
/// Registering the same `state` twice would make every walk visit it twice;
|
||||||
|
/// callers register exactly once, at mount time.
|
||||||
|
fn register(&self, state: &Arc<Mutex<State>>, notifier: Arc<OnceLock<Notifier>>) {
|
||||||
|
let mut states = self.0.lock();
|
||||||
|
states.retain(|m| m.state.strong_count() > 0);
|
||||||
|
states.push(MountedState {
|
||||||
|
state: Arc::downgrade(state),
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every live mount, reaping the entries whose session has gone. Returns
|
||||||
|
/// owned handles so callers take the per-state locks one at a time rather
|
||||||
|
/// than holding the registry lock across the work.
|
||||||
|
fn live(&self) -> Vec<LiveMount> {
|
||||||
|
let mut states = self.0.lock();
|
||||||
|
states.retain(|m| m.state.strong_count() > 0);
|
||||||
|
states
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| Some((m.state.upgrade()?, m.notifier.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why [`Core::apply_sync_folder_mode`] did not switch a folder. The two cases are
|
/// Why [`Core::apply_sync_folder_mode`] did not switch a folder. The two cases are
|
||||||
@@ -310,6 +475,54 @@ enum SwitchBlocked {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
|
/// Register an inode space so background work can reach it. Called once for
|
||||||
|
/// the primary mount and once per on-demand fork.
|
||||||
|
fn register_state(&self) {
|
||||||
|
self.states.register(&self.state, self.notifier.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `apply` against every live inode space — this daemon's primary mount
|
||||||
|
/// and each on-demand fork — one lock at a time.
|
||||||
|
///
|
||||||
|
/// The drain and the sync engine are per-daemon but node state is per-mount,
|
||||||
|
/// so anything they change about a node has to be offered to whichever mount
|
||||||
|
/// actually holds it. Which one that is cannot be known from the op: a
|
||||||
|
/// `pending_op` row records a uid, not the session that queued it. Applying
|
||||||
|
/// to all of them is correct because a uid is unique across mounts — at most
|
||||||
|
/// one state has an entry for it, and the rest are no-ops.
|
||||||
|
fn for_each_state(&self, mut apply: impl FnMut(&mut State)) {
|
||||||
|
for (state, _) in self.states.live() {
|
||||||
|
apply(&mut state.lock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Core::for_each_state`], but also hands over the mount's kernel
|
||||||
|
/// notification channel, so work that invalidates cached metadata reaches
|
||||||
|
/// the session that actually minted the inodes it is naming.
|
||||||
|
///
|
||||||
|
/// `None` for a mount whose session has not been spawned yet — there is
|
||||||
|
/// nothing to invalidate in a kernel that has never been told about it.
|
||||||
|
fn for_each_mount(&self, mut apply: impl FnMut(&mut State, Option<&Notifier>)) {
|
||||||
|
for (state, notifier) in self.states.live() {
|
||||||
|
apply(&mut state.lock(), notifier.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that this daemon just changed `uid` on the remote, so the event
|
||||||
|
/// feed's echo of that change is recognised instead of re-applied. Called
|
||||||
|
/// from the drain once the remote has actually accepted the change and the
|
||||||
|
/// tree has been brought level with it. See [`Core::self_changes`].
|
||||||
|
pub(crate) fn note_self_change(&self, uid: &NodeUid) {
|
||||||
|
note_self_change(&mut self.self_changes.lock(), uid, now_millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim one outstanding echo for `uid`, reporting whether this event is one
|
||||||
|
/// this daemon caused. Consuming rather than merely testing is what bounds
|
||||||
|
/// the suppression: the next event for the node applies normally.
|
||||||
|
fn take_self_change(&self, uid: &NodeUid) -> bool {
|
||||||
|
take_self_change(&mut self.self_changes.lock(), uid, now_millis())
|
||||||
|
}
|
||||||
|
|
||||||
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
|
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
|
||||||
/// start serves previously-seen metadata (stable inodes, instant listings)
|
/// start serves previously-seen metadata (stable inodes, instant listings)
|
||||||
/// without re-hitting the API. The root inode is already installed by
|
/// without re-hitting the API. The root inode is already installed by
|
||||||
@@ -1146,7 +1359,12 @@ impl Core {
|
|||||||
base_mtime: h.base_mtime,
|
base_mtime: h.base_mtime,
|
||||||
complete: authored == [(0, h.len)],
|
complete: authored == [(0, h.len)],
|
||||||
authored,
|
authored,
|
||||||
based_on: self.remote_baseline(&h.uid, h.base_mtime, h.base_size),
|
based_on: self.remote_baseline(
|
||||||
|
&h.uid,
|
||||||
|
h.base_mtime,
|
||||||
|
h.base_size,
|
||||||
|
h.base_revision_id.clone(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
self.enqueue_staged_write(&h.uid, h.ino, &h.path, meta)?;
|
self.enqueue_staged_write(&h.uid, h.ino, &h.path, meta)?;
|
||||||
debug!(uid = %h.uid, len = h.len, complete = filled, "queued revision upload");
|
debug!(uid = %h.uid, len = h.len, complete = filled, "queued revision upload");
|
||||||
@@ -1167,7 +1385,13 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// `None` for a node that has never existed remotely: there is no revision
|
/// `None` for a node that has never existed remotely: there is no revision
|
||||||
/// to conflict with until its create drains.
|
/// to conflict with until its create drains.
|
||||||
fn remote_baseline(&self, uid: &NodeUid, base_mtime: i64, base_size: u64) -> Option<Baseline> {
|
fn remote_baseline(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
base_mtime: i64,
|
||||||
|
base_size: u64,
|
||||||
|
base_revision_id: Option<String>,
|
||||||
|
) -> Option<Baseline> {
|
||||||
if is_local_uid(uid) {
|
if is_local_uid(uid) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1177,6 +1401,7 @@ impl Core {
|
|||||||
mtime: base_mtime,
|
mtime: base_mtime,
|
||||||
size: base_size,
|
size: base_size,
|
||||||
hash: None,
|
hash: None,
|
||||||
|
revision_id: base_revision_id,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1293,12 +1518,15 @@ impl Core {
|
|||||||
/// - Shrinking authors nothing: every remaining byte still comes from the
|
/// - Shrinking authors nothing: every remaining byte still comes from the
|
||||||
/// base, so it is the drain that has to fetch it.
|
/// base, so it is the drain that has to fetch it.
|
||||||
fn queue_truncate(&self, ino: u64, size: u64) -> Result<(), Errno> {
|
fn queue_truncate(&self, ino: u64, size: u64) -> Result<(), Errno> {
|
||||||
let (uid, base_mtime, base_size) = {
|
let (uid, base_mtime, base_size, base_revision_id) = {
|
||||||
let st = self.state.lock();
|
let st = self.state.lock();
|
||||||
match st.entries.get(&ino) {
|
match st.entries.get(&ino) {
|
||||||
Some(e) if e.node.is_file() => {
|
Some(e) if e.node.is_file() => (
|
||||||
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
|
e.uid.clone(),
|
||||||
}
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
node_revision_id(&e.node),
|
||||||
|
),
|
||||||
Some(_) => return Err(Errno::EISDIR),
|
Some(_) => return Err(Errno::EISDIR),
|
||||||
None => return Err(Errno::ENOENT),
|
None => return Err(Errno::ENOENT),
|
||||||
}
|
}
|
||||||
@@ -1374,7 +1602,7 @@ impl Core {
|
|||||||
base_mtime,
|
base_mtime,
|
||||||
authored,
|
authored,
|
||||||
complete,
|
complete,
|
||||||
based_on: self.remote_baseline(&uid, base_mtime, base_size),
|
based_on: self.remote_baseline(&uid, base_mtime, base_size, base_revision_id),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.enqueue_staged_write(&uid, ino, &path, meta)?;
|
self.enqueue_staged_write(&uid, ino, &path, meta)?;
|
||||||
@@ -1394,6 +1622,7 @@ impl Core {
|
|||||||
parent_uid: &NodeUid,
|
parent_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
is_dir: bool,
|
is_dir: bool,
|
||||||
|
hold: bool,
|
||||||
) -> Result<Node, Errno> {
|
) -> Result<Node, Errno> {
|
||||||
let uid = mint_local_uid();
|
let uid = mint_local_uid();
|
||||||
let op = PendingOp {
|
let op = PendingOp {
|
||||||
@@ -1407,7 +1636,10 @@ impl Core {
|
|||||||
created_at: now_millis(),
|
created_at: now_millis(),
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_attempt_at: 0,
|
// Parked when the name is transient: its bytes ride on this create
|
||||||
|
// (via `attach_blob_to_create`) and must not upload until a rename to
|
||||||
|
// the finished name un-parks it (docs/BUGS.md B70).
|
||||||
|
next_attempt_at: if hold { PARK_UNTIL } else { 0 },
|
||||||
};
|
};
|
||||||
self.db.enqueue_op(&op).map_err(|e| {
|
self.db.enqueue_op(&op).map_err(|e| {
|
||||||
error!(%parent_uid, name, error = %e, "queueing local node failed");
|
error!(%parent_uid, name, error = %e, "queueing local node failed");
|
||||||
@@ -1495,7 +1727,14 @@ impl Core {
|
|||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
self.hidden.lock().insert(uid.clone());
|
self.hidden.lock().insert(uid.clone());
|
||||||
self.state.lock().forget(uid);
|
// Withdrawn from every mount, not just the one the unlink came through:
|
||||||
|
// a sync folder maps a remote folder that also exists under My Files, so
|
||||||
|
// the same uid can be interned in two inode spaces at once and the other
|
||||||
|
// one would go on serving a file the user just trashed
|
||||||
|
// (`docs/BUGS.md` B74).
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
st.forget(uid);
|
||||||
|
});
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
self.wake_drain();
|
self.wake_drain();
|
||||||
@@ -2112,7 +2351,11 @@ impl Core {
|
|||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.rename_node(&uid, new_name, None))
|
.block_on(self.client.rename_node(&uid, new_name, None))
|
||||||
.map_err(|e| CoreError::from_api(&e, "rename"))?;
|
.map_err(|e| CoreError::from_api(&e, "rename"))?;
|
||||||
self.state.lock().forget(&uid);
|
// Every mount, so a fork showing the same node re-interns it under the
|
||||||
|
// new name instead of keeping the old one (`docs/BUGS.md` B74).
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
st.forget(&uid);
|
||||||
|
});
|
||||||
self.invalidate_parent_listing(rel);
|
self.invalidate_parent_listing(rel);
|
||||||
Ok(new_name.to_string())
|
Ok(new_name.to_string())
|
||||||
}
|
}
|
||||||
@@ -2173,7 +2416,9 @@ impl Core {
|
|||||||
fn remove_replaced(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
fn remove_replaced(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||||
if is_local_uid(uid) {
|
if is_local_uid(uid) {
|
||||||
self.discard_queued_ops(uid);
|
self.discard_queued_ops(uid);
|
||||||
self.state.lock().forget(uid);
|
self.for_each_state(|st| {
|
||||||
|
st.forget(uid);
|
||||||
|
});
|
||||||
debug!(%uid, name, "replaced a node whose create was still queued");
|
debug!(%uid, name, "replaced a node whose create was still queued");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -2186,7 +2431,11 @@ impl Core {
|
|||||||
return Err(Errno::EIO);
|
return Err(Errno::EIO);
|
||||||
}
|
}
|
||||||
self.discard_queued_ops(uid);
|
self.discard_queued_ops(uid);
|
||||||
self.state.lock().forget(uid);
|
// The node was trashed on the server; withdraw it from every inode
|
||||||
|
// space that had it, not only the mount the rename came through.
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
st.forget(uid);
|
||||||
|
});
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
@@ -2371,14 +2620,15 @@ impl Core {
|
|||||||
hidden.remove(uid);
|
hidden.remove(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
// Keyed by uid, so it applies to every mount: a restored node reappears
|
||||||
let mut st = self.state.lock();
|
// in whichever inode spaces show its parent, not only the primary one.
|
||||||
for parent in parents {
|
self.for_each_state(|st| {
|
||||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
for parent in &parents {
|
||||||
|
if let Some(&ino) = st.by_uid.get(parent) {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
Ok(parsed.len())
|
Ok(parsed.len())
|
||||||
}
|
}
|
||||||
@@ -2414,11 +2664,15 @@ impl Core {
|
|||||||
/// Forget every trace of nodes that no longer exist anywhere: their inode and
|
/// Forget every trace of nodes that no longer exist anywhere: their inode and
|
||||||
/// DB row, and their cached content.
|
/// DB row, and their cached content.
|
||||||
fn drop_local(&self, uids: &[NodeUid]) {
|
fn drop_local(&self, uids: &[NodeUid]) {
|
||||||
let mut st = self.state.lock();
|
// Every mount: these nodes are gone from the server, and a sync-folder
|
||||||
for uid in uids {
|
// fork showing the same uid would otherwise keep serving them
|
||||||
st.forget(uid);
|
// (`docs/BUGS.md` B74). A uid is unique across inode spaces, so this is
|
||||||
}
|
// a no-op in every mount but the one that holds it.
|
||||||
drop(st);
|
self.for_each_state(|st| {
|
||||||
|
for uid in uids {
|
||||||
|
st.forget(uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
for uid in uids {
|
for uid in uids {
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
@@ -2725,6 +2979,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
|||||||
total_size_on_storage: 0,
|
total_size_on_storage: 0,
|
||||||
// No revision has been sealed: nothing has been uploaded yet.
|
// No revision has been sealed: nothing has been uploaded yet.
|
||||||
active_revision_state: None,
|
active_revision_state: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
content_sha1: None,
|
||||||
claimed_size: Some(0),
|
claimed_size: Some(0),
|
||||||
claimed_modification_time: None,
|
claimed_modification_time: None,
|
||||||
}
|
}
|
||||||
@@ -2933,6 +3189,27 @@ fn node_size(node: &Node) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server revision id of a node's active revision, if it is a file that has
|
||||||
|
/// one. The stable identity the drain conflict-checks against (see [`Baseline`]).
|
||||||
|
fn node_revision_id(node: &Node) -> Option<String> {
|
||||||
|
match &node.kind {
|
||||||
|
NodeKind::Folder => None,
|
||||||
|
NodeKind::File {
|
||||||
|
active_revision_id, ..
|
||||||
|
} => active_revision_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plaintext content SHA-1 of a file node, if its active revision carried one.
|
||||||
|
/// A download-free content fingerprint the conflict sweep uses to prove two files
|
||||||
|
/// hold identical bytes before it removes one.
|
||||||
|
fn node_content_sha1(node: &Node) -> Option<String> {
|
||||||
|
match &node.kind {
|
||||||
|
NodeKind::Folder => None,
|
||||||
|
NodeKind::File { content_sha1, .. } => content_sha1.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `"1 file"` / `"3 files"` — a count with a correctly pluralised noun, for
|
/// `"1 file"` / `"3 files"` — a count with a correctly pluralised noun, for
|
||||||
/// human-readable activity-log lines.
|
/// human-readable activity-log lines.
|
||||||
fn count_noun(n: usize, one: &str, many: &str) -> String {
|
fn count_noun(n: usize, one: &str, many: &str) -> String {
|
||||||
@@ -3290,6 +3567,8 @@ mod pending_size_tests {
|
|||||||
media_type: "text/plain".into(),
|
media_type: "text/plain".into(),
|
||||||
total_size_on_storage: 0,
|
total_size_on_storage: 0,
|
||||||
active_revision_state: None,
|
active_revision_state: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
content_sha1: None,
|
||||||
claimed_size: Some(claimed),
|
claimed_size: Some(claimed),
|
||||||
claimed_modification_time: None,
|
claimed_modification_time: None,
|
||||||
},
|
},
|
||||||
@@ -3356,11 +3635,120 @@ mod pending_size_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
Intervals, PendingRevision, conflict_name, copy_pending_for_truncate, fuse_name,
|
HashMap, Intervals, PendingRevision, SELF_CHANGE_TTL_MS, StateRegistry, conflict_name,
|
||||||
is_stale_mount, node_visible, rename_needs_queue,
|
copy_pending_for_truncate, fuse_name, is_stale_mount, node_visible, note_self_change,
|
||||||
|
parse_node_uid, rename_needs_queue, take_self_change,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
|
|
||||||
|
/// The registry every mount publishes itself into is what lets the daemon's
|
||||||
|
/// single drain thread reach a node living in an on-demand fork's inode
|
||||||
|
/// space. A fork missing from it reads as an empty file for the life of the
|
||||||
|
/// daemon (`docs/BUGS.md` B74), so the walk has to see *every* registered
|
||||||
|
/// mount — and only the ones still mounted.
|
||||||
|
#[test]
|
||||||
|
fn state_registry_walks_every_live_mount_and_reaps_the_rest() {
|
||||||
|
let registry = StateRegistry::default();
|
||||||
|
assert!(registry.live().is_empty(), "a fresh registry has no mounts");
|
||||||
|
|
||||||
|
// The primary mount plus two on-demand forks.
|
||||||
|
let (primary, _p) = state_test_helper();
|
||||||
|
let (fork_a, _a) = state_test_helper();
|
||||||
|
let (fork_b, _b) = state_test_helper();
|
||||||
|
let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary));
|
||||||
|
let fork_a = std::sync::Arc::new(parking_lot::Mutex::new(fork_a));
|
||||||
|
let fork_b = std::sync::Arc::new(parking_lot::Mutex::new(fork_b));
|
||||||
|
for state in [&primary, &fork_a, &fork_b] {
|
||||||
|
registry.register(state, std::sync::Arc::new(std::sync::OnceLock::new()));
|
||||||
|
}
|
||||||
|
assert_eq!(registry.live().len(), 3, "every registered mount is walked");
|
||||||
|
|
||||||
|
// `live()` must not itself pin a mount alive, or an unmounted fork could
|
||||||
|
// never be reaped.
|
||||||
|
assert_eq!(
|
||||||
|
std::sync::Arc::strong_count(&fork_a),
|
||||||
|
1,
|
||||||
|
"the registry holds forks weakly"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unmounting a fork drops its state; the dead entry goes on the next walk.
|
||||||
|
drop(fork_a);
|
||||||
|
assert_eq!(registry.live().len(), 2, "an unmounted fork is dropped");
|
||||||
|
assert_eq!(
|
||||||
|
registry.0.lock().len(),
|
||||||
|
2,
|
||||||
|
"and its slot is reaped, not merely skipped"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(primary);
|
||||||
|
drop(fork_b);
|
||||||
|
assert!(
|
||||||
|
registry.live().is_empty(),
|
||||||
|
"the last mount going away empties the registry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An event feed carries no revision id, so the only thing that tells a
|
||||||
|
/// remote change apart from the echo of one this daemon just made is this
|
||||||
|
/// bookkeeping. Getting it wrong is expensive in both directions: too eager
|
||||||
|
/// and we evict the bytes we uploaded and re-download them from an API that
|
||||||
|
/// may still be serving the previous revision (a SQLite file on the mount
|
||||||
|
/// read back `malformed`); too generous and a real change from another device
|
||||||
|
/// is ignored.
|
||||||
|
#[test]
|
||||||
|
fn a_self_change_is_claimed_once_and_expires() {
|
||||||
|
let mut changes = HashMap::new();
|
||||||
|
let uid = parse_node_uid("vol~link").unwrap();
|
||||||
|
let other = parse_node_uid("vol~other").unwrap();
|
||||||
|
let t0 = 1_000_000;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!take_self_change(&mut changes, &uid, t0),
|
||||||
|
"a node we never touched is never ours"
|
||||||
|
);
|
||||||
|
|
||||||
|
note_self_change(&mut changes, &uid, t0);
|
||||||
|
assert!(
|
||||||
|
take_self_change(&mut changes, &uid, t0 + 5_000),
|
||||||
|
"the echo of our own change is claimed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!take_self_change(&mut changes, &uid, t0 + 6_000),
|
||||||
|
"and only once, so a later foreign change still applies"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A create followed by a revision upload is two changes to one node, and
|
||||||
|
// the feed reports both.
|
||||||
|
note_self_change(&mut changes, &uid, t0);
|
||||||
|
note_self_change(&mut changes, &uid, t0);
|
||||||
|
assert!(take_self_change(&mut changes, &uid, t0 + 1));
|
||||||
|
assert!(take_self_change(&mut changes, &uid, t0 + 2));
|
||||||
|
assert!(!take_self_change(&mut changes, &uid, t0 + 3));
|
||||||
|
|
||||||
|
// Past the window the attribution is a guess, and a wrong guess leaves
|
||||||
|
// the user looking at a stale file.
|
||||||
|
note_self_change(&mut changes, &uid, t0);
|
||||||
|
assert!(!take_self_change(
|
||||||
|
&mut changes,
|
||||||
|
&uid,
|
||||||
|
t0 + SELF_CHANGE_TTL_MS
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!changes.contains_key(&uid),
|
||||||
|
"an expired change is dropped, not left to be re-tested"
|
||||||
|
);
|
||||||
|
|
||||||
|
// An echo that never arrives must not accumulate for the life of the
|
||||||
|
// daemon; the next recorded change prunes it.
|
||||||
|
note_self_change(&mut changes, &uid, t0);
|
||||||
|
note_self_change(&mut changes, &other, t0 + SELF_CHANGE_TTL_MS);
|
||||||
|
assert_eq!(
|
||||||
|
changes.len(),
|
||||||
|
1,
|
||||||
|
"recording a change prunes the ones whose echo never came"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The predicate must answer *only* for a dead FUSE connection. A healthy
|
/// The predicate must answer *only* for a dead FUSE connection. A healthy
|
||||||
/// directory and an absent path are both "not stale" — widening it to any
|
/// directory and an absent path are both "not stale" — widening it to any
|
||||||
/// `metadata` error would make the on-demand restore lazily unmount paths
|
/// `metadata` error would make the on-demand restore lazily unmount paths
|
||||||
@@ -3486,6 +3874,7 @@ mod tests {
|
|||||||
mtime: 42,
|
mtime: 42,
|
||||||
size: 3,
|
size: 3,
|
||||||
hash: None,
|
hash: None,
|
||||||
|
revision_id: None,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -3661,6 +4050,8 @@ mod tests {
|
|||||||
media_type: "text/plain".into(),
|
media_type: "text/plain".into(),
|
||||||
total_size_on_storage: 0,
|
total_size_on_storage: 0,
|
||||||
active_revision_state: None,
|
active_revision_state: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
content_sha1: None,
|
||||||
claimed_size: Some(0),
|
claimed_size: Some(0),
|
||||||
claimed_modification_time: None,
|
claimed_modification_time: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ fn fetch_or_recall_root(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||||
|
/// caller, so `mount` itself never reads config or the environment.
|
||||||
|
pub struct MountOptions {
|
||||||
|
/// Proton account the session belongs to, for display and per-user paths.
|
||||||
|
pub username: String,
|
||||||
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
|
pub sweep_mode: SweepMode,
|
||||||
|
}
|
||||||
|
|
||||||
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
||||||
/// daemon is asked to stop.
|
/// daemon is asked to stop.
|
||||||
///
|
///
|
||||||
@@ -162,8 +171,12 @@ pub fn mount(
|
|||||||
cache: ContentCache,
|
cache: ContentCache,
|
||||||
control_socket: &Path,
|
control_socket: &Path,
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
username: String,
|
options: MountOptions,
|
||||||
) -> std::io::Result<MountOutcome> {
|
) -> std::io::Result<MountOutcome> {
|
||||||
|
let MountOptions {
|
||||||
|
username,
|
||||||
|
sweep_mode,
|
||||||
|
} = options;
|
||||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
|
|
||||||
@@ -196,6 +209,10 @@ pub fn mount(
|
|||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
sweep_mode,
|
||||||
|
own_sealed_revs: 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())),
|
||||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
@@ -206,7 +223,11 @@ pub fn mount(
|
|||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(HashMap::new())),
|
mounts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
states: Arc::new(StateRegistry::default()),
|
||||||
};
|
};
|
||||||
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
|
// every mount's inode space through this registry, not through `core.state`.
|
||||||
|
core.register_state();
|
||||||
|
|
||||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||||
// an upload, and reads must be served from their staged blobs until they land.
|
// an upload, and reads must be served from their staged blobs until they land.
|
||||||
@@ -226,6 +247,21 @@ pub fn mount(
|
|||||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
sync::spawn(core.clone(), sync_rx);
|
sync::spawn(core.clone(), sync_rx);
|
||||||
|
|
||||||
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
|
// Its own thread — it enumerates and trashes, and must never block a FUSE call.
|
||||||
|
// `Off` skips the thread entirely rather than starting an idle one, so the
|
||||||
|
// setting is verifiable from the outside (`ls /proc/<pid>/task/*/comm`).
|
||||||
|
if sweep_mode == SweepMode::Off {
|
||||||
|
info!("conflict sweep disabled by configuration");
|
||||||
|
} else {
|
||||||
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
|
let core = core.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pdfs-conflict-sweep".into())
|
||||||
|
.spawn(move || core.run_conflict_sweep_loop())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Mounted from the cache: watch for the network coming back so the mount can
|
// Mounted from the cache: watch for the network coming back so the mount can
|
||||||
// stop being read-only-ish without the user restarting the daemon.
|
// stop being read-only-ish without the user restarting the daemon.
|
||||||
if !online {
|
if !online {
|
||||||
@@ -310,15 +346,7 @@ pub fn mount(
|
|||||||
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
||||||
// can invalidate kernel-cached metadata without threading a handle through.
|
// can invalidate kernel-cached metadata without threading a handle through.
|
||||||
let _ = core.notifier.set(notifier.clone());
|
let _ = core.notifier.set(notifier.clone());
|
||||||
rt.spawn(run_event_sync(
|
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||||
client,
|
|
||||||
scope,
|
|
||||||
core.state,
|
|
||||||
core.cache,
|
|
||||||
core.db,
|
|
||||||
core.pending,
|
|
||||||
notifier,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||||
|
|||||||
@@ -466,15 +466,33 @@ impl Core {
|
|||||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||||
/// the caller then leaves the path empty.
|
/// the caller then leaves the path empty.
|
||||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||||
let st = self.state.lock();
|
// Searched across every mount: a node browsed through a sync folder is
|
||||||
let mut ino = *st.by_uid.get(uid)?;
|
// interned in that fork's inode space, so the primary state alone had no
|
||||||
let mut parts = Vec::new();
|
// entry for it and the caller silently showed an empty path
|
||||||
while ino != ROOT_INO {
|
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
||||||
let entry = st.entries.get(&ino)?;
|
// it, which is the one the user reached it through.
|
||||||
parts.push(entry.node.name.clone());
|
let mut path = None;
|
||||||
ino = entry.parent;
|
self.for_each_state(|st| {
|
||||||
}
|
if path.is_some() {
|
||||||
parts.reverse();
|
return;
|
||||||
Some(parts.join("/"))
|
}
|
||||||
|
path = walk_to_root(st, uid);
|
||||||
|
});
|
||||||
|
path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||||
|
/// that mount has never interned the node, or if the chain breaks partway —
|
||||||
|
/// a broken chain is a missing path, never a partial one.
|
||||||
|
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||||
|
let mut ino = *st.by_uid.get(uid)?;
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
while ino != ROOT_INO {
|
||||||
|
let entry = st.entries.get(&ino)?;
|
||||||
|
parts.push(entry.node.name.clone());
|
||||||
|
ino = entry.parent;
|
||||||
|
}
|
||||||
|
parts.reverse();
|
||||||
|
Some(parts.join("/"))
|
||||||
|
}
|
||||||
@@ -106,6 +106,12 @@ pub(crate) struct WriteHandle {
|
|||||||
pub(crate) base_size: u64,
|
pub(crate) base_size: u64,
|
||||||
/// Modification time of the remote base at open, validating its block cache.
|
/// Modification time of the remote base at open, validating its block cache.
|
||||||
pub(crate) base_mtime: i64,
|
pub(crate) base_mtime: i64,
|
||||||
|
/// Server revision id of the remote base at open, if it had one. This is the
|
||||||
|
/// stable identity the drain conflict-checks against: `base_mtime` alone
|
||||||
|
/// drifts when the server re-stamps the *same* revision, which reads as a
|
||||||
|
/// spurious conflict (see B16/B25). `None` for a file with no sealed remote
|
||||||
|
/// revision, or one read through a surface that does not carry the id.
|
||||||
|
pub(crate) base_revision_id: Option<String>,
|
||||||
/// Whether anything diverged from the remote and needs an upload.
|
/// Whether anything diverged from the remote and needs an upload.
|
||||||
pub(crate) dirty: bool,
|
pub(crate) dirty: bool,
|
||||||
/// Number of file handles currently sharing this scratch state.
|
/// Number of file handles currently sharing this scratch state.
|
||||||
@@ -438,6 +444,8 @@ mod tests {
|
|||||||
media_type: "text/plain".into(),
|
media_type: "text/plain".into(),
|
||||||
total_size_on_storage: 0,
|
total_size_on_storage: 0,
|
||||||
active_revision_state: None,
|
active_revision_state: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
content_sha1: None,
|
||||||
claimed_size: Some(0),
|
claimed_size: Some(0),
|
||||||
claimed_modification_time: None,
|
claimed_modification_time: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
//! Background conflict sweep.
|
||||||
|
//!
|
||||||
|
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
|
||||||
|
//! engine cut one when they cannot prove a queued write is safe to apply, so the
|
||||||
|
//! user never loses bytes. Most of those copies turn out to be byte-identical to
|
||||||
|
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
|
||||||
|
//! first-sync of an already-identical file. This sweep reconciles them after the
|
||||||
|
//! fact:
|
||||||
|
//!
|
||||||
|
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
|
||||||
|
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
|
||||||
|
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
|
||||||
|
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
|
||||||
|
//! user can decide which version wins.
|
||||||
|
//!
|
||||||
|
//! Identity is proved without downloading anything: the SHA-1 comes from the
|
||||||
|
//! revision's decrypted extended attributes, which enumeration already reads. A
|
||||||
|
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
|
||||||
|
//! never removes a file it cannot prove is a duplicate.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use pdfs_core::config::SweepMode;
|
||||||
|
use pdfs_core::control::ActivityKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::{Core, node_content_sha1, node_revision_id, node_size};
|
||||||
|
|
||||||
|
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
|
||||||
|
/// the sweep off the hot path while still cleaning up within a few minutes.
|
||||||
|
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Delay before the first sweep, so it does not compete with the burst of work a
|
||||||
|
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
||||||
|
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
||||||
|
/// returning the original file name it was a copy of. `None` when `name` is not a
|
||||||
|
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
||||||
|
/// formats, so the two halves of the product agree on what a conflict is.
|
||||||
|
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||||
|
const MARKER: &str = " (sync-conflict ";
|
||||||
|
let start = name.find(MARKER)?;
|
||||||
|
let after = start + MARKER.len();
|
||||||
|
let close_rel = name[after..].find(')')?;
|
||||||
|
let close = after + close_rel;
|
||||||
|
let inner = &name[after..close];
|
||||||
|
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
||||||
|
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||||
|
let mut parts = inner.splitn(2, '-');
|
||||||
|
let stamp = parts.next().unwrap_or_default();
|
||||||
|
let suffix = parts.next();
|
||||||
|
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||||
|
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut base = String::with_capacity(start + name.len() - close);
|
||||||
|
base.push_str(&name[..start]);
|
||||||
|
base.push_str(&name[close + 1..]);
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||||
|
/// docs for the identical/divergent policy.
|
||||||
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
|
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
||||||
|
loop {
|
||||||
|
self.sweep_conflicts_once();
|
||||||
|
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sweep pass over every node the daemon currently knows.
|
||||||
|
fn sweep_conflicts_once(&self) {
|
||||||
|
let stored = match self.db.load_all() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = ?e, "conflict sweep: load nodes failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let nodes: Vec<_> = stored.into_iter().map(|s| s.node).collect();
|
||||||
|
|
||||||
|
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
||||||
|
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
||||||
|
for node in &nodes {
|
||||||
|
if node.trashed || node.is_folder() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parent = node
|
||||||
|
.parent_uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||||
|
}
|
||||||
|
|
||||||
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
for node in &nodes {
|
||||||
|
if node.trashed || node.is_folder() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(base) = conflict_base_name(&node.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let parent = node
|
||||||
|
.parent_uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
|
||||||
|
|
||||||
|
match decide(node, sibling, &base, self.sweep_mode) {
|
||||||
|
SweepAction::Remove => {
|
||||||
|
// Only while online — the remote trash is the point. Offline,
|
||||||
|
// it waits for a later pass.
|
||||||
|
if online {
|
||||||
|
self.remove_conflict_copy(node, &base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trash a conflict copy proven identical to its live sibling, and unhook it
|
||||||
|
/// locally — the same sequence the interactive unlink path uses after a trash.
|
||||||
|
///
|
||||||
|
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
|
||||||
|
/// enumerated every node up front and has been doing network I/O since. Before
|
||||||
|
/// touching anything remote this re-reads the node and refuses to act unless
|
||||||
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||||
|
/// unopened. Without that, a user edit landing inside the window is destroyed
|
||||||
|
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
||||||
|
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
||||||
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||||
|
let uid = &snapshot.uid;
|
||||||
|
let name = snapshot.name.as_str();
|
||||||
|
if !self.duplicate_still_removable(snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if super::is_gone(&e) => {}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every mount, not just the primary: a conflict copy inside a sync
|
||||||
|
// folder lives in that fork's inode space, and leaving it hooked up
|
||||||
|
// there keeps a node we have just trashed remotely visible and
|
||||||
|
// readable there (`docs/BUGS.md` B74).
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
st.forget_or_unlink(uid);
|
||||||
|
});
|
||||||
|
self.discard_queued_ops(uid);
|
||||||
|
self.cache.evict(uid);
|
||||||
|
self.evict_reader(uid);
|
||||||
|
if let Err(e) = self.db.delete_node(uid) {
|
||||||
|
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
|
||||||
|
}
|
||||||
|
self.invalidate_trash();
|
||||||
|
self.conflict_notified.lock().remove(uid);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Trash,
|
||||||
|
name,
|
||||||
|
format!("auto-removed duplicate of {base}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-check, immediately before the destructive call, everything the pass
|
||||||
|
/// decided from its stale snapshot. Any doubt means "leave it alone": a
|
||||||
|
/// conflict copy that survives to the next pass costs nothing, a wrongly
|
||||||
|
/// removed one costs the user data.
|
||||||
|
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
|
||||||
|
let uid = &snapshot.uid;
|
||||||
|
let name = snapshot.name.as_str();
|
||||||
|
|
||||||
|
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
||||||
|
// would throw those away along with the staged blob holding them.
|
||||||
|
match self.db.has_any_op(&uid.to_string()) {
|
||||||
|
Ok(false) => {}
|
||||||
|
Ok(true) => {
|
||||||
|
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Cannot prove the queue is empty — assume it is not.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone is writing to it, or holds it open. Trashing now would pull the
|
||||||
|
// node out from under a live handle.
|
||||||
|
if self.is_busy(uid) {
|
||||||
|
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, the node itself must not have moved on since the snapshot.
|
||||||
|
match self.db.node_by_uid(&uid.to_string()) {
|
||||||
|
Ok(Some(fresh)) => {
|
||||||
|
if fresh.trashed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
|
||||||
|
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if node_revision_id(&fresh) != node_revision_id(snapshot)
|
||||||
|
|| node_size(&fresh) != node_size(snapshot)
|
||||||
|
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
|
||||||
|
{
|
||||||
|
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Already gone locally, or unreadable — either way, not ours to remove.
|
||||||
|
Ok(None) => false,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a node has a write in flight or a file handle open against it.
|
||||||
|
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
||||||
|
/// writer/holder check.
|
||||||
|
/// Asked across **every** mount. The sweep runs on the primary Core but
|
||||||
|
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
||||||
|
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||||
|
/// answered "idle" for every such file and let the sweep delete one with a
|
||||||
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||||
|
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
|
let mut busy = false;
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
busy |=
|
||||||
|
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
||||||
|
});
|
||||||
|
busy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||||
|
/// daemon run so the activity feed is not spammed each pass.
|
||||||
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sweep concluded about one `(sync-conflict …)` copy.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum SweepAction {
|
||||||
|
/// Provably redundant: trash it, subject to the runtime interlocks in
|
||||||
|
/// [`Core::duplicate_still_removable`].
|
||||||
|
Remove,
|
||||||
|
/// Only the user can resolve this one; surface it with this detail.
|
||||||
|
Flag(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide what to do about conflict copy `node` given its live sibling (if any)
|
||||||
|
/// and the configured `mode`.
|
||||||
|
///
|
||||||
|
/// Pure, so the policy that governs deletion is testable without a mount, a
|
||||||
|
/// runtime, or an account — the same split used for `revision_changed` and
|
||||||
|
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
|
||||||
|
fn decide(
|
||||||
|
node: &proton_drive_rs::Node,
|
||||||
|
sibling: Option<&proton_drive_rs::Node>,
|
||||||
|
base: &str,
|
||||||
|
mode: SweepMode,
|
||||||
|
) -> SweepAction {
|
||||||
|
match sibling {
|
||||||
|
Some(sib) if is_identical(node, sib) => {
|
||||||
|
if mode.is_enforcing() {
|
||||||
|
SweepAction::Remove
|
||||||
|
} else {
|
||||||
|
// Report-only: say what would happen, remove nothing. The point is
|
||||||
|
// to accumulate field evidence that the identity check is right
|
||||||
|
// before it is trusted with deletion (docs/BUGS.md B71).
|
||||||
|
SweepAction::Flag(format!(
|
||||||
|
"identical to {base}; would be removed (sweep is report-only)"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => SweepAction::Flag(format!("differs from {base}")),
|
||||||
|
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two file nodes provably hold the same bytes: equal size and equal,
|
||||||
|
/// present content SHA-1. A missing digest on either side is not proof, so the
|
||||||
|
/// sweep treats it as divergent rather than risk removing a real difference.
|
||||||
|
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
|
||||||
|
if node_size(a) != node_size(b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (node_content_sha1(a), node_content_sha1(b)) {
|
||||||
|
(Some(x), Some(y)) => x == y,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
|
||||||
|
/// A file node with the two fields the sweep's identity check reads.
|
||||||
|
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
|
||||||
|
proton_drive_rs::Node {
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
|
||||||
|
parent_uid: None,
|
||||||
|
kind: NodeKind::File {
|
||||||
|
media_type: "text/plain".into(),
|
||||||
|
total_size_on_storage: size,
|
||||||
|
active_revision_state: None,
|
||||||
|
active_revision_id: Some("rev".into()),
|
||||||
|
claimed_size: Some(size),
|
||||||
|
claimed_modification_time: None,
|
||||||
|
content_sha1: sha1.map(String::from),
|
||||||
|
},
|
||||||
|
name: name.into(),
|
||||||
|
creation_time: 0,
|
||||||
|
modification_time: 0,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only input shape that may ever produce a removal: same size, same
|
||||||
|
/// present digest, live sibling, enforcing mode.
|
||||||
|
#[test]
|
||||||
|
fn removes_only_a_provably_identical_copy_when_enforcing() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("aaa"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Remove
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_only_never_removes_but_still_says_what_it_would_do() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("aaa"));
|
||||||
|
// Same inputs as the removal case above; only the mode differs.
|
||||||
|
let SweepAction::Flag(detail) = decide(©, Some(&live), "f.txt", SweepMode::Report)
|
||||||
|
else {
|
||||||
|
panic!("report mode must never remove");
|
||||||
|
};
|
||||||
|
assert!(detail.contains("would be removed"), "{detail}");
|
||||||
|
assert!(detail.contains("report-only"), "{detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_differing_size_is_a_real_conflict() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("aaa"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("differs from f.txt".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_digest_is_never_proof_of_identity() {
|
||||||
|
// Equal sizes are not enough: two different 100-byte files are common.
|
||||||
|
// A copy the sweep cannot fingerprint must survive, in either direction.
|
||||||
|
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, a);
|
||||||
|
let live = file("f.txt", 100, b);
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("differs from f.txt".into()),
|
||||||
|
"{a:?} vs {b:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("bbb"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("differs from f.txt".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_orphaned_copy_is_surfaced_not_removed() {
|
||||||
|
// No live original left to reconcile against — the copy may be the only
|
||||||
|
// surviving version of the file, so it is never the sweep's to remove.
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, None, "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("no original f.txt to reconcile against".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_needs_both_size_and_digest_to_match() {
|
||||||
|
let a = file("a", 100, Some("aaa"));
|
||||||
|
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
|
||||||
|
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
|
||||||
|
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
|
||||||
|
assert!(!is_identical(&a, &file("b", 100, None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_conflict_names_and_leaves_others_alone() {
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
|
||||||
|
Some("test_export.xml")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("README (sync-conflict 42)").as_deref(),
|
||||||
|
Some("README")
|
||||||
|
);
|
||||||
|
// The sync engine's de-duplicated suffix form.
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||||
|
Some("notes.txt")
|
||||||
|
);
|
||||||
|
// Not a conflict copy.
|
||||||
|
assert_eq!(conflict_base_name("report.xml"), None);
|
||||||
|
// Looks close but the token is not a stamp.
|
||||||
|
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dotted_stem_keeps_its_inner_dots() {
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
|
||||||
|
Some("archive.tar.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -670,6 +670,27 @@ impl Core {
|
|||||||
|
|
||||||
match plan_file(l, r, baseline.get(rel)) {
|
match plan_file(l, r, baseline.get(rel)) {
|
||||||
FilePlan::Unchanged => {}
|
FilePlan::Unchanged => {}
|
||||||
|
FilePlan::AdoptBaseline => {
|
||||||
|
// First sync of an already-identical file: record the baseline
|
||||||
|
// in place so future divergence is detected, without any
|
||||||
|
// transfer or a spurious conflict copy.
|
||||||
|
let local = l.expect("adopt baseline requires a local file");
|
||||||
|
let remote = r.expect("adopt baseline requires a remote file");
|
||||||
|
if let Err(e) = self.db.sync_entry_upsert(
|
||||||
|
folder_id,
|
||||||
|
&StoredSyncEntry {
|
||||||
|
rel_path: rel.clone(),
|
||||||
|
remote_uid: Some(remote.uid.to_string()),
|
||||||
|
local_mtime: local.mtime,
|
||||||
|
local_size: local.size,
|
||||||
|
remote_rev: Some(remote.mtime.to_string()),
|
||||||
|
remote_hash: Some(remote.size.to_string()),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
warn!(rel, error = ?e, "sync: adopt baseline failed");
|
||||||
|
outcome.errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
FilePlan::Deferred => {
|
FilePlan::Deferred => {
|
||||||
debug!(
|
debug!(
|
||||||
rel,
|
rel,
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ pub(super) enum FilePlan {
|
|||||||
DeleteLocal,
|
DeleteLocal,
|
||||||
DeleteRemote,
|
DeleteRemote,
|
||||||
ForgetBaseline,
|
ForgetBaseline,
|
||||||
|
/// First sync of a file that already exists on both sides with identical
|
||||||
|
/// content: record the baseline without transferring anything. Without this,
|
||||||
|
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
|
||||||
|
/// pre-existing file, because a missing baseline makes both sides read as
|
||||||
|
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
|
||||||
|
AdoptBaseline,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn plan_file(
|
pub(super) fn plan_file(
|
||||||
@@ -219,10 +225,16 @@ pub(super) fn plan_file(
|
|||||||
});
|
});
|
||||||
|
|
||||||
match (local, remote) {
|
match (local, remote) {
|
||||||
(Some(_), Some(_)) => match (local_changed, remote_changed) {
|
(Some(l), Some(r)) => match (local_changed, remote_changed) {
|
||||||
(false, false) => FilePlan::Unchanged,
|
(false, false) => FilePlan::Unchanged,
|
||||||
(true, false) => FilePlan::UploadRevision,
|
(true, false) => FilePlan::UploadRevision,
|
||||||
(false, true) => FilePlan::Download,
|
(false, true) => FilePlan::Download,
|
||||||
|
// With a baseline, both-changed is a genuine concurrent edit and must
|
||||||
|
// be preserved as a conflict. Without one, "both changed" only means
|
||||||
|
// this is the first sync and there was nothing to compare to: adopt
|
||||||
|
// the baseline when the sizes already match rather than conflict-copy
|
||||||
|
// an already-identical file. Divergent sizes stay a real conflict.
|
||||||
|
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
|
||||||
(true, true) => FilePlan::Conflict,
|
(true, true) => FilePlan::Conflict,
|
||||||
},
|
},
|
||||||
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
||||||
@@ -350,6 +362,31 @@ mod file_plan_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
|
||||||
|
// No baseline yet (first sync). Same size on both sides is a pre-existing
|
||||||
|
// identical file, not a concurrent edit: adopt it rather than cut a
|
||||||
|
// conflict copy of every file in the folder.
|
||||||
|
let local = local(10, 40);
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
|
FilePlan::AdoptBaseline
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_sync_of_differing_sizes_is_still_a_conflict() {
|
||||||
|
// No baseline and the sizes disagree: a genuine both-sides divergence,
|
||||||
|
// preserved non-destructively as a conflict.
|
||||||
|
let local = local(10, 20);
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
|
FilePlan::Conflict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_remote_signature_is_treated_as_changed() {
|
fn a_missing_remote_signature_is_treated_as_changed() {
|
||||||
let local = local(10, 20);
|
let local = local(10, 20);
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Share => "Shared",
|
ActivityKind::Share => "Shared",
|
||||||
ActivityKind::PublicLink => "Linked",
|
ActivityKind::PublicLink => "Linked",
|
||||||
ActivityKind::Unshare => "Unshared",
|
ActivityKind::Unshare => "Unshared",
|
||||||
|
ActivityKind::Conflict => "Conflict",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +300,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Restore => "edit-undo-symbolic",
|
ActivityKind::Restore => "edit-undo-symbolic",
|
||||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||||
|
ActivityKind::Conflict => "dialog-warning-symbolic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ The background daemon relies on the following thread topology:
|
|||||||
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for front-end status/configuration requests.
|
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for front-end status/configuration requests.
|
||||||
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
|
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
|
||||||
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
|
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
|
||||||
|
6. **Conflict Sweep Thread (`pdfs-conflict-sweep`, optional):** Reconciles leftover `(sync-conflict …)` copies — 30 s warmup, then one pass every 5 minutes. A copy proven identical to its live sibling (equal size **and** equal `content_sha1`) is redundant; anything it cannot prove identical is surfaced to the activity feed and left alone. Governed by `AppConfig.conflict_sweep` / `PDFS_CONFLICT_SWEEP` (`SweepMode`): **report-only by default**, `off` skips the thread entirely, and only `enforce` lets it trash. Because it deletes, the enforcing path re-verifies revision id, size, digest, name, parent, queued ops and open handles immediately before acting rather than trusting its own pass-start snapshot. See `docs/BUGS.md` B69 and B71.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+536
@@ -1159,6 +1159,8 @@ retracted.
|
|||||||
|
|
||||||
**Verified:** Clean build and 200/200 workspace tests passing.
|
**Verified:** Clean build and 200/200 workspace tests passing.
|
||||||
|
|
||||||
|
**Residual (2026-07-24):** the two-part defense narrowed the window but did not close it — the baseline was still keyed on `(mtime, size)`, which drifts when the server re-stamps the *same* revision. Reproduced again on a plain application save (`test_export.xml`): base mtime `1784897777` vs sealed `1784897780`, identical size, no other device involved. The root cause is that mtime is not a revision identity. Fixed properly by **B69**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix
|
## B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix
|
||||||
@@ -2110,3 +2112,537 @@ the explicit remote-folder mapping. Repeated retries also add noisy warnings.
|
|||||||
rather than directly at the device root, reuse that folder by stable identity,
|
rather than directly at the device root, reuse that folder by stable identity,
|
||||||
and make backup health visible. Verify upload, replacement, restart restore, and
|
and make backup health visible. Verify upload, replacement, restart restore, and
|
||||||
byte-identical fresh-state recovery on a dedicated account.
|
byte-identical fresh-state recovery on a dedicated account.
|
||||||
|
|
||||||
|
## B69 — Spurious `(sync-conflict)` copies from mtime-keyed revision identity (B16/B25 root fix)
|
||||||
|
|
||||||
|
**Status:** Fixed — offline-tested 2026-07-25, **live-validated 2026-07-26** (see below)
|
||||||
|
**Found:** 2026-07-24, user reported `test_export (sync-conflict 1784898786).xml` created after a normal save from an application into the on-demand mount. A full-mount scan turned up ~156 conflict copies; 151 were byte-identical to their live sibling.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/{drain,filesystem,lib,state,sweep,sync,sync/planner}.rs`, `crates/pdfs-core/src/cache.rs`, `crates/pdfs-core/src/control.rs`; SDK `proton-drive-rs` `node.rs`/`client.rs`/`public_link.rs`.
|
||||||
|
|
||||||
|
**Cause:** Two independent false-positive sources, both from `(mtime, size)` being used as a revision identity (the weakness B25 flagged; the recurrence B16 could not fully close):
|
||||||
|
1. **Re-stamped mtime.** `revision_conflict` compared the queued write's baseline mtime against the remote's. The server stamps a sealed revision with its own commit time (`…780`), a few seconds off the optimistic time the client baselined at (`…777`). Same bytes, same size, drifted mtime → the drain diverted the write into a conflict copy of its own base.
|
||||||
|
2. **First-sync of pre-existing files.** `plan_file` classified `(local, remote, no-baseline)` as `Conflict`, so the *first* reconcile of a folder cut a conflict copy of every file that already existed identically on both sides — the ~151-file storm, all in one ~40s window.
|
||||||
|
|
||||||
|
**Fix:** Make conflict detection key on the server **revision id**, the true identity (it advances iff a new revision was sealed):
|
||||||
|
1. **SDK** exposes `active_revision_id` and `content_sha1` (plaintext SHA-1 from the revision's decrypted `XAttr`) on `NodeKind::File` — both already on the wire / already decrypted, no extra round trips.
|
||||||
|
2. **`Baseline`** gains `revision_id`, captured at write-open (`WriteHandle.base_revision_id`) and carried across supersede/rebaseline like the mtime. `revision_changed` (extracted, pure, unit-tested) trusts the id when both sides have it and only falls back to `(mtime, size)` for an old sidecar. A re-stamped mtime on the same id is no longer a conflict; a same-size edit by another device (new id) still is.
|
||||||
|
3. **Planner** adds `FilePlan::AdoptBaseline`: a no-baseline pair of equal size is recorded into the baseline instead of conflicted. Divergent sizes stay a real conflict.
|
||||||
|
4. **Auto-sweep** (`sweep.rs`, `pdfs-conflict-sweep` thread, 5-min cadence): removes conflict copies proven identical to their live sibling (equal size **and** equal `content_sha1`, trashing the copy remotely) and surfaces divergent/orphaned ones once as a new `ActivityKind::Conflict` entry. Never removes a copy it cannot prove is a duplicate.
|
||||||
|
|
||||||
|
**Verified:** `proton-sdk-rs` 50/61 lib tests + clippy clean; client `cargo fmt`/`clippy -D warnings`/`cargo test --workspace --locked` all green (258 tests, incl. new `revision_changed`, planner `AdoptBaseline`, and `conflict_base_name` cases). The 151 identical copies were removed manually during triage; the 5 divergent ones are left for the user.
|
||||||
|
|
||||||
|
**Live validation (2026-07-26):** 1.1.0 installed over the production account (6
|
||||||
|
FUSE mounts of real data), daemon restarted, sweep observed across its warmup pass
|
||||||
|
and a full 5-minute interval. Pre-run snapshot: exactly 2 `(sync-conflict)` copies
|
||||||
|
left on the account, both divergent in size *and* `content_sha1`, so the correct
|
||||||
|
behaviour was to remove nothing. Result: **nothing removed** — the conflict
|
||||||
|
manifest was byte-identical before and after, the activity feed gained zero
|
||||||
|
`auto-removed duplicate` entries, and the copies were flagged `differs from …` as
|
||||||
|
intended. (The 40 `trash` entries in the feed for that window are
|
||||||
|
`trashed from the mount` — a previous FUSE-acceptance run's queued cleanup ops
|
||||||
|
replaying through the drain, unrelated to the sweep.) The sweep's *removal* path
|
||||||
|
therefore remains unexercised against a real account; it is now report-only by
|
||||||
|
default (B71) precisely so that stays true until there is field evidence.
|
||||||
|
|
||||||
|
**Shipping note (resolved 2026-07-25):** the client's revision-id/sha1 use depends on new SDK surface (`proton-sdk`/`proton-drive-rs` **0.2.2**). That version is now **published to crates.io**; the client's workspace deps were bumped `"0.2"` → `"0.2.2"`, the `Cargo.lock` re-resolved to the registry crates, and the local `[patch.crates-io]` shim removed. No local patch remains. (Live validation of the fix against a real account is still pending.)
|
||||||
|
|
||||||
|
## B70 — Browser in-flight temp files uploaded and conflict-forked on the on-demand mount
|
||||||
|
|
||||||
|
**Status:** Layers A + B fixed in-tree 2026-07-25 — **live-validated 2026-07-26**.
|
||||||
|
Validation was blocked until B74 was fixed (its scenario ends in a rename, which
|
||||||
|
B74 made read back as empty); with that out of the way the acceptance case
|
||||||
|
`regression B70: transient download name is not sealed` passes live.
|
||||||
|
**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads`
|
||||||
|
(an on-demand mount) the complete files had been forked into `(sync-conflict)`
|
||||||
|
copies while their truncated partials kept the canonical name — e.g.
|
||||||
|
`teamspeak.tar.gz` (30 MB partial live vs a 3.77 GB conflict copy) and `nils.zip`
|
||||||
|
(17 MB vs 3.18 GB). The conflict copies were byte-exact to the user's independent
|
||||||
|
gdrive backups, proving the *conflict copy* was the finished download and the
|
||||||
|
*live* file the stub. The smoking gun: four `Unconfirmed NNNNN (sync-conflict …).crdownload`
|
||||||
|
copies — Brave's in-flight temp files, forked *mid-download*. User confirmed the
|
||||||
|
trigger: downloading with Brave directly into the on-demand folder, where a stall
|
||||||
|
+ "resume" is routine.
|
||||||
|
**Where:** on-demand write path — `crates/pdfs-fuse/src/filesystem.rs` `release`
|
||||||
|
(`queue_revision`, ~line 780) and `rename` (~line 933); drain seal in
|
||||||
|
`crates/pdfs-fuse/src/drain.rs`. The existing ignore system
|
||||||
|
(`crates/pdfs-core/src/syncignore.rs`, `DEFAULT_IGNORE_PATTERNS`) is wired **only**
|
||||||
|
into mirror reconcile (`sync.rs`), so it never sees on-demand writes.
|
||||||
|
|
||||||
|
**Cause:** A browser downloading into the mount writes a growing temp file
|
||||||
|
(`*.crdownload`, or `*.part` for Firefox) and, on a stall, closes the fd. `release`
|
||||||
|
hands every closed write to `queue_revision`, so a *partial* revision gets sealed
|
||||||
|
as the canonical file. On resume the browser reopens and rewrites the full file;
|
||||||
|
its baseline revision has moved (the client sealed the partial itself), so the
|
||||||
|
drain diverts the finished bytes into a `(sync-conflict)` copy. Net: the stub wins
|
||||||
|
the name, the real file is exiled to a conflict copy, and every abandoned
|
||||||
|
`.crdownload` is uploaded as its own multi-hundred-MB node. Unlike B69 this is a
|
||||||
|
*genuine* two-revision divergence, so B69's revision-id detection correctly refuses
|
||||||
|
to auto-sweep it — the fix has to stop the fork from happening, not reconcile it
|
||||||
|
after.
|
||||||
|
|
||||||
|
**Fix — two layers, both without an SDK release (A stops transient names ever
|
||||||
|
sealing; B stops a single-writer resume forking against its own seal):**
|
||||||
|
|
||||||
|
- **A — transient names never seal a revision until finalized (done 2026-07-25).**
|
||||||
|
A new `syncignore::is_transient_name` recognises browser download temps
|
||||||
|
(`*.crdownload`/`*.part`/`*.partial`/`*.download`), generic scratch
|
||||||
|
(`*.tmp`/`*.temp`), editor swap/backup (`*.swp`/`*.swx`/`*~`), and office/lock
|
||||||
|
files (`.~lock.*`, `~$*`). On `create` (filesystem.rs) a transient name is kept
|
||||||
|
**purely local** — no empty remote node is minted — and its queued create is
|
||||||
|
**parked**: `next_attempt_at` is set to the new `PARK_UNTIL` sentinel so
|
||||||
|
`Db::next_due_op` never selects it. Written bytes still ride on that create via
|
||||||
|
the existing `attach_blob_to_create`, whose `next_attempt_at` reset now
|
||||||
|
*preserves* a park (SQL `CASE … >= PARK_UNTIL`), so a growing download attaches
|
||||||
|
revision after revision without ever waking the drain. The finalize `rename`
|
||||||
|
(`foo.crdownload → foo`) goes through the existing `is_local_uid` branch
|
||||||
|
(`rewrite_op_target` renames the queued create); when the old name was transient
|
||||||
|
and the new one is not, it calls `Db::set_create_hold(uid, false)` and wakes the
|
||||||
|
drain, so the *completed* file uploads exactly once. Net: no partial and no
|
||||||
|
abandoned temp ever reaches Drive, and nothing forks. Reuses the whole
|
||||||
|
create/attach/rename/drain pipeline — the only new surface is the predicate, the
|
||||||
|
`PARK_UNTIL` sentinel, `set_create_hold`, a `hold` arg on `queue_local_node`,
|
||||||
|
and the attach-preserves-park `CASE`. Tests: `syncignore` predicate cases +
|
||||||
|
`db::a_parked_transient_create_stays_off_the_drain_until_finalized`. No SDK
|
||||||
|
dependency.
|
||||||
|
- *Not yet covered by A:* apps that write the final name in place with **no**
|
||||||
|
temp suffix (rare), and Firefox multi-connection `.part` files that it may
|
||||||
|
rename per-segment — those still rely on B. An abandoned transient file (a
|
||||||
|
cancelled download left on disk) now stays local-only forever rather than
|
||||||
|
polluting Drive; a later janitor could reclaim its staged blob.
|
||||||
|
- **B — don't self-conflict a single-writer sequential rewrite (done 2026-07-25,
|
||||||
|
no SDK dep).** The safety net for names A does not recognise (an app writing the
|
||||||
|
final name in place, Firefox per-segment `.part` renames, or any resume the
|
||||||
|
in-memory rebase machinery missed). The daemon now records the server revision
|
||||||
|
id it *itself* seals per node (`Core.own_sealed_revs`, written in
|
||||||
|
`refresh_after_upload` where the sealed node is already fetched, dropped on
|
||||||
|
trash). When a queued write drains and `revision_changed` fires,
|
||||||
|
`Core::revision_conflict` checks whether the remote sits at one of *our own*
|
||||||
|
sealed revisions (pure `is_own_self_supersede`): if so, no other device touched
|
||||||
|
the file — it is a single-writer stall→resume — so it chains (supersedes)
|
||||||
|
instead of forking a `(sync-conflict)` copy. Gated on `meta.complete`: an
|
||||||
|
incomplete blob's gaps still refer to the stale base, so it keeps the
|
||||||
|
non-destructive conflict-copy path. This is a superset guard over the existing
|
||||||
|
`refresh_after_upload`/`rebaseline_pending` rebasers, which only fire while a
|
||||||
|
handle/op is live; B catches the windows they miss (fresh open during the
|
||||||
|
earlier upload's flight, a stale inherited baseline). Tests: `is_own_self_supersede`
|
||||||
|
cases in `drain.rs`. *Residual:* the map is in-memory only, so a daemon restart
|
||||||
|
between the partial seal and the resume drops the record and that one write can
|
||||||
|
still fork — narrow, and B69's revision-id keying already covers the mtime-drift
|
||||||
|
half. A persisted own-seal ledger would close it.
|
||||||
|
|
||||||
|
**Triage of the copies already made is done (2026-07-25):** all completed files
|
||||||
|
were promoted back to their canonical names (metadata-only rename, no re-upload)
|
||||||
|
and the abandoned `.crdownload` stubs trashed.
|
||||||
|
|
||||||
|
## B71 — Conflict sweep is not production-ready (auto-trash loop, CRIT-13)
|
||||||
|
|
||||||
|
**Status:** Blockers 1–4 fixed in-tree 2026-07-26; items 5–10 and the remaining
|
||||||
|
cleanup still open. The sweep is safe to leave running (it is report-only by
|
||||||
|
default and cannot trash anything until explicitly switched to `enforce`).
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sweep.rs`, spawn site `crates/pdfs-fuse/src/mount.rs`,
|
||||||
|
config surface `crates/pdfs-core/src/config.rs`.
|
||||||
|
|
||||||
|
The B69 fix (see above) shipped a `pdfs-conflict-sweep` thread that **trashes user
|
||||||
|
files on the real Drive** from a background loop. The identity check itself is
|
||||||
|
conservative (equal size *and* equal `content_sha1`, missing digest treated as
|
||||||
|
divergent), but the machinery around it is not yet safe to run unattended. Review
|
||||||
|
found the following, ordered by severity. Each is a separate defect; they are
|
||||||
|
grouped because they gate the same feature.
|
||||||
|
|
||||||
|
**Blockers (all fixed 2026-07-26 — the sweep may not trash anything without them):**
|
||||||
|
|
||||||
|
1. **Data-loss race — trash-then-discard (CRIT).** `remove_conflict_copy`
|
||||||
|
(`sweep.rs:137-155`) trashes the node remotely and *then* calls
|
||||||
|
`discard_queued_ops(uid)`, which deletes the node's queued ops **and discards
|
||||||
|
their staged blobs** (`lib.rs:1544-1553`). The decision was made from the
|
||||||
|
`load_all()` snapshot taken at `sweep.rs:77`, with network round trips in
|
||||||
|
between. A user edit landing in that window is destroyed — the queued write is
|
||||||
|
dropped and its staged bytes, which may be the only copy, are discarded. This
|
||||||
|
violates the invariant that `staging/` is never purged. *Fix:* re-read the node
|
||||||
|
from the DB and re-verify `active_revision_id` immediately before trashing;
|
||||||
|
skip the node outright if `Core.pending` or the op table has any entry for it;
|
||||||
|
never let the sweep discard staged bytes.
|
||||||
|
2. **No kill-switch (CRIT).** `mount.rs:234-239` spawns the loop unconditionally.
|
||||||
|
There is no `AppConfig` field and no environment override, so the only way to
|
||||||
|
stop a destructive background loop is to uninstall. *Fix:* add
|
||||||
|
`AppConfig.conflict_sweep: Option<bool>` (same `Option` idiom as `cache_budget`
|
||||||
|
/ `ignore_patterns`), a `PDFS_CONFLICT_SWEEP=off` override for support, and a
|
||||||
|
Settings toggle.
|
||||||
|
3. **Open handles ignored (HIGH).** Nothing checks for a live file handle before
|
||||||
|
`forget_or_unlink` + `evict_reader`, so the sweep can trash a node a reader
|
||||||
|
currently has open. *Fix:* skip nodes with open handles.
|
||||||
|
4. **Enforcing on day one (HIGH, process).** The sweep exists because B69 proved
|
||||||
|
the previous revision-identity check was wrong; betting deletion on the
|
||||||
|
*replacement* check in the same release, with no field evidence, is the wrong
|
||||||
|
order. *Fix:* ship report-only (log `would-trash` plus the existing
|
||||||
|
`ActivityKind::Conflict`), collect a release cycle of real logs, then flip to
|
||||||
|
enforcing.
|
||||||
|
|
||||||
|
**Fix for 1–4 (landed 2026-07-26):**
|
||||||
|
|
||||||
|
- **`SweepMode`** (`pdfs-core/src/config.rs`) — `Off` / `Report` / `Enforce`,
|
||||||
|
serialised into `AppConfig.conflict_sweep` (`#[serde(default)]`, so configs
|
||||||
|
predating the field still load). `Default` is **`Report`**, which is blocker 4:
|
||||||
|
an existing install that has never heard of the setting cannot get the
|
||||||
|
enforcing behaviour on upgrade. `AppConfig::resolved_conflict_sweep` layers
|
||||||
|
`PDFS_CONFLICT_SWEEP` over the stored value; the precedence is a pure
|
||||||
|
`resolve_sweep_mode(env, stored)` so it is testable without mutating process
|
||||||
|
state. An unparseable value falls through to the config rather than guessing a
|
||||||
|
mode in either direction.
|
||||||
|
- **Spawn gate** (`mount.rs`) — `SweepMode::Off` skips the thread entirely rather
|
||||||
|
than starting an idle one, so the setting is verifiable from outside the
|
||||||
|
process (`ls /proc/<pid>/task/*/comm`). Both branches log the resolved mode at
|
||||||
|
startup. `mount` grew past clippy's argument limit, so `username` +
|
||||||
|
`sweep_mode` moved into a `MountOptions` struct resolved by the caller —
|
||||||
|
`mount` still reads neither config nor environment.
|
||||||
|
- **Interlocks** (`sweep.rs::duplicate_still_removable`) — re-checks, immediately
|
||||||
|
before the destructive call, everything the pass decided from its stale
|
||||||
|
snapshot: no queued op (new `Db::has_any_op`, a cheap `COUNT` — a queued op
|
||||||
|
means bytes are owed an upload and `discard_queued_ops` would throw away the
|
||||||
|
staged blob holding them), no open write handle or shared scratch file
|
||||||
|
(`Core::is_busy`, checking `active_writes` + `handles` under one `State` lock),
|
||||||
|
and the node still at the same revision id, size, digest, name, parent and
|
||||||
|
untrashed state. Any doubt — including a failed DB read — means leave it alone:
|
||||||
|
a copy that survives to the next pass costs nothing, a wrongly removed one
|
||||||
|
costs data. This is blockers 1 and 3.
|
||||||
|
- **Testability** — the decision moved into a pure
|
||||||
|
`decide(node, sibling, base, mode) -> SweepAction`, the same extraction used
|
||||||
|
for `revision_changed` and `is_own_self_supersede`; everything that can fail or
|
||||||
|
race stays in the caller. Tests now cover removal-when-enforcing,
|
||||||
|
report-mode-never-removes, size-differs, digest-differs, missing-digest in both
|
||||||
|
directions, and orphaned copies. Gate: `cargo fmt` / `clippy -D warnings` clean,
|
||||||
|
`cargo test --workspace` 282 passed (was 270).
|
||||||
|
|
||||||
|
**Should fix:**
|
||||||
|
|
||||||
|
5. **`load_all()` every pass (MED).** Each 300 s pass pulls the entire node table
|
||||||
|
into a `Vec<StoredNode>` and builds a full `(parent, name)` `HashMap`. *Fix:*
|
||||||
|
query only conflict-named rows (`name LIKE '% (sync-conflict %'`) and look up
|
||||||
|
siblings per parent — O(conflicts), not O(drive).
|
||||||
|
6. **No batching or per-pass cap (MED).** The B69 incident produced 151 copies;
|
||||||
|
that is 151 sequential `trash_nodes` calls in one pass with no cap and no
|
||||||
|
backoff. `trash_nodes` takes a slice — batch it and cap per pass.
|
||||||
|
7. **`online` sampled once per pass (MED).** Read at `sweep.rs:100`, ahead of a
|
||||||
|
loop that does network I/O; going offline mid-pass yields a long run of failing
|
||||||
|
calls. *Fix:* bail on the first transport error.
|
||||||
|
8. **Ambiguous sibling index (MED).** `by_parent_name.insert` (`sweep.rs:97`) is
|
||||||
|
last-write-wins, so with duplicate `(parent, name)` rows the surviving entry
|
||||||
|
depends on `load_all` row order. *Fix:* make the choice deterministic or skip
|
||||||
|
ambiguous parents.
|
||||||
|
9. **Trashed ancestors (LOW).** A copy under a trashed folder finds no live
|
||||||
|
sibling and is flagged "orphaned", spamming the activity feed. *Fix:* skip
|
||||||
|
nodes with a trashed ancestor.
|
||||||
|
10. **SHA-1 provenance unverified (MED).** `is_identical` (`sweep.rs:181`)
|
||||||
|
compares digests without asserting they belong to the current
|
||||||
|
`active_revision_id`. A stale `XAttr` digest from an earlier revision plus a
|
||||||
|
size collision would delete real data. (The *missing* digest case is already
|
||||||
|
handled correctly.)
|
||||||
|
|
||||||
|
**Also required to close:**
|
||||||
|
|
||||||
|
- **Tests.** The *policy* is now covered via the pure `decide` (see above). Still
|
||||||
|
untested are the *interlocks*, which need a `Core`: pending-op→skip,
|
||||||
|
open-handle→skip, revision-moved→skip, offline→skip. Those want a test harness
|
||||||
|
that can build a `Core` against a temp DB and a stub client.
|
||||||
|
- **`conflict_notified` is unbounded.** `HashSet<NodeUid>` that only grows except
|
||||||
|
on trash (`sweep.rs:157,170`).
|
||||||
|
- **Docs.** `docs/ARCHITECTURE.md` thread map does not list `pdfs-conflict-sweep`;
|
||||||
|
`docs/RECOVERY.md` needs a "the sweep trashed a file, recover it from Proton
|
||||||
|
trash" path.
|
||||||
|
|
||||||
|
**Mitigating context from the 2026-07-26 pre-run snapshot:** the account had only
|
||||||
|
2 conflict copies left, both divergent in size *and* `content_sha1`, so the
|
||||||
|
correct behaviour for the first live pass is to trash nothing.
|
||||||
|
|
||||||
|
## B72 — `pdfs trash` never returns
|
||||||
|
|
||||||
|
**Status:** Open — found 2026-07-26.
|
||||||
|
**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash`
|
||||||
|
produced no output and was still running when killed at 120 s. Daemon was healthy
|
||||||
|
and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly.
|
||||||
|
**Where:** unconfirmed — either the `Request` handler in `crates/pdfs-fuse/src/control.rs`
|
||||||
|
or the CLI's response read in `crates/pdfs-cli/src/main.rs`.
|
||||||
|
**Impact:** the trash listing is the user's recovery path after any destructive
|
||||||
|
operation, including the B71 sweep. It needs to work before the sweep is allowed
|
||||||
|
to trash anything.
|
||||||
|
|
||||||
|
## B73 — Unimplemented FUSE handlers log a WARN per probe
|
||||||
|
|
||||||
|
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
|
||||||
|
expanded acceptance suite.
|
||||||
|
**Found:** `scripts/fuse-acceptance.sh --live` step "unsupported operations refuse
|
||||||
|
cleanly". Each probe produced a multi-line `fuser` warning in the daemon journal:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARN fuser: [Not Implemented] symlink(parent: INodeNo(0x2d), link_name: "symlink", target: "link-target")
|
||||||
|
WARN fuser: [Not Implemented] link(ino: INodeNo(0x94), newparent: INodeNo(0x2d), newname: "hardlink")
|
||||||
|
WARN fuser: [Not Implemented] mknod(parent: INodeNo(0x2d), name: "fifo", mode: 4516, umask: 0x12, rdev: 0)
|
||||||
|
WARN fuser: [Not Implemented] setxattr(ino: INodeNo(0x91), name: "user.pdfs.probe", flags: 0x0, position: 0)
|
||||||
|
WARN fuser: [Not Implemented] lseek(ino: INodeNo(0x93), fh: 0, offset: 0, whence: 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** `ProtonFs` does not implement these callbacks, so `fuser`'s default
|
||||||
|
trait method answers `ENOSYS` *and logs at WARN*. The refusal is correct — the
|
||||||
|
acceptance suite accepts every one of them as a clean refusal — but the logging
|
||||||
|
is not conditional on anything the user did wrong.
|
||||||
|
**Impact:** cosmetic, but these are not exotic calls: `git` probes symlinks,
|
||||||
|
`cp -a` probes hard links, `tar` and `cp --sparse` probe `SEEK_HOLE`/`SEEK_DATA`
|
||||||
|
(`whence: 3` above is `SEEK_DATA`), and any xattr-aware tool probes `setxattr`.
|
||||||
|
Warnings that look like faults bury real ones and make both `--journal-check`
|
||||||
|
and manual triage noisier.
|
||||||
|
**Measured** across two full acceptance runs against one daemon lifetime (PID
|
||||||
|
887441, mount never remounted):
|
||||||
|
|
||||||
|
| op | warnings | behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `link` | 11 | repeats on every call |
|
||||||
|
| `symlink` | 4 | repeats on every call |
|
||||||
|
| `mknod` | 2 | repeats on every call |
|
||||||
|
| `setxattr` | 1 | kernel cached the `ENOSYS`; never asked again |
|
||||||
|
| `lseek` | 1 | cached |
|
||||||
|
| `copy_file_range` | 1 | cached |
|
||||||
|
| `fsyncdir` | 1 | cached |
|
||||||
|
|
||||||
|
So the FUSE kernel module remembers `ENOSYS` for the file-level operations and
|
||||||
|
stops issuing them for the lifetime of the mount, but not for the namespace
|
||||||
|
operations, which are re-issued forever.
|
||||||
|
|
||||||
|
`link` leads for a sharper reason than "tools probe it": git uses `link()` to
|
||||||
|
place **every loose object**, falling back to `rename()` when it fails. In one
|
||||||
|
workloads run, 9 of 10 `link` warnings carried a 38-hex-character name — the
|
||||||
|
`.git/objects/ab/<38 hex>` layout — from just 3 commits over 3 files. The volume
|
||||||
|
is therefore proportional to work done, not a fixed startup cost: a `git clone`
|
||||||
|
of a real repository emits one multi-line warning per object. The lone `symlink`
|
||||||
|
that is not the acceptance probe is git's `core.symlinks` detection at
|
||||||
|
`git init` (a random name pointing at `testing`).
|
||||||
|
|
||||||
|
That moves this from cosmetic to a genuine operational problem: ordinary use of
|
||||||
|
git inside a mount can bury every other log line.
|
||||||
|
|
||||||
|
**Fix (applied):** all seven callbacks are now implemented explicitly in
|
||||||
|
`crates/pdfs-fuse/src/filesystem.rs`, each answering with **the same errno
|
||||||
|
`fuser`'s default would** and nothing else — no WARN. Following `ioctl`
|
||||||
|
(which already used this pattern and says why in its comment), the errno
|
||||||
|
contract is unchanged, so the acceptance suite's clean-refusal assertions hold
|
||||||
|
byte for byte:
|
||||||
|
|
||||||
|
| op | errno | why that one |
|
||||||
|
|---|---|---|
|
||||||
|
| `symlink`, `link` | `EPERM` | meaningful operation, unrepresentable on Drive; git falls back to `rename()` |
|
||||||
|
| `mknod` | `ENOSYS` | no devices/FIFOs/sockets; regular files arrive via `create` |
|
||||||
|
| `setxattr` | `ENOSYS` | surfaces as `EOPNOTSUPP`; reads stay implemented for the thumbnail interface |
|
||||||
|
| `fsyncdir` | `ENOSYS` | kernel absorbs it and reports success; dir changes are already durable in SQLite |
|
||||||
|
| `lseek`, `copy_file_range` | `ENOSYS` | **must stay** `ENOSYS` — it is what makes the kernel emulate `SEEK_HOLE`/`SEEK_DATA` and coreutils fall back to read/write instead of failing |
|
||||||
|
|
||||||
|
Only the three namespace ops were strictly required (the other four silence
|
||||||
|
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
|
||||||
|
refusal explicit and documented at the call site rather than inherited.
|
||||||
|
|
||||||
|
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
|
||||||
|
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
|
||||||
|
in the journal across the whole run (was 17, 10 of them `link`).
|
||||||
|
**Regression cover:** the acceptance suite's clean-refusal probes assert the
|
||||||
|
errno contract; they pass unchanged.
|
||||||
|
|
||||||
|
## B74 — A drained create never reaches a sync-folder mount's inode space, so the file reads as empty
|
||||||
|
|
||||||
|
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
|
||||||
|
expanded acceptance suite (`regression B70` case), then isolated to a broader
|
||||||
|
trigger.
|
||||||
|
**Impact:** on a *sync folder* mount (not `~/ProtonDrive`), a file whose create
|
||||||
|
was still queued reads back as **0 bytes for as long as the daemon runs**, even
|
||||||
|
though the bytes are on Drive intact. This is the write-to-temp-then-rename
|
||||||
|
pattern used by every browser download, most editor saves, `rsync`, and `git`.
|
||||||
|
|
||||||
|
**Not data loss.** An earlier revision of this entry called it permanent data
|
||||||
|
loss; that was wrong and is corrected here. The uploaded revision is complete and
|
||||||
|
correct: for every "lost" file the node row carried the right `size` and
|
||||||
|
`active_revision_state: "Active"`, and a daemon restart made all of them read
|
||||||
|
back with the expected sha256. Nothing had to be recovered. The defect was
|
||||||
|
entirely local and entirely in the read path.
|
||||||
|
|
||||||
|
**Reproduction** (on an on-demand mount, per file: write, close, rename, wait for
|
||||||
|
`pdfs status` to report the queue drained, then read back):
|
||||||
|
|
||||||
|
| scenario | result |
|
||||||
|
|---|---|
|
||||||
|
| plain name, never renamed | content intact |
|
||||||
|
| plain name, renamed after close | **0 bytes until restart** |
|
||||||
|
| `.crdownload`, renamed after close | **0 bytes until restart** |
|
||||||
|
| `.crdownload`, never renamed | content intact |
|
||||||
|
| already-settled file, renamed | content intact |
|
||||||
|
| any of the above, after a daemon restart | content intact |
|
||||||
|
|
||||||
|
The gap between `close()` and `rename()` does not matter — 0 s, 2 s and 20 s all
|
||||||
|
behave the same. The boundary is not timing but state: the affected node is one
|
||||||
|
whose **create op was still queued**, on a mount that is not the primary one.
|
||||||
|
|
||||||
|
**Root cause — one drain thread, many inode spaces.** The daemon mounts
|
||||||
|
`~/ProtonDrive` plus one FUSE session per `ondemand` sync folder. Each of those
|
||||||
|
is a `Core::fork_state` clone with its **own** `State` — its own `entries`,
|
||||||
|
`by_uid`, `children`, `next_ino` — while sharing `db`, `cache`, `pending` and
|
||||||
|
`client`. There is exactly **one** `pdfs-drain` thread in the process, owned by
|
||||||
|
the primary mount, and it serves the whole shared `pending_op` table.
|
||||||
|
|
||||||
|
So when a create queued by a sync-folder mount lands, the drain calls
|
||||||
|
`adopt_real_uid` — which looked only at `self.state`, the *primary* mount's inode
|
||||||
|
space. The fork's entry keeps its `local~…` placeholder uid forever. And
|
||||||
|
`Core::read_range` (`crates/pdfs-fuse/src/reads.rs:314`) short-circuits a local
|
||||||
|
uid to an empty vec:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if is_local_uid(uid) { return Ok(Vec::new()); }
|
||||||
|
```
|
||||||
|
|
||||||
|
which is why the read is silent, instant, and zero-length. A restart rebuilds
|
||||||
|
every state from the DB, where `remap_local_uid` had already written the real
|
||||||
|
uid, so the file reads correctly from then on.
|
||||||
|
|
||||||
|
Confirmed live: `read handler uid=local~1785067300752-0 fsize=140000` three
|
||||||
|
seconds after `pending create landed`, and at the same moment
|
||||||
|
`adopt_real_uid … hit=None strays=[]` — the primary state had no entry for the
|
||||||
|
uid at all. `/proc/<pid>/task/*/comm` showed 1 `pdfs-drain` against 8 fuser
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
**Fix:** `Core` gained a `states: Arc<StateRegistry>` — a `Vec` of weakly-held
|
||||||
|
mounts that every mount publishes itself into (`Core::register_state`, called
|
||||||
|
from `mount.rs` for the primary and from `fork_state` for each fork), plus
|
||||||
|
`Core::for_each_state`, which walks every live inode space one lock at a time.
|
||||||
|
A uid is unique across mounts, so at most one state matches and the rest are
|
||||||
|
no-ops. Five drain sites were converted: `adopt_real_uid`, `adopt_drained_name`,
|
||||||
|
the conflict-copy `invalidate_listing`, the `active_writes` baseline rebase in
|
||||||
|
`refresh_after_upload`, and that function's closing `intern`.
|
||||||
|
|
||||||
|
**It was completely silent.** No `ERROR`, no `WARN`, no failed op, no conflict
|
||||||
|
copy, no retry — `pdfs status` reported the queue drained, because it had. Any
|
||||||
|
diagnosis that relies on the journal or on queue state will not see this.
|
||||||
|
|
||||||
|
**Regression cover:** `scripts/fuse-acceptance.py`, case
|
||||||
|
`regression B74: rename after close keeps its content` — the narrowest form
|
||||||
|
(plain names, no overwrite, no transient suffix). It failed in two consecutive
|
||||||
|
live runs before the fix and passes after it. The pre-existing B70 case failed on
|
||||||
|
the same cause and now passes too.
|
||||||
|
|
||||||
|
**Audit of the rest of the daemon.** Every background-thread `state.lock()` was
|
||||||
|
reviewed for the same shape. The rule that came out of it: **a uid is unique
|
||||||
|
across mounts, so uid-keyed work must be broadcast; an inode number is only
|
||||||
|
meaningful to the mount that minted it, so inode-keyed work must not be.** What
|
||||||
|
changed:
|
||||||
|
|
||||||
|
| file | site | why |
|
||||||
|
|---|---|---|
|
||||||
|
| `background.rs` | `apply_event` | the read-side twin of this bug — remote deletes, trashes and renames only ever reached the primary mount's tree |
|
||||||
|
| `sweep.rs` | conflict-copy `forget_or_unlink` | a conflict copy inside a sync folder lives in that fork's inode space |
|
||||||
|
| `sweep.rs` | `is_busy` | answered "idle" for every file open on a fork, letting the sweep delete one with a writer attached |
|
||||||
|
| `sharing.rs` | `rel_path_for_uid` | a share of a node in a sync folder could not be named |
|
||||||
|
| `drain.rs` | `node_place` | fell through to a DB lookup for fork-resident nodes |
|
||||||
|
| `lib.rs` | `queue_trash`, `rename`, `remove_replaced` (×2), `drop_local`, `restore` | uid-keyed `forget`/invalidate |
|
||||||
|
|
||||||
|
Deliberately left on `self.state`: `upload.rs` (`parent_ino`/`pino` are inode
|
||||||
|
numbers), the size-upgrade path in `lib.rs`, and `drain.rs`'s `root_uid`, which
|
||||||
|
is correctly primary-only. The 38 `filesystem.rs` sites are FUSE callbacks —
|
||||||
|
they already run on the right mount's session thread. `parent_is_gone` never
|
||||||
|
touches state at all; an earlier note here saying it did was wrong.
|
||||||
|
|
||||||
|
The registry is now a `StateRegistry` type with a unit test
|
||||||
|
(`state_registry_walks_every_live_mount_and_reaps_the_rest`) covering the case
|
||||||
|
this bug was: a fork that fails to appear in the walk, or one that lingers after
|
||||||
|
unmount.
|
||||||
|
|
||||||
|
**One regression came out of the audit, and is fixed.** Broadcasting
|
||||||
|
`apply_event`'s parent-listing invalidation meant forks started receiving
|
||||||
|
invalidations they had never received — and it invalidated a folder once per
|
||||||
|
event, while a burst of our own uploads produces one event per file all naming
|
||||||
|
the *same* folder. Every following lookup in that folder then re-enumerated it
|
||||||
|
from the API: quadratic in the folder's size.
|
||||||
|
|
||||||
|
Two mitigations, both in `background.rs`. A batch is collapsed to one
|
||||||
|
invalidation per folder — `apply_event` records the parent in `DirtyParents` and
|
||||||
|
`flush_dirty_parents` publishes it once after the batch loop (every `break` path
|
||||||
|
falls through to it). And a change the daemon made *itself* is recognised as its
|
||||||
|
own echo rather than treated as foreign: the drain records it
|
||||||
|
(`Core::note_self_change`, consumed once within `SELF_CHANGE_TTL_MS`), which
|
||||||
|
stops the feed's report of our own upload from evicting the content blob we just
|
||||||
|
sent. Neither weakens the result — the state after a batch is what it always was.
|
||||||
|
|
||||||
|
**The hang that showed up alongside this was a different bug, and this entry
|
||||||
|
originally misattributed it.** `readdir stability` at 133.94 s and a concurrency
|
||||||
|
case that blew past its timeout were blamed here on the invalidation storm. They
|
||||||
|
were not: the mount was freezing because network work runs on fuser's single
|
||||||
|
dispatch thread, which is B75. Fixing that took the concurrency case from two
|
||||||
|
timeouts in three runs to 16.9 / 24.1 / 29.1 s and `readdir stability` to 62.9 s.
|
||||||
|
The coalescing above is still worth having — it is a real amplification — but it
|
||||||
|
was never the stall.
|
||||||
|
|
||||||
|
**Note on B70:** B70's own fix was never implicated. The `.crdownload` park/un-park
|
||||||
|
behaves correctly in isolation; the empty read arrived with the rename, on the
|
||||||
|
same path a plain temp file takes. B70's live validation was blocked on this and
|
||||||
|
is now unblocked — its acceptance case passes.
|
||||||
|
|
||||||
|
## B75 — Network work on fuser's single dispatch thread freezes the whole mount
|
||||||
|
|
||||||
|
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 while
|
||||||
|
chasing what was thought to be a B74 regression.
|
||||||
|
**Impact:** any burst of concurrent writes froze the entire mount — not the
|
||||||
|
files being written, the *mount*. `ls` on an unrelated directory of it timed out
|
||||||
|
for minutes. The daemon itself stayed healthy throughout: the primary mount and
|
||||||
|
the control socket answered normally while a sync-folder mount was unreachable.
|
||||||
|
|
||||||
|
**Root cause.** `mount.rs` and `devices.rs` both build their session from
|
||||||
|
`Config::default()`, which leaves `n_threads` unset, and fuser 0.17 defaults it
|
||||||
|
to 1 (`session.rs:254`). One dispatch thread per mount reads a request, runs the
|
||||||
|
handler to completion, and only then reads the next. Six handlers were handed to
|
||||||
|
the [`Workers`] pool — `lookup`, `getattr`, `readdir`, `read`, `getxattr` and the
|
||||||
|
slow `serve_lookup` — and everything else ran inline, including handlers that
|
||||||
|
block on the API:
|
||||||
|
|
||||||
|
| handler | blocking work |
|
||||||
|
|---|---|
|
||||||
|
| `create` | `upload_file` to mint the node, then `fetch_node` — two round trips |
|
||||||
|
| `mkdir` | `create_folder`, then `fetch_node` |
|
||||||
|
| `rename` | `rename_node`, then `move_node`, plus a trash on the replaced-destination path |
|
||||||
|
| `unlink` / `rmdir` | `trash_nodes`; `rmdir` enumerates the folder first |
|
||||||
|
| `setattr` (path truncate) | `queue_truncate` → `fill_gaps`, which reads the kept prefix from the remote |
|
||||||
|
|
||||||
|
So eight applications each creating a file served one create at a time, and
|
||||||
|
every `read`, `open` and `lookup` on that mount queued behind them.
|
||||||
|
|
||||||
|
**Measured**, acceptance suite's `independent and shared-file concurrency` (8
|
||||||
|
threads × 16 files), and a 3-second `ls` of the mount root sampled every 4 s
|
||||||
|
throughout:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| concurrency case | 200 s watchdog ×2, 37 s ×1 | 16.9 / 24.1 / 29.1 s, all pass |
|
||||||
|
| `ls` of the mount during the run | 33 of 55 probes timed out | 0 of 85 |
|
||||||
|
| `readdir stability` | 148.8 s | 62.9 s |
|
||||||
|
| `namespace operations` | 31.3 s | 15.7 s |
|
||||||
|
|
||||||
|
**Fix:** each of those handlers now parses its arguments inline — cheap, and it
|
||||||
|
keeps the error paths prompt — and hands the body to the worker pool as a
|
||||||
|
`serve_*` method, the pattern `lookup`/`readdir` already used. Metadata work
|
||||||
|
takes `Lane::Meta`; the truncate path takes `Lane::Transfer` because it can pull
|
||||||
|
a whole file.
|
||||||
|
|
||||||
|
**`release` was deliberately left inline, and this is load-bearing.** Moving it
|
||||||
|
too made all three concurrency runs fail with `concurrent file 1 mismatch`. The
|
||||||
|
kernel does not wait for a `release` reply before letting `close(2)` return, so
|
||||||
|
the only thing sequencing the staging of the written bytes against the `open` +
|
||||||
|
`read` an application issues immediately afterwards is that both are served by
|
||||||
|
the same dispatch loop. From a worker, the read overtakes the staging and is
|
||||||
|
answered from the remote's older revision. Taking `release` off the loop — worth
|
||||||
|
doing, since `queue_revision` gap-fills a partial write from the network — needs
|
||||||
|
a per-node "staging in flight" barrier that reads wait on, not the dispatch
|
||||||
|
loop's accidental ordering.
|
||||||
|
|
||||||
|
**Not fixed here.** `n_threads` is still 1; with the blocking handlers moved off,
|
||||||
|
the loop is short enough that raising it is a separate question. Related findings
|
||||||
|
from the same audit, still open: cache/reader validity is keyed on
|
||||||
|
`(mtime, size)` while the revision identity is `active_revision_id`
|
||||||
|
(`cache.rs:84` — two same-size revisions within one mtime second collide, which
|
||||||
|
is what a SQLite page rewrite looks like); `local_finish_scan` holds the daemon's
|
||||||
|
one SQLite connection across a full FTS5 trigram rebuild (`db/local.rs:79`);
|
||||||
|
`earliest_due_at` has no `<= now` filter where `next_due_op` does, so the drain
|
||||||
|
busy-spins when offline with a due op (`db/ops.rs:385` vs `:421`); and
|
||||||
|
`drain_local_node` deletes its op before the fallible `adopt_real_uid`
|
||||||
|
(`drain.rs:346`).
|
||||||
+95
-3
@@ -14,9 +14,12 @@ cargo clippy --workspace --all-targets -- -D warnings
|
|||||||
The account-free suite exercises the filesystem syscall contract against a
|
The account-free suite exercises the filesystem syscall contract against a
|
||||||
temporary local directory. This validates the runner and covers creation/open
|
temporary local directory. This validates the runner and covers creation/open
|
||||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||||
`sendfile`, durability barriers, namespace/error semantics, open-file lifetime,
|
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||||
names and enumeration, and concurrent I/O. It never reads application state or
|
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||||
contacts Proton:
|
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
|
```console
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
@@ -34,6 +37,85 @@ Use a dedicated test account without irreplaceable data:
|
|||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The mount is diffed against an ordinary filesystem
|
||||||
|
|
||||||
|
The local reference run is not only a self-test of the runner. Every case
|
||||||
|
records the facts it established, and a live run is compared against those
|
||||||
|
recordings, so a case fails when the mount *differs* from an ordinary
|
||||||
|
filesystem — not only when it raises. The report names the exact key:
|
||||||
|
|
||||||
|
```
|
||||||
|
[DIVERGENCE] live FUSE /mnt/testmount does not match the reference filesystem:
|
||||||
|
! creation and open flags.truncated.size: live=6 reference=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Facts are recorded in one of two ways, chosen in the test body:
|
||||||
|
|
||||||
|
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||||
|
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||||
|
reported mode bits, and which optional syscalls exist at all. Reported under
|
||||||
|
`[capabilities]`, never a failure.
|
||||||
|
|
||||||
|
Because the choice is made at each call site, the set of accepted divergences
|
||||||
|
is visible in the test that accepts it. There is no separate allowlist to drift
|
||||||
|
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
||||||
|
ordinary filesystem can do something a network filesystem genuinely cannot.
|
||||||
|
|
||||||
|
### Operations the filesystem does not implement
|
||||||
|
|
||||||
|
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||||
|
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
||||||
|
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
||||||
|
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||||
|
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||||
|
one that is dirty breaks software that never asked for the feature.
|
||||||
|
|
||||||
|
### Regressions from the bug ledger
|
||||||
|
|
||||||
|
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
||||||
|
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
||||||
|
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||||
|
renamed after close must keep its content) need a real mount and a reachable
|
||||||
|
daemon; they are skipped, not failed, without one.
|
||||||
|
|
||||||
|
B74 is currently **failing** — it is a live data-loss defect, not a regression
|
||||||
|
guard, and it was found by exactly this mechanism: the B70 case failed, and
|
||||||
|
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||||
|
red run against a real mount until it is fixed.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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. A timeout ends the
|
||||||
|
run: later cases against a wedged mount produce noise, not information.
|
||||||
|
|
||||||
|
```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. `--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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Automated mode matrix
|
### Automated mode matrix
|
||||||
|
|
||||||
The managed live runner accepts two existing, empty, unmounted directories. It
|
The managed live runner accepts two existing, empty, unmounted directories. It
|
||||||
@@ -53,6 +135,16 @@ 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
|
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||||
example `target/debug/pdfs`.
|
example `target/debug/pdfs`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
release acceptance run must leave it unset so the complete contract executes.
|
||||||
|
|||||||
+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=1.0.0
|
pkgver=1.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
+1438
-65
File diff suppressed because it is too large.
Load diff
+51
-16
@@ -4,44 +4,79 @@ set -Eeuo pipefail
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
usage: scripts/fuse-acceptance.sh [--offline-only]
|
usage: scripts/fuse-acceptance.sh [--offline-only] [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...]
|
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR
|
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
||||||
|
|
||||||
The local reference suite always runs first and requires no account. --live then
|
The local reference suite always runs first and requires no account; the mount
|
||||||
runs the same contract on each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only
|
is then diffed against its recorded behaviour. --live runs the same contract on
|
||||||
when all live mountpoints show the same remote folder.
|
each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only when all live mountpoints
|
||||||
|
show the same remote folder.
|
||||||
|
|
||||||
|
Options are forwarded to the Python runner. The useful ones:
|
||||||
|
--list print every case and the targets it runs against
|
||||||
|
--timeout SECONDS per-case limit (default 180); a hung case dumps stacks
|
||||||
|
--fail-fast stop at the first failure instead of continuing
|
||||||
|
--report-json PATH machine-readable results and recorded observations
|
||||||
|
--report-junit PATH JUnit XML for CI
|
||||||
|
--budget SECONDS report any case slower than this
|
||||||
|
--journal-check fail if the daemon logs errors during the run
|
||||||
|
--durability restart the daemon mid-suite and re-verify bytes
|
||||||
EOF
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
||||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
runner=(python3 -u "$script_dir/fuse-acceptance.py")
|
||||||
|
|
||||||
|
mountpoints=()
|
||||||
|
passthrough=()
|
||||||
|
# Paths come first and options after them, so the first dash-prefixed argument
|
||||||
|
# ends the path list and everything from there is forwarded untouched.
|
||||||
|
collect_paths() {
|
||||||
|
while (( $# > 0 )) && [[ "$1" != -* ]]; do
|
||||||
|
mountpoints+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
passthrough=("$@")
|
||||||
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
-h|--help) usage ;;
|
-h|--help) usage ;;
|
||||||
--offline-only|"")
|
--offline-only|"")
|
||||||
(( $# <= 1 )) || usage
|
shift || true
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py"
|
exec "${runner[@]}" "$@"
|
||||||
|
;;
|
||||||
|
--live)
|
||||||
|
shift
|
||||||
|
collect_paths "$@"
|
||||||
|
(( ${#mountpoints[@]} > 0 )) || usage
|
||||||
;;
|
;;
|
||||||
--live) shift ; (( $# > 0 )) || usage ;;
|
|
||||||
--managed-live)
|
--managed-live)
|
||||||
shift
|
shift
|
||||||
(( $# == 2 )) || usage
|
(( $# >= 2 )) || usage
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py" --managed-live "$@"
|
first="$1"; second="$2"; shift 2
|
||||||
|
exec "${runner[@]}" --managed-live "$first" "$second" "$@"
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
# Bare options with no mode: an offline run with those options.
|
||||||
|
exec "${runner[@]}" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
collect_paths "$@"
|
||||||
;;
|
;;
|
||||||
--*) usage ;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
|
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
|
||||||
for mountpoint in "$@"; do
|
for mountpoint in "${mountpoints[@]}"; do
|
||||||
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
|
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
|
||||||
done
|
done
|
||||||
fstype="$(findmnt -T "$1" -n -o FSTYPE | head -n 1)"
|
fstype="$(findmnt -T "${mountpoints[0]}" -n -o FSTYPE | head -n 1)"
|
||||||
[[ "$fstype" == fuse* ]] || {
|
[[ "$fstype" == fuse* ]] || {
|
||||||
echo "refusing primary non-FUSE path $1 (type: ${fstype:-unknown})" >&2
|
echo "refusing primary non-FUSE path ${mountpoints[0]} (type: ${fstype:-unknown})" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py" --live "$@"
|
exec "${runner[@]}" --live "${mountpoints[@]}" "${passthrough[@]}"
|
||||||
Reference in new issue
Block a user