mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078774f6a1 | ||
|
|
326a9b4e92 | ||
|
|
78dc72a34d | ||
|
|
438b2b463e | ||
|
|
4cd49168c4 | ||
|
|
481c3fe0c7 | ||
|
|
098da2e97a | ||
|
|
d03d3eac3f | ||
|
|
433e206203 |
No files matched your search
Generated
+30
-8
@@ -2200,6 +2200,25 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -2367,6 +2386,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -3518,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.7.0"
|
version = "1.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3533,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.7.0"
|
version = "1.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -3554,7 +3574,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.7.0"
|
version = "1.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -3571,7 +3591,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.7.0"
|
version = "1.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3832,9 +3852,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d"
|
checksum = "fe0e39da5b61ceb862af2f4406870b9c1d9a1554889e8d40a32fff13882ec690"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3859,9 +3879,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3"
|
checksum = "97cde8cf8e3099d1dbb56302462a632a12e0ff818c012b9c9b0a7a5200544fd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4252,6 +4272,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -5110,6 +5131,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|||||||
+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.7.0"
|
version = "1.8.2"
|
||||||
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.5.1" }
|
proton-sdk = { version = "0.6.0" }
|
||||||
proton-drive-rs = { version = "0.5.1" }
|
proton-drive-rs = { version = "0.6.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
+893
-60
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 25;
|
pub(super) const SCHEMA_VERSION: i64 = 28;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -161,6 +161,63 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V25)?;
|
tx.execute_batch(MIGRATION_V25)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 26 {
|
||||||
|
// Same guard as V22: a fixture that never exercised the queue may
|
||||||
|
// not have `pending_op`, and altering an absent table is an error
|
||||||
|
// rather than a no-op.
|
||||||
|
// A fixture built from the current schema and then rewound to an
|
||||||
|
// older `schema_version` already has the column, and `ADD COLUMN`
|
||||||
|
// has no `IF NOT EXISTS`.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V26)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 27 {
|
||||||
|
// Same guards as V26, for the same two reasons: the table may not
|
||||||
|
// exist in an old fixture, and a fixture rewound from the current
|
||||||
|
// schema already has the column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
|
||||||
|
WHERE name = 'access_deferred_since'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V27)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 28 {
|
||||||
|
// Same guards as V26/V27, for the same two reasons.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||||
|
WHERE name = 'local_mtime_ns'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_entries: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_entries && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V28)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -725,3 +782,67 @@ UPDATE nodes
|
|||||||
|
|
||||||
DROP TABLE node_paths;
|
DROP TABLE node_paths;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// V26: `pending_op.claimed_at` — the drain's claim column.
|
||||||
|
///
|
||||||
|
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
|
||||||
|
/// every queued rename and small write behind it for as long as it ran. Several
|
||||||
|
/// workers can share the queue only if a claim is visible to the others: this
|
||||||
|
/// column is what makes "already being drained by someone" a fact about the row
|
||||||
|
/// rather than about one thread's local variable.
|
||||||
|
///
|
||||||
|
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
|
||||||
|
/// claim is process-local state that outlives nothing — the single-writer lock
|
||||||
|
/// means a non-zero value found at open belongs to a crashed run, and
|
||||||
|
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
|
||||||
|
///
|
||||||
|
/// The index is partial so it costs nothing on the ordinary row: only the
|
||||||
|
/// handful of claimed rows are in it, which is exactly the set the claim query
|
||||||
|
/// has to subtract to keep two workers off one node.
|
||||||
|
const MIGRATION_V26: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
|
||||||
|
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v27: when an op was first deferred by the local access check.
|
||||||
|
///
|
||||||
|
/// An access deferral consumes no attempt and records no error — the op has not
|
||||||
|
/// failed remotely, it has not been sent at all. That is right for a permission
|
||||||
|
/// change the user is about to undo, and wrong for a condition that never
|
||||||
|
/// clears: the row is re-deferred every
|
||||||
|
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
|
||||||
|
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
|
||||||
|
/// empty, so a queue that can never drain is indistinguishable from a busy one.
|
||||||
|
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
|
||||||
|
/// 30 days, none of them visible anywhere.
|
||||||
|
///
|
||||||
|
/// `0` means "not currently access-deferred"; anything else is the ms at which
|
||||||
|
/// the first consecutive deferral happened, which is what
|
||||||
|
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
|
||||||
|
/// deferral against.
|
||||||
|
const MIGRATION_V27: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v28: sub-second precision for a mirror folder's local baseline.
|
||||||
|
///
|
||||||
|
/// A reconcile pass decides whether the local side of a path changed by
|
||||||
|
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
|
||||||
|
/// that comparison were whole seconds, so an edit that landed in the same second
|
||||||
|
/// as the last sync and left the file the same length — a fixed-size record
|
||||||
|
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||||
|
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||||
|
/// older content until something else about the file moved (bugs.md B25).
|
||||||
|
///
|
||||||
|
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||||
|
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||||
|
/// zero for it would make every already-synced file compare as changed and
|
||||||
|
/// re-upload an entire mirror on first start. `NULL` means "this row predates
|
||||||
|
/// the column", which the comparison reads as "fall back to whole seconds" —
|
||||||
|
/// exactly the behaviour that row was written under. Each row gains a real value
|
||||||
|
/// the next time its path is synced.
|
||||||
|
const MIGRATION_V28: &str = "
|
||||||
|
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||||
|
";
|
||||||
@@ -230,7 +230,7 @@ impl Db {
|
|||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
Ok(Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
// No pool for `:memory:`. A second connection to that name is a
|
// No pool for `:memory:`. A second connection to that name is a
|
||||||
// second, empty database rather than another view of this one, so
|
// second, empty database rather than another view of this one, so
|
||||||
@@ -240,7 +240,17 @@ impl Db {
|
|||||||
idle: Mutex::new(Vec::new()),
|
idle: Mutex::new(Vec::new()),
|
||||||
}),
|
}),
|
||||||
_single_writer: single_writer,
|
_single_writer: single_writer,
|
||||||
})
|
};
|
||||||
|
// A drain claim names a worker thread in the process that took it, and
|
||||||
|
// the single-writer lock above says that process is not running. Any
|
||||||
|
// claim still on disk is therefore a crashed run's, and leaving it would
|
||||||
|
// hide those ops from every worker for good.
|
||||||
|
match db.clear_op_claims() {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
|
||||||
|
}
|
||||||
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A connection for a `SELECT`-only method.
|
/// A connection for a `SELECT`-only method.
|
||||||
|
|||||||
@@ -512,17 +512,115 @@ impl Db {
|
|||||||
Ok(op)
|
Ok(op)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the oldest due op for this worker, marking it claimed so no other
|
||||||
|
/// drain worker takes it, or `None` when there is nothing to do.
|
||||||
|
///
|
||||||
|
/// The drain is several workers over one queue, which is only safe because
|
||||||
|
/// the claim is a column: a row is offered to exactly one worker, and the
|
||||||
|
/// worker that took it is the only one that may retire, defer or fail it.
|
||||||
|
///
|
||||||
|
/// Two exclusions on top of [`next_due_op`](Self::next_due_op)'s:
|
||||||
|
///
|
||||||
|
/// * `claimed_at = 0` — not already in flight somewhere else.
|
||||||
|
/// * no *other* claimed op shares this `uid`. Ordering only has to hold per
|
||||||
|
/// node, but there it has to hold absolutely: a queued rename and a queued
|
||||||
|
/// revision of one file must land in the order they were made, and two
|
||||||
|
/// workers on one node would also race over its staged blob and its
|
||||||
|
/// pending entry. `ORDER BY id` plus this exclusion gives per-uid serial
|
||||||
|
/// order for free — the older op is claimed first and the newer one is
|
||||||
|
/// invisible until it retires.
|
||||||
|
///
|
||||||
|
/// Select and mark are one transaction, so two workers cannot both see the
|
||||||
|
/// row as unclaimed.
|
||||||
|
pub fn claim_next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let op = {
|
||||||
|
let mut stmt = tx.prepare_cached(&format!(
|
||||||
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
|
attempts, last_error, next_attempt_at
|
||||||
|
FROM pending_op
|
||||||
|
WHERE next_attempt_at <= ?1
|
||||||
|
AND claimed_at = 0
|
||||||
|
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
|
||||||
|
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||||
|
ORDER BY id LIMIT 1",
|
||||||
|
v = LOCAL_VOLUME,
|
||||||
|
n = LOCAL_VOLUME.len() + 1,
|
||||||
|
))?;
|
||||||
|
stmt.query_row(params![now], |r| {
|
||||||
|
Ok(PendingOp {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
uid: r.get(2)?,
|
||||||
|
parent_uid: r.get(3)?,
|
||||||
|
name: r.get(4)?,
|
||||||
|
blob_path: r.get(5)?,
|
||||||
|
meta_json: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
attempts: r.get(8)?,
|
||||||
|
last_error: r.get(9)?,
|
||||||
|
next_attempt_at: r.get(10)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
if let Some(op) = op.as_ref() {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = ?2 WHERE id = ?1",
|
||||||
|
params![op.id, now.max(1)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give a claimed op back to the queue, whatever became of the attempt.
|
||||||
|
///
|
||||||
|
/// Called on every path out of a drain attempt rather than only the failing
|
||||||
|
/// ones: a handler that retires its own row leaves nothing to release (the
|
||||||
|
/// `UPDATE` matches nothing), and a handler that returns without retiring
|
||||||
|
/// *and* without failing would otherwise leave the row claimed by a worker
|
||||||
|
/// that has moved on — invisible to every worker, forever.
|
||||||
|
pub fn release_op_claim(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = 0 WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every claim in the table.
|
||||||
|
///
|
||||||
|
/// A claim describes a worker in *this* process, so one surviving on disk
|
||||||
|
/// means the previous run died holding it. The single-writer lock
|
||||||
|
/// ([`Db::open`]) is what makes that inference safe: nobody else can be
|
||||||
|
/// draining this queue while we are opening it.
|
||||||
|
pub fn clear_op_claims(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = 0 WHERE claimed_at <> 0",
|
||||||
|
[],
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
|
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
|
||||||
/// queue is empty. Used by the drain loop to sleep exactly until the next
|
/// queue is empty. Used by the drain loop to sleep exactly until the next
|
||||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||||
/// idle-poll interval.
|
/// idle-poll interval.
|
||||||
|
///
|
||||||
|
/// Claimed ops are excluded: another worker is already on them, so they are
|
||||||
|
/// not work this one is waiting for, and counting them would have an idle
|
||||||
|
/// worker spin on a row it cannot have.
|
||||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let ts: Option<i64> = conn
|
let ts: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
"SELECT MIN(next_attempt_at) FROM pending_op \
|
||||||
WHERE parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~'",
|
WHERE claimed_at = 0 \
|
||||||
|
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')",
|
||||||
v = LOCAL_VOLUME,
|
v = LOCAL_VOLUME,
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
n = LOCAL_VOLUME.len() + 1,
|
||||||
),
|
),
|
||||||
@@ -623,11 +721,16 @@ impl Db {
|
|||||||
|
|
||||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||||
/// the staged bytes are still the only copy of the user's write.
|
/// the staged bytes are still the only copy of the user's write.
|
||||||
|
///
|
||||||
|
/// Clears any access-deferral window: whatever the op was waiting for has
|
||||||
|
/// now been reported, so the next uncleared deferral starts its own window
|
||||||
|
/// rather than escalating again on the following recheck.
|
||||||
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pending_op
|
"UPDATE pending_op
|
||||||
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3
|
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
|
||||||
|
access_deferred_since = 0
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![id, error, next_attempt_at],
|
params![id, error, next_attempt_at],
|
||||||
)?;
|
)?;
|
||||||
@@ -653,6 +756,60 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Defer an op the local access check refused, and report how long it has
|
||||||
|
/// been refused for.
|
||||||
|
///
|
||||||
|
/// Identical to [`defer_op_without_attempt`](Self::defer_op_without_attempt)
|
||||||
|
/// on the retry clock, plus the one thing that was missing: the first
|
||||||
|
/// deferral of a run stamps `access_deferred_since`, and every deferral
|
||||||
|
/// returns that stamp. A caller can therefore tell a deferral that is one
|
||||||
|
/// recheck old from one that has been repeating unchanged for days, which is
|
||||||
|
/// the difference between a permission the user is mid-way through changing
|
||||||
|
/// and a queue that will never drain.
|
||||||
|
///
|
||||||
|
/// Returns the ms at which the current run of deferrals began — `now` on the
|
||||||
|
/// first one.
|
||||||
|
pub fn defer_op_for_access(&self, id: i64, now: i64, next_attempt_at: i64) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op
|
||||||
|
SET next_attempt_at = CASE
|
||||||
|
WHEN next_attempt_at >= ?4 THEN next_attempt_at
|
||||||
|
ELSE ?3
|
||||||
|
END,
|
||||||
|
access_deferred_since = CASE
|
||||||
|
WHEN access_deferred_since = 0 THEN ?2
|
||||||
|
ELSE access_deferred_since
|
||||||
|
END
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id, now, next_attempt_at, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
let since = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access_deferred_since FROM pending_op WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
// A row that drained out from under this call leaves nothing to measure;
|
||||||
|
// reporting `now` says "this run just started", which is the reading that
|
||||||
|
// never escalates something that no longer exists.
|
||||||
|
Ok(since.filter(|since| *since != 0).unwrap_or(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget an op's access-deferral window, after an attempt the access check
|
||||||
|
/// let through. The op is no longer blocked, so a later deferral is the
|
||||||
|
/// start of a new run rather than the continuation of an old one.
|
||||||
|
pub fn clear_op_access_deferral(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op SET access_deferred_since = 0
|
||||||
|
WHERE id = ?1 AND access_deferred_since <> 0",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Every staged blob the queue still refers to.
|
/// Every staged blob the queue still refers to.
|
||||||
///
|
///
|
||||||
/// The startup staging reconcile subtracts this from what is actually in
|
/// The startup staging reconcile subtracts this from what is actually in
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
|
|||||||
pub remote_uid: Option<String>,
|
pub remote_uid: Option<String>,
|
||||||
pub local_mtime: i64,
|
pub local_mtime: i64,
|
||||||
pub local_size: i64,
|
pub local_size: i64,
|
||||||
|
/// The same modification time to nanosecond precision, when it is known.
|
||||||
|
///
|
||||||
|
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||||
|
/// second as the last sync — and left the file the same length — from no
|
||||||
|
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
||||||
|
/// written before schema 28, which the comparison reads as "compare whole
|
||||||
|
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||||
|
pub local_mtime_ns: Option<i64>,
|
||||||
pub remote_rev: Option<String>,
|
pub remote_rev: Option<String>,
|
||||||
pub remote_hash: Option<String>,
|
pub remote_hash: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -168,7 +176,8 @@ impl Db {
|
|||||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||||
|
local_mtime_ns
|
||||||
FROM sync_entry WHERE folder_id = ?1",
|
FROM sync_entry WHERE folder_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![folder_id], |r| {
|
let rows = stmt.query_map(params![folder_id], |r| {
|
||||||
@@ -179,6 +188,7 @@ impl Db {
|
|||||||
local_size: r.get(3)?,
|
local_size: r.get(3)?,
|
||||||
remote_rev: r.get(4)?,
|
remote_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
|
local_mtime_ns: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -216,14 +226,16 @@ impl Db {
|
|||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_entry
|
"INSERT INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
remote_hash, local_mtime_ns)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||||
remote_uid = excluded.remote_uid,
|
remote_uid = excluded.remote_uid,
|
||||||
local_mtime = excluded.local_mtime,
|
local_mtime = excluded.local_mtime,
|
||||||
local_size = excluded.local_size,
|
local_size = excluded.local_size,
|
||||||
remote_rev = excluded.remote_rev,
|
remote_rev = excluded.remote_rev,
|
||||||
remote_hash = excluded.remote_hash",
|
remote_hash = excluded.remote_hash,
|
||||||
|
local_mtime_ns = excluded.local_mtime_ns",
|
||||||
params![
|
params![
|
||||||
folder_id,
|
folder_id,
|
||||||
e.rel_path,
|
e.rel_path,
|
||||||
@@ -232,6 +244,7 @@ impl Db {
|
|||||||
e.local_size,
|
e.local_size,
|
||||||
e.remote_rev,
|
e.remote_rev,
|
||||||
e.remote_hash,
|
e.remote_hash,
|
||||||
|
e.local_mtime_ns,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1895,6 +1895,123 @@ fn failed_superseding_insert_keeps_the_old_pending_op() {
|
|||||||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
|
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B53's remaining half. The insert-failure regression above proves the *row*
|
||||||
|
/// rolls back; this proves the two things that make the rollback usable — the
|
||||||
|
/// old blob is not reported as superseded (so the caller does not delete the
|
||||||
|
/// bytes the surviving row still points at), and the rollback survives a
|
||||||
|
/// restart rather than living only in the connection that failed.
|
||||||
|
#[test]
|
||||||
|
fn a_rolled_back_supersede_keeps_its_blob_and_survives_reopen() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-db-b53-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("queue.sqlite");
|
||||||
|
let op = |blob: &str| PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_REVISION.to_string(),
|
||||||
|
uid: uid("durable").to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: Some(blob.to_string()),
|
||||||
|
meta_json: Some("{}".to_string()),
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
db.enqueue_op(&op("/staging/original")).unwrap();
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TRIGGER reject_replacement BEFORE INSERT ON pending_op
|
||||||
|
WHEN NEW.blob_path = '/staging/replacement'
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The failure must not hand back the old blob path. That value is the
|
||||||
|
// caller's instruction to delete those bytes, and the row that owns
|
||||||
|
// them is still queued.
|
||||||
|
assert!(db.enqueue_op(&op("/staging/replacement")).is_err());
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch("DROP TRIGGER reject_replacement")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let ops = db.pending_ops().unwrap();
|
||||||
|
assert_eq!(ops.len(), 1, "old or new — never neither");
|
||||||
|
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
|
||||||
|
|
||||||
|
// And once the injected failure is gone, superseding works and *does*
|
||||||
|
// report the blob it retired.
|
||||||
|
let (_, superseded) = db.enqueue_op(&op("/staging/replacement")).unwrap();
|
||||||
|
assert_eq!(superseded.as_deref(), Some("/staging/original"));
|
||||||
|
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full disk, as SQLite reports it. `max_page_count` makes the database
|
||||||
|
/// refuse to grow, which is the same `SQLITE_FULL` an out-of-space filesystem
|
||||||
|
/// produces — and the queue must come out of it with the acknowledged work
|
||||||
|
/// still in it (B53).
|
||||||
|
#[test]
|
||||||
|
fn a_full_database_rolls_the_supersede_back_rather_than_erasing_it() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let op = |blob: &str| PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_REVISION.to_string(),
|
||||||
|
uid: uid("full").to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: Some(blob.to_string()),
|
||||||
|
meta_json: Some("{}".to_string()),
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
};
|
||||||
|
db.enqueue_op(&op("/staging/original")).unwrap();
|
||||||
|
|
||||||
|
// Cap the file at its current size, so any page the insert needs fails.
|
||||||
|
let pages: i64 = db
|
||||||
|
.with_conn(|conn| Ok(conn.query_row("PRAGMA page_count", [], |r| r.get(0))?))
|
||||||
|
.unwrap();
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch(&format!("PRAGMA max_page_count = {pages}"))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A payload far larger than one page, so growth is unavoidable.
|
||||||
|
let mut fat = op("/staging/replacement");
|
||||||
|
fat.meta_json = Some("x".repeat(512 * 1024));
|
||||||
|
let result = db.enqueue_op(&fat);
|
||||||
|
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch("PRAGMA max_page_count = 1073741823")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_err(), "a database that cannot grow must refuse");
|
||||||
|
let ops = db.pending_ops().unwrap();
|
||||||
|
assert_eq!(ops.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
ops[0].blob_path.as_deref(),
|
||||||
|
Some("/staging/original"),
|
||||||
|
"the acknowledged upload is still queued and still owns its bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn atomic_trash_replacement_returns_owned_blobs_after_commit() {
|
fn atomic_trash_replacement_returns_owned_blobs_after_commit() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -2408,6 +2525,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
|||||||
local_size: 2,
|
local_size: 2,
|
||||||
remote_rev: Some("3".into()),
|
remote_rev: Some("3".into()),
|
||||||
remote_hash: Some("2".into()),
|
remote_hash: Some("2".into()),
|
||||||
|
local_mtime_ns: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2424,6 +2542,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
|||||||
local_size: 2,
|
local_size: 2,
|
||||||
remote_rev: Some("3".into()),
|
remote_rev: Some("3".into()),
|
||||||
remote_hash: Some("2".into()),
|
remote_hash: Some("2".into()),
|
||||||
|
local_mtime_ns: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -3491,6 +3610,132 @@ fn migration_v25_backfills_paths_for_an_existing_tree() {
|
|||||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A queue carried over from V26 keeps its rows, and every one of them starts
|
||||||
|
/// out not access-deferred — a migration must not invent a deferral window that
|
||||||
|
/// would report an ordinary queued upload as blocked.
|
||||||
|
#[test]
|
||||||
|
fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v26-fixture-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
db.enqueue_op(&crate::db::PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: crate::db::OP_REVISION.to_string(),
|
||||||
|
uid: "v~l".to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: Some("/tmp/blob".to_string()),
|
||||||
|
meta_json: Some("{}".to_string()),
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Put the file back in the state a released V26 database was in.
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE pending_op DROP COLUMN access_deferred_since;
|
||||||
|
UPDATE sync_state SET value = '26' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let ops = db.pending_ops().unwrap();
|
||||||
|
assert_eq!(ops.len(), 1, "the migration keeps the queued write");
|
||||||
|
let now = 5_000i64;
|
||||||
|
assert_eq!(
|
||||||
|
db.defer_op_for_access(ops[0].id, now, now + 5_000).unwrap(),
|
||||||
|
now,
|
||||||
|
"a carried-over op is not already deferred"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mirror baseline carried over from V27 keeps its rows, and each one reads
|
||||||
|
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||||||
|
/// comparison fall back to whole seconds for rows written before the column
|
||||||
|
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
||||||
|
/// every already-synced file in the folder (bugs.md B25).
|
||||||
|
#[test]
|
||||||
|
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v27-fixture-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let folder = db
|
||||||
|
.sync_folder_add("/home/me/Mirror", "vol~root", "share")
|
||||||
|
.unwrap();
|
||||||
|
db.sync_entry_upsert(
|
||||||
|
folder,
|
||||||
|
&StoredSyncEntry {
|
||||||
|
rel_path: "notes.txt".into(),
|
||||||
|
remote_uid: Some("vol~notes".into()),
|
||||||
|
local_mtime: 1_700_000_000,
|
||||||
|
local_mtime_ns: Some(1_700_000_000_123_456_789),
|
||||||
|
local_size: 42,
|
||||||
|
remote_rev: Some("1700000000".into()),
|
||||||
|
remote_hash: Some("42".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Put the file back in the state a released V27 database was in.
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE sync_entry DROP COLUMN local_mtime_ns;
|
||||||
|
UPDATE sync_state SET value = '27' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let entries = db.sync_entries(1).unwrap();
|
||||||
|
let entry = entries.get("notes.txt").expect("the baseline row survives");
|
||||||
|
assert_eq!(entry.local_mtime, 1_700_000_000);
|
||||||
|
assert_eq!(entry.local_size, 42);
|
||||||
|
assert_eq!(
|
||||||
|
entry.local_mtime_ns, None,
|
||||||
|
"a carried-over row must not claim a sub-second time it never recorded"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the column is live: a refreshed row keeps the precision it is given.
|
||||||
|
db.sync_entry_upsert(
|
||||||
|
1,
|
||||||
|
&StoredSyncEntry {
|
||||||
|
local_mtime_ns: Some(1_700_000_001_000_000_500),
|
||||||
|
..entry.clone()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.sync_entries(1).unwrap()["notes.txt"].local_mtime_ns,
|
||||||
|
Some(1_700_000_001_000_000_500)
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
// --- read-only connection pool (DB2) ---------------------------------------
|
// --- read-only connection pool (DB2) ---------------------------------------
|
||||||
|
|
||||||
/// A read must not queue behind whatever happens to be writing.
|
/// A read must not queue behind whatever happens to be writing.
|
||||||
@@ -3545,3 +3790,114 @@ fn a_read_does_not_wait_for_the_write_connection() {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5: the drain is several workers over one queue, and the claim column is the
|
||||||
|
/// only thing keeping them off each other's work. Two properties matter — no
|
||||||
|
/// row is ever handed to two workers, and no *node* is ever worked by two
|
||||||
|
/// workers even though its ops are separate rows.
|
||||||
|
#[test]
|
||||||
|
fn a_claimed_op_is_invisible_to_every_other_worker() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
for i in 0..3 {
|
||||||
|
db.enqueue_op(&bulk_op(i, &root)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = db.claim_next_due_op(10).unwrap().expect("an op is due");
|
||||||
|
let second = db
|
||||||
|
.claim_next_due_op(10)
|
||||||
|
.unwrap()
|
||||||
|
.expect("another op is due");
|
||||||
|
let third = db
|
||||||
|
.claim_next_due_op(10)
|
||||||
|
.unwrap()
|
||||||
|
.expect("a third op is due");
|
||||||
|
assert!(
|
||||||
|
db.claim_next_due_op(10).unwrap().is_none(),
|
||||||
|
"a fourth claim must find nothing left: every queued op is taken"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ids = [first.id, second.id, third.id];
|
||||||
|
ids.sort_unstable();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
[1, 2, 3],
|
||||||
|
"each worker must get a distinct row, oldest first"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Releasing puts one back, and it is the one that comes out next.
|
||||||
|
db.release_op_claim(second.id).unwrap();
|
||||||
|
let again = db.claim_next_due_op(10).unwrap().expect("the released op");
|
||||||
|
assert_eq!(again.id, second.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_workers_never_share_a_node() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
// Two ops of different kinds against one node — a queued rename and a
|
||||||
|
// queued revision, which is an ordinary `mv` plus a save.
|
||||||
|
let mut revision = bulk_op(0, &root);
|
||||||
|
revision.parent_uid = None;
|
||||||
|
db.enqueue_op(&revision).unwrap();
|
||||||
|
let rename = PendingOp {
|
||||||
|
kind: OP_RENAME.to_string(),
|
||||||
|
parent_uid: Some(root.clone()),
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: None,
|
||||||
|
..bulk_op(0, &root)
|
||||||
|
};
|
||||||
|
db.enqueue_op(&rename).unwrap();
|
||||||
|
|
||||||
|
let claimed = db.claim_next_due_op(10).unwrap().expect("an op is due");
|
||||||
|
assert_eq!(claimed.kind, OP_REVISION, "the older op goes first");
|
||||||
|
assert!(
|
||||||
|
db.claim_next_due_op(10).unwrap().is_none(),
|
||||||
|
"the second op targets the same node, so it must wait for the first: \
|
||||||
|
two workers on one uid would race over its staged blob and land its \
|
||||||
|
changes out of order"
|
||||||
|
);
|
||||||
|
|
||||||
|
db.release_op_claim(claimed.id).unwrap();
|
||||||
|
db.delete_op(claimed.id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.claim_next_due_op(10).unwrap().map(|o| o.kind),
|
||||||
|
Some(OP_RENAME.to_string()),
|
||||||
|
"once the revision retires, the rename is claimable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claims_do_not_survive_the_process_that_took_them() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
for i in 0..2 {
|
||||||
|
db.enqueue_op(&bulk_op(i, &root)).unwrap();
|
||||||
|
}
|
||||||
|
db.claim_next_due_op(10).unwrap().unwrap();
|
||||||
|
db.claim_next_due_op(10).unwrap().unwrap();
|
||||||
|
assert!(db.claim_next_due_op(10).unwrap().is_none());
|
||||||
|
|
||||||
|
// What `Db::open` does after the single-writer lock says the previous run
|
||||||
|
// is gone. Without it those ops are invisible to every worker forever.
|
||||||
|
assert_eq!(db.clear_op_claims().unwrap(), 2);
|
||||||
|
assert!(
|
||||||
|
db.claim_next_due_op(10).unwrap().is_some(),
|
||||||
|
"a crashed run's claims must not park its queue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
db.enqueue_op(&bulk_op(0, &root)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.earliest_due_at().unwrap(), Some(0));
|
||||||
|
db.claim_next_due_op(10).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.earliest_due_at().unwrap(),
|
||||||
|
None,
|
||||||
|
"an op somebody else is draining is not work this worker is waiting for"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Remote-event invalidation and local-index background workers.
|
//! Remote-event invalidation and local-index background workers.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::shutdown::Shutdown;
|
||||||
|
|
||||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
/// 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
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||||
@@ -437,6 +438,7 @@ pub(super) fn run_local_index(
|
|||||||
indexing: Arc<AtomicBool>,
|
indexing: Arc<AtomicBool>,
|
||||||
transfers: Arc<TransferRegistry>,
|
transfers: Arc<TransferRegistry>,
|
||||||
mountpoint: PathBuf,
|
mountpoint: PathBuf,
|
||||||
|
shutdown: Arc<Shutdown>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let age = db.local_indexed_at().ok().flatten();
|
let age = db.local_indexed_at().ok().flatten();
|
||||||
@@ -445,7 +447,11 @@ pub(super) fn run_local_index(
|
|||||||
if stale {
|
if stale {
|
||||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||||
}
|
}
|
||||||
std::thread::sleep(LOCAL_INDEX_CHECK);
|
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||||
|
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
||||||
|
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,31 @@ fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String
|
|||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn a CLI-supplied path into the mount that owns it and the path relative
|
||||||
|
/// to *that* mount's root.
|
||||||
|
///
|
||||||
|
/// [`rel_to_mount`] is the older, primary-only reading, and it is what every
|
||||||
|
/// mutating request still uses. It rejects anything outside the primary
|
||||||
|
/// mountpoint, which leaves a secondary on-demand mount — a synced folder
|
||||||
|
/// switched out of mirror mode — with no way to be named at all: `pdfs ls` and
|
||||||
|
/// `pdfs refresh` both answered "is not under the mountpoint", so a folder
|
||||||
|
/// serving a stale listing had no manual escape hatch (`docs/BUGS.md` B86).
|
||||||
|
///
|
||||||
|
/// A relative path keeps the old meaning, relative to the primary mount, since
|
||||||
|
/// there is nothing in it to choose a mount by.
|
||||||
|
fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
if !p.is_absolute() {
|
||||||
|
return Ok((core.clone(), p.to_path_buf()));
|
||||||
|
}
|
||||||
|
if let Some(routed) = core.rooted_at(p) {
|
||||||
|
return Ok(routed);
|
||||||
|
}
|
||||||
|
// Fall back to the primary reading so the error text stays the familiar one
|
||||||
|
// when the path is simply outside everything this daemon has mounted.
|
||||||
|
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
@@ -193,8 +218,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||||
pins: core.cache.list_pins(),
|
pins: core.cache.list_pins(),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_dir(&rel) {
|
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
@@ -202,9 +227,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::Refresh { scope }) => {
|
Ok(CtlRequest::Refresh { scope }) => {
|
||||||
let result = match &scope {
|
let result = match &scope {
|
||||||
RefreshScope::Dir { path } => {
|
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
||||||
rel_to_mount(mountpoint, path).and_then(|rel| core.refresh_dir(&rel))
|
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
||||||
}
|
|
||||||
RefreshScope::Trash => {
|
RefreshScope::Trash => {
|
||||||
core.invalidate_trash();
|
core.invalidate_trash();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1084,6 +1108,12 @@ pub(crate) fn run_control_socket(
|
|||||||
) {
|
) {
|
||||||
info!("control socket listening");
|
info!("control socket listening");
|
||||||
for conn in listener.incoming() {
|
for conn in listener.incoming() {
|
||||||
|
// Checked on every accept, including the deliberate poke teardown sends
|
||||||
|
// to wake this thread out of a blocking accept (bugs.md B44).
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
info!("control socket closing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
match conn {
|
match conn {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
let Some(permit) = ControlHandlerPermit::acquire() else {
|
let Some(permit) = ControlHandlerPermit::acquire() else {
|
||||||
|
|||||||
+640
-57
@@ -20,10 +20,14 @@
|
|||||||
//! are kept as a conflict copy rather than discarded — see
|
//! are kept as a conflict copy rather than discarded — see
|
||||||
//! [`Core::revision_conflict`].
|
//! [`Core::revision_conflict`].
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
use pdfs_core::batch;
|
use pdfs_core::batch;
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
@@ -36,17 +40,132 @@ use tracing::{debug, error, info, warn};
|
|||||||
use super::state::Intervals;
|
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, DRAIN_REVISION_DEBOUNCE,
|
||||||
|
DRAIN_REVISION_DEBOUNCE_MAX, ROOT_INO, UPLOAD_TIME_MEMORY, WriteAuthority, conflict_name,
|
||||||
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size,
|
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size,
|
||||||
now_millis, now_secs, parse_node_uid,
|
now_millis, now_secs, parse_node_uid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
|
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// How long an op may stay access-deferred before the deferral is reported as a
|
||||||
|
/// failure instead of retried in silence.
|
||||||
|
///
|
||||||
|
/// An access deferral costs no attempt and records no error, which is right for
|
||||||
|
/// the case it was written for: a share downgraded while the queue was moving,
|
||||||
|
/// undone a moment later. It is wrong for anything that does not clear. Until
|
||||||
|
/// this window existed such an op was re-deferred every
|
||||||
|
/// [`DRAIN_ACCESS_RECHECK`] indefinitely with `attempts` at zero, `last_error`
|
||||||
|
/// null and only a `debug!` line to show for it, so `pdfs status` counted it
|
||||||
|
/// among ordinary queued uploads and nothing ever said otherwise.
|
||||||
|
///
|
||||||
|
/// The condition that produced that in practice — a node absent from the local
|
||||||
|
/// tree, which [`Db::effective_node_access`] used to report indistinguishably
|
||||||
|
/// from a revoked permission — is now answered directly by
|
||||||
|
/// [`Core::resolve_unknown_authority`]. This window remains the backstop for
|
||||||
|
/// every deferral that does not resolve, including a refetch that keeps not
|
||||||
|
/// helping.
|
||||||
|
///
|
||||||
|
/// Five minutes is far longer than a permission change takes to settle and far
|
||||||
|
/// shorter than a user's patience with bytes that are not moving. Past it each
|
||||||
|
/// recheck records a failure, so the op enters the ordinary backoff and, at
|
||||||
|
/// [`FAILING_ATTEMPTS`], is counted and named by `Response::Status`.
|
||||||
|
///
|
||||||
|
/// [`Db::effective_node_access`]: pdfs_core::db::Db::effective_node_access
|
||||||
|
/// [`FAILING_ATTEMPTS`]: pdfs_core::db::FAILING_ATTEMPTS
|
||||||
|
const DRAIN_ACCESS_DEFER_LIMIT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// A registered cancellation flag for one in-flight upload, deregistered when
|
||||||
|
/// dropped. See [`Core::begin_cancellable_upload`].
|
||||||
|
struct UploadCancel {
|
||||||
|
registry: Arc<Mutex<HashMap<NodeUid, Arc<AtomicBool>>>>,
|
||||||
|
uid: NodeUid,
|
||||||
|
flag: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadCancel {
|
||||||
|
/// Whether this upload was told to stop. Distinguishes an upload we
|
||||||
|
/// abandoned on purpose from one the network broke.
|
||||||
|
fn cancelled(&self) -> bool {
|
||||||
|
self.flag.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flag(&self) -> Arc<AtomicBool> {
|
||||||
|
self.flag.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UploadCancel {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut registry = self.registry.lock();
|
||||||
|
// By identity: a supersede racing this upload's last bytes may already
|
||||||
|
// have registered the next one under the same uid.
|
||||||
|
if registry
|
||||||
|
.get(&self.uid)
|
||||||
|
.is_some_and(|current| Arc::ptr_eq(current, &self.flag))
|
||||||
|
{
|
||||||
|
registry.remove(&self.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The debounce arithmetic of [`Core::revision_debounce`], without the map:
|
||||||
|
/// the last measured upload time for this node, bounded on both sides.
|
||||||
|
///
|
||||||
|
/// A node nobody has uploaded yet gets the fixed grace period, which is the
|
||||||
|
/// same answer the fixed debounce always gave.
|
||||||
|
fn adaptive_debounce(measured: Option<Duration>) -> Duration {
|
||||||
|
measured
|
||||||
|
.unwrap_or(DRAIN_REVISION_DEBOUNCE)
|
||||||
|
.clamp(DRAIN_REVISION_DEBOUNCE, DRAIN_REVISION_DEBOUNCE_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum DrainDisposition {
|
enum DrainDisposition {
|
||||||
Applied,
|
Applied,
|
||||||
AccessDeferred,
|
AccessDeferred,
|
||||||
|
/// An authority this op needs is missing from the local tree, so no access
|
||||||
|
/// answer exists for it yet. Carries the uid to ask the remote about.
|
||||||
|
AuthorityUnknown(NodeUid),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an op went back on the clock without consuming an attempt. Only affects
|
||||||
|
/// what the user is told — every variant is deferred and reported identically.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum AccessDeferral {
|
||||||
|
/// An authority the tree knows about refuses the write.
|
||||||
|
NotWritable,
|
||||||
|
/// An authority missing from the tree was just re-read from the remote and
|
||||||
|
/// re-interned; the access check gets another look on the next pass.
|
||||||
|
AuthorityRefetched,
|
||||||
|
/// An authority missing from the tree could not be asked about.
|
||||||
|
RemoteUnreachable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessDeferral {
|
||||||
|
fn log_reason(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::NotWritable => "the node is not writable",
|
||||||
|
Self::AuthorityRefetched => "the node was missing locally and has been re-read",
|
||||||
|
Self::RemoteUnreachable => "the node is missing locally and the remote is unreachable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phrased to complete "<reason> for 300s." in a recorded `last_error`, so
|
||||||
|
/// the text the user sees names the actual condition rather than the
|
||||||
|
/// permission question it used to be flattened into.
|
||||||
|
fn failure_reason(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::NotWritable => "the share has not been writable",
|
||||||
|
Self::AuthorityRefetched => {
|
||||||
|
"the node has been missing from the local tree, and re-reading it from the remote \
|
||||||
|
has not restored it"
|
||||||
|
}
|
||||||
|
Self::RemoteUnreachable => {
|
||||||
|
"the node has been missing from the local tree and the remote has been unreachable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
|
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
|
||||||
@@ -89,7 +208,7 @@ fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::e
|
|||||||
|
|
||||||
fn run_authorized_drain(
|
fn run_authorized_drain(
|
||||||
op: &PendingOp,
|
op: &PendingOp,
|
||||||
mut require: impl FnMut(&NodeUid) -> Result<(), fuser::Errno>,
|
mut authority_of: impl FnMut(&NodeUid) -> WriteAuthority,
|
||||||
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
||||||
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
|
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
|
||||||
// This check is the admission/linearization point. A downgrade that lands
|
// This check is the admission/linearization point. A downgrade that lands
|
||||||
@@ -97,15 +216,14 @@ fn run_authorized_drain(
|
|||||||
// attempt. Every later queued operation rechecks. No permission lock is
|
// attempt. Every later queued operation rechecks. No permission lock is
|
||||||
// held across remote latency.
|
// held across remote latency.
|
||||||
for authority in pending_op_authorities(op)? {
|
for authority in pending_op_authorities(op)? {
|
||||||
match require(&authority) {
|
match authority_of(&authority) {
|
||||||
Ok(()) => {}
|
WriteAuthority::Writable => {}
|
||||||
Err(error) if error.code() == libc::EACCES => {
|
WriteAuthority::Denied => return Ok(DrainDisposition::AccessDeferred),
|
||||||
return Ok(DrainDisposition::AccessDeferred);
|
// Nothing in the tree can answer for this node. Deferring on it
|
||||||
}
|
// waits for a permission change that has no reason to come, so the
|
||||||
Err(error) => {
|
// caller asks the remote instead (B83).
|
||||||
return Err(
|
WriteAuthority::Unknown => {
|
||||||
format!("drain access check failed with errno {}", error.code()).into(),
|
return Ok(DrainDisposition::AuthorityUnknown(authority));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,48 +245,85 @@ impl Core {
|
|||||||
/// that is due — one file wedged against a folder that no longer exists must
|
/// that is due — one file wedged against a folder that no longer exists must
|
||||||
/// not hold up an unrelated upload behind it. The worker only blocks once
|
/// not hold up an unrelated upload behind it. The worker only blocks once
|
||||||
/// nothing is due at all.
|
/// nothing is due at all.
|
||||||
pub(crate) fn run_pending_drain(&self) {
|
///
|
||||||
|
/// Several of these run at once ([`DRAIN_WORKERS`]), sharing the queue
|
||||||
|
/// through [`Db::claim_next_due_op`]: the alternative was that a 10 GiB
|
||||||
|
/// upload held every queued rename, trash and small write behind it for as
|
||||||
|
/// long as it took. Ordering only has to hold *per node*, and the claim
|
||||||
|
/// query is what guarantees it — no two workers are ever on one uid.
|
||||||
|
///
|
||||||
|
/// `primary` marks the one worker that also runs the queue's idle chores.
|
||||||
|
/// They are cheap, but they are not per-op work and doing them in every
|
||||||
|
/// worker would just multiply them.
|
||||||
|
///
|
||||||
|
/// [`DRAIN_WORKERS`]: super::DRAIN_WORKERS
|
||||||
|
/// [`Db::claim_next_due_op`]: pdfs_core::db::Db::claim_next_due_op
|
||||||
|
pub(crate) fn run_pending_drain(&self, primary: bool) {
|
||||||
loop {
|
loop {
|
||||||
|
// Between ops, never inside one: an op that has started is either
|
||||||
|
// retired or released, so stopping here cannot leave the queue with
|
||||||
|
// a row claimed by a worker that no longer exists (bugs.md B44).
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
debug!(primary, "drain worker stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let now = now_millis();
|
let now = now_millis();
|
||||||
// One row, chosen by the database. Reading the whole queue to pick
|
// One row, chosen and *claimed* by the database. Reading the whole
|
||||||
// one op made a long queue quadratic to drain, and held the shared
|
// queue to pick one op made a long queue quadratic to drain, and
|
||||||
// connection — and so every FUSE metadata call — for the duration.
|
// held the shared connection — and so every FUSE metadata call —
|
||||||
let due = self.db.next_due_op(now).unwrap_or_default();
|
// for the duration.
|
||||||
|
let due = match self.db.claim_next_due_op(now) {
|
||||||
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
|
Ok(due) => due,
|
||||||
// Nothing to upload is exactly when the connection is free, so
|
Err(error) => {
|
||||||
// this is where the read path's buffered LRU touches get written
|
error!(%error, "claiming the next pending operation failed");
|
||||||
// (`ContentCache::flush_touches`). Cheap and a no-op when empty.
|
self.wait_for_drain_work();
|
||||||
self.cache.flush_touches();
|
continue;
|
||||||
self.recover_fsynced_writes();
|
}
|
||||||
// A debounced or backed-off op may be waiting: sleep only
|
};
|
||||||
// until it becomes due rather than the full idle-poll.
|
let online = self.online.load(Ordering::Relaxed);
|
||||||
self.wait_for_drain_work_or_due();
|
let op = match due {
|
||||||
continue;
|
Some(op) if online => op,
|
||||||
|
idle => {
|
||||||
|
// Claimed but offline: hand it straight back, or it stays
|
||||||
|
// invisible to every worker until this one happens to be
|
||||||
|
// the next to notice the reconnect.
|
||||||
|
if let Some(op) = idle {
|
||||||
|
self.release_claim(&op);
|
||||||
|
}
|
||||||
|
if primary {
|
||||||
|
// Nothing to upload is exactly when the connection is
|
||||||
|
// free, so this is where the read path's buffered LRU
|
||||||
|
// touches get written (`ContentCache::flush_touches`).
|
||||||
|
// Cheap and a no-op when empty.
|
||||||
|
self.cache.flush_touches();
|
||||||
|
self.recover_fsynced_writes();
|
||||||
|
}
|
||||||
|
// A debounced or backed-off op may be waiting: sleep only
|
||||||
|
// until it becomes due rather than the full idle-poll.
|
||||||
|
self.wait_for_drain_work_or_due();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let outcome = run_authorized_drain(
|
let outcome = run_authorized_drain(
|
||||||
&op,
|
&op,
|
||||||
|uid| self.require_uid_writable(uid),
|
|uid| self.uid_write_authority(uid),
|
||||||
|| self.drain_op(&op),
|
|| self.drain_op(&op),
|
||||||
);
|
);
|
||||||
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
|
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
|
||||||
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
|
self.defer_for_access(&op, AccessDeferral::NotWritable);
|
||||||
if let Err(error) = self.db.defer_op_without_attempt(op.id, next_attempt_at) {
|
|
||||||
error!(
|
|
||||||
uid = %op.uid,
|
|
||||||
%error,
|
|
||||||
"deferring access-blocked pending operation failed"
|
|
||||||
);
|
|
||||||
self.wait_for_drain_work();
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
uid = %op.uid,
|
|
||||||
next_attempt_at,
|
|
||||||
"pending operation deferred until access is writable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Ok(DrainDisposition::AuthorityUnknown(ref authority)) = outcome {
|
||||||
|
self.resolve_unknown_authority(&op, authority);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The access check let this attempt through, so any earlier run of
|
||||||
|
// deferrals is over and must not be counted against the next one.
|
||||||
|
if matches!(outcome, Ok(DrainDisposition::Applied))
|
||||||
|
&& let Err(error) = self.db.clear_op_access_deferral(op.id)
|
||||||
|
{
|
||||||
|
debug!(uid = %op.uid, %error, "clearing an access-deferral window failed");
|
||||||
|
}
|
||||||
if let Err(e) = outcome {
|
if let Err(e) = outcome {
|
||||||
let attempts = op.attempts + 1;
|
let attempts = op.attempts + 1;
|
||||||
let err_str = e.to_string();
|
let err_str = e.to_string();
|
||||||
@@ -188,9 +343,234 @@ impl Core {
|
|||||||
now_millis() + backoff.as_millis() as i64,
|
now_millis() + backoff.as_millis() as i64,
|
||||||
) {
|
) {
|
||||||
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
||||||
|
self.release_claim(&op);
|
||||||
self.wait_for_drain_work();
|
self.wait_for_drain_work();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Unconditionally, on every path: a handler that retired its own row
|
||||||
|
// leaves nothing to release, and one that returned without retiring
|
||||||
|
// must not leave the row claimed by a worker that has moved on.
|
||||||
|
self.release_claim(&op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put an op the access check refused back on the clock, and decide whether
|
||||||
|
/// its refusal is still news.
|
||||||
|
///
|
||||||
|
/// Inside [`DRAIN_ACCESS_DEFER_LIMIT`] a deferral is what it always was: no
|
||||||
|
/// attempt consumed, no error recorded, retried in five seconds. Past it the
|
||||||
|
/// deferral is reported through [`Db::record_op_failure`] like any other
|
||||||
|
/// stuck op, which is what puts it in `attempts`, in `last_error`, and — at
|
||||||
|
/// [`FAILING_ATTEMPTS`] — in the `failing_ops` count and error text that
|
||||||
|
/// `pdfs status` and the GUI show. The row and its staged blob stay exactly
|
||||||
|
/// where they are either way; this changes only whether the user can see
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// [`Db::record_op_failure`]: pdfs_core::db::Db::record_op_failure
|
||||||
|
/// [`FAILING_ATTEMPTS`]: pdfs_core::db::FAILING_ATTEMPTS
|
||||||
|
fn defer_for_access(&self, op: &PendingOp, reason: AccessDeferral) {
|
||||||
|
let now = now_millis();
|
||||||
|
let next_attempt_at = now + DRAIN_ACCESS_RECHECK.as_millis() as i64;
|
||||||
|
let since = match self.db.defer_op_for_access(op.id, now, next_attempt_at) {
|
||||||
|
Ok(since) => since,
|
||||||
|
Err(error) => {
|
||||||
|
error!(
|
||||||
|
uid = %op.uid,
|
||||||
|
%error,
|
||||||
|
"deferring access-blocked pending operation failed"
|
||||||
|
);
|
||||||
|
self.release_claim(op);
|
||||||
|
self.wait_for_drain_work();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let blocked = Duration::from_millis(now.saturating_sub(since).max(0) as u64);
|
||||||
|
if since == now {
|
||||||
|
// The first deferral of a run, at `warn!`: a write the mount accepted
|
||||||
|
// is not going anywhere for now, and that is worth a line even when
|
||||||
|
// the next recheck clears it.
|
||||||
|
warn!(
|
||||||
|
uid = %op.uid,
|
||||||
|
kind = %op.kind,
|
||||||
|
reason = reason.log_reason(),
|
||||||
|
"pending operation deferred"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if blocked >= DRAIN_ACCESS_DEFER_LIMIT {
|
||||||
|
let attempts = op.attempts + 1;
|
||||||
|
let backoff = DRAIN_BACKOFF_MIN
|
||||||
|
.saturating_mul(1u32 << attempts.min(6))
|
||||||
|
.min(DRAIN_BACKOFF_MAX);
|
||||||
|
let error = format!(
|
||||||
|
"{} for {}s. The staged bytes are kept.",
|
||||||
|
reason.failure_reason(),
|
||||||
|
blocked.as_secs()
|
||||||
|
);
|
||||||
|
warn!(uid = %op.uid, attempts, blocked_secs = blocked.as_secs(),
|
||||||
|
"pending operation has been access-blocked past the limit; reporting it");
|
||||||
|
if let Err(error) =
|
||||||
|
self.db
|
||||||
|
.record_op_failure(op.id, &error, now_millis() + backoff.as_millis() as i64)
|
||||||
|
{
|
||||||
|
error!(uid = %op.uid, %error, "recording an access-deferral failure failed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
uid = %op.uid,
|
||||||
|
next_attempt_at,
|
||||||
|
blocked_secs = blocked.as_secs(),
|
||||||
|
reason = reason.log_reason(),
|
||||||
|
"pending operation deferred until access is writable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.release_claim(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an authority the local tree has no row for by asking the remote,
|
||||||
|
/// rather than deferring on a permission question nobody asked (B83).
|
||||||
|
///
|
||||||
|
/// Three outcomes, and the point of the whole exercise is that they are
|
||||||
|
/// three:
|
||||||
|
///
|
||||||
|
/// * the node is still there — the tree simply lost it (a pruned subtree, a
|
||||||
|
/// mount whose registration went away). Re-intern it and the next pass has
|
||||||
|
/// a real access answer to work with.
|
||||||
|
/// * the node is gone remotely. That is a permanent, *known* condition, so
|
||||||
|
/// it is recorded as a failure immediately instead of waiting out
|
||||||
|
/// [`DRAIN_ACCESS_DEFER_LIMIT`] to say so. The row and its staged blob are
|
||||||
|
/// kept, as on every other failure path — the bytes are still the user's,
|
||||||
|
/// and `pdfs recover` is how they get them back.
|
||||||
|
/// * we could not ask. Ordinary deferral; the network is the network.
|
||||||
|
fn resolve_unknown_authority(&self, op: &PendingOp, authority: &NodeUid) {
|
||||||
|
match self.fetch_node_remote(authority) {
|
||||||
|
Ok(Some(node)) => {
|
||||||
|
if let Err(error) = self.db.upsert_node(&node) {
|
||||||
|
error!(uid = %op.uid, %authority, %error,
|
||||||
|
"re-interning a queued operation's missing authority failed");
|
||||||
|
} else {
|
||||||
|
info!(uid = %op.uid, %authority,
|
||||||
|
"re-interned an authority missing from the local tree; retrying");
|
||||||
|
}
|
||||||
|
// Deferred, not applied: the re-read has to go through the same
|
||||||
|
// access check as everything else, on the next pass. The window
|
||||||
|
// keeps running, so a refetch that never actually helps still
|
||||||
|
// gets reported instead of looping in silence.
|
||||||
|
self.defer_for_access(op, AccessDeferral::AuthorityRefetched);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let attempts = op.attempts + 1;
|
||||||
|
let backoff = DRAIN_BACKOFF_MIN
|
||||||
|
.saturating_mul(1u32 << attempts.min(6))
|
||||||
|
.min(DRAIN_BACKOFF_MAX);
|
||||||
|
let error = format!(
|
||||||
|
"{authority} no longer exists remotely, so this operation has nothing to \
|
||||||
|
apply to. The staged bytes are kept."
|
||||||
|
);
|
||||||
|
warn!(uid = %op.uid, %authority, attempts,
|
||||||
|
"a queued operation's authority is gone remotely; reporting it");
|
||||||
|
if let Err(error) = self.db.record_op_failure(
|
||||||
|
op.id,
|
||||||
|
&error,
|
||||||
|
now_millis() + backoff.as_millis() as i64,
|
||||||
|
) {
|
||||||
|
error!(uid = %op.uid, %error, "recording a missing-authority failure failed");
|
||||||
|
}
|
||||||
|
self.release_claim(op);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
debug!(uid = %op.uid, %authority, %error,
|
||||||
|
"could not ask the remote about a missing authority");
|
||||||
|
self.defer_for_access(op, AccessDeferral::RemoteUnreachable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand a claimed op back to the queue, logging rather than propagating —
|
||||||
|
/// there is no caller left to propagate to, and the drain must not stop.
|
||||||
|
fn release_claim(&self, op: &PendingOp) {
|
||||||
|
if let Err(error) = self.db.release_op_claim(op.id) {
|
||||||
|
error!(uid = %op.uid, %error, "releasing a drain claim failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a freshly queued revision of `uid` should wait before a worker
|
||||||
|
/// may pick it up.
|
||||||
|
///
|
||||||
|
/// [`DRAIN_REVISION_DEBOUNCE`] exists for a tool that preallocates a file
|
||||||
|
/// and then writes it, where two seconds is the right scale. It is the wrong
|
||||||
|
/// scale for a large file on a slow link: an editor saving a 200 MB project
|
||||||
|
/// every ten seconds queues a write, the drain starts sending it, the next
|
||||||
|
/// save supersedes it mid-flight, and the file never finishes uploading
|
||||||
|
/// while the user keeps working. Waiting roughly as long as the last upload
|
||||||
|
/// of *this* node took moves that supersede into the queue, where replacing
|
||||||
|
/// a row costs nothing.
|
||||||
|
///
|
||||||
|
/// Never shorter than the fixed debounce and never longer than
|
||||||
|
/// [`DRAIN_REVISION_DEBOUNCE_MAX`]: a node whose upload is genuinely slow
|
||||||
|
/// still has to reach Drive, and a single pathological stall must not park
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// [`DRAIN_REVISION_DEBOUNCE`]: super::DRAIN_REVISION_DEBOUNCE
|
||||||
|
/// [`DRAIN_REVISION_DEBOUNCE_MAX`]: super::DRAIN_REVISION_DEBOUNCE_MAX
|
||||||
|
pub(crate) fn revision_debounce(&self, uid: &NodeUid) -> Duration {
|
||||||
|
let measured = self
|
||||||
|
.upload_times
|
||||||
|
.lock()
|
||||||
|
.get(&uid.to_string())
|
||||||
|
.map(|&(ms, _)| Duration::from_millis(ms));
|
||||||
|
adaptive_debounce(measured)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember how long an upload of `uid` took, for the next write's
|
||||||
|
/// debounce.
|
||||||
|
///
|
||||||
|
/// Only a *completed* upload is a measurement. A cancelled or failed one
|
||||||
|
/// says how long it ran before it stopped, which is not how long the file
|
||||||
|
/// takes to send, and feeding that back would let one network fault widen
|
||||||
|
/// the debounce for good.
|
||||||
|
fn record_upload_time(&self, uid: &NodeUid, took: Duration) {
|
||||||
|
let mut times = self.upload_times.lock();
|
||||||
|
let now = now_millis();
|
||||||
|
if times.len() >= UPLOAD_TIME_MEMORY
|
||||||
|
&& !times.contains_key(&uid.to_string())
|
||||||
|
&& let Some(oldest) = times
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, (_, at))| *at)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
{
|
||||||
|
times.remove(&oldest);
|
||||||
|
}
|
||||||
|
times.insert(uid.to_string(), (took.as_millis() as u64, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a cancellation flag for an upload of `uid` that is about to
|
||||||
|
/// start, returning a guard that deregisters it.
|
||||||
|
///
|
||||||
|
/// Deregistration is by identity rather than by key: a supersede that
|
||||||
|
/// arrives just as this upload finishes may already have registered the
|
||||||
|
/// *next* upload's flag under the same uid, and removing that one would
|
||||||
|
/// leave the newer upload uncancellable.
|
||||||
|
fn begin_cancellable_upload(&self, uid: &NodeUid) -> UploadCancel {
|
||||||
|
let flag = Arc::new(AtomicBool::new(false));
|
||||||
|
self.upload_cancel.lock().insert(uid.clone(), flag.clone());
|
||||||
|
UploadCancel {
|
||||||
|
registry: self.upload_cancel.clone(),
|
||||||
|
uid: uid.clone(),
|
||||||
|
flag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell an upload of `uid` that is on the wire to stop, if there is one.
|
||||||
|
///
|
||||||
|
/// Called when a newer revision of the file is queued. The upload does not
|
||||||
|
/// stop instantly — it stops when the SDK next asks its reader for bytes,
|
||||||
|
/// which for a 4 MiB block is soon enough to matter and late enough to be
|
||||||
|
/// safe: the staged blob it is reading stays open until it lets go.
|
||||||
|
pub(crate) fn cancel_upload(&self, uid: &NodeUid) {
|
||||||
|
if let Some(flag) = self.upload_cancel.lock().get(uid) {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
debug!(%uid, "cancelling a superseded upload");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,15 +1287,37 @@ impl Core {
|
|||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
let reader = CountingReader::new(File::open(&blob)?, &guard);
|
// Registered before the first byte moves, so a write that lands while
|
||||||
self.rt.block_on(self.client.upload_new_revision_from(
|
// this is on the wire can stop it rather than paying for a revision the
|
||||||
|
// op it queued will immediately replace.
|
||||||
|
let cancel = self.begin_cancellable_upload(&uid);
|
||||||
|
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
||||||
|
let started = Instant::now();
|
||||||
|
let sent = self.rt.block_on(self.client.upload_new_revision_from(
|
||||||
&uid,
|
&uid,
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
))?;
|
));
|
||||||
|
let took = started.elapsed();
|
||||||
|
if let Err(e) = sent {
|
||||||
|
if cancel.cancelled() {
|
||||||
|
// Not a failure, and not this op's row to retire: the write that
|
||||||
|
// cancelled us deleted it and took ownership of the staged blob
|
||||||
|
// when it superseded this revision. Leaving the blob alone here
|
||||||
|
// is the whole point — it is either already unlinked or it
|
||||||
|
// belongs to the newer op.
|
||||||
|
info!(%uid, "upload abandoned; a newer write superseded it");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
drop(cancel);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
// Only a completed upload times how long this file takes to send, which
|
||||||
|
// is what the next write's debounce is scaled from.
|
||||||
|
self.record_upload_time(&uid, took);
|
||||||
|
|
||||||
// Retire the op before dropping the blob: a crash between the two leaves
|
// Retire the op before dropping the blob: a crash between the two leaves
|
||||||
// an orphaned file (harmless), whereas the reverse would leave a queued
|
// an orphaned file (harmless), whereas the reverse would leave a queued
|
||||||
@@ -1227,12 +1629,10 @@ mod tests {
|
|||||||
let applied = Cell::new(false);
|
let applied = Cell::new(false);
|
||||||
let disposition = run_authorized_drain(
|
let disposition = run_authorized_drain(
|
||||||
&op,
|
&op,
|
||||||
|uid| {
|
|uid| match db.effective_node_access(uid).unwrap() {
|
||||||
db.effective_node_access(uid)
|
Some(access) if access.writable() => WriteAuthority::Writable,
|
||||||
.unwrap()
|
Some(_) => WriteAuthority::Denied,
|
||||||
.filter(|access| access.writable())
|
None => WriteAuthority::Unknown,
|
||||||
.map(|_| ())
|
|
||||||
.ok_or(fuser::Errno::EACCES)
|
|
||||||
},
|
},
|
||||||
|| {
|
|| {
|
||||||
applied.set(true);
|
applied.set(true);
|
||||||
@@ -1272,7 +1672,7 @@ mod tests {
|
|||||||
|
|
||||||
let disposition = run_authorized_drain(
|
let disposition = run_authorized_drain(
|
||||||
&op,
|
&op,
|
||||||
|_| Err(fuser::Errno::EACCES),
|
|_| WriteAuthority::Denied,
|
||||||
|| {
|
|| {
|
||||||
applied.set(true);
|
applied.set(true);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1293,13 +1693,157 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A deferral that never clears has to stop being silent. Found in the field
|
||||||
|
/// as revision ops whose node had left the local tree: `require_uid_access`
|
||||||
|
/// reads an unknown node as `EACCES`, so they were re-deferred every five
|
||||||
|
/// seconds for thirty days with `attempts` at zero, `last_error` null and
|
||||||
|
/// `failing_ops` at zero — 18 GiB of accepted writes that `pdfs status`
|
||||||
|
/// reported as ordinary queued uploads.
|
||||||
|
#[test]
|
||||||
|
fn an_access_deferral_that_outlives_the_limit_is_reported_as_failing() {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static N: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-drain-defer-limit-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db = Db::open(&dir.join("cache.db")).unwrap();
|
||||||
|
db.enqueue_op(&pending(OP_REVISION)).unwrap();
|
||||||
|
let op = db.pending_ops().unwrap().remove(0);
|
||||||
|
|
||||||
|
// Two deferrals a recheck apart: the window opens on the first and the
|
||||||
|
// second is measured against it, not against itself.
|
||||||
|
let now = 1_000_000i64;
|
||||||
|
let since = db.defer_op_for_access(op.id, now, now + 5_000).unwrap();
|
||||||
|
assert_eq!(since, now, "the first deferral opens the window");
|
||||||
|
let later = now + DRAIN_ACCESS_RECHECK.as_millis() as i64;
|
||||||
|
assert_eq!(
|
||||||
|
db.defer_op_for_access(op.id, later, later + 5_000).unwrap(),
|
||||||
|
now,
|
||||||
|
"a later deferral keeps the original stamp"
|
||||||
|
);
|
||||||
|
let queued = db.pending_ops().unwrap();
|
||||||
|
assert_eq!(queued[0].attempts, 0, "deferrals consume no attempt");
|
||||||
|
assert!(queued[0].last_error.is_none());
|
||||||
|
|
||||||
|
// Past the limit the same deferral is reported like any other stuck op.
|
||||||
|
let past = now + DRAIN_ACCESS_DEFER_LIMIT.as_millis() as i64;
|
||||||
|
assert_eq!(
|
||||||
|
db.defer_op_for_access(op.id, past, past + 5_000).unwrap(),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
db.record_op_failure(op.id, "not writable locally", past + 10_000)
|
||||||
|
.unwrap();
|
||||||
|
let failed = db.pending_ops().unwrap();
|
||||||
|
assert_eq!(failed.len(), 1, "the row and its staged bytes stay queued");
|
||||||
|
assert_eq!(failed[0].attempts, 1);
|
||||||
|
assert_eq!(
|
||||||
|
failed[0].last_error.as_deref(),
|
||||||
|
Some("not writable locally")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reporting closes the window, so the next deferral starts a fresh one
|
||||||
|
// rather than escalating on every recheck from here on.
|
||||||
|
let after = past + 20_000;
|
||||||
|
assert_eq!(
|
||||||
|
db.defer_op_for_access(op.id, after, after + 5_000).unwrap(),
|
||||||
|
after,
|
||||||
|
"recording the failure re-armed the window"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An op that gets through the access check is no longer blocked, so a
|
||||||
|
/// deferral after it must not inherit the earlier run's age and escalate
|
||||||
|
/// immediately.
|
||||||
|
#[test]
|
||||||
|
fn clearing_the_window_makes_the_next_deferral_a_fresh_run() {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static N: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-drain-defer-clear-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db = Db::open(&dir.join("cache.db")).unwrap();
|
||||||
|
db.enqueue_op(&pending(OP_REVISION)).unwrap();
|
||||||
|
let op = db.pending_ops().unwrap().remove(0);
|
||||||
|
|
||||||
|
let now = 2_000_000i64;
|
||||||
|
assert_eq!(
|
||||||
|
db.defer_op_for_access(op.id, now, now + 5_000).unwrap(),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
db.clear_op_access_deferral(op.id).unwrap();
|
||||||
|
let later = now + DRAIN_ACCESS_DEFER_LIMIT.as_millis() as i64;
|
||||||
|
assert_eq!(
|
||||||
|
db.defer_op_for_access(op.id, later, later + 5_000).unwrap(),
|
||||||
|
later,
|
||||||
|
"the window reopens at the new deferral, not the old one"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_authority_the_tree_does_not_have_is_reported_as_unknown_not_deferred() {
|
||||||
|
// The B83 distinction, at the point it is made: a uid with no row must
|
||||||
|
// not come back as `AccessDeferred`, because deferring waits on a
|
||||||
|
// permission change that nothing is going to make. The caller answers
|
||||||
|
// an `AuthorityUnknown` by asking the remote instead.
|
||||||
|
let op = pending(OP_REVISION);
|
||||||
|
let applied = Cell::new(false);
|
||||||
|
let disposition = run_authorized_drain(
|
||||||
|
&op,
|
||||||
|
|_| WriteAuthority::Unknown,
|
||||||
|
|| {
|
||||||
|
applied.set(true);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
disposition,
|
||||||
|
DrainDisposition::AuthorityUnknown(parse_node_uid(&op.uid).unwrap())
|
||||||
|
);
|
||||||
|
assert!(!applied.get(), "an unresolved authority never applies");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_parent_authority_names_the_parent_not_the_node() {
|
||||||
|
// A create's authority is its parent, so that is the uid the remote has
|
||||||
|
// to be asked about — reporting the node instead would refetch the
|
||||||
|
// thing that does not exist yet.
|
||||||
|
let mut op = pending(OP_CREATE);
|
||||||
|
let parent = folder_node("vanished-parent", None);
|
||||||
|
op.parent_uid = Some(parent.uid.to_string());
|
||||||
|
let disposition =
|
||||||
|
run_authorized_drain(&op, |_| WriteAuthority::Unknown, || Ok(())).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
disposition,
|
||||||
|
DrainDisposition::AuthorityUnknown(parent.uid.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn owner_and_editor_drain_authorities_apply_normally() {
|
fn owner_and_editor_drain_authorities_apply_normally() {
|
||||||
for access in [Access::Owner, Access::Editor] {
|
for access in [Access::Owner, Access::Editor] {
|
||||||
let applied = Cell::new(false);
|
let applied = Cell::new(false);
|
||||||
let disposition = run_authorized_drain(
|
let disposition = run_authorized_drain(
|
||||||
&pending(OP_REVISION),
|
&pending(OP_REVISION),
|
||||||
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|
|_| {
|
||||||
|
if access.writable() {
|
||||||
|
WriteAuthority::Writable
|
||||||
|
} else {
|
||||||
|
WriteAuthority::Denied
|
||||||
|
}
|
||||||
|
},
|
||||||
|| {
|
|| {
|
||||||
applied.set(true);
|
applied.set(true);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1413,3 +1957,42 @@ mod tests {
|
|||||||
assert!(!is_own_self_supersede(true, None, None));
|
assert!(!is_own_self_supersede(true, None, None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod debounce_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_never_uploaded_keeps_the_fixed_grace_period() {
|
||||||
|
assert_eq!(adaptive_debounce(None), DRAIN_REVISION_DEBOUNCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fast_upload_does_not_shorten_the_grace_period() {
|
||||||
|
// The 2 seconds are not about upload time at all — they are the window
|
||||||
|
// in which a preallocate-then-write tool supersedes its own first
|
||||||
|
// close. A file that sends in 50 ms must still get them.
|
||||||
|
assert_eq!(
|
||||||
|
adaptive_debounce(Some(Duration::from_millis(50))),
|
||||||
|
DRAIN_REVISION_DEBOUNCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_slow_upload_widens_the_grace_period_to_match() {
|
||||||
|
// The case the fixed debounce got wrong: saves arriving faster than the
|
||||||
|
// upload completes, each superseding one already on the wire.
|
||||||
|
assert_eq!(
|
||||||
|
adaptive_debounce(Some(Duration::from_secs(25))),
|
||||||
|
Duration::from_secs(25)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pathological_upload_cannot_park_a_node() {
|
||||||
|
assert_eq!(
|
||||||
|
adaptive_debounce(Some(Duration::from_secs(6000))),
|
||||||
|
DRAIN_REVISION_DEBOUNCE_MAX
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Kernel-facing FUSE callback adapter.
|
//! Kernel-facing FUSE callback adapter.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::state::DirListing;
|
||||||
|
|
||||||
impl Filesystem for ProtonFs {
|
impl Filesystem for ProtonFs {
|
||||||
/// Negotiate kernel-side behaviour once, before any other callback.
|
/// Negotiate kernel-side behaviour once, before any other callback.
|
||||||
@@ -162,15 +163,46 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Freeze this directory's listing for the life of the handle.
|
||||||
|
///
|
||||||
|
/// `readdir` is paged, and its continuation offset only means anything
|
||||||
|
/// against a listing that does not move underneath it. Taking the snapshot
|
||||||
|
/// here — once per `opendir`, which the kernel pairs exactly with one
|
||||||
|
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
||||||
|
/// during an enumeration can no longer make the caller skip or repeat an
|
||||||
|
/// entry (bugs.md B43).
|
||||||
|
///
|
||||||
|
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
||||||
|
/// the same reason `lookup` and `readdir` do.
|
||||||
|
fn opendir(&self, _req: &Request, ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) {
|
||||||
|
let ino = ino.0;
|
||||||
|
if self.core.children_cached(ino) {
|
||||||
|
self.serve_opendir(ino, reply);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fs = self.clone();
|
||||||
|
self.core
|
||||||
|
.workers
|
||||||
|
.run(Lane::Meta, move || fs.serve_opendir(ino, reply));
|
||||||
|
}
|
||||||
|
|
||||||
fn readdir(
|
fn readdir(
|
||||||
&self,
|
&self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
ino: INodeNo,
|
ino: INodeNo,
|
||||||
_fh: FileHandle,
|
fh: FileHandle,
|
||||||
offset: u64,
|
offset: u64,
|
||||||
reply: ReplyDirectory,
|
reply: ReplyDirectory,
|
||||||
) {
|
) {
|
||||||
let ino = ino.0;
|
let ino = ino.0;
|
||||||
|
// The snapshot is the whole point: served from it, this is a pure
|
||||||
|
// in-memory walk with no chance of a remote call, so it never needs a
|
||||||
|
// worker. Only a handle whose `opendir` failed to snapshot (or a kernel
|
||||||
|
// that issued `readdir` without one) falls back to the live path.
|
||||||
|
if let Some(listing) = self.core.state().dir_snapshots.get(&fh.0).cloned() {
|
||||||
|
reply_listing(&listing, offset, reply);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Same split as `lookup`: only the cold, remote-enumerating path pays
|
// Same split as `lookup`: only the cold, remote-enumerating path pays
|
||||||
// for a worker.
|
// for a worker.
|
||||||
if self.core.children_cached(ino) {
|
if self.core.children_cached(ino) {
|
||||||
@@ -183,6 +215,18 @@ impl Filesystem for ProtonFs {
|
|||||||
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn releasedir(
|
||||||
|
&self,
|
||||||
|
_req: &Request,
|
||||||
|
_ino: INodeNo,
|
||||||
|
fh: FileHandle,
|
||||||
|
_flags: OpenFlags,
|
||||||
|
reply: ReplyEmpty,
|
||||||
|
) {
|
||||||
|
self.core.state().dir_snapshots.remove(&fh.0);
|
||||||
|
reply.ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// A read-only open is a map lookup and answers inline; a write open has to
|
/// A read-only open is a map lookup and answers inline; a write open has to
|
||||||
/// allocate a scratch file and, when a queued revision is the base, duplicate
|
/// allocate a scratch file and, when a queued revision is the base, duplicate
|
||||||
/// a blob that is the whole file — so it is served from a worker rather than
|
/// a blob that is the whole file — so it is served from a worker rather than
|
||||||
@@ -912,6 +956,58 @@ pub struct ProtonFs {
|
|||||||
gid: u32,
|
gid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many times a write `open` will re-read its base after a `release`
|
||||||
|
/// published a newer queued revision underneath it (bugs.md B32).
|
||||||
|
///
|
||||||
|
/// One retry covers the race, which needs a close and an open of the same file
|
||||||
|
/// to overlap on different threads. More than that is not contention, it is a
|
||||||
|
/// file being republished continuously, and looping on it would keep a FUSE
|
||||||
|
/// worker busy indefinitely.
|
||||||
|
const OPEN_BASE_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
|
||||||
|
/// the interned children in `children` order.
|
||||||
|
///
|
||||||
|
/// Split out because it is built from two places that must agree — `opendir`
|
||||||
|
/// freezes it, and the fallback `readdir` builds it live — and a divergence
|
||||||
|
/// between them would show up as entries appearing or vanishing depending on
|
||||||
|
/// whether the kernel happened to send an `opendir`.
|
||||||
|
fn build_listing(st: &State, ino: u64) -> DirListing {
|
||||||
|
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||||
|
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||||
|
let mut listing = vec![
|
||||||
|
(ino, true, ".".to_string()),
|
||||||
|
(parent, true, "..".to_string()),
|
||||||
|
];
|
||||||
|
if let Some(kids) = st.children.get(&ino) {
|
||||||
|
for &kid in kids {
|
||||||
|
if let Some(e) = st.entries.get(&kid) {
|
||||||
|
listing.push((kid, e.node.is_folder(), e.node.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill a `readdir` reply from `listing`, resuming at `offset`.
|
||||||
|
///
|
||||||
|
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
||||||
|
/// continues exactly where the last one stopped — which is only true because
|
||||||
|
/// the listing behind those indexes is a snapshot (bugs.md B43).
|
||||||
|
fn reply_listing(listing: &DirListing, offset: u64, mut reply: ReplyDirectory) {
|
||||||
|
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||||
|
let ft = if *is_dir {
|
||||||
|
FileType::Directory
|
||||||
|
} else {
|
||||||
|
FileType::RegularFile
|
||||||
|
};
|
||||||
|
if reply.add(INodeNo(*ino), (i + 1) as u64, ft, name) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply.ok();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
|
pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
|
||||||
(!mask.contains(AccessFlags::W_OK) || access.writable())
|
(!mask.contains(AccessFlags::W_OK) || access.writable())
|
||||||
&& (!mask.contains(AccessFlags::X_OK) || is_dir)
|
&& (!mask.contains(AccessFlags::X_OK) || is_dir)
|
||||||
@@ -1027,40 +1123,39 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
|
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
|
||||||
fn serve_readdir(&self, ino: u64, offset: u64, mut reply: ReplyDirectory) {
|
/// The body of [`Filesystem::opendir`]: enumerate the folder if it is cold,
|
||||||
|
/// then publish one frozen listing under a fresh handle.
|
||||||
|
///
|
||||||
|
/// A failure to enumerate is reported as a failed `opendir` rather than
|
||||||
|
/// deferred to the first `readdir` — the caller gets the errno at the syscall
|
||||||
|
/// that can still act on it.
|
||||||
|
fn serve_opendir(&self, ino: u64, reply: ReplyOpen) {
|
||||||
|
if let Err(e) = self.core.ensure_children(ino) {
|
||||||
|
reply.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut st = self.core.state();
|
||||||
|
let listing = Arc::new(build_listing(&st, ino));
|
||||||
|
// Shares the counter with write handles, so a directory handle can never
|
||||||
|
// collide with a file handle (both are `fh` values in one kernel space).
|
||||||
|
let fh = st.next_fh;
|
||||||
|
st.next_fh += 1;
|
||||||
|
st.dir_snapshots.insert(fh, listing);
|
||||||
|
drop(st);
|
||||||
|
reply.opened(FileHandle(fh), FopenFlags::empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live, un-snapshotted `readdir`. Reached only by a handle that has no
|
||||||
|
/// snapshot — see [`Filesystem::readdir`].
|
||||||
|
fn serve_readdir(&self, ino: u64, offset: u64, reply: ReplyDirectory) {
|
||||||
if let Err(e) = self.core.ensure_children(ino) {
|
if let Err(e) = self.core.ensure_children(ino) {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let st = self.core.state();
|
let st = self.core.state();
|
||||||
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
let listing = build_listing(&st, ino);
|
||||||
|
|
||||||
// "." and ".." occupy offsets 0 and 1; real children follow.
|
|
||||||
let mut listing: Vec<(u64, FileType, String)> = vec![
|
|
||||||
(ino, FileType::Directory, ".".to_string()),
|
|
||||||
(parent, FileType::Directory, "..".to_string()),
|
|
||||||
];
|
|
||||||
if let Some(kids) = st.children.get(&ino) {
|
|
||||||
for &kid in kids {
|
|
||||||
if let Some(e) = st.entries.get(&kid) {
|
|
||||||
let ft = if e.node.is_folder() {
|
|
||||||
FileType::Directory
|
|
||||||
} else {
|
|
||||||
FileType::RegularFile
|
|
||||||
};
|
|
||||||
listing.push((kid, ft, e.node.name.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(st);
|
drop(st);
|
||||||
|
reply_listing(&listing, offset, reply);
|
||||||
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
|
|
||||||
// The stored offset is that of the *next* entry to resume at.
|
|
||||||
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reply.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
|
fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
|
||||||
@@ -1142,66 +1237,101 @@ impl ProtonFs {
|
|||||||
// any network access. An incomplete blob still has gaps referring to
|
// any network access. An incomplete blob still has gaps referring to
|
||||||
// the remote base; stacking another write on it cannot be represented
|
// the remote base; stacking another write on it cannot be represented
|
||||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
// safely by WriteHandle, so fail the open rather than risk corruption.
|
||||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
//
|
||||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
// Reading that blob and installing the handle cannot be done under one
|
||||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
// lock — the copy is the size of the file — so the base is re-checked at
|
||||||
entry.open_count = entry.open_count.saturating_sub(1);
|
// the moment the handle goes in, and a `release` that published a newer
|
||||||
}
|
// revision in between sends this around again. Without the re-check the
|
||||||
error!(%uid, "refusing write over incomplete queued revision");
|
// new handle carried the *older* base, and its own release published
|
||||||
reply.error(Errno::EIO);
|
// content that silently discarded the revision closed a moment earlier
|
||||||
return;
|
// (bugs.md B32). A second attempt is already unlikely and a third would
|
||||||
}
|
// mean something is republishing continuously, so give up rather than
|
||||||
let (file, path) = match self.core.cache.create_scratch() {
|
// spin: the caller's `open` fails instead of its data being lost.
|
||||||
Ok(x) => x,
|
//
|
||||||
Err(e) => {
|
// `reply` is consumed by whichever arm answers, so the loop yields an
|
||||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
// outcome and the answer is sent once, after it.
|
||||||
entry.open_count = entry.open_count.saturating_sub(1);
|
let outcome = 'install: {
|
||||||
|
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
||||||
|
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||||
|
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||||
|
error!(%uid, "refusing write over incomplete queued revision");
|
||||||
|
break 'install Err(Errno::EIO);
|
||||||
}
|
}
|
||||||
error!(%uid, error = %e, "create scratch file failed");
|
let (file, path) = match self.core.cache.create_scratch() {
|
||||||
reply.error(Errno::EIO);
|
Ok(x) => x,
|
||||||
return;
|
Err(e) => {
|
||||||
|
error!(%uid, error = %e, "create scratch file failed");
|
||||||
|
break 'install Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut initial_written = Intervals::default();
|
||||||
|
if let Some(pending) = &pending_base {
|
||||||
|
// Reflink where the filesystem can: this blob is the whole
|
||||||
|
// file, and a byte-for-byte copy of a multi-GB pending
|
||||||
|
// revision is the kind of work `open` must not do
|
||||||
|
// (`ContentCache::clone_or_copy`).
|
||||||
|
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
error!(%uid, source = %pending.path.display(), error = %e,
|
||||||
|
"copy queued revision into write scratch failed");
|
||||||
|
break 'install Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
initial_written.add(0, pending.meta.len);
|
||||||
|
}
|
||||||
|
// Sampled *before* the state lock, never under it: no site in
|
||||||
|
// the daemon holds `pending` and `state` at once (see
|
||||||
|
// `stamp_pending_sizes`) and this must not become the first.
|
||||||
|
// What that costs is a window of a few instructions instead of
|
||||||
|
// a lock-tight comparison — against the multi-gigabyte copy
|
||||||
|
// above, which is the window that actually loses data.
|
||||||
|
let current = self.core.pending_blob(&uid);
|
||||||
|
let mut st = self.core.state();
|
||||||
|
// Only a handle this open is about to *create* can carry a stale
|
||||||
|
// base; if one already exists, that handle is authoritative and
|
||||||
|
// the scratch prepared above is discarded either way.
|
||||||
|
let creating = !st.active_writes.contains_key(&ino.0);
|
||||||
|
if creating && current != pending_base.map(|p| p.path) {
|
||||||
|
drop(st);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
warn!(%uid, attempt, "queued revision changed while opening for write; retrying");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let fh = st.next_fh;
|
||||||
|
st.next_fh += 1;
|
||||||
|
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
||||||
|
WriteHandle {
|
||||||
|
ino: ino.0,
|
||||||
|
uid: uid.clone(),
|
||||||
|
file: Arc::new(file),
|
||||||
|
path,
|
||||||
|
written: initial_written,
|
||||||
|
// Starts at the current size; reads in [0, base_size)
|
||||||
|
// come from the base until overwritten.
|
||||||
|
len: base_size,
|
||||||
|
base_size,
|
||||||
|
base_mtime,
|
||||||
|
base_revision_id,
|
||||||
|
dirty: false,
|
||||||
|
open_count: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
aw.open_count += 1;
|
||||||
|
st.handles.insert(fh, ino.0);
|
||||||
|
break 'install Ok(fh);
|
||||||
}
|
}
|
||||||
|
error!(%uid, "queued revision kept changing while opening for write");
|
||||||
|
Err(Errno::EIO)
|
||||||
};
|
};
|
||||||
let mut initial_written = Intervals::default();
|
match outcome {
|
||||||
if let Some(pending) = &pending_base {
|
Ok(fh) => reply.opened(FileHandle(fh), FopenFlags::empty()),
|
||||||
// Reflink where the filesystem can: this blob is the whole file, and
|
Err(error) => {
|
||||||
// a byte-for-byte copy of a multi-GB pending revision is the kind of
|
// The lookup count was raised before any of this could fail.
|
||||||
// work `open` must not do (`ContentCache::clone_or_copy`).
|
|
||||||
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
|
||||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||||
entry.open_count = entry.open_count.saturating_sub(1);
|
entry.open_count = entry.open_count.saturating_sub(1);
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&path);
|
reply.error(error);
|
||||||
error!(%uid, source = %pending.path.display(), error = %e,
|
|
||||||
"copy queued revision into write scratch failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
initial_written.add(0, pending.meta.len);
|
|
||||||
}
|
}
|
||||||
let mut st = self.core.state();
|
|
||||||
let fh = st.next_fh;
|
|
||||||
st.next_fh += 1;
|
|
||||||
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
|
||||||
WriteHandle {
|
|
||||||
ino: ino.0,
|
|
||||||
uid,
|
|
||||||
file: Arc::new(file),
|
|
||||||
path,
|
|
||||||
written: initial_written,
|
|
||||||
// Starts at the current size; reads in [0, base_size) come from
|
|
||||||
// the base until overwritten.
|
|
||||||
len: base_size,
|
|
||||||
base_size,
|
|
||||||
base_mtime,
|
|
||||||
base_revision_id,
|
|
||||||
dirty: false,
|
|
||||||
open_count: 0,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
aw.open_count += 1;
|
|
||||||
st.handles.insert(fh, ino.0);
|
|
||||||
reply.opened(FileHandle(fh), FopenFlags::empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
||||||
|
|||||||
+360
-27
@@ -55,7 +55,7 @@ use fuser::{
|
|||||||
};
|
};
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
use pdfs_core::batch;
|
use pdfs_core::batch;
|
||||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
use pdfs_core::cache::{BLOCK_SIZE, Baseline, BlockGeometry, BlockSpan, ContentCache, StagedWrite};
|
||||||
use pdfs_core::config::{AppDirs, SweepMode};
|
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,
|
||||||
@@ -92,6 +92,7 @@ mod profile;
|
|||||||
mod reads;
|
mod reads;
|
||||||
mod revisions;
|
mod revisions;
|
||||||
mod sharing;
|
mod sharing;
|
||||||
|
mod shutdown;
|
||||||
mod state;
|
mod state;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
mod sync;
|
mod sync;
|
||||||
@@ -166,6 +167,30 @@ const DRAIN_IDLE_POLL: Duration = Duration::from_secs(30);
|
|||||||
/// creates a conflict copy of itself. Holding the op for a short window gives
|
/// creates a conflict copy of itself. Holding the op for a short window gives
|
||||||
/// the follow-up write time to supersede it.
|
/// the follow-up write time to supersede it.
|
||||||
const DRAIN_REVISION_DEBOUNCE: Duration = Duration::from_secs(2);
|
const DRAIN_REVISION_DEBOUNCE: Duration = Duration::from_secs(2);
|
||||||
|
/// Ceiling on the *adaptive* part of that grace period.
|
||||||
|
///
|
||||||
|
/// The fixed 2 seconds is enough for a preallocate-then-write tool, and wrong
|
||||||
|
/// for a file that takes half a minute to upload: an editor saving every ten
|
||||||
|
/// seconds queues a write, watches it start uploading, and supersedes it — each
|
||||||
|
/// save uploading bytes that the next one replaces, and never catching up.
|
||||||
|
/// Widening the debounce toward how long this node's last upload actually took
|
||||||
|
/// makes the supersede happen in the queue, where it is free, instead of on the
|
||||||
|
/// wire. Bounded so a file on a slow link still reaches Drive in a minute rather
|
||||||
|
/// than deferring indefinitely, and so a one-off stall cannot park a node.
|
||||||
|
const DRAIN_REVISION_DEBOUNCE_MAX: Duration = Duration::from_secs(60);
|
||||||
|
/// How many nodes' measured upload times to remember for that. Bounds a map
|
||||||
|
/// that would otherwise grow with every file ever written; the debounce falls
|
||||||
|
/// back to [`DRAIN_REVISION_DEBOUNCE`] for anything evicted, which is the
|
||||||
|
/// behaviour a node that has never been uploaded gets anyway.
|
||||||
|
const UPLOAD_TIME_MEMORY: usize = 512;
|
||||||
|
/// Threads draining the pending-op queue.
|
||||||
|
///
|
||||||
|
/// One meant a single 10 GiB upload blocked every queued rename, trash and
|
||||||
|
/// small write behind it. Ordering only has to hold per node — enforced by the
|
||||||
|
/// claim query, not by the thread count — so the rest is throughput. Kept small:
|
||||||
|
/// these are uploads, and past a handful they compete for the same uplink and
|
||||||
|
/// for the SDK's own per-request concurrency.
|
||||||
|
const DRAIN_WORKERS: usize = 3;
|
||||||
|
|
||||||
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
||||||
const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
||||||
@@ -386,6 +411,10 @@ struct Core {
|
|||||||
/// kernel read-ahead, our own prefetch) share a single download and decrypt.
|
/// kernel read-ahead, our own prefetch) share a single download and decrypt.
|
||||||
/// See [`BlockFlight`].
|
/// See [`BlockFlight`].
|
||||||
block_flight: Arc<BlockFlight>,
|
block_flight: Arc<BlockFlight>,
|
||||||
|
/// One lock per file being repaired by [`Core::repair_block`], so the
|
||||||
|
/// concurrent block reads that all discover the same untrustworthy block
|
||||||
|
/// table share a single whole-file download instead of one each.
|
||||||
|
content_repairs: Arc<tokio::sync::Mutex<HashMap<NodeUid, Arc<tokio::sync::Mutex<()>>>>>,
|
||||||
/// Per-file sequential-read detection driving [`Core::prefetch`].
|
/// Per-file sequential-read detection driving [`Core::prefetch`].
|
||||||
prefetch: Arc<Prefetch>,
|
prefetch: Arc<Prefetch>,
|
||||||
/// Permits bounding speculative block fetches, so prefetch cannot queue in
|
/// Permits bounding speculative block fetches, so prefetch cannot queue in
|
||||||
@@ -423,9 +452,26 @@ struct Core {
|
|||||||
/// Nodes removed through this daemon which a briefly stale remote listing
|
/// Nodes removed through this daemon which a briefly stale remote listing
|
||||||
/// may still return. Uids are not reused; an explicit restore clears one.
|
/// may still return. Uids are not reused; an explicit restore clears one.
|
||||||
hidden: Arc<Mutex<HashSet<NodeUid>>>,
|
hidden: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Nudges the drain worker: set true and notify to have it re-examine the
|
/// Nudges the drain workers: set true and notify to have them re-examine
|
||||||
/// queue instead of waiting out its backoff.
|
/// the queue instead of waiting out their backoff.
|
||||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||||
|
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||||
|
/// was cloned into so teardown can end and join them (bugs.md B44).
|
||||||
|
shutdown: Arc<shutdown::Shutdown>,
|
||||||
|
/// How long this node's last upload actually took, in milliseconds, with
|
||||||
|
/// the time it was measured for eviction. Feeds
|
||||||
|
/// [`Core::revision_debounce`]: a file that takes 30 seconds to send needs
|
||||||
|
/// a grace period on that scale, or every save supersedes an upload that
|
||||||
|
/// was already on the wire. Bounded by [`UPLOAD_TIME_MEMORY`].
|
||||||
|
upload_times: Arc<Mutex<HashMap<String, (u64, i64)>>>,
|
||||||
|
/// Cancellation flags for uploads currently on the wire, keyed by node.
|
||||||
|
///
|
||||||
|
/// Set when a newer revision of the same file is queued: the bytes in
|
||||||
|
/// flight have just been superseded, and the only thing finishing them
|
||||||
|
/// achieves is spending the user's uplink on a revision the next op
|
||||||
|
/// replaces. Read by the upload's own reader ([`CountingReader`]), which is
|
||||||
|
/// the one thing the SDK calls often enough to notice.
|
||||||
|
upload_cancel: Arc<Mutex<HashMap<NodeUid, Arc<AtomicBool>>>>,
|
||||||
/// True while a background refresh of the photos timeline (resp. the trash) is
|
/// True while a background refresh of the photos timeline (resp. the trash) is
|
||||||
/// already running, so a burst of page requests against a stale listing kicks
|
/// already running, so a burst of page requests against a stale listing kicks
|
||||||
/// off one refresh rather than one per request.
|
/// off one refresh rather than one per request.
|
||||||
@@ -669,6 +715,26 @@ struct MountedState {
|
|||||||
/// [`Core::notifier`], still empty until its session is spawned.
|
/// [`Core::notifier`], still empty until its session is spawned.
|
||||||
notifier: Arc<OnceLock<Notifier>>,
|
notifier: Arc<OnceLock<Notifier>>,
|
||||||
session_live: Arc<AtomicBool>,
|
session_live: Arc<AtomicBool>,
|
||||||
|
/// This mount's in-flight size upgrades, keyed by *its* folder inodes — so
|
||||||
|
/// a `Core` rebuilt onto this inode space by [`Core::rooted_at`] shares the
|
||||||
|
/// batch the mount's own session is already running rather than starting a
|
||||||
|
/// second one against inode numbers that mean nothing to it.
|
||||||
|
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-mount half of a [`Core`] — every field [`Core::fork_state`] gives a
|
||||||
|
/// fork a fresh copy of — recovered from the registry.
|
||||||
|
///
|
||||||
|
/// This is what lets a control request naming a path under a secondary mount be
|
||||||
|
/// answered *in that mount's inode space* instead of being rejected for not
|
||||||
|
/// being under the primary mountpoint (`docs/BUGS.md` B86). See
|
||||||
|
/// [`Core::rooted_at`].
|
||||||
|
struct MountParts {
|
||||||
|
mountpoint: PathBuf,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
notifier: Arc<OnceLock<Notifier>>,
|
||||||
|
session_live: Arc<AtomicBool>,
|
||||||
|
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every mounted inode space in the daemon, shared by the primary `Core` and
|
/// Every mounted inode space in the daemon, shared by the primary `Core` and
|
||||||
@@ -691,6 +757,7 @@ impl StateRegistry {
|
|||||||
state: &Arc<Mutex<State>>,
|
state: &Arc<Mutex<State>>,
|
||||||
notifier: Arc<OnceLock<Notifier>>,
|
notifier: Arc<OnceLock<Notifier>>,
|
||||||
session_live: Arc<AtomicBool>,
|
session_live: Arc<AtomicBool>,
|
||||||
|
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
||||||
) {
|
) {
|
||||||
let mut states = self.0.lock();
|
let mut states = self.0.lock();
|
||||||
states.retain(|m| m.state.strong_count() > 0);
|
states.retain(|m| m.state.strong_count() > 0);
|
||||||
@@ -705,6 +772,7 @@ impl StateRegistry {
|
|||||||
state: Arc::downgrade(state),
|
state: Arc::downgrade(state),
|
||||||
notifier,
|
notifier,
|
||||||
session_live,
|
session_live,
|
||||||
|
size_upgrades,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,9 +799,9 @@ impl StateRegistry {
|
|||||||
///
|
///
|
||||||
/// On-demand roots can be nested below another location. Selecting by the
|
/// On-demand roots can be nested below another location. Selecting by the
|
||||||
/// longest component prefix ensures the nested session wins rather than the
|
/// longest component prefix ensures the nested session wins rather than the
|
||||||
/// broader primary mount. Callers currently need the selected mount only;
|
/// broader primary mount. Callers that need the relative suffix derive it
|
||||||
/// a future path router can derive a relative suffix with
|
/// with `path.strip_prefix(mountpoint)`; [`StateRegistry::covering_parts`]
|
||||||
/// `path.strip_prefix(mountpoint)` without storing a second path value here.
|
/// is the variant that also hands back enough to *serve* a request there.
|
||||||
fn covering(&self, path: &Path) -> Option<LiveMount> {
|
fn covering(&self, path: &Path) -> Option<LiveMount> {
|
||||||
self.live()
|
self.live()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -743,6 +811,48 @@ impl StateRegistry {
|
|||||||
.max_by_key(|(mountpoint, _, _, _)| mountpoint.components().count())
|
.max_by_key(|(mountpoint, _, _, _)| mountpoint.components().count())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`StateRegistry::register`] with an empty size-upgrade map, for the
|
||||||
|
/// tests that exercise the reap-and-route rules and nothing else.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn register_bare(
|
||||||
|
&self,
|
||||||
|
mountpoint: &Path,
|
||||||
|
state: &Arc<Mutex<State>>,
|
||||||
|
notifier: Arc<OnceLock<Notifier>>,
|
||||||
|
session_live: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
self.register(
|
||||||
|
mountpoint,
|
||||||
|
state,
|
||||||
|
notifier,
|
||||||
|
session_live,
|
||||||
|
Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`StateRegistry::covering`], but returning every per-mount field a
|
||||||
|
/// [`Core`] needs to be re-rooted onto that inode space.
|
||||||
|
///
|
||||||
|
/// Kept separate from `covering` so the common callers — which only ask
|
||||||
|
/// *whether* a path is mounted — keep the cheaper tuple.
|
||||||
|
fn covering_parts(&self, path: &Path) -> Option<MountParts> {
|
||||||
|
let mut states = self.0.lock();
|
||||||
|
states.retain(|m| m.state.strong_count() > 0);
|
||||||
|
states
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.session_live.load(Ordering::Acquire) && path.starts_with(&m.mountpoint))
|
||||||
|
.max_by_key(|m| m.mountpoint.components().count())
|
||||||
|
.and_then(|m| {
|
||||||
|
Some(MountParts {
|
||||||
|
mountpoint: m.mountpoint.clone(),
|
||||||
|
state: m.state.upgrade()?,
|
||||||
|
notifier: m.notifier.clone(),
|
||||||
|
session_live: m.session_live.clone(),
|
||||||
|
size_upgrades: m.size_upgrades.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn is_mounted_at(&self, path: &Path) -> bool {
|
fn is_mounted_at(&self, path: &Path) -> bool {
|
||||||
self.covering(path).is_some_and(|(mountpoint, _, _, live)| {
|
self.covering(path).is_some_and(|(mountpoint, _, _, live)| {
|
||||||
mountpoint == path && live.load(Ordering::Acquire)
|
mountpoint == path && live.load(Ordering::Acquire)
|
||||||
@@ -862,9 +972,41 @@ impl Core {
|
|||||||
&self.state,
|
&self.state,
|
||||||
self.notifier.clone(),
|
self.notifier.clone(),
|
||||||
self.session_live.clone(),
|
self.session_live.clone(),
|
||||||
|
self.size_upgrades.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This `Core`, re-rooted onto whichever live mount most specifically covers
|
||||||
|
/// the absolute path `abs`, together with `abs` relative to that mount's
|
||||||
|
/// root.
|
||||||
|
///
|
||||||
|
/// Everything a `Core` holds is per-daemon except the five fields
|
||||||
|
/// [`Core::fork_state`] replaces, so swapping exactly those turns the
|
||||||
|
/// primary `Core` into the fork that owns the path — sharing the fork's
|
||||||
|
/// inode space, notification channel and size-upgrade batches rather than
|
||||||
|
/// shadowing them. Without this, every path-addressed control request is
|
||||||
|
/// answered against the primary inode space, so anything under a secondary
|
||||||
|
/// on-demand mount is rejected as "not under the mountpoint" and the user
|
||||||
|
/// has no way to re-enumerate a folder they can see (`docs/BUGS.md` B86).
|
||||||
|
///
|
||||||
|
/// `None` when no live mount covers `abs`.
|
||||||
|
fn rooted_at(&self, abs: &Path) -> Option<(Core, PathBuf)> {
|
||||||
|
let parts = self.states.covering_parts(abs)?;
|
||||||
|
let rel = abs.strip_prefix(&parts.mountpoint).ok()?.to_path_buf();
|
||||||
|
let mut core = self.clone();
|
||||||
|
if !Arc::ptr_eq(&parts.state, &self.state) {
|
||||||
|
// A different inode space, so adopt its per-mount half wholesale.
|
||||||
|
// `primary` is a property of the mount, and the only mount this
|
||||||
|
// `Core` can be the primary of is the one it was built for.
|
||||||
|
core.primary = false;
|
||||||
|
core.state = parts.state;
|
||||||
|
core.notifier = parts.notifier;
|
||||||
|
core.session_live = parts.session_live;
|
||||||
|
core.size_upgrades = parts.size_upgrades;
|
||||||
|
}
|
||||||
|
Some((core, rel))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run `apply` against every live inode space — this daemon's primary mount
|
/// Run `apply` against every live inode space — this daemon's primary mount
|
||||||
/// and each on-demand fork — one lock at a time.
|
/// and each on-demand fork — one lock at a time.
|
||||||
///
|
///
|
||||||
@@ -935,6 +1077,27 @@ impl Core {
|
|||||||
/// downgrade cannot be hidden by stale live state, and a live downgrade
|
/// downgrade cannot be hidden by stale live state, and a live downgrade
|
||||||
/// cannot be hidden by a restored DB row. Missing UIDs fail closed.
|
/// cannot be hidden by a restored DB row. Missing UIDs fail closed.
|
||||||
fn require_uid_writable(&self, uid: &NodeUid) -> Result<(), Errno> {
|
fn require_uid_writable(&self, uid: &NodeUid) -> Result<(), Errno> {
|
||||||
|
match self.uid_write_authority(uid) {
|
||||||
|
WriteAuthority::Writable => Ok(()),
|
||||||
|
// A uid nobody can speak for is refused here just as a denied one
|
||||||
|
// is: a syscall is a live request against a live tree, and a stale
|
||||||
|
// handle must not be admitted because the tree forgot the node.
|
||||||
|
// The drain, which replays intent recorded long ago, is the one
|
||||||
|
// caller that has to tell the two apart — see
|
||||||
|
// [`Core::uid_write_authority`].
|
||||||
|
WriteAuthority::Denied | WriteAuthority::Unknown => Err(Errno::EACCES),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full answer behind [`Core::require_uid_writable`]: whether every
|
||||||
|
/// authority agrees this node is writable, refuses it, or has never heard
|
||||||
|
/// of it.
|
||||||
|
///
|
||||||
|
/// The third case is the one worth naming. A node absent from the local
|
||||||
|
/// tree yields no access row, and collapsing that into "denied" told the
|
||||||
|
/// drain to wait for a permission change that was never coming, because
|
||||||
|
/// nothing about the node's permissions was ever the problem (B83).
|
||||||
|
pub(crate) fn uid_write_authority(&self, uid: &NodeUid) -> WriteAuthority {
|
||||||
let mut live_access = Vec::new();
|
let mut live_access = Vec::new();
|
||||||
{
|
{
|
||||||
let state = self.state();
|
let state = self.state();
|
||||||
@@ -950,22 +1113,38 @@ impl Core {
|
|||||||
live_access.push(access);
|
live_access.push(access);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
require_uid_access(&self.db, uid, &live_access)
|
uid_write_authority(&self.db, uid, &live_access)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_uid_access(db: &Db, uid: &NodeUid, live_access: &[Access]) -> Result<(), Errno> {
|
/// Whether a node may be written, as agreed by every authority that has an
|
||||||
|
/// opinion — or the fact that none of them does.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum WriteAuthority {
|
||||||
|
/// The persisted tree and every resident mount agree: writable.
|
||||||
|
Writable,
|
||||||
|
/// At least one authority refuses it, or could not be consulted. Fails
|
||||||
|
/// closed, so a lookup error lands here.
|
||||||
|
Denied,
|
||||||
|
/// The node is not in the local tree, so there is no authority to consult.
|
||||||
|
/// Not the same as a refusal, and callers that can ask the remote should.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uid_write_authority(db: &Db, uid: &NodeUid, live_access: &[Access]) -> WriteAuthority {
|
||||||
let persisted = match db.effective_node_access(uid) {
|
let persisted = match db.effective_node_access(uid) {
|
||||||
Ok(Some(access)) => access,
|
Ok(Some(access)) => access,
|
||||||
Ok(None) => return Err(Errno::EACCES),
|
Ok(None) => return WriteAuthority::Unknown,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
error!(%uid, %error, "db effective access lookup failed");
|
error!(%uid, %error, "db effective access lookup failed");
|
||||||
return Err(Errno::EACCES);
|
return WriteAuthority::Denied;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(persisted.writable() && live_access.iter().all(|access| access.writable()))
|
if persisted.writable() && live_access.iter().all(|access| access.writable()) {
|
||||||
.then_some(())
|
WriteAuthority::Writable
|
||||||
.ok_or(Errno::EACCES)
|
} else {
|
||||||
|
WriteAuthority::Denied
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One kernel notification, recorded while the State lock is held so it can be
|
/// One kernel notification, recorded while the State lock is held so it can be
|
||||||
@@ -1633,7 +1812,9 @@ impl Core {
|
|||||||
fn run_online_probe(&self) {
|
fn run_online_probe(&self) {
|
||||||
let mut delay = ONLINE_PROBE_MIN;
|
let mut delay = ONLINE_PROBE_MIN;
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(delay);
|
if !self.shutdown.sleep(delay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
match self.rt.block_on(self.client.get_my_files_folder()) {
|
match self.rt.block_on(self.client.get_my_files_folder()) {
|
||||||
Ok(root) => {
|
Ok(root) => {
|
||||||
{
|
{
|
||||||
@@ -2756,6 +2937,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let meta_json = serde_json::to_string(&meta).unwrap_or_default();
|
let meta_json = serde_json::to_string(&meta).unwrap_or_default();
|
||||||
|
// Whatever this write supersedes, it supersedes now — including an
|
||||||
|
// upload already on the wire, whose bytes describe a revision the row
|
||||||
|
// written just below replaces. Signalled before the enqueue so the
|
||||||
|
// in-flight reader stops at its next block rather than after however
|
||||||
|
// much of the file is left.
|
||||||
|
self.cancel_upload(uid);
|
||||||
let superseded = if is_local_uid(uid) {
|
let superseded = if is_local_uid(uid) {
|
||||||
// The node has no server-side identity to hang a revision on, so the
|
// The node has no server-side identity to hang a revision on, so the
|
||||||
// bytes ride on the create that will mint it.
|
// bytes ride on the create that will mint it.
|
||||||
@@ -2789,7 +2976,7 @@ impl Core {
|
|||||||
created_at: now_millis(),
|
created_at: now_millis(),
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_attempt_at: now_millis() + DRAIN_REVISION_DEBOUNCE.as_millis() as i64,
|
next_attempt_at: now_millis() + self.revision_debounce(uid).as_millis() as i64,
|
||||||
};
|
};
|
||||||
let (_id, superseded) = self.db.enqueue_op(&op).map_err(|e| {
|
let (_id, superseded) = self.db.enqueue_op(&op).map_err(|e| {
|
||||||
error!(%uid, error = %e, "queueing upload failed");
|
error!(%uid, error = %e, "queueing upload failed");
|
||||||
@@ -3095,6 +3282,12 @@ impl Core {
|
|||||||
/// worse in every way.
|
/// worse in every way.
|
||||||
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||||
self.require_uid_writable(uid)?;
|
self.require_uid_writable(uid)?;
|
||||||
|
// The bytes below are about to be discarded, and a drain worker may be
|
||||||
|
// reading them onto the wire right now. Stop it first: with several
|
||||||
|
// workers the trash op this queues can be claimed while that upload is
|
||||||
|
// still running, and the two would race to decide whether the file
|
||||||
|
// exists.
|
||||||
|
self.cancel_upload(uid);
|
||||||
let (_, blobs) = self
|
let (_, blobs) = self
|
||||||
.db
|
.db
|
||||||
.replace_ops_with_trash(&uid.to_string(), name, now_millis())
|
.replace_ops_with_trash(&uid.to_string(), name, now_millis())
|
||||||
@@ -3124,6 +3317,9 @@ impl Core {
|
|||||||
|
|
||||||
/// Drop every op queued against a node, and the staged bytes they own.
|
/// Drop every op queued against a node, and the staged bytes they own.
|
||||||
fn discard_queued_ops(&self, uid: &NodeUid) -> Result<(), Errno> {
|
fn discard_queued_ops(&self, uid: &NodeUid) -> Result<(), Errno> {
|
||||||
|
// Same reason as `queue_trash`: an upload may be reading the very blobs
|
||||||
|
// this is about to unlink.
|
||||||
|
self.cancel_upload(uid);
|
||||||
let blobs = self.db.delete_ops_for_uid(&uid.to_string()).map_err(|e| {
|
let blobs = self.db.delete_ops_for_uid(&uid.to_string()).map_err(|e| {
|
||||||
error!(%uid, error = %e, "dropping queued ops failed");
|
error!(%uid, error = %e, "dropping queued ops failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
@@ -3135,6 +3331,15 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which staged blob is currently queued for `uid`, if any.
|
||||||
|
///
|
||||||
|
/// The path identifies the revision: publication stages a fresh file and
|
||||||
|
/// swaps the map entry to point at it, so an unchanged path means the base a
|
||||||
|
/// caller sampled earlier is still the base now (bugs.md B32).
|
||||||
|
fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> {
|
||||||
|
self.pending.lock().get(uid).map(|p| p.path.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Nudge the drain worker to re-examine the queue now.
|
/// Nudge the drain worker to re-examine the queue now.
|
||||||
fn wake_drain(&self) {
|
fn wake_drain(&self) {
|
||||||
let (lock, cv) = &*self.drain_wake;
|
let (lock, cv) = &*self.drain_wake;
|
||||||
@@ -3825,6 +4030,33 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the cached child listing of the folder `parent`, everywhere it is
|
||||||
|
/// held — naming the folder by uid rather than by inode.
|
||||||
|
///
|
||||||
|
/// [`Core::invalidate_parent_listing`] is the path-based sibling, and it
|
||||||
|
/// only reaches *this* mount's inode space. A remote mutation made by the
|
||||||
|
/// sync engine needs more than that, for two reasons. The folder it changed
|
||||||
|
/// is usually not resident in any inode space at the time — a mirror folder
|
||||||
|
/// has no FUSE session at all — so there is no inode to name. And the stale
|
||||||
|
/// listing that outlives the pass is not a hot-cache map but the `listed`
|
||||||
|
/// flag on the folder's DB row, which survives a daemon restart and is what
|
||||||
|
/// a later `ensure_children` trusts.
|
||||||
|
///
|
||||||
|
/// So the flag is cleared unconditionally, and the resident inode spaces are
|
||||||
|
/// invalidated on top of it. That is what makes a mirror folder switched to
|
||||||
|
/// on-demand re-enumerate instead of serving a snapshot that predates the
|
||||||
|
/// engine's own uploads — `docs/BUGS.md` B86.
|
||||||
|
pub(crate) fn invalidate_children_of(&self, parent: &NodeUid) {
|
||||||
|
if let Err(e) = self.db.set_listed(parent, false) {
|
||||||
|
warn!(uid = %parent, error = ?e, "could not clear cached listing");
|
||||||
|
}
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
if let Some(&ino) = st.by_uid.get(parent) {
|
||||||
|
st.invalidate_listing(ino);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Rename a file or folder to `new_name`. `rel` is mountpoint-relative.
|
/// Rename a file or folder to `new_name`. `rel` is mountpoint-relative.
|
||||||
/// Mirrors the FUSE `rename` write path: rename on the remote, forget the
|
/// Mirrors the FUSE `rename` write path: rename on the remote, forget the
|
||||||
/// node so it re-interns under its new name, and drop the parent listing so
|
/// node so it re-interns under its new name, and drop the parent listing so
|
||||||
@@ -5701,9 +5933,19 @@ mod tests {
|
|||||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
preserve_on_access_denied, publish_virtual_root_in_listing,
|
||||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
||||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
||||||
require_node_parent_access, require_rename_access, require_uid_access,
|
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
||||||
resolve_anywhere_with, shared_with_me_uid, take_self_change, virtual_node,
|
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node,
|
||||||
};
|
};
|
||||||
|
use super::{Db, WriteAuthority};
|
||||||
|
|
||||||
|
/// The collapse [`Core::require_uid_writable`] performs, without a `Core`:
|
||||||
|
/// every non-writable authority is one `EACCES` on the syscall path.
|
||||||
|
fn require_uid_access(db: &Db, uid: &NodeUid, live: &[Access]) -> Result<(), Errno> {
|
||||||
|
match uid_write_authority(db, uid, live) {
|
||||||
|
WriteAuthority::Writable => Ok(()),
|
||||||
|
WriteAuthority::Denied | WriteAuthority::Unknown => Err(Errno::EACCES),
|
||||||
|
}
|
||||||
|
}
|
||||||
use crate::filesystem::access_allowed;
|
use crate::filesystem::access_allowed;
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
use pdfs_core::db::{OP_REVISION, OP_TRASH, PendingOp};
|
use pdfs_core::db::{OP_REVISION, OP_TRASH, PendingOp};
|
||||||
@@ -5734,7 +5976,7 @@ mod tests {
|
|||||||
("/mnt/fork-a", &fork_a),
|
("/mnt/fork-a", &fork_a),
|
||||||
("/mnt/fork-b", &fork_b),
|
("/mnt/fork-b", &fork_b),
|
||||||
] {
|
] {
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new(mountpoint),
|
std::path::Path::new(mountpoint),
|
||||||
state,
|
state,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -5785,19 +6027,19 @@ mod tests {
|
|||||||
let nested_live = session_flag(false);
|
let nested_live = session_flag(false);
|
||||||
let sibling_live = session_flag(true);
|
let sibling_live = session_flag(true);
|
||||||
|
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
primary_path,
|
primary_path,
|
||||||
&primary,
|
&primary,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
primary_live.clone(),
|
primary_live.clone(),
|
||||||
);
|
);
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
nested_path,
|
nested_path,
|
||||||
&nested,
|
&nested,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
nested_live.clone(),
|
nested_live.clone(),
|
||||||
);
|
);
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
sibling_path,
|
sibling_path,
|
||||||
&sibling,
|
&sibling,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -5875,6 +6117,71 @@ mod tests {
|
|||||||
assert_eq!(registry.live().len(), 2);
|
assert_eq!(registry.live().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The routing half of `docs/BUGS.md` B86: a path under a secondary mount
|
||||||
|
/// has to resolve to *that* mount's inode space and a suffix relative to
|
||||||
|
/// *its* root, or `pdfs ls` and `pdfs refresh` can only ever name the
|
||||||
|
/// primary mount and a stale on-demand folder has no escape hatch.
|
||||||
|
#[test]
|
||||||
|
fn covering_parts_route_a_path_to_the_mount_that_owns_it() {
|
||||||
|
let registry = StateRegistry::default();
|
||||||
|
let (primary, _primary_dir) = rooted_state("primary-volume", "primary");
|
||||||
|
let (nested, _nested_dir) = rooted_state("nested-volume", "nested");
|
||||||
|
let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary));
|
||||||
|
let nested = std::sync::Arc::new(parking_lot::Mutex::new(nested));
|
||||||
|
let primary_path = std::path::Path::new("/home/me/ProtonDrive");
|
||||||
|
let nested_path = std::path::Path::new("/home/me/ProtonDrive/Device");
|
||||||
|
let primary_upgrades = std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new()));
|
||||||
|
let nested_upgrades = std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
primary_path,
|
||||||
|
&primary,
|
||||||
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
|
session_flag(true),
|
||||||
|
primary_upgrades.clone(),
|
||||||
|
);
|
||||||
|
registry.register(
|
||||||
|
nested_path,
|
||||||
|
&nested,
|
||||||
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
|
session_flag(true),
|
||||||
|
nested_upgrades.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let deep = nested_path.join("sub/file.txt");
|
||||||
|
let parts = registry.covering_parts(&deep).expect("nested mount covers");
|
||||||
|
assert_eq!(parts.mountpoint, nested_path, "the nested mount wins");
|
||||||
|
assert!(
|
||||||
|
std::sync::Arc::ptr_eq(&parts.state, &nested),
|
||||||
|
"the request must be answered in the nested inode space"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::sync::Arc::ptr_eq(&parts.size_upgrades, &nested_upgrades),
|
||||||
|
"and share that mount's in-flight size upgrades, not the primary's"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
deep.strip_prefix(&parts.mountpoint).unwrap(),
|
||||||
|
std::path::Path::new("sub/file.txt"),
|
||||||
|
"the suffix is relative to the mount that owns it, not the primary"
|
||||||
|
);
|
||||||
|
|
||||||
|
let shallow = primary_path.join("Documents");
|
||||||
|
let parts = registry
|
||||||
|
.covering_parts(&shallow)
|
||||||
|
.expect("primary mount covers");
|
||||||
|
assert!(
|
||||||
|
std::sync::Arc::ptr_eq(&parts.state, &primary),
|
||||||
|
"a path outside the nested root still routes to the primary"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.covering_parts(std::path::Path::new("/tmp/outside"))
|
||||||
|
.is_none(),
|
||||||
|
"a path under no mount routes nowhere, and the caller reports that"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_registry_resolves_uids_from_any_resident_mount() {
|
fn state_registry_resolves_uids_from_any_resident_mount() {
|
||||||
let registry = StateRegistry::default();
|
let registry = StateRegistry::default();
|
||||||
@@ -5888,13 +6195,13 @@ mod tests {
|
|||||||
fork.children.insert(super::ROOT_INO, vec![fork_ino]);
|
fork.children.insert(super::ROOT_INO, vec![fork_ino]);
|
||||||
let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary));
|
let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary));
|
||||||
let fork = std::sync::Arc::new(parking_lot::Mutex::new(fork));
|
let fork = std::sync::Arc::new(parking_lot::Mutex::new(fork));
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new("/mnt/primary"),
|
std::path::Path::new("/mnt/primary"),
|
||||||
&primary,
|
&primary,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
session_flag(true),
|
session_flag(true),
|
||||||
);
|
);
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new("/mnt/device"),
|
std::path::Path::new("/mnt/device"),
|
||||||
&fork,
|
&fork,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -5922,7 +6229,7 @@ mod tests {
|
|||||||
let child_ino = state.intern(super::ROOT_INO, child_node);
|
let child_ino = state.intern(super::ROOT_INO, child_node);
|
||||||
state.children.insert(super::ROOT_INO, vec![child_ino]);
|
state.children.insert(super::ROOT_INO, vec![child_ino]);
|
||||||
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new("/mnt/device"),
|
std::path::Path::new("/mnt/device"),
|
||||||
&state,
|
&state,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -5985,7 +6292,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new("/mnt/device"),
|
std::path::Path::new("/mnt/device"),
|
||||||
&state,
|
&state,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -6253,7 +6560,7 @@ mod tests {
|
|||||||
state.children.insert(revoked_ino, vec![child_ino]);
|
state.children.insert(revoked_ino, vec![child_ino]);
|
||||||
|
|
||||||
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new("/mnt/device"),
|
std::path::Path::new("/mnt/device"),
|
||||||
&state,
|
&state,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -6307,7 +6614,7 @@ mod tests {
|
|||||||
state.children.insert(cycle_b, vec![cycle_a]);
|
state.children.insert(cycle_b, vec![cycle_a]);
|
||||||
|
|
||||||
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
let state = std::sync::Arc::new(parking_lot::Mutex::new(state));
|
||||||
registry.register(
|
registry.register_bare(
|
||||||
std::path::Path::new("/mnt/device"),
|
std::path::Path::new("/mnt/device"),
|
||||||
&state,
|
&state,
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new()),
|
std::sync::Arc::new(std::sync::OnceLock::new()),
|
||||||
@@ -6710,6 +7017,19 @@ mod tests {
|
|||||||
require_uid_access(&db, &stale, &[]).unwrap_err().code(),
|
require_uid_access(&db, &stale, &[]).unwrap_err().code(),
|
||||||
libc::EACCES
|
libc::EACCES
|
||||||
);
|
);
|
||||||
|
// Same refusal on the syscall path, but a distinguishable one
|
||||||
|
// underneath: the drain asks the remote about `Unknown` rather than
|
||||||
|
// waiting on a permission change (B83).
|
||||||
|
assert_eq!(
|
||||||
|
uid_write_authority(&db, &stale, &[]),
|
||||||
|
WriteAuthority::Unknown,
|
||||||
|
"a uid the tree has no row for is unknown, not denied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
uid_write_authority(&db, &uid, &[]),
|
||||||
|
WriteAuthority::Denied,
|
||||||
|
"a uid the tree knows and refuses is denied"
|
||||||
|
);
|
||||||
assert_eq!(db.pending_ops().unwrap().len(), before);
|
assert_eq!(db.pending_ops().unwrap().len(), before);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6876,6 +7196,19 @@ mod tests {
|
|||||||
let trash = function_source(source, "fn queue_trash(");
|
let trash = function_source(source, "fn queue_trash(");
|
||||||
assert_before(trash, "replace_ops_with_trash", "discard_staged");
|
assert_before(trash, "replace_ops_with_trash", "discard_staged");
|
||||||
|
|
||||||
|
// §5, parallel drain: every path that unlinks a staged blob or hands
|
||||||
|
// the same node's work to another worker has to stop the upload that
|
||||||
|
// may be reading those bytes *first*. Late is a superseded revision on
|
||||||
|
// the wire, or two workers deciding whether the file exists.
|
||||||
|
for (signature, mutation) in [
|
||||||
|
("fn enqueue_staged_write(", "attach_blob_to_create"),
|
||||||
|
("fn queue_trash(", "replace_ops_with_trash"),
|
||||||
|
("fn discard_queued_ops(", "delete_ops_for_uid"),
|
||||||
|
] {
|
||||||
|
let function = function_source(source, signature);
|
||||||
|
assert_before(function, "cancel_upload", mutation);
|
||||||
|
}
|
||||||
|
|
||||||
let filesystem = include_str!("filesystem.rs");
|
let filesystem = include_str!("filesystem.rs");
|
||||||
for signature in ["fn serve_unlink(", "fn serve_rmdir("] {
|
for signature in ["fn serve_unlink(", "fn serve_rmdir("] {
|
||||||
let function = function_source(filesystem, signature);
|
let function = function_source(filesystem, signature);
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ pub fn mount(
|
|||||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||||
block_flight: Arc::new(BlockFlight::default()),
|
block_flight: Arc::new(BlockFlight::default()),
|
||||||
|
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
prefetch: Arc::new(Prefetch::default()),
|
prefetch: Arc::new(Prefetch::default()),
|
||||||
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||||
@@ -376,6 +377,9 @@ pub fn mount(
|
|||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
@@ -413,16 +417,29 @@ pub fn mount(
|
|||||||
// And finally the staged blobs neither of those accounts for: bytes a
|
// And finally the staged blobs neither of those accounts for: bytes a
|
||||||
// failed release left behind with nothing pointing at them.
|
// failed release left behind with nothing pointing at them.
|
||||||
core.reconcile_staging();
|
core.reconcile_staging();
|
||||||
{
|
// Several drain workers over one queue. One thread meant a single large
|
||||||
|
// upload held every queued rename, trash and small write behind it for as
|
||||||
|
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
|
||||||
|
// workers off each other's nodes, which is the only ordering the queue
|
||||||
|
// actually needs. Worker 0 additionally runs the queue's idle chores.
|
||||||
|
//
|
||||||
|
// Every long-lived thread started from here on is kept in `workers` and
|
||||||
|
// joined at the end of this function. Leaving them running turned an
|
||||||
|
// in-process remount into a second generation of drain workers, sync engines
|
||||||
|
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
||||||
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-drain".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_pending_drain())?;
|
.name(format!("pdfs-drain-{worker}"))
|
||||||
|
.spawn(move || core.run_pending_drain(worker == 0))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
// 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);
|
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||||
|
|
||||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
// out identical to the live file, surface the ones that genuinely diverge.
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
@@ -434,18 +451,22 @@ pub fn mount(
|
|||||||
} else {
|
} else {
|
||||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-conflict-sweep".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_conflict_sweep_loop())?;
|
.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 {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-online-probe".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_online_probe())?;
|
.name("pdfs-online-probe".into())
|
||||||
|
.spawn(move || core.run_online_probe())?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
@@ -455,9 +476,12 @@ pub fn mount(
|
|||||||
let indexing = core.indexing.clone();
|
let indexing = core.indexing.clone();
|
||||||
let transfers = core.transfers.clone();
|
let transfers = core.transfers.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
std::thread::Builder::new()
|
let shutdown = core.shutdown.clone();
|
||||||
.name("pdfs-localindex".into())
|
workers.push(
|
||||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
std::thread::Builder::new()
|
||||||
|
.name("pdfs-localindex".into())
|
||||||
|
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the control socket before the FUSE session takes over the thread. A
|
// Bind the control socket before the FUSE session takes over the thread. A
|
||||||
@@ -494,23 +518,29 @@ pub fn mount(
|
|||||||
let control_core = core.clone();
|
let control_core = core.clone();
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
if let Err(error) = std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("pdfs-control".into())
|
.name("pdfs-control".into())
|
||||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||||
{
|
{
|
||||||
let secondaries = core.mounts.lock().close_and_drain();
|
Ok(handle) => workers.push(handle),
|
||||||
for (id, mount) in secondaries {
|
Err(error) => {
|
||||||
if let Err(unmount_error) = mount.teardown() {
|
// A failure here abandons the mount, so the background threads
|
||||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
// started above have to be told to stop with it.
|
||||||
|
stop_workers(&core, control_socket, workers);
|
||||||
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
|
for (id, mount) in secondaries {
|
||||||
|
if let Err(unmount_error) = mount.teardown() {
|
||||||
|
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||||
|
umount_session_unblocked(bg, main_conn)
|
||||||
|
}) {
|
||||||
|
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
return Err(error);
|
||||||
}
|
}
|
||||||
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
|
||||||
umount_session_unblocked(bg, main_conn)
|
|
||||||
}) {
|
|
||||||
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_file(control_socket);
|
|
||||||
return Err(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-establish on-demand mounts only after the primary session is live.
|
// Re-establish on-demand mounts only after the primary session is live.
|
||||||
@@ -619,10 +649,43 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = std::fs::remove_file(control_socket);
|
// Last, because the workers may legitimately be finishing an upload, and
|
||||||
|
// nothing they can still do is harmful once the mounts are down.
|
||||||
|
stop_workers(&core, control_socket, workers);
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||||
|
///
|
||||||
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
|
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||||
|
/// did not, and started a second full set on top of the first (bugs.md B44).
|
||||||
|
///
|
||||||
|
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||||
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
|
/// inside `accept`, which only returns when a connection arrives — so it gets
|
||||||
|
/// one, from us, after the flag is already set. A join that takes a while is
|
||||||
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
|
/// than abandoning the user's bytes mid-flight.
|
||||||
|
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
|
core.shutdown.stop();
|
||||||
|
core.wake_drain();
|
||||||
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
|
// Connect while the socket still exists — that is the whole point of the
|
||||||
|
// poke — then unlink it so nothing else can arrive behind us. The listener
|
||||||
|
// re-checks the flag after every accept, so even a connection that races the
|
||||||
|
// unlink cannot put it back to sleep.
|
||||||
|
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||||
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
for worker in workers {
|
||||||
|
if worker.join().is_err() {
|
||||||
|
warn!("a background worker panicked before shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!("background workers stopped");
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|||||||
+306
-71
@@ -26,6 +26,11 @@ const PREFETCH_MAX: u64 = 8;
|
|||||||
/// Window a sequential read starts at, and returns to after a seek.
|
/// Window a sequential read starts at, and returns to after a seek.
|
||||||
const PREFETCH_MIN: u64 = 2;
|
const PREFETCH_MIN: u64 = 2;
|
||||||
|
|
||||||
|
/// Largest file [`Core::repair_block`] will pull down whole to work around a
|
||||||
|
/// revision whose block table understates it. Past this a single read would cost
|
||||||
|
/// gigabytes of download, so the read fails instead and says to pin the file.
|
||||||
|
pub(super) const REPAIR_MAX: u64 = 512 * 1024 * 1024;
|
||||||
|
|
||||||
/// Blocks that may be prefetching at once, across every file. Prefetch takes a
|
/// Blocks that may be prefetching at once, across every file. Prefetch takes a
|
||||||
/// permit and gives up rather than queueing, so it can never sit in front of a
|
/// permit and gives up rather than queueing, so it can never sit in front of a
|
||||||
/// demand read in the SDK's flat block semaphore.
|
/// demand read in the SDK's flat block semaphore.
|
||||||
@@ -97,22 +102,38 @@ static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
|
|||||||
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
|
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
|
||||||
/// whose tag no longer matches has its blocks dropped rather than served, so a
|
/// whose tag no longer matches has its blocks dropped rather than served, so a
|
||||||
/// new revision can never be stitched together from the old one.
|
/// new revision can never be stitched together from the old one.
|
||||||
|
/// A ring entry's identity: the node, and the plaintext range the block covers.
|
||||||
|
///
|
||||||
|
/// The range, rather than the block index, because the index alone stopped
|
||||||
|
/// naming one range when the geometry started coming from the revision — a
|
||||||
|
/// block cached under the [`BlockGeometry::uniform`] fallback and the block at
|
||||||
|
/// the same index under the real geometry are different bytes
|
||||||
|
/// (`docs/BUGS.md` B85). A range is the same identity under either plan, so a
|
||||||
|
/// re-planned read misses rather than being served the wrong bytes.
|
||||||
|
type RingKey = (NodeUid, BlockSpan);
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct BlockRing {
|
pub(super) struct BlockRing {
|
||||||
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
|
blocks: HashMap<RingKey, Arc<Vec<u8>>>,
|
||||||
/// Keys least-recently-used first, for eviction.
|
/// Keys least-recently-used first, for eviction.
|
||||||
order: VecDeque<(NodeUid, u64)>,
|
order: VecDeque<RingKey>,
|
||||||
/// Per-node validity tag, `(mtime, size)`.
|
/// Per-node validity tag, `(mtime, size)`.
|
||||||
tags: HashMap<NodeUid, (i64, u64)>,
|
tags: HashMap<NodeUid, (i64, u64)>,
|
||||||
bytes: u64,
|
bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlockRing {
|
impl BlockRing {
|
||||||
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
|
fn get(
|
||||||
|
&mut self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
size: u64,
|
||||||
|
span: BlockSpan,
|
||||||
|
) -> Option<Arc<Vec<u8>>> {
|
||||||
if self.tags.get(uid) != Some(&(mtime, size)) {
|
if self.tags.get(uid) != Some(&(mtime, size)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let key = (uid.clone(), idx);
|
let key = (uid.clone(), span);
|
||||||
let hit = self.blocks.get(&key).cloned()?;
|
let hit = self.blocks.get(&key).cloned()?;
|
||||||
// Move to the young end: a block being read repeatedly (the whole reason
|
// Move to the young end: a block being read repeatedly (the whole reason
|
||||||
// this exists) must not age out under a sequential read passing through.
|
// this exists) must not age out under a sequential read passing through.
|
||||||
@@ -123,14 +144,21 @@ impl BlockRing {
|
|||||||
Some(hit)
|
Some(hit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
|
fn insert(
|
||||||
|
&mut self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
size: u64,
|
||||||
|
span: BlockSpan,
|
||||||
|
bytes: Arc<Vec<u8>>,
|
||||||
|
) {
|
||||||
if self.tags.get(uid) != Some(&(mtime, size)) {
|
if self.tags.get(uid) != Some(&(mtime, size)) {
|
||||||
// Revision changed under us (or first sight): anything held for this
|
// Revision changed under us (or first sight): anything held for this
|
||||||
// node describes the old one.
|
// node describes the old one.
|
||||||
self.drop_node(uid);
|
self.drop_node(uid);
|
||||||
self.tags.insert(uid.clone(), (mtime, size));
|
self.tags.insert(uid.clone(), (mtime, size));
|
||||||
}
|
}
|
||||||
let key = (uid.clone(), idx);
|
let key = (uid.clone(), span);
|
||||||
if self.blocks.contains_key(&key) {
|
if self.blocks.contains_key(&key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,8 +199,10 @@ impl BlockRing {
|
|||||||
/// The result of fetching one block, shared between everyone who wanted it.
|
/// The result of fetching one block, shared between everyone who wanted it.
|
||||||
type BlockResult = Result<Arc<Vec<u8>>, Errno>;
|
type BlockResult = Result<Arc<Vec<u8>>, Errno>;
|
||||||
|
|
||||||
/// Identifies a block *of a revision*: node, validity tag, index.
|
/// Identifies a block *of a revision*: node, validity tag, and the plaintext
|
||||||
type BlockKey = (NodeUid, i64, u64, u64);
|
/// range the block covers — see [`RingKey`] for why it is the range and not the
|
||||||
|
/// index.
|
||||||
|
type BlockKey = (NodeUid, i64, u64, BlockSpan);
|
||||||
|
|
||||||
/// A claim on a block key: who owns the fetch, and where its result is published.
|
/// A claim on a block key: who owns the fetch, and where its result is published.
|
||||||
type Claim = (u64, tokio::sync::watch::Receiver<Option<BlockResult>>);
|
type Claim = (u64, tokio::sync::watch::Receiver<Option<BlockResult>>);
|
||||||
@@ -412,11 +442,30 @@ impl Core {
|
|||||||
self.prefetch.streams.lock().remove(uid);
|
self.prefetch.streams.lock().remove(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How this revision's plaintext is divided into content blocks.
|
||||||
|
///
|
||||||
|
/// The revision's own geometry when a previous read has learned and recorded
|
||||||
|
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||||
|
/// first read, because learning it means opening a `RevisionReader`, and
|
||||||
|
/// opening one per read is precisely the per-file key derivation the block
|
||||||
|
/// cache exists to avoid (bugs.md B12, B85).
|
||||||
|
///
|
||||||
|
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
||||||
|
/// real sizes and clamps to what was asked for either way. It costs a
|
||||||
|
/// straddling fetch and a cached block the next read will not ask for, and
|
||||||
|
/// both settle once the geometry is known.
|
||||||
|
fn block_geometry(&self, uid: &NodeUid, mtime: i64, fsize: u64) -> BlockGeometry {
|
||||||
|
self.cache
|
||||||
|
.block_geometry(uid, mtime, fsize)
|
||||||
|
.unwrap_or_else(|| BlockGeometry::uniform(fsize))
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
|
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
|
||||||
/// the on-disk caches before the network: a whole-file blob (pinned files)
|
/// the on-disk caches before the network: a whole-file blob (pinned files)
|
||||||
/// first, then the block cache — fetching only the [`BLOCK_SIZE`]-aligned
|
/// first, then the block cache — fetching only the blocks that overlap the
|
||||||
/// blocks that overlap the request and caching each. `mtime`/`fsize` validate
|
/// request, on the revision's own boundaries ([`Core::block_geometry`]), and
|
||||||
/// both caches. Network I/O runs without any lock held.
|
/// caching each. `mtime`/`fsize` validate both caches. Network I/O runs
|
||||||
|
/// without any lock held.
|
||||||
pub(super) fn read_range(
|
pub(super) fn read_range(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -495,7 +544,7 @@ impl Core {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch block `bidx`, joining a fetch already in flight for it rather than
|
/// Fetch the block at `span`, joining a fetch already in flight rather than
|
||||||
/// issuing a second one, and publish the result to everyone who joined.
|
/// issuing a second one, and publish the result to everyone who joined.
|
||||||
///
|
///
|
||||||
/// See [`BlockFlight`]. A follower whose leader disappears (a panic, or a
|
/// See [`BlockFlight`]. A follower whose leader disappears (a panic, or a
|
||||||
@@ -507,10 +556,10 @@ impl Core {
|
|||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
fsize: u64,
|
fsize: u64,
|
||||||
bidx: u64,
|
span: BlockSpan,
|
||||||
cache_blocks: bool,
|
cache_blocks: bool,
|
||||||
) -> BlockResult {
|
) -> BlockResult {
|
||||||
let key: BlockKey = (uid.clone(), mtime, fsize, bidx);
|
let key: BlockKey = (uid.clone(), mtime, fsize, span);
|
||||||
let claim = {
|
let claim = {
|
||||||
let mut inflight = self.block_flight.inflight.lock();
|
let mut inflight = self.block_flight.inflight.lock();
|
||||||
match inflight.get(&key) {
|
match inflight.get(&key) {
|
||||||
@@ -542,12 +591,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return self
|
return self
|
||||||
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
|
.read_block(reader, uid, mtime, fsize, span, cache_blocks)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let result = self
|
let result = self
|
||||||
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
|
.read_block(reader, uid, mtime, fsize, span, cache_blocks)
|
||||||
.await;
|
.await;
|
||||||
// Retire the claim before publishing, so a racer that wakes and re-enters
|
// Retire the claim before publishing, so a racer that wakes and re-enters
|
||||||
// finds no stale entry to join.
|
// finds no stale entry to join.
|
||||||
@@ -563,36 +612,193 @@ impl Core {
|
|||||||
/// The disk store is handed to a blocking thread — it is a 4 MiB write plus
|
/// The disk store is handed to a blocking thread — it is a 4 MiB write plus
|
||||||
/// a cache-index update, and neither the reactor nor the kernel read waiting
|
/// a cache-index update, and neither the reactor nor the kernel read waiting
|
||||||
/// on these bytes should be held up by it.
|
/// on these bytes should be held up by it.
|
||||||
|
///
|
||||||
|
/// A block that comes back shorter than the file's size says it must be is
|
||||||
|
/// never served, cached or promoted: it is repaired from the whole-file
|
||||||
|
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
|
||||||
async fn read_block(
|
async fn read_block(
|
||||||
&self,
|
&self,
|
||||||
reader: &Arc<RevisionReader>,
|
reader: &Arc<RevisionReader>,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
fsize: u64,
|
fsize: u64,
|
||||||
bidx: u64,
|
span: BlockSpan,
|
||||||
cache_blocks: bool,
|
cache_blocks: bool,
|
||||||
) -> BlockResult {
|
) -> BlockResult {
|
||||||
let bstart = bidx * BLOCK_SIZE;
|
// The reader derives every block boundary from the revision's recorded
|
||||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
// block sizes. If they do not add up to the size the node reports, this
|
||||||
let bytes = reader.read_at(bstart, blen).await.map_err(|e| {
|
// file's boundaries are wrong, and the failure is not confined to short
|
||||||
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
// reads: sizes that *overstate* a block shift every block after it and
|
||||||
Errno::EIO
|
// return full-length reads of the wrong bytes. So a disagreement of any
|
||||||
})?;
|
// kind takes the file off the range path entirely (bugs.md B84).
|
||||||
|
let bytes = if reader.size() != fsize {
|
||||||
|
warn!(
|
||||||
|
%uid, bidx = span.idx, fsize,
|
||||||
|
reader_size = reader.size(),
|
||||||
|
reader_blocks = reader.block_sizes().len(),
|
||||||
|
"the revision's block table disagrees with the node size"
|
||||||
|
);
|
||||||
|
self.repair_block(uid, mtime, fsize, span).await?
|
||||||
|
} else {
|
||||||
|
// Learned here rather than on a path of its own: this is where a
|
||||||
|
// reader is open anyway, and recording the real geometry is what
|
||||||
|
// lets the *next* read plan its blocks on the revision's own
|
||||||
|
// boundaries instead of the 4 MiB fallback (bugs.md B85).
|
||||||
|
if cache_blocks {
|
||||||
|
self.cache
|
||||||
|
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||||
|
}
|
||||||
|
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
|
||||||
|
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
if bytes.len() as u64 == span.len {
|
||||||
|
bytes
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
%uid, bidx = span.idx, blen = span.len, got = bytes.len(), fsize,
|
||||||
|
"block came back shorter than the range asked for"
|
||||||
|
);
|
||||||
|
self.repair_block(uid, mtime, fsize, span).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
let bytes = Arc::new(bytes);
|
let bytes = Arc::new(bytes);
|
||||||
if cache_blocks {
|
if cache_blocks {
|
||||||
let cache = self.cache.clone();
|
let cache = self.cache.clone();
|
||||||
let uid = uid.clone();
|
let uid = uid.clone();
|
||||||
let bytes = bytes.clone();
|
let bytes = bytes.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let _ = cache.store_block(&uid, mtime, fsize, bidx, &bytes);
|
let _ = cache.store_block(&uid, mtime, fsize, span, &bytes);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.block_ring
|
self.block_ring
|
||||||
.lock()
|
.lock()
|
||||||
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
.insert(uid, mtime, fsize, span, bytes.clone());
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recover the block at `span` of a file whose per-block reads cannot be
|
||||||
|
/// by fetching the whole revision through the manifest-verified download and
|
||||||
|
/// serving the block out of that.
|
||||||
|
///
|
||||||
|
/// The range path derives every block boundary from the revision's recorded
|
||||||
|
/// block sizes. When those understate the file, reads come back short with
|
||||||
|
/// no error anywhere, and the caller hands userspace a truncated file that
|
||||||
|
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
|
||||||
|
/// arithmetic — it streams the block list — so it returns the whole file
|
||||||
|
/// where the range path cannot, which is why pinning was the workaround.
|
||||||
|
/// This does the same thing on the user's behalf, once, and the resulting
|
||||||
|
/// blob answers every later read of the file before the block path is
|
||||||
|
/// consulted at all.
|
||||||
|
///
|
||||||
|
/// Bounded by [`REPAIR_MAX`]: past that, one unlucky read would pull down
|
||||||
|
/// gigabytes, and a clear `EIO` plus a `pdfs pin` is the better trade.
|
||||||
|
async fn repair_block(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
span: BlockSpan,
|
||||||
|
) -> Result<Vec<u8>, Errno> {
|
||||||
|
let (bstart, blen) = (span.start, span.len);
|
||||||
|
if fsize > REPAIR_MAX {
|
||||||
|
warn!(
|
||||||
|
%uid, fsize,
|
||||||
|
"file is too large to repair from a whole-file download; pin it to read it"
|
||||||
|
);
|
||||||
|
return Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
// One download per file, not per block: every block of a file with a bad
|
||||||
|
// block table discovers the same problem at once.
|
||||||
|
let lock = {
|
||||||
|
let mut locks = self.content_repairs.lock().await;
|
||||||
|
locks.entry(uid.clone()).or_default().clone()
|
||||||
|
};
|
||||||
|
let guard = lock.clone().lock_owned().await;
|
||||||
|
let downloaded = match self.cache.read_range(uid, mtime, fsize, bstart, blen) {
|
||||||
|
// The first arrival did the work; the rest just read its blob.
|
||||||
|
Some(bytes) if bytes.len() as u64 == blen => Ok(Some(bytes)),
|
||||||
|
_ => self
|
||||||
|
.download_whole_revision(uid, mtime, fsize)
|
||||||
|
.await
|
||||||
|
.map(|()| None),
|
||||||
|
};
|
||||||
|
// Before releasing, so the map entry is only dropped once no waiter is
|
||||||
|
// still holding it.
|
||||||
|
drop(guard);
|
||||||
|
self.release_repair_lock(uid, lock).await;
|
||||||
|
if let Some(bytes) = downloaded? {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
match self.cache.read_range(uid, mtime, fsize, bstart, blen) {
|
||||||
|
Some(bytes) if bytes.len() as u64 == blen => {
|
||||||
|
info!(%uid, bidx = span.idx, "served a short block from a repaired whole-file download");
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error!(%uid, bidx = span.idx, fsize, "repairing a short block did not produce the block");
|
||||||
|
Err(Errno::EIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the whole of `uid`'s active revision to disk and adopt it as the
|
||||||
|
/// cached blob, so the range path is bypassed entirely for this file.
|
||||||
|
///
|
||||||
|
/// Streamed into a scratch file rather than memory: this runs for files up
|
||||||
|
/// to [`REPAIR_MAX`], and buffering that per concurrent reader is not a cost
|
||||||
|
/// a read should carry. A download that does not produce exactly `fsize`
|
||||||
|
/// bytes is discarded — a short blob would fail the cache's own validity
|
||||||
|
/// check anyway, and storing it would only hide the disagreement one layer
|
||||||
|
/// further down.
|
||||||
|
async fn download_whole_revision(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
) -> Result<(), Errno> {
|
||||||
|
let (mut file, path) = self.cache.create_scratch().map_err(|error| {
|
||||||
|
error!(%uid, %error, "creating a scratch file for a repair download failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
let outcome = self.client.download_file_to(uid, &mut file).await;
|
||||||
|
let stored = (|| {
|
||||||
|
outcome.map_err(|error| {
|
||||||
|
warn!(%uid, %error, "repair download failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
file.sync_all().map_err(|error| {
|
||||||
|
warn!(%uid, %error, "syncing a repair download failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
let got = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
if got != fsize {
|
||||||
|
warn!(%uid, fsize, got, "repair download did not match the node size");
|
||||||
|
return Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
self.cache
|
||||||
|
.store_file(uid, mtime, fsize, &path)
|
||||||
|
.map_err(|error| {
|
||||||
|
error!(%uid, %error, "adopting a repair download as cached content failed");
|
||||||
|
Errno::EIO
|
||||||
|
})
|
||||||
|
})();
|
||||||
|
// The blob is a hardlink to the same inode, so unlinking the scratch
|
||||||
|
// name leaves it intact.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
stored
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a repair lock's map entry once nobody else holds it, so a file
|
||||||
|
/// repaired long ago does not keep a mutex alive forever.
|
||||||
|
async fn release_repair_lock(&self, uid: &NodeUid, lock: Arc<tokio::sync::Mutex<()>>) {
|
||||||
|
let mut locks = self.content_repairs.lock().await;
|
||||||
|
// Two: the map's and ours. Anything more means a waiter still needs it.
|
||||||
|
if Arc::strong_count(&lock) <= 2 {
|
||||||
|
locks.remove(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Warm the blocks after a sequential read into the caches, in the
|
/// Warm the blocks after a sequential read into the caches, in the
|
||||||
/// background, so the next read finds them instead of paying a round-trip.
|
/// background, so the next read finds them instead of paying a round-trip.
|
||||||
///
|
///
|
||||||
@@ -619,17 +825,19 @@ impl Core {
|
|||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let last_block = fsize.saturating_sub(1) / BLOCK_SIZE;
|
let geometry = self.block_geometry(uid, mtime, fsize);
|
||||||
let wanted: Vec<u64> = ((last + 1)..=(last + depth).min(last_block))
|
let last_block = geometry.blocks().saturating_sub(1);
|
||||||
.filter(|&bidx| {
|
let wanted: Vec<BlockSpan> = ((last + 1)..=(last + depth).min(last_block))
|
||||||
|
.filter_map(|bidx| geometry.span(bidx))
|
||||||
|
.filter(|&span| {
|
||||||
// Blocks already in memory need nothing; blocks already on disk
|
// Blocks already in memory need nothing; blocks already on disk
|
||||||
// are cheap enough to serve that spending a round-trip to move
|
// are cheap enough to serve that spending a round-trip to move
|
||||||
// them into the ring would be the wrong trade.
|
// them into the ring would be the wrong trade.
|
||||||
self.block_ring
|
self.block_ring
|
||||||
.lock()
|
.lock()
|
||||||
.get(uid, mtime, fsize, bidx)
|
.get(uid, mtime, fsize, span)
|
||||||
.is_none()
|
.is_none()
|
||||||
&& !(cache_blocks && self.cache.has_block(uid, mtime, fsize, bidx))
|
&& !(cache_blocks && self.cache.has_block(uid, mtime, fsize, span))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if wanted.is_empty() {
|
if wanted.is_empty() {
|
||||||
@@ -646,7 +854,7 @@ impl Core {
|
|||||||
// round-trips, and the reader catches up with the window before it
|
// round-trips, and the reader catches up with the window before it
|
||||||
// finishes.
|
// finishes.
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for bidx in wanted {
|
for span in wanted {
|
||||||
let Ok(permit) = core.prefetch_budget.clone().try_acquire_owned() else {
|
let Ok(permit) = core.prefetch_budget.clone().try_acquire_owned() else {
|
||||||
break; // the budget is spent; the demand path can have it
|
break; // the budget is spent; the demand path can have it
|
||||||
};
|
};
|
||||||
@@ -654,10 +862,10 @@ impl Core {
|
|||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
if let Err(error) = core
|
if let Err(error) = core
|
||||||
.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
|
.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
debug!(%uid, bidx, ?error, "prefetch failed");
|
debug!(%uid, bidx = span.idx, ?error, "prefetch failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -687,8 +895,13 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let end = offset.saturating_add(len).min(fsize);
|
let end = offset.saturating_add(len).min(fsize);
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
let first = offset / BLOCK_SIZE;
|
// On the revision's own boundaries where they are known, and on the
|
||||||
let last = (end - 1) / BLOCK_SIZE;
|
// 4 MiB fallback where they are not (bugs.md B85).
|
||||||
|
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
||||||
|
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||||
|
return Ok(out);
|
||||||
|
};
|
||||||
|
let (first, last) = (head.idx, tail.idx);
|
||||||
|
|
||||||
// Collect the blocks overlapping the request, serving any already cached
|
// Collect the blocks overlapping the request, serving any already cached
|
||||||
// and fetching the rest concurrently. A multi-block read (e.g. a media
|
// and fetching the rest concurrently. A multi-block read (e.g. a media
|
||||||
@@ -696,31 +909,31 @@ impl Core {
|
|||||||
// would otherwise stall on each block round-trip in turn; downloading the
|
// would otherwise stall on each block round-trip in turn; downloading the
|
||||||
// misses in parallel saturates the connection and bounds latency at the
|
// misses in parallel saturates the connection and bounds latency at the
|
||||||
// slowest single block instead of their sum.
|
// slowest single block instead of their sum.
|
||||||
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
|
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity(wanted.len());
|
||||||
let mut misses: Vec<u64> = Vec::new();
|
let mut misses: Vec<BlockSpan> = Vec::new();
|
||||||
for bidx in first..=last {
|
for &span in &wanted {
|
||||||
// The in-memory ring comes first on both paths: a streaming read has
|
// The in-memory ring comes first on both paths: a streaming read has
|
||||||
// no other cache at all, and a cached one would otherwise pay two
|
// no other cache at all, and a cached one would otherwise pay two
|
||||||
// opens, a JSON parse and a 4 MiB read+alloc for every 128 KiB the
|
// opens, a JSON parse and a 4 MiB read+alloc for every 128 KiB the
|
||||||
// kernel asks for (audit T3).
|
// kernel asks for (audit T3).
|
||||||
let hit = self.block_ring.lock().get(uid, mtime, fsize, bidx);
|
let hit = self.block_ring.lock().get(uid, mtime, fsize, span);
|
||||||
let hit = hit.or_else(|| {
|
let hit = hit.or_else(|| {
|
||||||
if !cache_blocks {
|
if !cache_blocks {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let bytes = Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?);
|
let bytes = Arc::new(self.cache.cached_block(uid, mtime, fsize, span)?);
|
||||||
// Promote it, so the other ~31 reads of this block are answered
|
// Promote it, so the other ~31 reads of this block are answered
|
||||||
// from memory.
|
// from memory.
|
||||||
self.block_ring
|
self.block_ring
|
||||||
.lock()
|
.lock()
|
||||||
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
.insert(uid, mtime, fsize, span, bytes.clone());
|
||||||
Some(bytes)
|
Some(bytes)
|
||||||
});
|
});
|
||||||
match hit {
|
match hit {
|
||||||
Some(b) => blocks.push(Some(b)),
|
Some(b) => blocks.push(Some(b)),
|
||||||
None => {
|
None => {
|
||||||
blocks.push(None);
|
blocks.push(None);
|
||||||
misses.push(bidx);
|
misses.push(span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,12 +946,12 @@ impl Core {
|
|||||||
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
||||||
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for &bidx in &misses {
|
for &span in &misses {
|
||||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
core.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
|
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||||
.await
|
.await
|
||||||
.map(|bytes| (bidx, bytes))
|
.map(|bytes| (span.idx, bytes))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity(misses.len());
|
let mut out = Vec::with_capacity(misses.len());
|
||||||
@@ -756,11 +969,22 @@ impl Core {
|
|||||||
// ahead of a sequential reader instead of stalling once it catches up.
|
// ahead of a sequential reader instead of stalling once it catches up.
|
||||||
self.prefetch_ahead(uid, mtime, fsize, first, last, cache_blocks);
|
self.prefetch_ahead(uid, mtime, fsize, first, last, cache_blocks);
|
||||||
|
|
||||||
for (i, block) in blocks.into_iter().enumerate() {
|
for (span, block) in wanted.into_iter().zip(blocks) {
|
||||||
let bidx = first + i as u64;
|
let bstart = span.start;
|
||||||
let bstart = bidx * BLOCK_SIZE;
|
|
||||||
// Every slot is populated: cache hits up front, misses by the fetch above.
|
// Every slot is populated: cache hits up front, misses by the fetch above.
|
||||||
let block = block.expect("block fetched or cached");
|
let block = block.expect("block fetched or cached");
|
||||||
|
// Both sources guarantee the length the geometry says the block has
|
||||||
|
// — the cache validates it on read, the network path in `read_block`
|
||||||
|
// — so this refuses rather than quietly contributing fewer bytes
|
||||||
|
// than the range covers, which is what turned a bad block into a
|
||||||
|
// truncated file (bugs.md B84).
|
||||||
|
if block.len() as u64 != span.len {
|
||||||
|
error!(
|
||||||
|
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||||
|
"assembling a read hit a short block"
|
||||||
|
);
|
||||||
|
return Err(Errno::EIO);
|
||||||
|
}
|
||||||
let s = (offset.max(bstart) - bstart) as usize;
|
let s = (offset.max(bstart) - bstart) as usize;
|
||||||
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||||
if s < e {
|
if s < e {
|
||||||
@@ -850,13 +1074,13 @@ impl Core {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
for bidx in (offset / BLOCK_SIZE)..=((end - 1) / BLOCK_SIZE) {
|
for span in self.block_geometry(uid, mtime, fsize).spans(offset, end) {
|
||||||
let ring = self.block_ring.lock().get(uid, mtime, fsize, bidx);
|
let ring = self.block_ring.lock().get(uid, mtime, fsize, span);
|
||||||
let block = match ring {
|
let block = match ring {
|
||||||
Some(bytes) => bytes,
|
Some(bytes) => bytes,
|
||||||
None => Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?),
|
None => Arc::new(self.cache.cached_block(uid, mtime, fsize, span)?),
|
||||||
};
|
};
|
||||||
let bstart = bidx * BLOCK_SIZE;
|
let bstart = span.start;
|
||||||
let s = (offset.max(bstart) - bstart) as usize;
|
let s = (offset.max(bstart) - bstart) as usize;
|
||||||
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||||
if s >= e {
|
if s >= e {
|
||||||
@@ -976,31 +1200,42 @@ mod block_ring_tests {
|
|||||||
RING_BYTES / BLOCK_SIZE
|
RING_BYTES / BLOCK_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Block `idx` under the uniform geometry — the shape these tests are
|
||||||
|
/// written against, since what they exercise is tagging and eviction rather
|
||||||
|
/// than block layout.
|
||||||
|
fn span(idx: u64) -> BlockSpan {
|
||||||
|
BlockSpan {
|
||||||
|
idx,
|
||||||
|
start: idx * BLOCK_SIZE,
|
||||||
|
len: BLOCK_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_block_is_served_back() {
|
fn a_stored_block_is_served_back() {
|
||||||
let mut ring = BlockRing::default();
|
let mut ring = BlockRing::default();
|
||||||
let node = uid("a");
|
let node = uid("a");
|
||||||
ring.insert(&node, 7, 100, 3, block(0xab));
|
ring.insert(&node, 7, 100, span(3), block(0xab));
|
||||||
assert_eq!(ring.get(&node, 7, 100, 3).map(|b| b[0]), Some(0xab));
|
assert_eq!(ring.get(&node, 7, 100, span(3)).map(|b| b[0]), Some(0xab));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_block_of_another_revision_is_never_served() {
|
fn a_block_of_another_revision_is_never_served() {
|
||||||
let mut ring = BlockRing::default();
|
let mut ring = BlockRing::default();
|
||||||
let node = uid("a");
|
let node = uid("a");
|
||||||
ring.insert(&node, 7, 100, 0, block(1));
|
ring.insert(&node, 7, 100, span(0), block(1));
|
||||||
// Same node, newer revision: the tag moved, so the old bytes go.
|
// Same node, newer revision: the tag moved, so the old bytes go.
|
||||||
assert!(ring.get(&node, 8, 100, 0).is_none());
|
assert!(ring.get(&node, 8, 100, span(0)).is_none());
|
||||||
assert!(ring.get(&node, 7, 101, 0).is_none());
|
assert!(ring.get(&node, 7, 101, span(0)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_new_revision_drops_what_the_old_one_left() {
|
fn a_new_revision_drops_what_the_old_one_left() {
|
||||||
let mut ring = BlockRing::default();
|
let mut ring = BlockRing::default();
|
||||||
let node = uid("a");
|
let node = uid("a");
|
||||||
ring.insert(&node, 7, 100, 0, block(1));
|
ring.insert(&node, 7, 100, span(0), block(1));
|
||||||
ring.insert(&node, 8, 100, 0, block(2));
|
ring.insert(&node, 8, 100, span(0), block(2));
|
||||||
assert_eq!(ring.get(&node, 8, 100, 0).map(|b| b[0]), Some(2));
|
assert_eq!(ring.get(&node, 8, 100, span(0)).map(|b| b[0]), Some(2));
|
||||||
assert_eq!(ring.bytes, BLOCK_SIZE);
|
assert_eq!(ring.bytes, BLOCK_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,13 +1244,13 @@ mod block_ring_tests {
|
|||||||
let mut ring = BlockRing::default();
|
let mut ring = BlockRing::default();
|
||||||
let node = uid("a");
|
let node = uid("a");
|
||||||
for idx in 0..capacity() {
|
for idx in 0..capacity() {
|
||||||
ring.insert(&node, 1, u64::MAX, idx, block(idx as u8));
|
ring.insert(&node, 1, u64::MAX, span(idx), block(idx as u8));
|
||||||
}
|
}
|
||||||
// Re-reading block 0 makes block 1 the oldest instead.
|
// Re-reading block 0 makes block 1 the oldest instead.
|
||||||
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
|
assert!(ring.get(&node, 1, u64::MAX, span(0)).is_some());
|
||||||
ring.insert(&node, 1, u64::MAX, capacity(), block(0xff));
|
ring.insert(&node, 1, u64::MAX, span(capacity()), block(0xff));
|
||||||
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
|
assert!(ring.get(&node, 1, u64::MAX, span(0)).is_some());
|
||||||
assert!(ring.get(&node, 1, u64::MAX, 1).is_none());
|
assert!(ring.get(&node, 1, u64::MAX, span(1)).is_none());
|
||||||
assert_eq!(ring.bytes, RING_BYTES);
|
assert_eq!(ring.bytes, RING_BYTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,9 +1258,9 @@ mod block_ring_tests {
|
|||||||
fn a_node_whose_last_block_ages_out_keeps_no_tag() {
|
fn a_node_whose_last_block_ages_out_keeps_no_tag() {
|
||||||
let mut ring = BlockRing::default();
|
let mut ring = BlockRing::default();
|
||||||
let (old, new) = (uid("old"), uid("new"));
|
let (old, new) = (uid("old"), uid("new"));
|
||||||
ring.insert(&old, 1, u64::MAX, 0, block(1));
|
ring.insert(&old, 1, u64::MAX, span(0), block(1));
|
||||||
for idx in 0..capacity() {
|
for idx in 0..capacity() {
|
||||||
ring.insert(&new, 1, u64::MAX, idx, block(2));
|
ring.insert(&new, 1, u64::MAX, span(idx), block(2));
|
||||||
}
|
}
|
||||||
assert!(!ring.tags.contains_key(&old));
|
assert!(!ring.tags.contains_key(&old));
|
||||||
assert!(ring.tags.contains_key(&new));
|
assert!(ring.tags.contains_key(&new));
|
||||||
@@ -1035,11 +1270,11 @@ mod block_ring_tests {
|
|||||||
fn dropping_a_node_frees_only_its_own_bytes() {
|
fn dropping_a_node_frees_only_its_own_bytes() {
|
||||||
let mut ring = BlockRing::default();
|
let mut ring = BlockRing::default();
|
||||||
let (a, b) = (uid("a"), uid("b"));
|
let (a, b) = (uid("a"), uid("b"));
|
||||||
ring.insert(&a, 1, 100, 0, block(1));
|
ring.insert(&a, 1, 100, span(0), block(1));
|
||||||
ring.insert(&b, 1, 100, 0, block(2));
|
ring.insert(&b, 1, 100, span(0), block(2));
|
||||||
ring.drop_node(&a);
|
ring.drop_node(&a);
|
||||||
assert!(ring.get(&a, 1, 100, 0).is_none());
|
assert!(ring.get(&a, 1, 100, span(0)).is_none());
|
||||||
assert_eq!(ring.get(&b, 1, 100, 0).map(|x| x[0]), Some(2));
|
assert_eq!(ring.get(&b, 1, 100, span(0)).map(|x| x[0]), Some(2));
|
||||||
assert_eq!(ring.bytes, BLOCK_SIZE);
|
assert_eq!(ring.bytes, BLOCK_SIZE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! One shutdown signal shared by every long-lived worker in the daemon.
|
||||||
|
//!
|
||||||
|
//! The mount used to end by unmounting and returning, leaving the drain
|
||||||
|
//! workers, the sync engine, the conflict sweep, the online probe and the local
|
||||||
|
//! indexer running on `Core` clones. In a process that exits immediately that is
|
||||||
|
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||||
|
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||||
|
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||||
|
//! longer exists (bugs.md B44).
|
||||||
|
//!
|
||||||
|
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||||
|
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||||
|
//! returns early — and reports `false` — the moment [`Shutdown::stop`] is
|
||||||
|
//! called, so teardown does not have to wait out a five-minute sweep interval to
|
||||||
|
//! join a thread.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex};
|
||||||
|
|
||||||
|
/// The daemon's stop signal. Set once, never cleared: a `Core` that has been
|
||||||
|
/// told to stop belongs to a mount that is going away.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Shutdown {
|
||||||
|
stopping: Mutex<bool>,
|
||||||
|
changed: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shutdown {
|
||||||
|
/// Whether teardown has begun. Loops check this where they would otherwise
|
||||||
|
/// start another unit of work.
|
||||||
|
pub(crate) fn is_stopping(&self) -> bool {
|
||||||
|
*self.stopping.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin teardown and wake everything waiting in [`sleep`](Self::sleep).
|
||||||
|
/// Idempotent, because more than one path can reach teardown (a signal, the
|
||||||
|
/// kernel mount ending, a failed startup).
|
||||||
|
pub(crate) fn stop(&self) {
|
||||||
|
let mut stopping = self.stopping.lock();
|
||||||
|
*stopping = true;
|
||||||
|
self.changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait up to `duration`, or until teardown starts.
|
||||||
|
///
|
||||||
|
/// Returns `false` if the daemon is stopping — including when it was already
|
||||||
|
/// stopping on entry, so `while shutdown.sleep(interval)` is a complete and
|
||||||
|
/// correct loop condition and no separate check is needed at the top.
|
||||||
|
pub(crate) fn sleep(&self, duration: Duration) -> bool {
|
||||||
|
let mut stopping = self.stopping.lock();
|
||||||
|
if !*stopping {
|
||||||
|
self.changed.wait_for(&mut stopping, duration);
|
||||||
|
}
|
||||||
|
!*stopping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sleeping_returns_early_once_stopped() {
|
||||||
|
let shutdown = Arc::new(Shutdown::default());
|
||||||
|
assert!(!shutdown.is_stopping());
|
||||||
|
|
||||||
|
let waker = shutdown.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
waker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
// An hour's worth of interval, cut short by the stop.
|
||||||
|
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"stop must interrupt the wait, not wait it out"
|
||||||
|
);
|
||||||
|
assert!(shutdown.is_stopping());
|
||||||
|
// Already stopping: the next wait does not sleep at all.
|
||||||
|
let started = Instant::now();
|
||||||
|
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sleeping_runs_to_the_timeout_while_running() {
|
||||||
|
let shutdown = Shutdown::default();
|
||||||
|
assert!(shutdown.sleep(Duration::from_millis(10)));
|
||||||
|
assert!(!shutdown.is_stopping());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,11 @@ pub(crate) struct PendingRevision {
|
|||||||
pub(crate) meta: StagedWrite,
|
pub(crate) meta: StagedWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One directory's entries as `readdir` reports them: `(ino, is_dir, name)` per
|
||||||
|
/// entry, in reply order. The kernel's file-type enum is mapped in at reply
|
||||||
|
/// time so this module stays free of `fuser`.
|
||||||
|
pub(crate) type DirListing = Vec<(u64, bool, String)>;
|
||||||
|
|
||||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||||
/// `Filesystem` trait through `&self`.
|
/// `Filesystem` trait through `&self`.
|
||||||
pub(crate) struct State {
|
pub(crate) struct State {
|
||||||
@@ -158,6 +163,16 @@ pub(crate) struct State {
|
|||||||
/// have no entry here.
|
/// have no entry here.
|
||||||
pub(crate) handles: HashMap<u64, u64>,
|
pub(crate) handles: HashMap<u64, u64>,
|
||||||
pub(crate) next_fh: u64,
|
pub(crate) next_fh: u64,
|
||||||
|
/// One frozen listing per open directory handle, taken at `opendir`.
|
||||||
|
///
|
||||||
|
/// `readdir` resumes from a byte offset into the listing it is walking, so
|
||||||
|
/// that listing has to stay the same across the calls of one `getdents`
|
||||||
|
/// sequence. Rebuilding it live meant a create or a trash landing between
|
||||||
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
|
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||||
|
/// module stays free of `fuser` (bugs.md B43).
|
||||||
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
/// kernel only after releasing the State lock.
|
/// kernel only after releasing the State lock.
|
||||||
@@ -317,6 +332,7 @@ impl State {
|
|||||||
active_writes: HashMap::new(),
|
active_writes: HashMap::new(),
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
|
dir_snapshots: HashMap::new(),
|
||||||
access_changes: HashSet::new(),
|
access_changes: HashSet::new(),
|
||||||
share_access,
|
share_access,
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -67,10 +67,16 @@ impl Core {
|
|||||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||||
/// docs for the identical/divergent policy.
|
/// docs for the identical/divergent policy.
|
||||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
// Both waits are interruptible, so teardown does not have to sit out a
|
||||||
|
// five-minute interval to join this thread (bugs.md B44).
|
||||||
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
self.sweep_conflicts_once();
|
self.sweep_conflicts_once();
|
||||||
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+585
-77
@@ -54,7 +54,7 @@ use engine::{classify, settle_with};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use local_scan::is_write_mode;
|
use local_scan::is_write_mode;
|
||||||
use local_scan::open_for_write_set;
|
use local_scan::open_for_write_set;
|
||||||
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
|
use model::{LocalItem, LocalSig, Outcome, PassAbort, Pending, RemoteItem};
|
||||||
pub(crate) use planner::base_name;
|
pub(crate) use planner::base_name;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use planner::conflict_path;
|
use planner::conflict_path;
|
||||||
@@ -525,6 +525,9 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
trashed.push(rel.clone());
|
trashed.push(rel.clone());
|
||||||
|
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -687,6 +690,7 @@ impl Core {
|
|||||||
rel_path: rel.clone(),
|
rel_path: rel.clone(),
|
||||||
remote_uid: Some(remote.uid.to_string()),
|
remote_uid: Some(remote.uid.to_string()),
|
||||||
local_mtime: local.mtime,
|
local_mtime: local.mtime,
|
||||||
|
local_mtime_ns: local.mtime_ns,
|
||||||
local_size: local.size,
|
local_size: local.size,
|
||||||
remote_rev: Some(remote.mtime.to_string()),
|
remote_rev: Some(remote.mtime.to_string()),
|
||||||
remote_hash: Some(remote.size.to_string()),
|
remote_hash: Some(remote.size.to_string()),
|
||||||
@@ -717,6 +721,9 @@ impl Core {
|
|||||||
uid: remote.uid.clone(),
|
uid: remote.uid.clone(),
|
||||||
mtime: remote.mtime,
|
mtime: remote.mtime,
|
||||||
size: remote.size,
|
size: remote.size,
|
||||||
|
// What the walk saw here, so the apply step can tell a
|
||||||
|
// stale plan from a live one before it overwrites.
|
||||||
|
local: l.map(LocalSig::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
FilePlan::Conflict => {
|
FilePlan::Conflict => {
|
||||||
@@ -739,7 +746,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
FilePlan::DeleteLocal => {
|
FilePlan::DeleteLocal => {
|
||||||
if let Err(e) = std::fs::remove_file(local_root.join(rel_to_path(rel))) {
|
if let Err(e) =
|
||||||
|
removed_locally(std::fs::remove_file(local_root.join(rel_to_path(rel))))
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: remove local file failed");
|
warn!(rel, error = %e, "sync: remove local file failed");
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -771,6 +780,9 @@ impl Core {
|
|||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -797,7 +809,9 @@ impl Core {
|
|||||||
// Deferred folder deletions, deepest first.
|
// Deferred folder deletions, deepest first.
|
||||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||||
for rel in delete_local_dirs {
|
for rel in delete_local_dirs {
|
||||||
if let Err(e) = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))) {
|
if let Err(e) =
|
||||||
|
removed_locally(std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))))
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: remove local folder failed");
|
warn!(rel, error = %e, "sync: remove local folder failed");
|
||||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
@@ -824,6 +838,9 @@ impl Core {
|
|||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = parent_uid_of(&rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -849,68 +866,9 @@ impl Core {
|
|||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
||||||
for entry in entries {
|
self.progress_scanned(folder_id, base_name(rel))
|
||||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
})
|
||||||
let path = entry.path();
|
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
|
||||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
||||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"local path {} escaped sync root {}: {e}",
|
|
||||||
path.display(),
|
|
||||||
root.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let rel = stripped.to_str().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
// Ignore our own in-flight download temp files.
|
|
||||||
if rel.contains(".pdfs-tmp-") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Ignored paths are dropped here as well as from the classification
|
|
||||||
// union — not for correctness (the union filter is what makes this
|
|
||||||
// safe) but so an ignored `node_modules/` is never descended into or
|
|
||||||
// counted against scan progress.
|
|
||||||
if rules.is_ignored(rel, meta.is_dir()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if meta.is_dir() {
|
|
||||||
out.insert(
|
|
||||||
rel.to_string(),
|
|
||||||
LocalItem {
|
|
||||||
is_dir: true,
|
|
||||||
mtime: 0,
|
|
||||||
size: 0,
|
|
||||||
open_for_write: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.progress_scanned(folder_id, base_name(rel));
|
|
||||||
self.walk_local(folder_id, root, &path, rules, writing, out)?;
|
|
||||||
} else if meta.is_file() {
|
|
||||||
// Check whether any process holds this file open for writing.
|
|
||||||
// The canonical path is used because /proc/*/fd links resolve
|
|
||||||
// to canonical targets.
|
|
||||||
let ofw = std::fs::canonicalize(&path)
|
|
||||||
.map(|canon| writing.contains(&canon))
|
|
||||||
.unwrap_or(false);
|
|
||||||
out.insert(
|
|
||||||
rel.to_string(),
|
|
||||||
LocalItem {
|
|
||||||
is_dir: false,
|
|
||||||
mtime: system_mtime(&meta),
|
|
||||||
size: meta.len() as i64,
|
|
||||||
open_for_write: ofw,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.progress_scanned(folder_id, base_name(rel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
||||||
@@ -1069,6 +1027,7 @@ impl Core {
|
|||||||
rel_path: rel.to_string(),
|
rel_path: rel.to_string(),
|
||||||
remote_uid: Some(uid.to_string()),
|
remote_uid: Some(uid.to_string()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
@@ -1175,6 +1134,9 @@ impl Core {
|
|||||||
.create_folder(parent, base_name(rel), Some(now_secs()))
|
.create_folder(parent, base_name(rel), Some(now_secs()))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
||||||
|
// The parent has gained a child that no cached listing of it
|
||||||
|
// knows about (B86).
|
||||||
|
self.invalidate_children_of(parent);
|
||||||
self.baseline_dir(folder_id, rel, &uid)?;
|
self.baseline_dir(folder_id, rel, &uid)?;
|
||||||
Ok(Applied::Dir(rel.clone(), uid))
|
Ok(Applied::Dir(rel.clone(), uid))
|
||||||
}
|
}
|
||||||
@@ -1192,8 +1154,9 @@ impl Core {
|
|||||||
uid,
|
uid,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
|
local,
|
||||||
} => {
|
} => {
|
||||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, *local)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Applied::Downloaded)
|
Ok(Applied::Downloaded)
|
||||||
}
|
}
|
||||||
@@ -1211,7 +1174,11 @@ impl Core {
|
|||||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||||
info!(rel, "sync: kept local changes as a conflict copy");
|
info!(rel, "sync: kept local changes as a conflict copy");
|
||||||
}
|
}
|
||||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
// The local copy has just been moved aside, so the state this
|
||||||
|
// download expects to find at the path is "nothing" — anything
|
||||||
|
// that appears while it runs is a *new* racing write, and gets
|
||||||
|
// its own conflict copy rather than being overwritten (B39).
|
||||||
|
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, None)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Applied::Conflict)
|
Ok(Applied::Conflict)
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1217,9 @@ impl Core {
|
|||||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
|
// Taken from the descriptor being streamed, so it names the content that
|
||||||
|
// actually goes up rather than whatever is at the path when we finish.
|
||||||
|
let streamed = LocalSig::from(&meta);
|
||||||
// Count the bytes as they are read, so this upload shows up in
|
// Count the bytes as they are read, so this upload shows up in
|
||||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||||
// draft is sealed, so the transfer registers without one.
|
// draft is sealed, so the transfer registers without one.
|
||||||
@@ -1276,7 +1246,13 @@ impl Core {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload {rel}: {e}"))?;
|
.map_err(|e| format!("upload {rel}: {e}"))?;
|
||||||
self.record_file_baseline(folder_id, rel, &path, &uid).await
|
// The parent has gained a child that no cached listing of it knows
|
||||||
|
// about. Without this, a folder later switched to on-demand serves a
|
||||||
|
// snapshot that predates this upload (B86).
|
||||||
|
self.invalidate_children_of(parent);
|
||||||
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
self.record_file_baseline(folder_id, rel, Some(streamed), &uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a changed local file as a new revision of an existing remote node.
|
/// Upload a changed local file as a new revision of an existing remote node.
|
||||||
@@ -1291,6 +1267,7 @@ impl Core {
|
|||||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
|
let streamed = LocalSig::from(&meta);
|
||||||
let reader = OwnedCountingReader::new(
|
let reader = OwnedCountingReader::new(
|
||||||
file,
|
file,
|
||||||
self.transfers.begin(
|
self.transfers.begin(
|
||||||
@@ -1304,11 +1281,20 @@ impl Core {
|
|||||||
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
self.record_file_baseline(folder_id, rel, Some(streamed), uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a remote file to its local path (atomically via a temp file),
|
/// Download a remote file to its local path (atomically via a temp file),
|
||||||
/// stamp the local mtime to match the remote, then record baseline.
|
/// stamp the local mtime to match the remote, then record baseline.
|
||||||
|
///
|
||||||
|
/// `expected_local` is the identity planning saw at the destination. The
|
||||||
|
/// rename below is the destructive step, and the download in between can run
|
||||||
|
/// for minutes, so the destination is re-examined right before it: a local
|
||||||
|
/// file that no longer matches the plan is set aside as a conflict copy
|
||||||
|
/// instead of being replaced (bugs.md B39).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
@@ -1317,6 +1303,7 @@ impl Core {
|
|||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: i64,
|
size: i64,
|
||||||
|
expected_local: Option<LocalSig>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = local_root.join(rel_to_path(rel));
|
let path = local_root.join(rel_to_path(rel));
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -1351,6 +1338,13 @@ impl Core {
|
|||||||
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
||||||
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
||||||
}
|
}
|
||||||
|
// The last moment at which the destination can still be saved. Anything
|
||||||
|
// other than the state planning classified means a writer got there
|
||||||
|
// first, and its bytes are the ones nothing else has a copy of.
|
||||||
|
if let Err(e) = keep_racing_local_edit(&path, expected_local) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(format!("set aside racing local edit for {rel}: {e}"));
|
||||||
|
}
|
||||||
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
||||||
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
||||||
let f = std::fs::File::options()
|
let f = std::fs::File::options()
|
||||||
@@ -1366,23 +1360,36 @@ impl Core {
|
|||||||
.and_then(|dir| dir.sync_all())
|
.and_then(|dir| dir.sync_all())
|
||||||
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
||||||
}
|
}
|
||||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
// Here the file on disk *is* the transferred content: it was published
|
||||||
|
// by a rename and stamped just above, so its stat is the identity.
|
||||||
|
self.record_file_baseline(folder_id, rel, local_sig(&path), uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh a file's baseline from ground truth: the local stat and the
|
/// Refresh a file's baseline from ground truth: the local identity these
|
||||||
/// remote node's reported signature. Called after every upload/download so
|
/// bytes came from and the remote node's reported signature. Called after
|
||||||
/// the very next reconcile sees the path as unchanged.
|
/// every upload/download so the very next reconcile sees the path as
|
||||||
|
/// unchanged.
|
||||||
|
///
|
||||||
|
/// `local` is the identity the *transferred content* corresponds to, which
|
||||||
|
/// is not always the file's current stat. An upload streams the live file,
|
||||||
|
/// so it passes the identity it read the first byte at: if a writer changed
|
||||||
|
/// the file mid-stream, the remote now holds a torn revision, and recording
|
||||||
|
/// the pre-upload identity is what makes the next pass see the path as
|
||||||
|
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||||
|
/// post-upload stat instead declared the torn revision synchronized and left
|
||||||
|
/// it as the file's content on Drive (bugs.md B40).
|
||||||
async fn record_file_baseline(
|
async fn record_file_baseline(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
local_path: &Path,
|
local: Option<LocalSig>,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (lmtime, lsize) = match std::fs::metadata(local_path) {
|
let Some(local) = local else {
|
||||||
Ok(m) => (system_mtime(&m), m.len() as i64),
|
return Err(format!("stat {rel}: no local identity to baseline"));
|
||||||
Err(e) => return Err(format!("stat {rel}: {e}")),
|
|
||||||
};
|
};
|
||||||
|
let (lmtime, lsize) = (local.mtime, local.size);
|
||||||
// Re-fetch the node so the stored remote signature is exactly what a walk
|
// Re-fetch the node so the stored remote signature is exactly what a walk
|
||||||
// will report next time.
|
// will report next time.
|
||||||
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
||||||
@@ -1402,6 +1409,7 @@ impl Core {
|
|||||||
rel_path: rel.to_string(),
|
rel_path: rel.to_string(),
|
||||||
remote_uid: Some(uid.to_string()),
|
remote_uid: Some(uid.to_string()),
|
||||||
local_mtime: lmtime,
|
local_mtime: lmtime,
|
||||||
|
local_mtime_ns: local.mtime_ns,
|
||||||
local_size: lsize,
|
local_size: lsize,
|
||||||
remote_rev: Some(rmtime.to_string()),
|
remote_rev: Some(rmtime.to_string()),
|
||||||
remote_hash: Some(rsize.to_string()),
|
remote_hash: Some(rsize.to_string()),
|
||||||
@@ -1411,6 +1419,177 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Say so when a file changed under an upload that was streaming it.
|
||||||
|
///
|
||||||
|
/// Purely diagnostic — the correction is already in place, because the baseline
|
||||||
|
/// records the identity the upload *read* rather than the one on disk now, so
|
||||||
|
/// the next pass classifies the path as locally changed and replaces the torn
|
||||||
|
/// revision. Without the log there is nothing in the journal to explain the
|
||||||
|
/// extra revision (bugs.md B40).
|
||||||
|
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||||
|
let now = local_sig(path);
|
||||||
|
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||||
|
warn!(
|
||||||
|
rel,
|
||||||
|
?streamed,
|
||||||
|
?now,
|
||||||
|
"sync: file changed while uploading; the remote revision may be torn \
|
||||||
|
and will be replaced on the next pass"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity a reconcile pass uses to recognise a local file it has already
|
||||||
|
/// looked at: `None` when nothing is at the path.
|
||||||
|
fn local_sig(path: &Path) -> Option<LocalSig> {
|
||||||
|
std::fs::metadata(path).ok().map(|m| LocalSig {
|
||||||
|
mtime: system_mtime(&m),
|
||||||
|
mtime_ns: system_mtime_ns(&m),
|
||||||
|
size: m.len() as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guard the destination of a download: if what is at `path` is no longer what
|
||||||
|
/// planning classified, move it aside as a conflict copy before the caller
|
||||||
|
/// renames over it.
|
||||||
|
///
|
||||||
|
/// A reconcile pass decides what to do from a walk that finished before the
|
||||||
|
/// transfer started, and a download of a large file can run for minutes. Without
|
||||||
|
/// this, a local edit completed inside that window was replaced by the remote
|
||||||
|
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||||
|
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
||||||
|
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||||
|
/// conflict already gets.
|
||||||
|
///
|
||||||
|
/// A path that is empty when it was expected to be is not a conflict: the
|
||||||
|
/// download is about to recreate it, and there is nothing to preserve.
|
||||||
|
fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::Result<()> {
|
||||||
|
let actual = local_sig(path);
|
||||||
|
let unchanged = match (actual, expected) {
|
||||||
|
(Some(a), Some(e)) => a.same_content(&e),
|
||||||
|
// Nothing there: the download is about to create it, and there is
|
||||||
|
// nothing to preserve either way.
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(_), None) => false,
|
||||||
|
};
|
||||||
|
if unchanged {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
?expected,
|
||||||
|
?actual,
|
||||||
|
"sync: local file changed while downloading; keeping it as a conflict copy"
|
||||||
|
);
|
||||||
|
preserve_conflict_copy(path, now_secs()).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a local removal as the sync engine has to read it: the *state* it was
|
||||||
|
/// asking for is "this path is gone", so a path that was already gone is a
|
||||||
|
/// success, not a failure.
|
||||||
|
///
|
||||||
|
/// It matters because the baseline is only dropped on success. Treating
|
||||||
|
/// `NotFound` as an error left the baseline in place for a file that no longer
|
||||||
|
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||||
|
/// every pass, forever. Every other error is still an error, and keeping the
|
||||||
|
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||||
|
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
||||||
|
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||||
|
match result {
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||||
|
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
||||||
|
///
|
||||||
|
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
||||||
|
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
||||||
|
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
||||||
|
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
||||||
|
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
||||||
|
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
||||||
|
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
||||||
|
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
||||||
|
/// pass retries once the transient condition clears.
|
||||||
|
///
|
||||||
|
/// Split out from the method so exactly that can be tested against a real tree
|
||||||
|
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||||
|
fn walk_local_tree(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
writing: &HashSet<PathBuf>,
|
||||||
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
scanned: &mut dyn FnMut(&str),
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let meta = std::fs::symlink_metadata(&path)
|
||||||
|
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
||||||
|
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"local path {} escaped sync root {}: {e}",
|
||||||
|
path.display(),
|
||||||
|
root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let rel = stripped.to_str().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Ignore our own in-flight download temp files.
|
||||||
|
if rel.contains(".pdfs-tmp-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ignored paths are dropped here as well as from the classification
|
||||||
|
// union — not for correctness (the union filter is what makes this
|
||||||
|
// safe) but so an ignored `node_modules/` is never descended into or
|
||||||
|
// counted against scan progress.
|
||||||
|
if rules.is_ignored(rel, meta.is_dir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
out.insert(
|
||||||
|
rel.to_string(),
|
||||||
|
LocalItem {
|
||||||
|
is_dir: true,
|
||||||
|
mtime: 0,
|
||||||
|
mtime_ns: None,
|
||||||
|
size: 0,
|
||||||
|
open_for_write: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanned(rel);
|
||||||
|
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
||||||
|
} else if meta.is_file() {
|
||||||
|
// Check whether any process holds this file open for writing.
|
||||||
|
// The canonical path is used because /proc/*/fd links resolve
|
||||||
|
// to canonical targets.
|
||||||
|
let ofw = std::fs::canonicalize(&path)
|
||||||
|
.map(|canon| writing.contains(&canon))
|
||||||
|
.unwrap_or(false);
|
||||||
|
out.insert(
|
||||||
|
rel.to_string(),
|
||||||
|
LocalItem {
|
||||||
|
is_dir: false,
|
||||||
|
mtime: system_mtime(&meta),
|
||||||
|
mtime_ns: system_mtime_ns(&meta),
|
||||||
|
size: meta.len() as i64,
|
||||||
|
open_for_write: ofw,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanned(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||||
/// same timestamp. The original is removed only after the new copy is durable.
|
/// same timestamp. The original is removed only after the new copy is durable.
|
||||||
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
||||||
@@ -1444,6 +1623,26 @@ fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
|||||||
unreachable!("u32 conflict suffix space exhausted")
|
unreachable!("u32 conflict suffix space exhausted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The remote uid of `rel`'s parent folder: from the pass's folder-uid map
|
||||||
|
/// first, and from the baseline second for a parent the map never covered.
|
||||||
|
///
|
||||||
|
/// Used to name the folder whose cached listing a remote mutation has just
|
||||||
|
/// invalidated (B86). `None` is not an error — it only means this pass cannot
|
||||||
|
/// say which folder to invalidate, and the caller skips it.
|
||||||
|
fn parent_uid_of(
|
||||||
|
rel: &str,
|
||||||
|
remote_dirs: &HashMap<String, NodeUid>,
|
||||||
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
) -> Option<NodeUid> {
|
||||||
|
let parent = parent_rel(rel);
|
||||||
|
remote_dirs.get(parent).cloned().or_else(|| {
|
||||||
|
baseline
|
||||||
|
.get(parent)
|
||||||
|
.and_then(|e| e.remote_uid.as_deref())
|
||||||
|
.and_then(parse_uid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||||
match path.parent() {
|
match path.parent() {
|
||||||
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
||||||
@@ -1476,10 +1675,77 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
|||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same modification time in nanoseconds, when the filesystem reports one.
|
||||||
|
///
|
||||||
|
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||||
|
/// sync from no edit at all, and if the size did not change either, that file
|
||||||
|
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
||||||
|
/// cannot answer, which falls the comparison back to seconds rather than
|
||||||
|
/// asserting an mtime of zero.
|
||||||
|
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||||
|
meta.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|d| i64::try_from(d.as_nanos()).ok())
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A removal of something already gone has produced the state the sync
|
||||||
|
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||||
|
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
||||||
|
#[test]
|
||||||
|
fn an_already_absent_path_counts_as_removed() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||||
|
let missing = root.join("never-existed");
|
||||||
|
assert!(removed_locally(std::fs::remove_file(&missing)).is_ok());
|
||||||
|
assert!(removed_locally(std::fs::remove_dir_all(&missing)).is_ok());
|
||||||
|
|
||||||
|
// A directory in the way of `remove_file` is a real failure, and must
|
||||||
|
// stay one: the file survives, and its baseline has to survive with it.
|
||||||
|
let occupied = root.join("a-directory");
|
||||||
|
std::fs::create_dir_all(&occupied).unwrap();
|
||||||
|
assert!(removed_locally(std::fs::remove_file(&occupied)).is_err());
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The download guard: a destination that still matches the plan is
|
||||||
|
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
||||||
|
#[test]
|
||||||
|
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let path = root.join("report.txt");
|
||||||
|
|
||||||
|
// Nothing there, nothing expected: the download simply creates it.
|
||||||
|
assert!(keep_racing_local_edit(&path, None).is_ok());
|
||||||
|
assert!(!path.exists());
|
||||||
|
|
||||||
|
// Unchanged since planning: no copy, the caller overwrites it.
|
||||||
|
std::fs::write(&path, b"planned").unwrap();
|
||||||
|
let planned = local_sig(&path).unwrap();
|
||||||
|
keep_racing_local_edit(&path, Some(planned)).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"planned");
|
||||||
|
|
||||||
|
// Changed since planning: the racing bytes are the only copy of
|
||||||
|
// themselves, so they must survive the overwrite as a conflict copy.
|
||||||
|
std::fs::write(&path, b"a much longer local edit").unwrap();
|
||||||
|
keep_racing_local_edit(&path, Some(planned)).unwrap();
|
||||||
|
let kept: Vec<_> = std::fs::read_dir(&root)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| std::fs::read(e.path()).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
kept.iter()
|
||||||
|
.any(|bytes| bytes == b"a much longer local edit"),
|
||||||
|
"the racing edit must still exist somewhere: {kept:?}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
||||||
/// immediately. Returns the sender's join handle so the test can keep it
|
/// immediately. Returns the sender's join handle so the test can keep it
|
||||||
/// alive (dropping the sender early would look like a disconnect).
|
/// alive (dropping the sender early would look like a disconnect).
|
||||||
@@ -1565,6 +1831,7 @@ mod tests {
|
|||||||
|
|
||||||
fn local_item(is_dir: bool) -> LocalItem {
|
fn local_item(is_dir: bool) -> LocalItem {
|
||||||
LocalItem {
|
LocalItem {
|
||||||
|
mtime_ns: None,
|
||||||
is_dir,
|
is_dir,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
size: 1,
|
size: 1,
|
||||||
@@ -1722,6 +1989,44 @@ mod tests {
|
|||||||
assert!(guard_local_wipe(&base, &fresh).is_err());
|
assert!(guard_local_wipe(&base, &fresh).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B54's remaining case, end to end: a folder whose local root is still
|
||||||
|
/// *readable* but is no longer the tree it was — an empty directory left
|
||||||
|
/// where a mount used to be, or a different device mounted over it.
|
||||||
|
///
|
||||||
|
/// Nothing fails here. The scan succeeds and honestly reports an empty
|
||||||
|
/// directory, so the only thing standing between the user and a trashed
|
||||||
|
/// remote folder is the guard, and it has to fire for a one-file baseline
|
||||||
|
/// exactly as it does for a large one.
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_mountpoint_trips_the_guard_at_every_baseline_size() {
|
||||||
|
let root = sync_test_dir("wipe-replaced-mount");
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||||
|
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||||
|
|
||||||
|
for size in 1..=3 {
|
||||||
|
let baseline: HashMap<String, ()> =
|
||||||
|
(0..size).map(|i| (format!("file{i}.txt"), ())).collect();
|
||||||
|
assert!(
|
||||||
|
guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_err(),
|
||||||
|
"a {size}-path baseline against an empty root is not a deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the guard steps aside the moment the real tree is back, so a
|
||||||
|
// remounted folder resumes rather than staying wedged.
|
||||||
|
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||||
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||||
|
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||||
|
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
||||||
/// an empty local directory and a full remote. That must reconcile as
|
/// an empty local directory and a full remote. That must reconcile as
|
||||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||||
@@ -1764,6 +2069,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: Some(mtime.to_string()),
|
remote_rev: Some(mtime.to_string()),
|
||||||
remote_hash: Some(size.to_string()),
|
remote_hash: Some(size.to_string()),
|
||||||
},
|
},
|
||||||
@@ -1801,6 +2107,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
},
|
},
|
||||||
@@ -1815,6 +2122,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 10,
|
local_mtime: 10,
|
||||||
local_size: 20,
|
local_size: 20,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: Some("1700".into()),
|
remote_rev: Some("1700".into()),
|
||||||
remote_hash: Some("4096".into()),
|
remote_hash: Some("4096".into()),
|
||||||
};
|
};
|
||||||
@@ -1828,6 +2136,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
};
|
};
|
||||||
@@ -1863,6 +2172,7 @@ mod tests {
|
|||||||
uid: uid(),
|
uid: uid(),
|
||||||
mtime: 0,
|
mtime: 0,
|
||||||
size: 10,
|
size: 10,
|
||||||
|
local: None,
|
||||||
};
|
};
|
||||||
assert_eq!(op.kind(), ActivityKind::Download);
|
assert_eq!(op.kind(), ActivityKind::Download);
|
||||||
}
|
}
|
||||||
@@ -1918,6 +2228,204 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B49's remaining cases. Preservation is the step that makes a download
|
||||||
|
/// safe to publish, so every way it can fail has to leave the local file
|
||||||
|
/// where it was — the download is aborted, not the file replaced.
|
||||||
|
#[test]
|
||||||
|
fn conflict_preservation_failures_all_keep_the_local_file() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
// An unwritable directory: the conflict copy cannot be created at all.
|
||||||
|
let dir = sync_test_dir("conflict-eacces");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"the only copy").unwrap();
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||||
|
let denied = preserve_conflict_copy(&source, 1700);
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
assert!(denied.is_err(), "a read-only directory must fail the copy");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&source).unwrap(),
|
||||||
|
b"the only copy",
|
||||||
|
"the local file survives byte-identical"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// A source that vanished between planning and preservation. The pass
|
||||||
|
// must report it rather than publish the download over the gap.
|
||||||
|
let dir = sync_test_dir("conflict-enoent");
|
||||||
|
assert!(preserve_conflict_copy(&dir.join("gone.txt"), 1700).is_err());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// A write error partway through the copy — induced by making the
|
||||||
|
// destination name a directory, so `create_new` fails as something
|
||||||
|
// other than `AlreadyExists`.
|
||||||
|
let dir = sync_test_dir("conflict-eisdir");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"still the only copy").unwrap();
|
||||||
|
std::fs::create_dir(conflict_path(&source, 1700)).unwrap();
|
||||||
|
// Suffix 0 is taken by a directory; `create_new` on it reports
|
||||||
|
// `AlreadyExists`, so preservation moves on to suffix 1 and succeeds.
|
||||||
|
// What must not happen is the source being removed without a copy.
|
||||||
|
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||||
|
assert!(preserved.is_file());
|
||||||
|
assert_eq!(std::fs::read(&preserved).unwrap(), b"still the only copy");
|
||||||
|
assert!(!source.exists());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A conflict copy keeps the bytes *and* the metadata that identifies them,
|
||||||
|
/// so the next pass uploads the user's version rather than a file that looks
|
||||||
|
/// freshly written.
|
||||||
|
#[test]
|
||||||
|
fn a_conflict_copy_is_byte_and_mtime_identical() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let dir = sync_test_dir("conflict-identity");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"local edit that must survive").unwrap();
|
||||||
|
let before = std::fs::metadata(&source).unwrap();
|
||||||
|
|
||||||
|
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||||
|
let after = std::fs::metadata(&preserved).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&preserved).unwrap(),
|
||||||
|
b"local edit that must survive"
|
||||||
|
);
|
||||||
|
assert_eq!(after.len(), before.len());
|
||||||
|
assert_eq!(after.modified().unwrap(), before.modified().unwrap());
|
||||||
|
assert_eq!(after.permissions().mode(), before.permissions().mode());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- B55: an incomplete local scan is never a set of deletions ---------
|
||||||
|
|
||||||
|
fn no_progress() -> impl FnMut(&str) {
|
||||||
|
|_: &str| {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the pass cannot read must fail the pass. Omitting it would
|
||||||
|
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
||||||
|
/// having deleted that subtree and propagates to Drive (B55).
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-eacces");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
let locked = root.join("locked");
|
||||||
|
std::fs::create_dir(&locked).unwrap();
|
||||||
|
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let error = result.expect_err("an unreadable directory must abort the pass");
|
||||||
|
assert!(
|
||||||
|
error.contains("locked"),
|
||||||
|
"the error should name the subtree it could not read: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||||
|
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_root_fails_the_scan() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-root-eacces");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A root that is gone entirely — an unmounted device folder, a removed
|
||||||
|
/// directory — is the sharpest version of the same case: every baseline path
|
||||||
|
/// is "missing", and the scan must refuse rather than report an empty tree.
|
||||||
|
#[test]
|
||||||
|
fn an_absent_root_fails_the_scan() {
|
||||||
|
let root = sync_test_dir("scan-absent");
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
||||||
|
/// cannot be compared against the baseline either. Refusing the pass is the
|
||||||
|
/// only safe reading: skipping it would classify the file as deleted.
|
||||||
|
#[test]
|
||||||
|
fn a_non_utf8_name_fails_the_scan() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-non-utf8");
|
||||||
|
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
||||||
|
assert!(error.contains("UTF-8"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complement: a scan that *can* read everything reports everything, so
|
||||||
|
/// the strictness above does not come at the cost of a working pass. A
|
||||||
|
/// symlink is skipped by design rather than by failure, and a dangling one
|
||||||
|
/// stats fine through `symlink_metadata`.
|
||||||
|
#[test]
|
||||||
|
fn a_readable_tree_scans_completely() {
|
||||||
|
let root = sync_test_dir("scan-complete");
|
||||||
|
std::fs::write(root.join("a.txt"), b"aa").unwrap();
|
||||||
|
std::fs::create_dir(root.join("sub")).unwrap();
|
||||||
|
std::fs::write(root.join("sub/b.txt"), b"bbb").unwrap();
|
||||||
|
std::os::unix::fs::symlink("nowhere", root.join("dangling")).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut seen: Vec<String> = Vec::new();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
||||||
|
seen.push(rel.to_string())
|
||||||
|
});
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
result.unwrap();
|
||||||
|
let mut keys: Vec<&str> = out.keys().map(String::as_str).collect();
|
||||||
|
keys.sort_unstable();
|
||||||
|
assert_eq!(keys, vec!["a.txt", "sub", "sub/b.txt"]);
|
||||||
|
assert_eq!(out["a.txt"].size, 2);
|
||||||
|
assert_eq!(out["sub/b.txt"].size, 3);
|
||||||
|
assert!(out["sub"].is_dir);
|
||||||
|
assert_eq!(seen.len(), 3, "every reported entry ticks scan progress");
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_test_dir(label: &str) -> PathBuf {
|
fn sync_test_dir(label: &str) -> PathBuf {
|
||||||
let nonce = SystemTime::now()
|
let nonce = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -32,17 +32,24 @@ pub(crate) enum SyncMsg {
|
|||||||
ReconcileAll,
|
ReconcileAll,
|
||||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||||
Rewatch,
|
Rewatch,
|
||||||
|
/// The daemon is tearing down: finish the current pass and end the engine
|
||||||
|
/// thread, so the mount can join it (bugs.md B44).
|
||||||
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
||||||
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
||||||
/// on `rx`; senders live in [`Core::sync_tx`].
|
/// on `rx`; senders live in [`Core::sync_tx`].
|
||||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
|
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
|
||||||
if let Err(e) = std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("pdfs-sync".into())
|
.name("pdfs-sync".into())
|
||||||
.spawn(move || engine_loop(core, rx))
|
.spawn(move || engine_loop(core, rx))
|
||||||
{
|
{
|
||||||
warn!(error = %e, "failed to start sync engine");
|
Ok(handle) => Some(handle),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to start sync engine");
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +81,11 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
// Periodic remote poll.
|
// Periodic remote poll.
|
||||||
{
|
{
|
||||||
let tx = core.sync_tx.clone();
|
let tx = core.sync_tx.clone();
|
||||||
|
let shutdown = core.shutdown.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pdfs-sync-poll".into())
|
.name("pdfs-sync-poll".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
loop {
|
while shutdown.sleep(POLL_INTERVAL) {
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
|
||||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -94,8 +101,12 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
let mut do_rewatch = false;
|
let mut do_rewatch = false;
|
||||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||||
|
{
|
||||||
|
info!("sync engine stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if do_rewatch {
|
if do_rewatch {
|
||||||
rewatch(&core, &mut watcher, &watched);
|
rewatch(&core, &mut watcher, &watched);
|
||||||
@@ -116,9 +127,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||||
{
|
{
|
||||||
let tx = core.sync_tx.clone();
|
let tx = core.sync_tx.clone();
|
||||||
|
let shutdown = core.shutdown.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
while shutdown.sleep(POLL_INTERVAL) {
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
|
||||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -130,8 +141,12 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
let mut do_rewatch = false;
|
let mut do_rewatch = false;
|
||||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||||
|
{
|
||||||
|
info!("sync engine stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if all {
|
if all {
|
||||||
reconcile_all(core);
|
reconcile_all(core);
|
||||||
} else {
|
} else {
|
||||||
@@ -158,7 +173,7 @@ pub(super) fn settle_with(
|
|||||||
do_rewatch: &mut bool,
|
do_rewatch: &mut bool,
|
||||||
quiet: Duration,
|
quiet: Duration,
|
||||||
cap: Duration,
|
cap: Duration,
|
||||||
) {
|
) -> bool {
|
||||||
let deadline = Instant::now() + cap;
|
let deadline = Instant::now() + cap;
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -170,28 +185,49 @@ pub(super) fn settle_with(
|
|||||||
match rx.recv_timeout(wait) {
|
match rx.recv_timeout(wait) {
|
||||||
// Another event: the burst is still going, so the quiet window
|
// Another event: the burst is still going, so the quiet window
|
||||||
// starts again from here.
|
// starts again from here.
|
||||||
Ok(m) => classify(m, ids, all, do_rewatch),
|
// A stop lands mid-burst: give up the rest of the burst rather
|
||||||
|
// than settling for it, and let the caller end the loop.
|
||||||
|
Ok(m) => {
|
||||||
|
if !classify(m, ids, all, do_rewatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Quiet for a full window: whatever was being written has stopped.
|
// Quiet for a full window: whatever was being written has stopped.
|
||||||
Err(RecvTimeoutError::Timeout) => break,
|
Err(RecvTimeoutError::Timeout) => break,
|
||||||
// Every sender is gone; the caller's loop is about to end anyway.
|
// Every sender is gone; the caller's loop is about to end anyway.
|
||||||
Err(RecvTimeoutError::Disconnected) => break,
|
Err(RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`settle_with`] at the real timings.
|
/// [`settle_with`] at the real timings.
|
||||||
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
|
fn settle(
|
||||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
|
rx: &Receiver<SyncMsg>,
|
||||||
|
ids: &mut HashSet<i64>,
|
||||||
|
all: &mut bool,
|
||||||
|
do_rewatch: &mut bool,
|
||||||
|
) -> bool {
|
||||||
|
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
|
/// Fold one message into the pending work of the current burst. Returns `false`
|
||||||
|
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
|
||||||
|
pub(super) fn classify(
|
||||||
|
msg: SyncMsg,
|
||||||
|
ids: &mut HashSet<i64>,
|
||||||
|
all: &mut bool,
|
||||||
|
rewatch: &mut bool,
|
||||||
|
) -> bool {
|
||||||
match msg {
|
match msg {
|
||||||
SyncMsg::Reconcile(id) => {
|
SyncMsg::Reconcile(id) => {
|
||||||
ids.insert(id);
|
ids.insert(id);
|
||||||
}
|
}
|
||||||
SyncMsg::ReconcileAll => *all = true,
|
SyncMsg::ReconcileAll => *all = true,
|
||||||
SyncMsg::Rewatch => *rewatch = true,
|
SyncMsg::Rewatch => *rewatch = true,
|
||||||
|
SyncMsg::Stop => return false,
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn.
|
/// Reconcile every mirror folder in turn.
|
||||||
|
|||||||
@@ -2,9 +2,73 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// What a reconcile pass remembers about the local side of one path: enough to
|
||||||
|
/// recognise the same content again, and no more.
|
||||||
|
///
|
||||||
|
/// `mtime_ns` is the whole reason this is a struct rather than a pair — see
|
||||||
|
/// [`StoredSyncEntry::local_mtime_ns`](pdfs_core::db::StoredSyncEntry).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct LocalSig {
|
||||||
|
pub(super) mtime: i64,
|
||||||
|
pub(super) mtime_ns: Option<i64>,
|
||||||
|
pub(super) size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalSig {
|
||||||
|
/// Whether these two observations describe the same local content.
|
||||||
|
///
|
||||||
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
|
/// falls back to whole seconds, which is what a baseline row written before
|
||||||
|
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||||
|
/// (bugs.md B25).
|
||||||
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
|
if self.size != other.size {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (self.mtime_ns, other.mtime_ns) {
|
||||||
|
(Some(a), Some(b)) => a == b,
|
||||||
|
_ => self.mtime == other.mtime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&LocalItem> for LocalSig {
|
||||||
|
fn from(item: &LocalItem) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: item.mtime,
|
||||||
|
mtime_ns: item.mtime_ns,
|
||||||
|
size: item.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&std::fs::Metadata> for LocalSig {
|
||||||
|
fn from(meta: &std::fs::Metadata) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: system_mtime(meta),
|
||||||
|
mtime_ns: system_mtime_ns(meta),
|
||||||
|
size: meta.len() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline's record of the local side, as [`LocalSig`] compares it.
|
||||||
|
impl From<&StoredSyncEntry> for LocalSig {
|
||||||
|
fn from(entry: &StoredSyncEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: entry.local_mtime,
|
||||||
|
mtime_ns: entry.local_mtime_ns,
|
||||||
|
size: entry.local_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct LocalItem {
|
pub(super) struct LocalItem {
|
||||||
pub(super) is_dir: bool,
|
pub(super) is_dir: bool,
|
||||||
pub(super) mtime: i64,
|
pub(super) mtime: i64,
|
||||||
|
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
|
||||||
|
/// reports one.
|
||||||
|
pub(super) mtime_ns: Option<i64>,
|
||||||
pub(super) size: i64,
|
pub(super) size: i64,
|
||||||
/// True when another process holds this file open for writing (detected via
|
/// True when another process holds this file open for writing (detected via
|
||||||
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
||||||
@@ -91,11 +155,18 @@ pub(super) enum Pending {
|
|||||||
UploadRevision { rel: String, uid: NodeUid },
|
UploadRevision { rel: String, uid: NodeUid },
|
||||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
||||||
/// remote's reported size, used as the transfer's expected total.
|
/// remote's reported size, used as the transfer's expected total.
|
||||||
|
///
|
||||||
|
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
||||||
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
|
/// against it immediately before it renames over the destination: a writer
|
||||||
|
/// that finished an edit in the meantime must not be overwritten without a
|
||||||
|
/// conflict copy (bugs.md B39).
|
||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: i64,
|
size: i64,
|
||||||
|
local: Option<LocalSig>,
|
||||||
},
|
},
|
||||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||||
Conflict {
|
Conflict {
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ pub(super) fn plan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_changed = local.is_some_and(|item| {
|
let local_changed = local.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
|
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
||||||
|
// could not see an edit made in the same second as the last sync that
|
||||||
|
// left the file the same length, so it was never uploaded.
|
||||||
|
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||||
});
|
});
|
||||||
let remote_changed = remote.is_some_and(|item| {
|
let remote_changed = remote.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
||||||
@@ -252,6 +255,7 @@ mod file_plan_tests {
|
|||||||
|
|
||||||
fn local(mtime: i64, size: i64) -> LocalItem {
|
fn local(mtime: i64, size: i64) -> LocalItem {
|
||||||
LocalItem {
|
LocalItem {
|
||||||
|
mtime_ns: None,
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
@@ -278,12 +282,49 @@ mod file_plan_tests {
|
|||||||
rel_path: "file".into(),
|
rel_path: "file".into(),
|
||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime,
|
local_mtime,
|
||||||
|
local_mtime_ns: None,
|
||||||
local_size,
|
local_size,
|
||||||
remote_rev: Some(remote_mtime.to_string()),
|
remote_rev: Some(remote_mtime.to_string()),
|
||||||
remote_hash: Some(remote_size.to_string()),
|
remote_hash: Some(remote_size.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||||
|
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||||
|
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
||||||
|
#[test]
|
||||||
|
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||||
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
base.local_mtime_ns = Some(10_000_000_000);
|
||||||
|
let edited = LocalItem {
|
||||||
|
mtime_ns: Some(10_400_000_000),
|
||||||
|
..local(10, 20)
|
||||||
|
};
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::UploadRevision
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same file untouched still reads as unchanged.
|
||||||
|
let same = LocalItem {
|
||||||
|
mtime_ns: Some(10_000_000_000),
|
||||||
|
..local(10, 20)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&same), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a row from before the column exists compares on seconds, exactly
|
||||||
|
// as it did when it was written — no mass re-upload on upgrade.
|
||||||
|
let old = baseline(10, 20, 30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&remote), Some(&old)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_plan_covers_every_presence_and_change_shape() {
|
fn file_plan_covers_every_presence_and_change_shape() {
|
||||||
let same_local = local(10, 20);
|
let same_local = local(10, 20);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use parking_lot::Mutex;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||||
@@ -272,20 +272,50 @@ impl<R: Read> Read for OwnedCountingReader<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
|
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path),
|
||||||
|
/// and can be told to stop.
|
||||||
pub struct CountingReader<'a, R> {
|
pub struct CountingReader<'a, R> {
|
||||||
inner: R,
|
inner: R,
|
||||||
guard: &'a TransferGuard,
|
guard: &'a TransferGuard,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, R: Read> CountingReader<'a, R> {
|
impl<'a, R: Read> CountingReader<'a, R> {
|
||||||
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
||||||
Self { inner, guard }
|
Self {
|
||||||
|
inner,
|
||||||
|
guard,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon the upload as soon as the SDK asks for its next bytes when
|
||||||
|
/// `cancel` is set.
|
||||||
|
///
|
||||||
|
/// This is the only cancellation point an upload has: the SDK takes a plain
|
||||||
|
/// blocking [`Read`] and pulls from it a block at a time, so refusing to
|
||||||
|
/// hand over the next block is what stops it. Used when a newer revision of
|
||||||
|
/// the same file is queued — the bytes on the wire have already been
|
||||||
|
/// superseded, and finishing them only spends uplink on a revision the next
|
||||||
|
/// op replaces.
|
||||||
|
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
|
||||||
|
self.cancel = Some(cancel);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Read> Read for CountingReader<'_, R> {
|
impl<R: Read> Read for CountingReader<'_, R> {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
if let Some(cancel) = &self.cancel
|
||||||
|
&& cancel.load(Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||||
|
// and the SDK would seal a truncated revision from it.
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Interrupted,
|
||||||
|
"upload superseded by a newer write",
|
||||||
|
));
|
||||||
|
}
|
||||||
let n = self.inner.read(buf)?;
|
let n = self.inner.read(buf)?;
|
||||||
self.guard.add(n as u64);
|
self.guard.add(n as u64);
|
||||||
Ok(n)
|
Ok(n)
|
||||||
|
|||||||
@@ -153,8 +153,9 @@ Because Proton Drive does not support partial byte writes, modified files must b
|
|||||||
2. **Close/Release (`queue_revision`):** When the application closes the file descriptor, the daemon:
|
2. **Close/Release (`queue_revision`):** When the application closes the file descriptor, the daemon:
|
||||||
- Fetches any untouched gaps from the remote base file to compile the full file.
|
- Fetches any untouched gaps from the remote base file to compile the full file.
|
||||||
- Durably publishes the scratch data and authored-range sidecar into `staging` under a `{uid}-{millis}-{counter}` name. Temporary data and metadata are synced before atomic rename, and their directory is synced before the source is removed.
|
- Durably publishes the scratch data and authored-range sidecar into `staging` under a `{uid}-{millis}-{counter}` name. Temporary data and metadata are synced before atomic rename, and their directory is synced before the source is removed.
|
||||||
- Transactionally queues or supersedes a pending database operation (`PendingOp`), so insertion failure cannot erase the previously acknowledged upload. For `OP_REVISION` ops, execution is debounced by 2 seconds (`DRAIN_REVISION_DEBOUNCE = 2s`) to give rapid follow-up writes (e.g. `aria2c` preallocation followed by writing) time to supersede the staged blob before network transmission.
|
- Transactionally queues or supersedes a pending database operation (`PendingOp`), so insertion failure cannot erase the previously acknowledged upload. For `OP_REVISION` ops, execution is debounced to give rapid follow-up writes (e.g. `aria2c` preallocation followed by writing) time to supersede the staged blob before network transmission. The debounce is adaptive (`Core::revision_debounce`): it starts at `DRAIN_REVISION_DEBOUNCE = 2s` and, once a node has been uploaded, widens toward how long that upload actually took, bounded by `DRAIN_REVISION_DEBOUNCE_MAX = 60s`. A file saved faster than it can be sent therefore supersedes in the queue rather than mid-upload.
|
||||||
3. **Async Drain Thread (`run_pending_drain`):** The background drain worker picks up the database operations queue, handles revision uploads, resolves conflicts, and cleans up staging files. Upon landing a revision upload (`refresh_after_upload`), it rebaselines both still-queued ops (`rebaseline_pending`) and open write handles targeting the same node to prevent false self-conflict copies on subsequent writes.
|
- Signals `Core::cancel_upload` for the node before touching the queue. A drain worker may already be reading the blob this write supersedes; the flag is read by the upload's `CountingReader`, which refuses the SDK's next block. `queue_trash` and `discard_queued_ops` do the same, because they unlink the blob outright.
|
||||||
|
3. **Async Drain Threads (`run_pending_drain`):** `DRAIN_WORKERS = 3` background workers share the operations queue. They pick work up through `Db::claim_next_due_op`, which marks the row `claimed_at` in the same transaction that selects it and excludes any op whose uid another worker already holds — ordering only has to hold *per node*, and that exclusion is what guarantees it. Worker 0 additionally runs the queue's idle chores (LRU touch flush, `recover_fsynced_writes`). A claim is process-local state: the single-writer `flock` means one found at open belongs to a crashed run, and `Db::clear_op_claims` drops the lot so those ops are not invisible forever. Each worker handles revision uploads, resolves conflicts, and cleans up staging files. Upon landing a revision upload (`refresh_after_upload`), it rebaselines both still-queued ops (`rebaseline_pending`) and open write handles targeting the same node to prevent false self-conflict copies on subsequent writes.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
|
|||||||
+878
-19
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,455 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the Linux client since the 1.0.0 release. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/1.1.0/); versions follow semver, and every
|
||||||
|
release is tagged `vX.Y.Z` with `Cargo.toml` and `packaging/PKGBUILD` in agreement.
|
||||||
|
|
||||||
|
`Schema:` lines record the SQLite `SCHEMA_VERSION` (`pdfs-core/src/db/migrations.rs`) a
|
||||||
|
release ships. Migrations are forward-only — a database written by a newer client is a hard
|
||||||
|
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
||||||
|
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||||
|
|
||||||
|
## [1.8.2] — 2026-08-17
|
||||||
|
|
||||||
|
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
|
||||||
|
audit entries that had been sitting as "fixed in code, verification pending" since 1.0 (B49–B55).
|
||||||
|
No schema change. Schema: **28**.
|
||||||
|
|
||||||
|
The read path no longer assumes a revision is a series of 4 MiB blocks; it takes the geometry from
|
||||||
|
the revision. The B85 investigation also **contradicted its own bug report** — see Notes, because
|
||||||
|
the conclusion recorded there was that reads of such files were losing data, and they were not.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A folder switched from mirror to on-demand now shows the files the mirror pass itself uploaded.
|
||||||
|
The sync engine created nodes on the remote without invalidating the parent's cached child
|
||||||
|
listing, and that listing is a database flag rather than an in-memory cache — so it survived a
|
||||||
|
daemon restart, and the files stayed invisible until the folder was switched back. Every remote
|
||||||
|
mutation the engine makes (folder create, file upload, and the three trash paths) now drops the
|
||||||
|
parent's cached listing by uid, which reaches a folder that no mount has resident — the case a
|
||||||
|
mirror folder always is (`docs/BUGS.md` B86).
|
||||||
|
- `pdfs ls` and `pdfs refresh` accept a path under any mount the daemon owns, not only the primary
|
||||||
|
one. Both previously answered "is not under the mountpoint" for anything inside a secondary
|
||||||
|
on-demand mount, which left a folder serving a stale listing with no manual way to re-enumerate
|
||||||
|
it. A path under nothing this daemon has mounted still gets the same error as before.
|
||||||
|
- A file whose blocks are not 4 MiB is no longer read twice over. Block offsets came from
|
||||||
|
`index * 4 MiB`, so on a revision laid out differently every block the client asked for
|
||||||
|
straddled two of the revision's own, and each was fetched and decrypted to answer for one — then
|
||||||
|
cached at boundaries the next read would not ask about. Offsets now come from the revision's
|
||||||
|
recorded block sizes, and the on-disk cache, the in-memory ring and the in-flight fetch key
|
||||||
|
address a block by the byte range it covers rather than by its index (`docs/BUGS.md` B85).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The block cache records each block's plaintext start alongside its `(mtime, size)` tag, and each
|
||||||
|
revision's block table in a small sidecar beside its blocks. Blocks cached by an earlier release
|
||||||
|
carry no start; they are honoured wherever the old 4 MiB assumption and the new plan agree on
|
||||||
|
the offset, which is every file this client uploaded — so the existing cache survives the
|
||||||
|
upgrade intact and is quietly refetched only for the files it was placing wrongly.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- **B85's diagnosis was wrong, and the entry has been corrected rather than closed.** It read a
|
||||||
|
set of journal lines as proof that this client was requesting wrong byte ranges and being saved
|
||||||
|
only by B84's repair path. `RevisionReader::read_at` does not work that way: it plans over the
|
||||||
|
revision's real block sizes and clamps every block to the range asked for, identically in the
|
||||||
|
0.6.0 the client builds against and in every earlier version. A read can therefore never return
|
||||||
|
more bytes than it requested — yet every quoted line shows exactly that (`blen=4194304
|
||||||
|
got=4939506`), and the one place in the client that logs that shape can only reach it on the
|
||||||
|
opposite condition. The numbers were either transcribed with two fields swapped or captured from
|
||||||
|
a build not in the history; they want re-capturing from the journal before anything is concluded
|
||||||
|
from them. What was real is the assumption itself, and that is what this release removes — a
|
||||||
|
cost fix, not a correctness one. Nothing here changes what a read returns.
|
||||||
|
- The first read of a file still plans on the 4 MiB assumption, and learns the real geometry as a
|
||||||
|
side effect of opening the reader it needs anyway. Resolving a reader merely to *plan* would
|
||||||
|
reintroduce the per-read key derivation the block cache exists to avoid, so the first read of a
|
||||||
|
non-4-MiB file pays one straddling fetch and then self-corrects.
|
||||||
|
- Seven data-safety audit entries (B49–B55) moved from "fixed in code, verification pending" to
|
||||||
|
verified. The fixes were all genuinely present and needed no change; what they lacked was the
|
||||||
|
injected-failure testing each entry asked for, and that is what was written — unwritable
|
||||||
|
directories and `chmod 000` subtrees for permission failures, a real `/dev/shm`↔`/tmp` device
|
||||||
|
boundary for the cross-filesystem fallbacks, SQLite's `max_page_count` for a full disk, an abort
|
||||||
|
trigger for a failed insert, and truncated and unpublished sidecars for the crash boundaries.
|
||||||
|
Two seams were added to make failure arrangeable at all: `stage_write_at`, mirroring the
|
||||||
|
`preserve_write_at` that already existed, and `walk_local_tree` lifted out of the sync engine so
|
||||||
|
the local scan can be driven against a real tree without a daemon.
|
||||||
|
- **Two of those entries are not fully closed, and say so.** B51 and B52 concern crash consistency,
|
||||||
|
and nothing short of a machine actually losing power tests that; the crash *boundaries* are
|
||||||
|
covered by reconstructing on disk exactly what a crash at each point leaves. B54 also diverges
|
||||||
|
from its required fix on purpose: it has no "deliberate delete" confirmation path, so a total
|
||||||
|
local wipe is refused outright rather than offered for confirmation. That is stricter than asked
|
||||||
|
for, and the cost is that a user who really did delete everything must remove and re-add the
|
||||||
|
sync folder.
|
||||||
|
- B86's fix routes only `ls` and `refresh` to the owning mount. The other path-addressed control
|
||||||
|
requests — pin, rename, move, delete, sharing — still resolve against the primary mount only.
|
||||||
|
That is a larger change and was not what blocked the repro; it is recorded as an open follow-up
|
||||||
|
(`docs/BUGS.md` B88).
|
||||||
|
- Three findings were opened rather than fixed: the geometry above is learned one read late, so a
|
||||||
|
non-4-MiB file pays a single wasted fetch on its first read (B87); the remaining path-addressed
|
||||||
|
control requests are still primary-only (B88); and the container-level `CLAUDE.md` still
|
||||||
|
describes the SDK dependency as 0.1.11 when 1.7.1 bumped it to 0.6.0, which is what sent the
|
||||||
|
B85 investigation to the wrong source tree to begin with (B89).
|
||||||
|
|
||||||
|
## [1.8.1] — 2026-08-16
|
||||||
|
|
||||||
|
Some files read short through the mount and reported success — an application got a truncated
|
||||||
|
copy with nothing anywhere saying so (`docs/BUGS.md` B84). Found because GNOME's file indexer
|
||||||
|
kept complaining that PNGs under `~/ProtonDrive/Pictures` were corrupt; it was right. The rest
|
||||||
|
of this release is the long-standing architectural backlog from the July concurrency audits
|
||||||
|
(B25, B32, B39, B40, B43, B44), each verified against the current code before being fixed.
|
||||||
|
Schema: **28** — migration V28 adds `sync_entry.local_mtime_ns`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A read no longer trusts a revision whose recorded block sizes disagree with the file's size.
|
||||||
|
Such a file leaves the block/range path entirely: it is fetched through the whole-file,
|
||||||
|
manifest-verified download — the same path `pdfs pin` uses, and the reason pinning was the
|
||||||
|
workaround — and served from the resulting cached blob. One download per file however many
|
||||||
|
reads discover the problem at once, and files above 512 MiB fail `EIO` with a note to pin
|
||||||
|
them rather than pulling down gigabytes for one read.
|
||||||
|
- A block that comes back shorter than the file's size implies is never served, cached or kept
|
||||||
|
in memory. Assembling a read out of short blocks is what quietly produced the truncation:
|
||||||
|
the missing bytes were simply not appended, the kernel handed userspace a short read, and
|
||||||
|
every tool reads a short read as end-of-file.
|
||||||
|
- A synced folder no longer reports the same deletion as an error on every pass. Deleting a
|
||||||
|
local file that something else had already deleted counted as a failure, which kept its
|
||||||
|
baseline and made the next pass try again — forever. A path that is already gone is the
|
||||||
|
state the sync pass wanted (`docs/BUGS.md` B42).
|
||||||
|
- A mirror folder no longer misses an edit made in the same second as its last sync. Change
|
||||||
|
detection compared whole seconds, so rewriting a file in place without changing its length
|
||||||
|
read as "unchanged" and never uploaded — the remote silently stayed behind until something
|
||||||
|
else about the file moved. Baselines now record the modification time to nanoseconds
|
||||||
|
(`docs/BUGS.md` B25).
|
||||||
|
- A sync download no longer overwrites an edit that finished while it was running. The
|
||||||
|
destination is re-checked in the last moment before the download is published, and a local
|
||||||
|
file that has moved on since the pass planned for it is kept as a conflict copy — the same
|
||||||
|
resolution a both-sides-changed conflict gets (`docs/BUGS.md` B39).
|
||||||
|
- An upload interrupted by a writer no longer reports itself as synchronized. The baseline now
|
||||||
|
records the content that actually went up rather than whatever is on disk when the transfer
|
||||||
|
ends, so a revision torn by a concurrent write is replaced by a clean one on the next pass
|
||||||
|
instead of standing as the file's content on Drive (`docs/BUGS.md` B40).
|
||||||
|
- Listing a directory that changes while it is being read no longer skips or repeats entries.
|
||||||
|
`readdir` resumed by index into a listing rebuilt on every call, so a create or a trash
|
||||||
|
between two pages shifted everything after it. The listing is now frozen for the life of the
|
||||||
|
directory handle (`docs/BUGS.md` B43).
|
||||||
|
- Opening a file for writing while a previous close is still publishing no longer discards that
|
||||||
|
close's revision. The base is re-checked at the moment the write handle is installed, rather
|
||||||
|
than trusted across a copy that can be the size of the whole file (`docs/BUGS.md` B32).
|
||||||
|
- Unmounting now stops and joins the daemon's background workers instead of leaving them
|
||||||
|
running. The drain, sync engine, conflict sweep, online probe, local indexer and control
|
||||||
|
listener share a stop signal and interruptible waits, so an in-process remount no longer
|
||||||
|
starts a second set on top of the first (`docs/BUGS.md` B44).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The size `stat` reports was always the correct one; it is the reader's belief about where
|
||||||
|
blocks end that was wrong, so nothing about reported sizes changes.
|
||||||
|
- Verified live on the affected account: four multi-block PNGs that had been reading short now
|
||||||
|
deliver their full size, and the journal shows each one repaired. The diagnostics also
|
||||||
|
answered the open question, and not as expected — the revision metadata is fine; those files
|
||||||
|
simply do not have 4 MiB blocks, and this client assumes they do. Filed as `docs/BUGS.md`
|
||||||
|
B85. Until that is fixed, a file with non-4-MiB blocks is downloaded whole on its first read.
|
||||||
|
- Migration V28's new column is nullable rather than converted from the existing seconds. A
|
||||||
|
baseline row written before this release has no sub-second time to recover, and inventing
|
||||||
|
zero for it would make every already-synced file compare as changed and re-upload an entire
|
||||||
|
mirror on first start; `NULL` keeps those rows comparing exactly as they did, and each gains
|
||||||
|
a real value the next time its path syncs.
|
||||||
|
- Three audit entries closed without a code change, having been fixed already or being correct
|
||||||
|
as they stand: sync-folder removal is serialized against reconciliation (B41), the drain's
|
||||||
|
database contention was removed by the read-only connection pool and the claim column (B30),
|
||||||
|
and the refusals for symlinks, hard links, device nodes and `chmod` are deliberate — Drive
|
||||||
|
cannot represent them, and a local-only representation no other Proton client can read would
|
||||||
|
be worse than a clean refusal (B31).
|
||||||
|
- Six of this release's fixes were then driven live against the installed 1.8.1 on a real
|
||||||
|
account: the same-second edit uploads (B25), three rapid rewrites of a 64 MiB file publish the
|
||||||
|
third one byte for byte (B32), a write racing a download survives as a conflict copy (B39), an
|
||||||
|
upload torn by a concurrent write heals itself on the following pass (B40), a `getdents64`
|
||||||
|
walk with a 160-byte buffer against a directory mutated between pages returns every entry
|
||||||
|
exactly once (B43), and a restart of a daemon with six mounts and a busy drain stops in 110 ms
|
||||||
|
and comes back at the same thread count (B44). B42 could not be driven from a single machine —
|
||||||
|
see B86 below.
|
||||||
|
- One new bug came out of the verification: an on-demand mount serves a folder listing that
|
||||||
|
predates the sync engine's own uploads, so files the mirror pass created are invisible until
|
||||||
|
the folder is switched back (`docs/BUGS.md` B86). Nothing is lost — they are on the server and
|
||||||
|
come back on the next mirror pass — but they cannot be seen or named while the folder is
|
||||||
|
on-demand, which is also what blocks B42's live verification.
|
||||||
|
|
||||||
|
## [1.8.0] — 2026-08-16
|
||||||
|
|
||||||
|
A queued write whose node had left the local tree was retried every five seconds forever and
|
||||||
|
counted as an ordinary pending upload — 28 of them, holding 18.4 GiB, had been doing that for
|
||||||
|
30 days on the machine that found it (`docs/BUGS.md` B83). Schema: 27 — migration V27 adds
|
||||||
|
`pending_op.access_deferred_since`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The drain no longer reads "I have never heard of this node" as "you may not write this
|
||||||
|
node". A missing authority is now resolved against the remote: the node is re-interned if
|
||||||
|
it is still there, and reported as a real failure if it is gone. Ops that had been stuck
|
||||||
|
invisibly for weeks now say what is wrong with them on the first pass.
|
||||||
|
- A create or mkdir resolves its *parent* as the authority, so the refetch asks about the
|
||||||
|
folder rather than the node it has not made yet.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Access deferrals are bounded. A deferral still costs no attempt and records no error for
|
||||||
|
the first five minutes — the case it exists for is a share downgraded mid-queue and undone
|
||||||
|
a moment later — but past that window each recheck records a failure, so the op enters the
|
||||||
|
ordinary backoff and appears in `failing_ops` and the `pdfs status` error text like any
|
||||||
|
other stuck operation. The first deferral of a run is logged at `warn` rather than `debug`.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- No staged data is touched by any of this. A reported failure keeps its `pending_op` row and
|
||||||
|
its staged blob exactly as before; the change is in what the user is told, and in the queue
|
||||||
|
no longer waiting on a permission change that was never coming.
|
||||||
|
|
||||||
|
## [1.7.1] — 2026-08-16
|
||||||
|
|
||||||
|
Write-back throughput follow-up to 1.7.0, same audit
|
||||||
|
(`docs/plans/audit-2026-08-15-perf-locks.md`). Schema: 26 — migration V26 adds
|
||||||
|
`pending_op.claimed_at`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Parallel write-back drain: three workers share the queue through a `pending_op.claimed_at`
|
||||||
|
claim column (migration V26), so a large upload no longer holds every queued rename, trash
|
||||||
|
and small write behind it. Ordering is preserved per node — the claim query never offers
|
||||||
|
two workers ops for the same uid — and claims left by a crashed run are cleared at open.
|
||||||
|
- An upload already on the wire is cancelled when a newer write, a trash or a discard
|
||||||
|
supersedes it, instead of spending the uplink on a revision the next operation replaces.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The revision debounce is adaptive: it widens toward how long that node's last upload
|
||||||
|
actually took (bounded to 2–60 s), so a file saved faster than it can be sent supersedes
|
||||||
|
in the queue rather than mid-upload.
|
||||||
|
- Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.6.0.
|
||||||
|
|
||||||
|
## [1.7.0] — 2026-08-16
|
||||||
|
|
||||||
|
Performance and robustness pass over the mount, from the architecture audit in
|
||||||
|
`docs/plans/audit-2026-08-15-perf-locks.md`. Schema: 25.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Sequential-read prefetch (`Core::prefetch_ahead`): per-file sequential detection, depth
|
||||||
|
doubling 2→8, reset to 0 after a seek, skipping blocks already cached, and bounded by its
|
||||||
|
own permit budget so it can never queue ahead of a demand read.
|
||||||
|
- Single-flight block fetching (`BlockFlight`) keyed on `(uid, mtime, size, block)`, so a
|
||||||
|
demand read, kernel read-ahead and prefetch of the same block share one download+decrypt.
|
||||||
|
- In-memory block LRU (`BlockRing`) in front of *both* block paths; a block read off disk is
|
||||||
|
promoted into it, so the following ~31 kernel reads of that 4 MiB block cost nothing.
|
||||||
|
- Read-only SQLite connection pool (`ReadPool` / `Db::read`) with every `SELECT`-only method
|
||||||
|
routed at it, so a read no longer waits on the write connection.
|
||||||
|
- `nodes.path` persisted and backfilled, replacing the recursive `path_of` walk.
|
||||||
|
- Single-writer `flock` on the database; a `SQLITE_CORRUPT`/`NOTADB` file is quarantined as
|
||||||
|
`cache.db.corrupt-<ts>` (WAL sidecars too) and rebuilt rather than failing the mount.
|
||||||
|
- `VACUUM` and the deep `integrity_check` now ack immediately and run as background jobs;
|
||||||
|
`pdfs doctor` polls for the verdict.
|
||||||
|
- Staging reconcile at mount: staged blobs no queued operation refers to are re-queued, or
|
||||||
|
named in the log when they cannot be addressed.
|
||||||
|
- Queue/staging visibility — `Status` gained `parked_uploads`, `failing_ops`,
|
||||||
|
`failing_error`, `staged_bytes`, `staged_oldest_secs`; `pdfs status` prints them when
|
||||||
|
non-zero.
|
||||||
|
- `statfs` implemented against the account quota; FUSE `init` negotiates `max_readahead`,
|
||||||
|
`max_background`, `PARALLEL_DIROPS` and `CACHE_SYMLINKS`; `blksize` raised to 1 MiB.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `State` no longer writes to SQLite under the inode lock — mutations queue into an outbox
|
||||||
|
applied after the lock is released, so interning a 5000-child listing no longer commits a
|
||||||
|
transaction with the whole mount frozen.
|
||||||
|
- Re-listing an unchanged folder no longer touches its subtree; when a subtree really moved,
|
||||||
|
one recursive walk carries both the new path and the inherited trashed flag.
|
||||||
|
- `release(2)` no longer gap-fills from the network: it stages an incomplete blob from local
|
||||||
|
caches only and the drain thread finishes it off the dispatch loop.
|
||||||
|
- A partial write over an undrained one is merged instead of refused.
|
||||||
|
- Size-upgrade waiters park on a queue instead of occupying a worker thread, capped at 8
|
||||||
|
folders in flight; past the cap `stat` answers provisionally.
|
||||||
|
- Block-cache disk writes moved off the read's critical path; no more fsync per cached block.
|
||||||
|
- Cache LRU touches buffered in memory and flushed in one transaction.
|
||||||
|
- Search: sync-folder list and pin lookups hoisted out of the per-hit loop.
|
||||||
|
- SQLite tuned (`cache_size`, `mmap_size`, `temp_store=MEMORY`, `wal_autocheckpoint`) and
|
||||||
|
three new indexes added.
|
||||||
|
- Mount registry lock released before FUSE teardown/join; kernel notifications batched under
|
||||||
|
the state lock and flushed after it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Data loss:** `ENOSPC` is now propagated from `write`/`fsync`/staging instead of being
|
||||||
|
swallowed, with emergency eviction wired up and scratch marked durable before any staging
|
||||||
|
error; a failed `record_pending_write` fails `release` rather than dropping the write.
|
||||||
|
- **Data loss:** the conflict sweep no longer discards queued operations after the trash
|
||||||
|
round trip.
|
||||||
|
- **Data loss:** `rescue_scratch` moves a blob with an unreadable sidecar to `recovery/`
|
||||||
|
unaccompanied instead of fabricating a sidecar for it.
|
||||||
|
- Own-sealed revisions are persisted (7-day TTL), so a restart no longer reopens the
|
||||||
|
transient-download fork window fixed in 1.1.0.
|
||||||
|
- Depth-capped `is_pinned` (cycle-safe), daemon write timeout, and every client-supplied
|
||||||
|
`limit` clamped.
|
||||||
|
- `recover_fsynced_writes` no longer scans the recovery directory on every idle pass.
|
||||||
|
|
||||||
|
## [1.6.0] — 2026-08-12
|
||||||
|
|
||||||
|
Search-index correctness and ranking (`docs/plans/search-index.md`). Schema: 21 —
|
||||||
|
migration V21 rebuilds `nodes_fts`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **B80** — 29% of the account was missing from the search index. `node_is_indexable_tx`
|
||||||
|
required a null-parent ancestor, which only the My Files root has, so every node under a
|
||||||
|
device folder was dropped from `nodes_fts` on its first upsert after the v16 backfill
|
||||||
|
(2,705 of 9,252 nodes on the account this was diagnosed against). The rule now matches the
|
||||||
|
backfill, migration V21 replays it, nodes under a trashed folder stay out, and the walk is
|
||||||
|
depth-capped.
|
||||||
|
- **B81** — the prompt labelled every Drive hit "My files", including device folders; pin
|
||||||
|
rows also took `is_dir` from `Pin::recursive`.
|
||||||
|
- **B82** — a `parent_uid` cycle hung the daemon on any short search: the recursive CTEs in
|
||||||
|
`path_of` and the pin walk use `UNION ALL`, which does not deduplicate. `path_of` is now
|
||||||
|
depth-capped at 256 and returns the truncated path.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Local search ranking: whole-query prefix matches get their own lane (newest first) ahead
|
||||||
|
of trigram rank, which on a home directory full of dependency trees was burying the
|
||||||
|
obvious answer — 73% of the 500 best-ranked candidates for "test" came from one Go module
|
||||||
|
cache. The single-character lane, which recovers a typo that destroys every trigram, stays
|
||||||
|
behind it.
|
||||||
|
|
||||||
|
## [1.5.0] — 2026-08-11
|
||||||
|
|
||||||
|
Schema: 20.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`pdfs-prompt --dmenu`** — the same search through an external dmenu-style launcher
|
||||||
|
(fuzzel, rofi, wofi, tofi, bemenu, dmenu) instead of the built-in GTK HUD, so the prompt
|
||||||
|
matches a tiling-WM desktop. A launcher filters a fixed list rather than re-querying per
|
||||||
|
keystroke, so searching is a two-step loop; `--query` skips the first step, fuzzel and rofi
|
||||||
|
get file-type icons. Configurable as `"prompt": { "mode": "dmenu", "menu": [...] }`, with
|
||||||
|
`--gtk` overriding for one invocation.
|
||||||
|
- **Configurable open-with rules** (`pdfs-core/src/opener.rs`): an ordered list of name/class
|
||||||
|
patterns in `config.json` under `open_with`, each mapped to argv and optionally wrapped in
|
||||||
|
a terminal, first match wins, falling back to `xdg-open`. Lets "open a text file" mean
|
||||||
|
`nvim` in a terminal without breaking every graphical caller. Absent config reproduces the
|
||||||
|
old behaviour exactly.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The prompt and dmenu openers hand applications the cached file while matching rules
|
||||||
|
against the *original* Drive filename, so e.g. `.md` files stop being routed by their
|
||||||
|
cache-path extension.
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-08-10
|
||||||
|
|
||||||
|
Schema: 20.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **File version history** (`pdfs-fuse/src/revisions.rs` + a GTK versions dialog): list a
|
||||||
|
file's revisions, restore one, delete one, or write an old one out to a local path. A
|
||||||
|
restore re-points the file server-side, so no content crosses the wire and nothing enters
|
||||||
|
the drain queue; the daemon drops its cached content and repopulates on the next read.
|
||||||
|
- **Persistent SDK entity cache** (`pdfs-core/src/sdkcache.rs`): the SDK's decrypted node
|
||||||
|
metadata now survives a daemon restart instead of re-walking the tree. It lives in its own
|
||||||
|
`sdk_cache.db`, deliberately off the daemon's hot `cache.db` mutex, holds no key material,
|
||||||
|
needs no migrations, and is cleared on logout.
|
||||||
|
- Per-node outcomes for batch operations (`pdfs-core/src/batch.rs`): `trash`/`restore`/
|
||||||
|
`delete`/`move` report one result per node, so a node the server rejects no longer fails
|
||||||
|
the whole batch.
|
||||||
|
- Photo viewer page in the GUI and richer photo metadata in the details pane.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- SDK telemetry routed into `tracing`, so its transfer/block-storage/request spans land in
|
||||||
|
the daemon log instead of a no-op sink.
|
||||||
|
- A file that fits in one block uploads as a single atomic request rather than the
|
||||||
|
draft/block/commit sequence.
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-07-31
|
||||||
|
|
||||||
|
Schema: 19.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Photo albums** — `PhotoAlbums` / `AlbumPhotos` control requests, daemon-side `albums.rs`
|
||||||
|
and a GUI albums page (the former gallery view, renamed). Albums other people share with
|
||||||
|
you are included; their photos are persisted per album because a shared album's photos
|
||||||
|
never appear in your own timeline. Served stale-while-refreshing like the timeline.
|
||||||
|
- **GUI "Locations" page** — one row per mount spec: the primary `~/ProtonDrive` mount
|
||||||
|
alongside every folder this computer backs up, mirrored or on-demand. Called *Locations*
|
||||||
|
rather than *Mounts* because a mirror folder is a plain directory with no FUSE session.
|
||||||
|
It absorbs the old Computers page's actions and the mountpoint chooser from Settings.
|
||||||
|
- `role` on shared-node metadata (`viewer`/`editor`/`admin`), defaulted for wire-compat with
|
||||||
|
older clients and daemons.
|
||||||
|
- FUSE acceptance coverage for the permission-based mounts.
|
||||||
|
|
||||||
|
## [1.2.1] — 2026-07-31
|
||||||
|
|
||||||
|
Schema: 18.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Migration bug that could leave the `ProtonDrive` mount unwritable after upgrade.
|
||||||
|
- Permission error on on-demand mounts.
|
||||||
|
- B78.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Release pipeline: `.deb` signing added and `.rpm` signing fixed.
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-07-29
|
||||||
|
|
||||||
|
Sharing and multi-mount release. Schema: 18.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Shared with me**: shares others sent you are listed, verified and mountable, including on
|
||||||
|
the primary mount.
|
||||||
|
They appear under a synthetic `Shared with me` directory on a virtual volume
|
||||||
|
(`pdfs-fuse/src/virtual.rs`), with name collisions and over-long components resolved by
|
||||||
|
its own naming rules.
|
||||||
|
- Per-share access model (`db/share_access.rs`): the effective role of each shared root is
|
||||||
|
persisted and kept resident in mount state, so interning an inode never costs a query. The
|
||||||
|
database stays the restart/offline authority — every role change is written through before
|
||||||
|
the cache updates. A share you cannot write is presented POSIX read-only.
|
||||||
|
- Unified location model (`pdfs-core/src/mounts.rs`, `db/mounts.rs`): one presentation shape
|
||||||
|
for every local Proton Drive location, joining device sync state with a new `mount` table.
|
||||||
|
`MountMode` is `Mirror` (full local copy, reconciled both ways) or `OnDemand` (FUSE session
|
||||||
|
fetching on access), with an `Unknown` fallback so a mode written by a newer client does
|
||||||
|
not break an older one.
|
||||||
|
|
||||||
|
## [1.1.1] — 2026-07-27
|
||||||
|
|
||||||
|
Schema: 16.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Trash refresh did not pull updates.
|
||||||
|
- Packaging: `PKGBUILD` was left at 1.1.0 by the 1.1.1 version bump, failing the release
|
||||||
|
workflow's tag/manifest/PKGBUILD agreement check.
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-07-26
|
||||||
|
|
||||||
|
Conflict-handling and concurrency release. Schema: 16.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Conflict sweep (`pdfs-fuse/src/sweep.rs`, 5-minute cadence): removes conflict copies proven
|
||||||
|
identical by size + `content_sha1`, surfaces divergent ones once as an activity entry, and
|
||||||
|
never removes what it cannot prove.
|
||||||
|
- Sweep mode and `.pdfsignore`-driven conflict handling; ignore rules are rebuilt per sync
|
||||||
|
pass so edits take effect without a restart.
|
||||||
|
- Substantially extended FUSE acceptance suite.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **B69** — spurious `(sync-conflict)` copies. Revision identity now uses the server's
|
||||||
|
`revision_id` rather than `(mtime, size)`, which the server re-stamps on seal; the planner
|
||||||
|
gained `AdoptBaseline` so an equal-size no-baseline pair is recorded instead of forked on
|
||||||
|
first reconcile.
|
||||||
|
- **B70** — browser downloads into an on-demand mount had their in-flight temporaries sealed
|
||||||
|
as canonical revisions. Transient names are recognised and held until the name is finalized.
|
||||||
|
- Assorted race conditions and concurrency bugs on the write/drain paths.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.2.2.
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-07-22
|
||||||
|
|
||||||
|
First stable release: FUSE files-on-demand mount, sync daemon under `proton-drive.service`,
|
||||||
|
`pdfs` CLI and the GTK4 app, on top of the pure-Rust Proton Drive SDK. Schema: 16.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
|
||||||
|
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
|
||||||
|
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
|
||||||
|
[1.7.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.7.1
|
||||||
|
[1.7.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.7.0
|
||||||
|
[1.6.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.6.0
|
||||||
|
[1.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.5.0
|
||||||
|
[1.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.4.0
|
||||||
|
[1.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.3.0
|
||||||
|
[1.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.2.1
|
||||||
|
[1.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.2.0
|
||||||
|
[1.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.1.1
|
||||||
|
[1.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.0.0
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# Search index + prompt display fixes
|
|
||||||
|
|
||||||
Working plan. Findings come from an audit of a real `cache.db`
|
|
||||||
(9,252 Drive nodes, 110,187 local files) on 2026-08-12.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
```
|
|
||||||
nodes 9252
|
|
||||||
nodes_fts 6547 ← 2705 rows never indexed
|
|
||||||
nodes in orphan subtrees 2705 ← exactly the missing set
|
|
||||||
local_files 110187
|
|
||||||
/home/narl/go/pkg/mod 63399 (58% of the local index)
|
|
||||||
query "test" 46405 local matches, candidate pool capped at 1000
|
|
||||||
top 500 by fts rank 367 (73%) from go/pkg/mod
|
|
||||||
```
|
|
||||||
|
|
||||||
`pdfs search tickets` returns only `Documents/Tickets` (My Files); the
|
|
||||||
`tickets.pdf` sitting in the device folder `Downloads` is invisible.
|
|
||||||
|
|
||||||
## P1 — 29% of Drive is unsearchable
|
|
||||||
|
|
||||||
`node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs:606`) indexes a node
|
|
||||||
only when the ancestor walk terminates at a row with `parent_uid IS NULL`. The
|
|
||||||
DB holds exactly one such row (the My Files root). Device roots live in
|
|
||||||
`device`/`sync_folder`, never in `nodes`, so every device-folder subtree
|
|
||||||
(`Downloads`, `Pictures`, `Documents`, `Music`, `Videos` under device
|
|
||||||
*nthinkpad*) walks up to a `parent_uid` with no row and is judged unindexable.
|
|
||||||
Stale `pdfs-acceptance-*` roots hit the same path.
|
|
||||||
|
|
||||||
- [x] Relax the rule to "the walk terminates and no ancestor is trashed",
|
|
||||||
keeping the existing cycle guard. A subtree whose top row's parent is
|
|
||||||
simply not cached is still real, reachable data.
|
|
||||||
- [x] `MIGRATION_V21`: rebuild `nodes_fts` under the new rule, so existing
|
|
||||||
installs recover the missing rows instead of waiting for each node to be
|
|
||||||
upserted again.
|
|
||||||
- [x] Tests: a node under an uncached parent is indexed; a trashed ancestor
|
|
||||||
still excludes; the migration backfills.
|
|
||||||
|
|
||||||
### Found while testing: a parent cycle hangs the daemon
|
|
||||||
|
|
||||||
Writing the cycle test surfaced a live defect, not a test artefact. A query
|
|
||||||
below the trigram minimum takes the `LIKE` lane, which reads `nodes` directly
|
|
||||||
and never consults the index, and every row it returns goes through `path_of` —
|
|
||||||
whose parent walk is an unguarded `UNION ALL`. On a `parent_uid` cycle it never
|
|
||||||
terminates, while holding the daemon's only SQLite connection.
|
|
||||||
|
|
||||||
- [x] Depth-cap `path_of` (`db/utils.rs`), matching the 1024 cap
|
|
||||||
`path_relative_to` already carried.
|
|
||||||
- [x] Depth-cap the `descendants` CTE in `upsert_node_tx`, which has the same
|
|
||||||
exposure on the write side.
|
|
||||||
- [x] Keep cycles out of the index (part of the P1 rule above).
|
|
||||||
|
|
||||||
Still unguarded, not touched here: `pin_is_pinned`'s ancestor walk.
|
|
||||||
|
|
||||||
## P2 — local index noise crowds real files out of the candidate pool
|
|
||||||
|
|
||||||
`SKIP_DIRS` (`crates/pdfs-core/src/localindex.rs:23`) misses the Go module
|
|
||||||
cache and build output dirs. `search_v2` clamps candidates to ≤1000 and scores
|
|
||||||
*after* SQLite, so for any common term the pool is exhausted by vendored files
|
|
||||||
before a real one is considered.
|
|
||||||
|
|
||||||
- [x] Skip `go/pkg/mod` (path-relative, not a bare name), `dist`, `build`,
|
|
||||||
`.cache`-style artefact dirs.
|
|
||||||
- [x] Give `search_local_candidates` a name-prefix lane, mirroring what
|
|
||||||
`search_candidates` already does for Drive, so an exact-prefix match
|
|
||||||
cannot be starved by trigram rank.
|
|
||||||
- [x] Tests for both.
|
|
||||||
|
|
||||||
## P3 — display lies about where a hit lives
|
|
||||||
|
|
||||||
- [x] `Hit::location()` (`crates/pdfs-gui/src/prompt.rs:150`) hardcodes
|
|
||||||
`My files / …` for every Drive hit. Derive the label from
|
|
||||||
`mounted_path` when the daemon resolved one (device folder, sync folder)
|
|
||||||
and fall back to `My files` only for the primary mount.
|
|
||||||
- [x] Pin rows set `is_dir: pin.recursive` (`dmenu.rs:133`, `prompt.rs:872`) —
|
|
||||||
a non-recursively pinned folder renders and activates as a file.
|
|
||||||
- [ ] `pdfs locations` prints `Device folder 1..5` rather than the device and
|
|
||||||
folder name. (Cosmetic; deferred.)
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Done, 2026-08-12:
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo fmt --all -- --check clean
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings clean
|
|
||||||
cargo test --workspace --locked 439 passed, 0 failed
|
|
||||||
```
|
|
||||||
|
|
||||||
The V21 backfill was also replayed against a read-only `.backup` of the live
|
|
||||||
`cache.db`:
|
|
||||||
|
|
||||||
```
|
|
||||||
live_nodes 9276
|
|
||||||
indexed 9276 (was 6547)
|
|
||||||
downloads_subtree 300
|
|
||||||
tickets.pdf -> Downloads/tickets.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
That run is what caught the last defect: the backfill originally seeded an
|
|
||||||
uncached-parent root with an empty path, so a node indexed by the migration
|
|
||||||
(`tickets.pdf`) and the same node after a later upsert (`Downloads/tickets.pdf`)
|
|
||||||
disagreed. The seed now keeps the name for any row that has a parent, matching
|
|
||||||
`path_of`.
|
|
||||||
|
|
||||||
Still to do, on the live system: rebuild, restart `proton-drive.service`, and
|
|
||||||
confirm `pdfs search tickets` returns the device folder's `tickets.pdf`. The
|
|
||||||
schema move to 21 is one-way — an older `pdfs` refuses a v21 database. The
|
|
||||||
`local_files` shrink (~110k → ~47k) lands with the next background scan.
|
|
||||||
+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.7.0
|
pkgver=1.8.2
|
||||||
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')
|
||||||
|
|||||||
Reference in new issue
Block a user