mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-06 01:12:10 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78dc72a34d | ||
|
|
438b2b463e | ||
|
|
4cd49168c4 | ||
|
|
481c3fe0c7 | ||
|
|
098da2e97a | ||
|
|
d03d3eac3f | ||
|
|
433e206203 |
No files matched your search
Generated
+30
-8
@@ -2200,6 +2200,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -2367,6 +2386,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -3518,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.4",
|
||||
@@ -3533,7 +3553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"directories",
|
||||
@@ -3554,7 +3574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"futures",
|
||||
@@ -3571,7 +3591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3832,9 +3852,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d"
|
||||
checksum = "fe0e39da5b61ceb862af2f4406870b9c1d9a1554889e8d40a32fff13882ec690"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -3859,9 +3879,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3"
|
||||
checksum = "97cde8cf8e3099d1dbb56302462a632a12e0ff818c012b9c9b0a7a5200544fd6"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
@@ -4252,6 +4272,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -5110,6 +5131,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
+3
-3
@@ -3,15 +3,15 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.5.1" }
|
||||
proton-drive-rs = { version = "0.5.1" }
|
||||
proton-sdk = { version = "0.6.0" }
|
||||
proton-drive-rs = { version = "0.6.0" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -84,7 +84,11 @@ const TOUCH_FLUSH_AT: usize = 256;
|
||||
|
||||
/// How long block `idx` of a file of `size` bytes must be: a full
|
||||
/// [`BLOCK_SIZE`] except for the last one, which holds the remainder.
|
||||
fn block_len(size: u64, idx: u64) -> u64 {
|
||||
///
|
||||
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
||||
/// this cache: the network read path validates against it too, so a block that
|
||||
/// comes back short is refused before it can truncate a file (bugs.md B84).
|
||||
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||
}
|
||||
@@ -651,6 +655,12 @@ impl ContentCache {
|
||||
/// Store `bytes` as cached block `idx` of `uid`, tagged `(mtime, size)`.
|
||||
/// Temp-file-then-rename like [`store`](Self::store); meta written last so a
|
||||
/// crash mid-store fails validation. Enforces the block-cache LRU budget.
|
||||
///
|
||||
/// The caller owes it a block of exactly [`block_len`] bytes; anything else
|
||||
/// reads back as a miss ([`cached_block`](Self::cached_block) checks), so a
|
||||
/// bad block cannot escape this cache even though this does not check.
|
||||
/// Validating a block is the *reader's* job, at the point it is fetched —
|
||||
/// see `Core::read_block` (bugs.md B84).
|
||||
pub fn store_block(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
@@ -2248,6 +2258,34 @@ mod tests {
|
||||
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
|
||||
}
|
||||
|
||||
/// The geometry the whole read path validates against, here and over the
|
||||
/// network (bugs.md B84): full blocks, then whatever is left.
|
||||
#[test]
|
||||
fn block_len_is_full_blocks_then_the_remainder() {
|
||||
let size = BLOCK_SIZE * 2 + 17;
|
||||
assert_eq!(block_len(size, 0), BLOCK_SIZE);
|
||||
assert_eq!(block_len(size, 1), BLOCK_SIZE);
|
||||
assert_eq!(block_len(size, 2), 17);
|
||||
// Past the end there is no block, not a short one.
|
||||
assert_eq!(block_len(size, 3), 0);
|
||||
// An exact multiple has no short tail at all.
|
||||
assert_eq!(block_len(BLOCK_SIZE, 0), BLOCK_SIZE);
|
||||
assert_eq!(block_len(BLOCK_SIZE, 1), 0);
|
||||
}
|
||||
|
||||
/// A block of the wrong length cannot escape the cache even though the
|
||||
/// store does not check: the read validates it and reports a miss, so the
|
||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
||||
#[test]
|
||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||
let (c, _d) = cache();
|
||||
let u = uid("a");
|
||||
// A 10-byte file's only block is 10 bytes; 5 is not a short tail.
|
||||
c.store_block(&u, 100, 10, 0, b"block").unwrap();
|
||||
assert!(c.cached_block(&u, 100, 10, 0).is_none());
|
||||
assert!(!c.has_block(&u, 100, 10, 0));
|
||||
}
|
||||
|
||||
/// The block cache is written without an fsync, so a crash can leave a blob
|
||||
/// shorter than the sidecar beside it claims. Serving that as content would
|
||||
/// hand the reader a hole; it has to read as a miss.
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// 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 {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -161,6 +161,63 @@ impl Db {
|
||||
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(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -725,3 +782,67 @@ UPDATE nodes
|
||||
|
||||
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),
|
||||
};
|
||||
Ok(Self {
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
// No pool for `:memory:`. A second connection to that name is a
|
||||
// second, empty database rather than another view of this one, so
|
||||
@@ -240,7 +240,17 @@ impl Db {
|
||||
idle: Mutex::new(Vec::new()),
|
||||
}),
|
||||
_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.
|
||||
|
||||
@@ -512,17 +512,115 @@ impl Db {
|
||||
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
|
||||
/// 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
|
||||
/// 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>> {
|
||||
let conn = self.read();
|
||||
let ts: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"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,
|
||||
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 —
|
||||
/// 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<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"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",
|
||||
params![id, error, next_attempt_at],
|
||||
)?;
|
||||
@@ -653,6 +756,60 @@ impl Db {
|
||||
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.
|
||||
///
|
||||
/// The startup staging reconcile subtracts this from what is actually in
|
||||
|
||||
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
|
||||
pub remote_uid: Option<String>,
|
||||
pub local_mtime: 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_hash: Option<String>,
|
||||
}
|
||||
@@ -168,7 +176,8 @@ impl Db {
|
||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||
let conn = self.read();
|
||||
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",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder_id], |r| {
|
||||
@@ -179,6 +188,7 @@ impl Db {
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut map = HashMap::new();
|
||||
@@ -216,14 +226,16 @@ impl Db {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash",
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash,
|
||||
local_mtime_ns = excluded.local_mtime_ns",
|
||||
params![
|
||||
folder_id,
|
||||
e.rel_path,
|
||||
@@ -232,6 +244,7 @@ impl Db {
|
||||
e.local_size,
|
||||
e.remote_rev,
|
||||
e.remote_hash,
|
||||
e.local_mtime_ns,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
|
||||
@@ -2408,6 +2408,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
local_size: 2,
|
||||
remote_rev: Some("3".into()),
|
||||
remote_hash: Some("2".into()),
|
||||
local_mtime_ns: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2424,6 +2425,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
local_size: 2,
|
||||
remote_rev: Some("3".into()),
|
||||
remote_hash: Some("2".into()),
|
||||
local_mtime_ns: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3491,6 +3493,132 @@ fn migration_v25_backfills_paths_for_an_existing_tree() {
|
||||
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) ---------------------------------------
|
||||
|
||||
/// A read must not queue behind whatever happens to be writing.
|
||||
@@ -3545,3 +3673,114 @@ fn a_read_does_not_wait_for_the_write_connection() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
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.
|
||||
|
||||
use super::*;
|
||||
use crate::shutdown::Shutdown;
|
||||
|
||||
/// 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
|
||||
@@ -437,6 +438,7 @@ pub(super) fn run_local_index(
|
||||
indexing: Arc<AtomicBool>,
|
||||
transfers: Arc<TransferRegistry>,
|
||||
mountpoint: PathBuf,
|
||||
shutdown: Arc<Shutdown>,
|
||||
) {
|
||||
loop {
|
||||
let age = db.local_indexed_at().ok().flatten();
|
||||
@@ -445,7 +447,11 @@ pub(super) fn run_local_index(
|
||||
if stale {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1084,6 +1084,12 @@ pub(crate) fn run_control_socket(
|
||||
) {
|
||||
info!("control socket listening");
|
||||
for conn in listener.incoming() {
|
||||
// Checked on every accept, including the deliberate poke teardown sends
|
||||
// to wake this thread out of a blocking accept (bugs.md B44).
|
||||
if core.shutdown.is_stopping() {
|
||||
info!("control socket closing");
|
||||
return;
|
||||
}
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
let Some(permit) = ControlHandlerPermit::acquire() else {
|
||||
|
||||
+640
-57
@@ -20,10 +20,14 @@
|
||||
//! are kept as a conflict copy rather than discarded — see
|
||||
//! [`Core::revision_conflict`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
@@ -36,17 +40,132 @@ use tracing::{debug, error, info, warn};
|
||||
use super::state::Intervals;
|
||||
use super::transfers::CountingReader;
|
||||
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,
|
||||
now_millis, now_secs, parse_node_uid,
|
||||
};
|
||||
|
||||
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 {
|
||||
Applied,
|
||||
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>> {
|
||||
@@ -89,7 +208,7 @@ fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::e
|
||||
|
||||
fn run_authorized_drain(
|
||||
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>>,
|
||||
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
|
||||
// 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
|
||||
// held across remote latency.
|
||||
for authority in pending_op_authorities(op)? {
|
||||
match require(&authority) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.code() == libc::EACCES => {
|
||||
return Ok(DrainDisposition::AccessDeferred);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(
|
||||
format!("drain access check failed with errno {}", error.code()).into(),
|
||||
);
|
||||
match authority_of(&authority) {
|
||||
WriteAuthority::Writable => {}
|
||||
WriteAuthority::Denied => 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
|
||||
// caller asks the remote instead (B83).
|
||||
WriteAuthority::Unknown => {
|
||||
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
|
||||
/// not hold up an unrelated upload behind it. The worker only blocks once
|
||||
/// 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 {
|
||||
// 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();
|
||||
// One row, chosen by the database. Reading the whole queue to pick
|
||||
// one op made a long queue quadratic to drain, and held the shared
|
||||
// connection — and so every FUSE metadata call — for the duration.
|
||||
let due = self.db.next_due_op(now).unwrap_or_default();
|
||||
|
||||
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
|
||||
// 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;
|
||||
// One row, chosen and *claimed* by the database. Reading the whole
|
||||
// queue to pick one op made a long queue quadratic to drain, and
|
||||
// held the shared connection — and so every FUSE metadata call —
|
||||
// for the duration.
|
||||
let due = match self.db.claim_next_due_op(now) {
|
||||
Ok(due) => due,
|
||||
Err(error) => {
|
||||
error!(%error, "claiming the next pending operation failed");
|
||||
self.wait_for_drain_work();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let online = self.online.load(Ordering::Relaxed);
|
||||
let op = match due {
|
||||
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(
|
||||
&op,
|
||||
|uid| self.require_uid_writable(uid),
|
||||
|uid| self.uid_write_authority(uid),
|
||||
|| self.drain_op(&op),
|
||||
);
|
||||
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
|
||||
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
|
||||
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"
|
||||
);
|
||||
}
|
||||
self.defer_for_access(&op, AccessDeferral::NotWritable);
|
||||
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 {
|
||||
let attempts = op.attempts + 1;
|
||||
let err_str = e.to_string();
|
||||
@@ -188,9 +343,234 @@ impl Core {
|
||||
now_millis() + backoff.as_millis() as i64,
|
||||
) {
|
||||
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
||||
self.release_claim(&op);
|
||||
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 =
|
||||
self.transfers
|
||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
let reader = CountingReader::new(File::open(&blob)?, &guard);
|
||||
self.rt.block_on(self.client.upload_new_revision_from(
|
||||
// Registered before the first byte moves, so a write that lands while
|
||||
// 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,
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
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);
|
||||
// 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
|
||||
// an orphaned file (harmless), whereas the reverse would leave a queued
|
||||
@@ -1227,12 +1629,10 @@ mod tests {
|
||||
let applied = Cell::new(false);
|
||||
let disposition = run_authorized_drain(
|
||||
&op,
|
||||
|uid| {
|
||||
db.effective_node_access(uid)
|
||||
.unwrap()
|
||||
.filter(|access| access.writable())
|
||||
.map(|_| ())
|
||||
.ok_or(fuser::Errno::EACCES)
|
||||
|uid| match db.effective_node_access(uid).unwrap() {
|
||||
Some(access) if access.writable() => WriteAuthority::Writable,
|
||||
Some(_) => WriteAuthority::Denied,
|
||||
None => WriteAuthority::Unknown,
|
||||
},
|
||||
|| {
|
||||
applied.set(true);
|
||||
@@ -1272,7 +1672,7 @@ mod tests {
|
||||
|
||||
let disposition = run_authorized_drain(
|
||||
&op,
|
||||
|_| Err(fuser::Errno::EACCES),
|
||||
|_| WriteAuthority::Denied,
|
||||
|| {
|
||||
applied.set(true);
|
||||
Ok(())
|
||||
@@ -1293,13 +1693,157 @@ mod tests {
|
||||
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]
|
||||
fn owner_and_editor_drain_authorities_apply_normally() {
|
||||
for access in [Access::Owner, Access::Editor] {
|
||||
let applied = Cell::new(false);
|
||||
let disposition = run_authorized_drain(
|
||||
&pending(OP_REVISION),
|
||||
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|
||||
|_| {
|
||||
if access.writable() {
|
||||
WriteAuthority::Writable
|
||||
} else {
|
||||
WriteAuthority::Denied
|
||||
}
|
||||
},
|
||||
|| {
|
||||
applied.set(true);
|
||||
Ok(())
|
||||
@@ -1413,3 +1957,42 @@ mod tests {
|
||||
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.
|
||||
|
||||
use super::*;
|
||||
use crate::state::DirListing;
|
||||
|
||||
impl Filesystem for ProtonFs {
|
||||
/// 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(
|
||||
&self,
|
||||
_req: &Request,
|
||||
ino: INodeNo,
|
||||
_fh: FileHandle,
|
||||
fh: FileHandle,
|
||||
offset: u64,
|
||||
reply: ReplyDirectory,
|
||||
) {
|
||||
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
|
||||
// for a worker.
|
||||
if self.core.children_cached(ino) {
|
||||
@@ -183,6 +215,18 @@ impl Filesystem for ProtonFs {
|
||||
.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
|
||||
/// 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
|
||||
@@ -912,6 +956,58 @@ pub struct ProtonFs {
|
||||
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 {
|
||||
(!mask.contains(AccessFlags::W_OK) || access.writable())
|
||||
&& (!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.
|
||||
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) {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let st = self.core.state();
|
||||
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||
|
||||
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||
let mut listing: Vec<(u64, FileType, String)> = vec![
|
||||
(ino, FileType::Directory, ".".to_string()),
|
||||
(parent, FileType::Directory, "..".to_string()),
|
||||
];
|
||||
if let Some(kids) = st.children.get(&ino) {
|
||||
for &kid in kids {
|
||||
if let Some(e) = st.entries.get(&kid) {
|
||||
let ft = if e.node.is_folder() {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
};
|
||||
listing.push((kid, ft, e.node.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let listing = build_listing(&st, ino);
|
||||
drop(st);
|
||||
|
||||
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
|
||||
// The stored offset is that of the *next* entry to resume at.
|
||||
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reply.ok();
|
||||
reply_listing(&listing, offset, reply);
|
||||
}
|
||||
|
||||
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
|
||||
// the remote base; stacking another write on it cannot be represented
|
||||
// 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) {
|
||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
error!(%uid, "refusing write over incomplete queued revision");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
//
|
||||
// Reading that blob and installing the handle cannot be done under one
|
||||
// lock — the copy is the size of the file — so the base is re-checked at
|
||||
// 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
|
||||
// new handle carried the *older* base, and its own release published
|
||||
// content that silently discarded the revision closed a moment earlier
|
||||
// (bugs.md B32). A second attempt is already unlikely and a third would
|
||||
// mean something is republishing continuously, so give up rather than
|
||||
// spin: the caller's `open` fails instead of its data being lost.
|
||||
//
|
||||
// `reply` is consumed by whichever arm answers, so the loop yields an
|
||||
// outcome and the answer is sent once, after it.
|
||||
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");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
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();
|
||||
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) {
|
||||
match outcome {
|
||||
Ok(fh) => reply.opened(FileHandle(fh), FopenFlags::empty()),
|
||||
Err(error) => {
|
||||
// The lookup count was raised before any of this could fail.
|
||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
error!(%uid, source = %pending.path.display(), error = %e,
|
||||
"copy queued revision into write scratch failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
reply.error(error);
|
||||
}
|
||||
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.
|
||||
|
||||
+159
-14
@@ -55,7 +55,7 @@ use fuser::{
|
||||
};
|
||||
use futures::StreamExt as _;
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite, block_len};
|
||||
use pdfs_core::config::{AppDirs, SweepMode};
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||
@@ -92,6 +92,7 @@ mod profile;
|
||||
mod reads;
|
||||
mod revisions;
|
||||
mod sharing;
|
||||
mod shutdown;
|
||||
mod state;
|
||||
mod sweep;
|
||||
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
|
||||
/// the follow-up write time to supersede it.
|
||||
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_MAX: Duration = Duration::from_secs(300);
|
||||
@@ -386,6 +411,10 @@ struct Core {
|
||||
/// kernel read-ahead, our own prefetch) share a single download and decrypt.
|
||||
/// See [`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`].
|
||||
prefetch: Arc<Prefetch>,
|
||||
/// 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
|
||||
/// may still return. Uids are not reused; an explicit restore clears one.
|
||||
hidden: Arc<Mutex<HashSet<NodeUid>>>,
|
||||
/// Nudges the drain worker: set true and notify to have it re-examine the
|
||||
/// queue instead of waiting out its backoff.
|
||||
/// Nudges the drain workers: set true and notify to have them re-examine
|
||||
/// the queue instead of waiting out their backoff.
|
||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||
/// was cloned into so teardown can end and join them (bugs.md B44).
|
||||
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
|
||||
/// already running, so a burst of page requests against a stale listing kicks
|
||||
/// off one refresh rather than one per request.
|
||||
@@ -935,6 +981,27 @@ impl Core {
|
||||
/// downgrade cannot be hidden by stale live state, and a live downgrade
|
||||
/// cannot be hidden by a restored DB row. Missing UIDs fail closed.
|
||||
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 state = self.state();
|
||||
@@ -950,22 +1017,38 @@ impl Core {
|
||||
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) {
|
||||
Ok(Some(access)) => access,
|
||||
Ok(None) => return Err(Errno::EACCES),
|
||||
Ok(None) => return WriteAuthority::Unknown,
|
||||
Err(error) => {
|
||||
error!(%uid, %error, "db effective access lookup failed");
|
||||
return Err(Errno::EACCES);
|
||||
return WriteAuthority::Denied;
|
||||
}
|
||||
};
|
||||
(persisted.writable() && live_access.iter().all(|access| access.writable()))
|
||||
.then_some(())
|
||||
.ok_or(Errno::EACCES)
|
||||
if persisted.writable() && live_access.iter().all(|access| access.writable()) {
|
||||
WriteAuthority::Writable
|
||||
} else {
|
||||
WriteAuthority::Denied
|
||||
}
|
||||
}
|
||||
|
||||
/// One kernel notification, recorded while the State lock is held so it can be
|
||||
@@ -1633,7 +1716,9 @@ impl Core {
|
||||
fn run_online_probe(&self) {
|
||||
let mut delay = ONLINE_PROBE_MIN;
|
||||
loop {
|
||||
std::thread::sleep(delay);
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return;
|
||||
}
|
||||
match self.rt.block_on(self.client.get_my_files_folder()) {
|
||||
Ok(root) => {
|
||||
{
|
||||
@@ -2756,6 +2841,12 @@ impl Core {
|
||||
}
|
||||
})?;
|
||||
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) {
|
||||
// The node has no server-side identity to hang a revision on, so the
|
||||
// bytes ride on the create that will mint it.
|
||||
@@ -2789,7 +2880,7 @@ impl Core {
|
||||
created_at: now_millis(),
|
||||
attempts: 0,
|
||||
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| {
|
||||
error!(%uid, error = %e, "queueing upload failed");
|
||||
@@ -3095,6 +3186,12 @@ impl Core {
|
||||
/// worse in every way.
|
||||
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
|
||||
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
|
||||
.db
|
||||
.replace_ops_with_trash(&uid.to_string(), name, now_millis())
|
||||
@@ -3124,6 +3221,9 @@ impl Core {
|
||||
|
||||
/// Drop every op queued against a node, and the staged bytes they own.
|
||||
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| {
|
||||
error!(%uid, error = %e, "dropping queued ops failed");
|
||||
Errno::EIO
|
||||
@@ -3135,6 +3235,15 @@ impl Core {
|
||||
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.
|
||||
fn wake_drain(&self) {
|
||||
let (lock, cv) = &*self.drain_wake;
|
||||
@@ -5701,9 +5810,19 @@ mod tests {
|
||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
||||
require_node_parent_access, require_rename_access, require_uid_access,
|
||||
resolve_anywhere_with, shared_with_me_uid, take_self_change, virtual_node,
|
||||
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
||||
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 pdfs_core::cache::{Baseline, StagedWrite};
|
||||
use pdfs_core::db::{OP_REVISION, OP_TRASH, PendingOp};
|
||||
@@ -6710,6 +6829,19 @@ mod tests {
|
||||
require_uid_access(&db, &stale, &[]).unwrap_err().code(),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -6876,6 +7008,19 @@ mod tests {
|
||||
let trash = function_source(source, "fn queue_trash(");
|
||||
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");
|
||||
for signature in ["fn serve_unlink(", "fn serve_rmdir("] {
|
||||
let function = function_source(filesystem, signature);
|
||||
|
||||
@@ -365,6 +365,7 @@ pub fn mount(
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||
block_flight: Arc::new(BlockFlight::default()),
|
||||
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
prefetch: Arc::new(Prefetch::default()),
|
||||
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
@@ -376,6 +377,9 @@ pub fn mount(
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::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)),
|
||||
albums_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
|
||||
// failed release left behind with nothing pointing at them.
|
||||
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();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-drain".into())
|
||||
.spawn(move || core.run_pending_drain())?;
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.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
|
||||
// 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
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
@@ -434,18 +451,22 @@ pub fn mount(
|
||||
} else {
|
||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?;
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?,
|
||||
);
|
||||
}
|
||||
|
||||
// Mounted from the cache: watch for the network coming back so the mount can
|
||||
// stop being read-only-ish without the user restarting the daemon.
|
||||
if !online {
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-online-probe".into())
|
||||
.spawn(move || core.run_online_probe())?;
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-online-probe".into())
|
||||
.spawn(move || core.run_online_probe())?,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 transfers = core.transfers.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
||||
let shutdown = core.shutdown.clone();
|
||||
workers.push(
|
||||
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
|
||||
@@ -494,23 +518,29 @@ pub fn mount(
|
||||
let control_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||
{
|
||||
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");
|
||||
Ok(handle) => workers.push(handle),
|
||||
Err(error) => {
|
||||
// A failure here abandons the mount, so the background threads
|
||||
// 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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -26,6 +26,11 @@ const PREFETCH_MAX: u64 = 8;
|
||||
/// Window a sequential read starts at, and returns to after a seek.
|
||||
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
|
||||
/// 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.
|
||||
@@ -563,6 +568,10 @@ impl Core {
|
||||
/// 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
|
||||
/// 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(
|
||||
&self,
|
||||
reader: &Arc<RevisionReader>,
|
||||
@@ -573,11 +582,36 @@ impl Core {
|
||||
cache_blocks: bool,
|
||||
) -> BlockResult {
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||
let bytes = reader.read_at(bstart, blen).await.map_err(|e| {
|
||||
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
let blen = block_len(fsize, bidx);
|
||||
// The reader derives every block boundary from the revision's recorded
|
||||
// block sizes. If they do not add up to the size the node reports, this
|
||||
// file's boundaries are wrong, and the failure is not confined to short
|
||||
// reads: sizes that *overstate* a block shift every block after it and
|
||||
// return full-length reads of the wrong bytes. So a disagreement of any
|
||||
// kind takes the file off the range path entirely (bugs.md B84).
|
||||
let bytes = if reader.size() != fsize {
|
||||
warn!(
|
||||
%uid, bidx, 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, bidx).await?
|
||||
} else {
|
||||
let bytes = reader.read_at(bstart, blen).await.map_err(|e| {
|
||||
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
if bytes.len() as u64 == blen {
|
||||
bytes
|
||||
} else {
|
||||
warn!(
|
||||
%uid, bidx, blen, got = bytes.len(), fsize,
|
||||
"block came back shorter than the file's size implies"
|
||||
);
|
||||
self.repair_block(uid, mtime, fsize, bidx).await?
|
||||
}
|
||||
};
|
||||
let bytes = Arc::new(bytes);
|
||||
if cache_blocks {
|
||||
let cache = self.cache.clone();
|
||||
@@ -593,6 +627,129 @@ impl Core {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Recover block `bidx` of a file whose per-block reads cannot be trusted,
|
||||
/// 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,
|
||||
bidx: u64,
|
||||
) -> Result<Vec<u8>, Errno> {
|
||||
let blen = block_len(fsize, bidx);
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
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, "served a short block from a repaired whole-file download");
|
||||
Ok(bytes)
|
||||
}
|
||||
_ => {
|
||||
error!(%uid, bidx, 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
|
||||
/// background, so the next read finds them instead of paying a round-trip.
|
||||
///
|
||||
@@ -761,6 +918,15 @@ impl Core {
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
// Every slot is populated: cache hits up front, misses by the fetch above.
|
||||
let block = block.expect("block fetched or cached");
|
||||
// Both sources guarantee the length the file's size implies — 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 != block_len(fsize, bidx) {
|
||||
error!(%uid, bidx, len = block.len(), fsize, "assembling a read hit a short block");
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
let s = (offset.max(bstart) - bstart) as usize;
|
||||
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||
if s < e {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `Filesystem` trait through `&self`.
|
||||
pub(crate) struct State {
|
||||
@@ -158,6 +163,16 @@ pub(crate) struct State {
|
||||
/// have no entry here.
|
||||
pub(crate) handles: HashMap<u64, 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
|
||||
/// intern/root refresh. Core drains this set and notifies the matching
|
||||
/// kernel only after releasing the State lock.
|
||||
@@ -317,6 +332,7 @@ impl State {
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
dir_snapshots: HashMap::new(),
|
||||
access_changes: HashSet::new(),
|
||||
share_access,
|
||||
db,
|
||||
|
||||
@@ -67,10 +67,16 @@ impl Core {
|
||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
||||
// 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 {
|
||||
self.sweep_conflicts_once();
|
||||
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+223
-15
@@ -54,7 +54,7 @@ use engine::{classify, settle_with};
|
||||
#[cfg(test)]
|
||||
use local_scan::is_write_mode;
|
||||
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;
|
||||
#[cfg(test)]
|
||||
use planner::conflict_path;
|
||||
@@ -687,6 +687,7 @@ impl Core {
|
||||
rel_path: rel.clone(),
|
||||
remote_uid: Some(remote.uid.to_string()),
|
||||
local_mtime: local.mtime,
|
||||
local_mtime_ns: local.mtime_ns,
|
||||
local_size: local.size,
|
||||
remote_rev: Some(remote.mtime.to_string()),
|
||||
remote_hash: Some(remote.size.to_string()),
|
||||
@@ -717,6 +718,9 @@ impl Core {
|
||||
uid: remote.uid.clone(),
|
||||
mtime: remote.mtime,
|
||||
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 => {
|
||||
@@ -739,7 +743,9 @@ impl Core {
|
||||
}
|
||||
},
|
||||
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");
|
||||
self.log_activity(
|
||||
ActivityKind::Trash,
|
||||
@@ -797,7 +803,9 @@ impl Core {
|
||||
// Deferred folder deletions, deepest first.
|
||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||
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");
|
||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||
outcome.errors += 1;
|
||||
@@ -885,6 +893,7 @@ impl Core {
|
||||
LocalItem {
|
||||
is_dir: true,
|
||||
mtime: 0,
|
||||
mtime_ns: None,
|
||||
size: 0,
|
||||
open_for_write: false,
|
||||
},
|
||||
@@ -903,6 +912,7 @@ impl Core {
|
||||
LocalItem {
|
||||
is_dir: false,
|
||||
mtime: system_mtime(&meta),
|
||||
mtime_ns: system_mtime_ns(&meta),
|
||||
size: meta.len() as i64,
|
||||
open_for_write: ofw,
|
||||
},
|
||||
@@ -1069,6 +1079,7 @@ impl Core {
|
||||
rel_path: rel.to_string(),
|
||||
remote_uid: Some(uid.to_string()),
|
||||
local_mtime: 0,
|
||||
local_mtime_ns: None,
|
||||
local_size: 0,
|
||||
remote_rev: None,
|
||||
remote_hash: None,
|
||||
@@ -1192,8 +1203,9 @@ impl Core {
|
||||
uid,
|
||||
mtime,
|
||||
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?;
|
||||
Ok(Applied::Downloaded)
|
||||
}
|
||||
@@ -1211,7 +1223,11 @@ impl Core {
|
||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||
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?;
|
||||
Ok(Applied::Conflict)
|
||||
}
|
||||
@@ -1250,6 +1266,9 @@ impl Core {
|
||||
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 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
|
||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||
// draft is sealed, so the transfer registers without one.
|
||||
@@ -1276,7 +1295,9 @@ impl Core {
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("upload {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
|
||||
}
|
||||
|
||||
/// Upload a changed local file as a new revision of an existing remote node.
|
||||
@@ -1291,6 +1312,7 @@ impl Core {
|
||||
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 mtime = system_mtime(&meta);
|
||||
let streamed = LocalSig::from(&meta);
|
||||
let reader = OwnedCountingReader::new(
|
||||
file,
|
||||
self.transfers.begin(
|
||||
@@ -1304,11 +1326,20 @@ impl Core {
|
||||
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
||||
.await
|
||||
.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),
|
||||
/// 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(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
@@ -1317,6 +1348,7 @@ impl Core {
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
size: i64,
|
||||
expected_local: Option<LocalSig>,
|
||||
) -> Result<(), String> {
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -1351,6 +1383,13 @@ impl Core {
|
||||
out.flush().map_err(|e| format!("flush 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}"))?;
|
||||
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
||||
let f = std::fs::File::options()
|
||||
@@ -1366,23 +1405,36 @@ impl Core {
|
||||
.and_then(|dir| dir.sync_all())
|
||||
.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
|
||||
/// remote node's reported signature. Called after every upload/download so
|
||||
/// the very next reconcile sees the path as unchanged.
|
||||
/// Refresh a file's baseline from ground truth: the local identity these
|
||||
/// bytes came from and the remote node's reported signature. Called after
|
||||
/// 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(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
rel: &str,
|
||||
local_path: &Path,
|
||||
local: Option<LocalSig>,
|
||||
uid: &NodeUid,
|
||||
) -> Result<(), String> {
|
||||
let (lmtime, lsize) = match std::fs::metadata(local_path) {
|
||||
Ok(m) => (system_mtime(&m), m.len() as i64),
|
||||
Err(e) => return Err(format!("stat {rel}: {e}")),
|
||||
let Some(local) = local else {
|
||||
return Err(format!("stat {rel}: no local identity to baseline"));
|
||||
};
|
||||
let (lmtime, lsize) = (local.mtime, local.size);
|
||||
// Re-fetch the node so the stored remote signature is exactly what a walk
|
||||
// will report next time.
|
||||
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
||||
@@ -1402,6 +1454,7 @@ impl Core {
|
||||
rel_path: rel.to_string(),
|
||||
remote_uid: Some(uid.to_string()),
|
||||
local_mtime: lmtime,
|
||||
local_mtime_ns: local.mtime_ns,
|
||||
local_size: lsize,
|
||||
remote_rev: Some(rmtime.to_string()),
|
||||
remote_hash: Some(rsize.to_string()),
|
||||
@@ -1411,6 +1464,88 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||
/// 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> {
|
||||
@@ -1476,10 +1611,77 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.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)]
|
||||
mod tests {
|
||||
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
|
||||
/// immediately. Returns the sender's join handle so the test can keep it
|
||||
/// alive (dropping the sender early would look like a disconnect).
|
||||
@@ -1565,6 +1767,7 @@ mod tests {
|
||||
|
||||
fn local_item(is_dir: bool) -> LocalItem {
|
||||
LocalItem {
|
||||
mtime_ns: None,
|
||||
is_dir,
|
||||
mtime: 1,
|
||||
size: 1,
|
||||
@@ -1764,6 +1967,7 @@ mod tests {
|
||||
remote_uid: Some("v~l".into()),
|
||||
local_mtime: 0,
|
||||
local_size: 0,
|
||||
local_mtime_ns: None,
|
||||
remote_rev: Some(mtime.to_string()),
|
||||
remote_hash: Some(size.to_string()),
|
||||
},
|
||||
@@ -1801,6 +2005,7 @@ mod tests {
|
||||
remote_uid: Some("v~l".into()),
|
||||
local_mtime: 0,
|
||||
local_size: 0,
|
||||
local_mtime_ns: None,
|
||||
remote_rev: None,
|
||||
remote_hash: None,
|
||||
},
|
||||
@@ -1815,6 +2020,7 @@ mod tests {
|
||||
remote_uid: Some("v~l".into()),
|
||||
local_mtime: 10,
|
||||
local_size: 20,
|
||||
local_mtime_ns: None,
|
||||
remote_rev: Some("1700".into()),
|
||||
remote_hash: Some("4096".into()),
|
||||
};
|
||||
@@ -1828,6 +2034,7 @@ mod tests {
|
||||
remote_uid: Some("v~l".into()),
|
||||
local_mtime: 0,
|
||||
local_size: 0,
|
||||
local_mtime_ns: None,
|
||||
remote_rev: None,
|
||||
remote_hash: None,
|
||||
};
|
||||
@@ -1863,6 +2070,7 @@ mod tests {
|
||||
uid: uid(),
|
||||
mtime: 0,
|
||||
size: 10,
|
||||
local: None,
|
||||
};
|
||||
assert_eq!(op.kind(), ActivityKind::Download);
|
||||
}
|
||||
|
||||
@@ -32,17 +32,24 @@ pub(crate) enum SyncMsg {
|
||||
ReconcileAll,
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
Rewatch,
|
||||
/// The daemon is tearing down: finish the current pass and end the engine
|
||||
/// thread, so the mount can join it (bugs.md B44).
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
||||
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
||||
/// on `rx`; senders live in [`Core::sync_tx`].
|
||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-sync".into())
|
||||
.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.
|
||||
{
|
||||
let tx = core.sync_tx.clone();
|
||||
let shutdown = core.shutdown.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-sync-poll".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
while shutdown.sleep(POLL_INTERVAL) {
|
||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -94,8 +101,12 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
||||
settle(&rx, &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)
|
||||
{
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
|
||||
if do_rewatch {
|
||||
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>) {
|
||||
{
|
||||
let tx = core.sync_tx.clone();
|
||||
let shutdown = core.shutdown.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
while shutdown.sleep(POLL_INTERVAL) {
|
||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -130,8 +141,12 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
||||
settle(&rx, &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)
|
||||
{
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
if all {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
@@ -158,7 +173,7 @@ pub(super) fn settle_with(
|
||||
do_rewatch: &mut bool,
|
||||
quiet: Duration,
|
||||
cap: Duration,
|
||||
) {
|
||||
) -> bool {
|
||||
let deadline = Instant::now() + cap;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
@@ -170,28 +185,49 @@ pub(super) fn settle_with(
|
||||
match rx.recv_timeout(wait) {
|
||||
// Another event: the burst is still going, so the quiet window
|
||||
// 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.
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
// Every sender is gone; the caller's loop is about to end anyway.
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// [`settle_with`] at the real timings.
|
||||
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
|
||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
|
||||
fn 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 {
|
||||
SyncMsg::Reconcile(id) => {
|
||||
ids.insert(id);
|
||||
}
|
||||
SyncMsg::ReconcileAll => *all = true,
|
||||
SyncMsg::Rewatch => *rewatch = true,
|
||||
SyncMsg::Stop => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn.
|
||||
|
||||
@@ -2,9 +2,73 @@
|
||||
|
||||
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) is_dir: bool,
|
||||
pub(super) mtime: i64,
|
||||
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
|
||||
/// reports one.
|
||||
pub(super) mtime_ns: Option<i64>,
|
||||
pub(super) size: i64,
|
||||
/// 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
|
||||
@@ -91,11 +155,18 @@ pub(super) enum Pending {
|
||||
UploadRevision { rel: String, uid: NodeUid },
|
||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
||||
/// 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 {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
mtime: i64,
|
||||
size: i64,
|
||||
local: Option<LocalSig>,
|
||||
},
|
||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||
Conflict {
|
||||
|
||||
@@ -218,7 +218,10 @@ pub(super) fn plan_file(
|
||||
}
|
||||
|
||||
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| {
|
||||
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 {
|
||||
LocalItem {
|
||||
mtime_ns: None,
|
||||
is_dir: false,
|
||||
mtime,
|
||||
size,
|
||||
@@ -278,12 +282,49 @@ mod file_plan_tests {
|
||||
rel_path: "file".into(),
|
||||
remote_uid: Some("v~l".into()),
|
||||
local_mtime,
|
||||
local_mtime_ns: None,
|
||||
local_size,
|
||||
remote_rev: Some(remote_mtime.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]
|
||||
fn file_plan_covers_every_presence_and_change_shape() {
|
||||
let same_local = local(10, 20);
|
||||
|
||||
@@ -19,7 +19,7 @@ use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
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> {
|
||||
inner: R,
|
||||
guard: &'a TransferGuard,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl<'a, R: Read> CountingReader<'a, R> {
|
||||
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> {
|
||||
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)?;
|
||||
self.guard.add(n as u64);
|
||||
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:
|
||||
- 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.
|
||||
- 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.
|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
sequenceDiagram
|
||||
|
||||
+478
-12
@@ -12,6 +12,243 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B84 — A block read that comes back short truncates the file, silently
|
||||
|
||||
**Status:** Fixed and **live-verified 2026-08-16**. **Data correctness** — an application reading a large
|
||||
file through the mount could get a truncated copy that reported success. The truncation is now
|
||||
impossible: a disagreement is repaired from the whole-file download, or the read fails `EIO`.
|
||||
Unverified because the repair path has not yet been driven against the three known files on a
|
||||
live mount, and the metadata question below is still open.
|
||||
**Found:** 2026-08-16, chasing what looked like log noise. GNOME's file indexer
|
||||
(`localsearch-3`) was filling the journal with `libpng error: Read Error`,
|
||||
`libpng error: IDAT: CRC error` and `File too small to be a PNG` on files under
|
||||
`~/ProtonDrive/Pictures`, interleaved with 49 `ERROR fuser::reply: Failed to send FUSE reply:
|
||||
Invalid argument (os error 22)` from the daemon in one afternoon. The indexer was right and we
|
||||
were wrong.
|
||||
|
||||
**Where:** `Core::read_block` (`crates/pdfs-fuse/src/reads.rs:566`), the assembly loop in
|
||||
`Core::read_range_remote` (`reads.rs:759-769`); upstream, `RevisionReader::read_at` /
|
||||
`splice_block` and `rank_block_sizes` (`proton-drive-rs` 0.6.0, `revision.rs`,
|
||||
`transport.rs:256`).
|
||||
|
||||
**Repro.** Deterministic per file, no account state needed beyond the files themselves:
|
||||
|
||||
```bash
|
||||
stat -c %s FILE # what the mount reports
|
||||
cat FILE | wc -c # what the mount can actually deliver
|
||||
```
|
||||
|
||||
| file | `nodes.size` / `stat` | bytes readable |
|
||||
|---|---|---|
|
||||
| `Pictures/old/92433593_p0.png` | 4317497 | 4194304 (tail block yields **0**) |
|
||||
| `Pictures/old/79779657_p0.png` | 11574982 | 10567052 |
|
||||
| `Pictures/old/2022-07-09_04-53.png` | 6388959 | 4389310 |
|
||||
|
||||
`pdfs pin` on the third file, then re-read: **6388959 bytes, a fully valid PNG** — every chunk
|
||||
CRC passes, IEND is reached, the IDAT stream inflates to 44.8 MB. So the remote data is intact,
|
||||
`nodes.size` is right, and the whole-file (manifest-verified) download path is right. Only the
|
||||
block/range path truncates. Pinning is the workaround.
|
||||
|
||||
**Root cause.** `read_block` asks the SDK for `blen = BLOCK_SIZE.min(fsize - bstart)` bytes and
|
||||
never checks how many it got:
|
||||
|
||||
```rust
|
||||
let bytes = reader.read_at(bstart, blen).await.map_err(...)?; // reads.rs:577
|
||||
```
|
||||
|
||||
`RevisionReader::read_at` clamps the range to its own `file_size` — summed from the *believed*
|
||||
per-block sizes `rank_block_sizes` resolved — and `splice_block` clamps again to
|
||||
`plaintext.len()`. Both are silent. A short block then vanishes in the assembly loop: with
|
||||
`block.len()` short, `e = (end.min(bstart + block.len()) - bstart)` collapses, and for an empty
|
||||
block `s < e` is false, so nothing is appended at all. The FUSE read returns fewer bytes than
|
||||
asked for, the kernel hands userspace a short read, and every tool reads a short read as EOF.
|
||||
The `EINVAL` reply errors are the same mismatch seen from the other side.
|
||||
|
||||
Nothing on this path validates a block against the length the file's own size implies
|
||||
(`block_len(fsize, bidx)` — which the *cache* does check, in `ContentCache::cached_block`, and
|
||||
which is why a short blob on disk is rejected and refetched while a short blob from the network
|
||||
is served).
|
||||
|
||||
**Not yet known:** which rung of `rank_block_sizes` these revisions land on. The three
|
||||
reader-vs-node deltas fit no constant ratio, so this is per-revision metadata rather than block
|
||||
padding — the terminal `block_count <= 1` fallback (`vec![4 MiB; block_count]`) would explain
|
||||
the file that stops at exactly 4 MiB but not the other two. Needs a live probe printing
|
||||
`RevisionReader::block_sizes()` against `nodes.size` for these three uids. That answer decides
|
||||
whether the real fix belongs in this repo or in `proton-sdk-rs`.
|
||||
|
||||
**Fix (2026-08-16).** `Core::read_block` no longer trusts the range path blindly.
|
||||
|
||||
1. **The disagreement is caught before any bytes are read.** If `reader.size() != fsize`, the
|
||||
revision's block table does not describe this file and *no* block of it can be trusted — not
|
||||
just the short ones. Sizes that overstate a block shift every block after it and return
|
||||
full-length reads of the wrong bytes, which no length check would ever catch (the SDK's
|
||||
`rank_block_sizes` documents exactly this failure). So the whole file leaves the range path.
|
||||
2. **A short block is caught as a second net,** compared against `block_len(fsize, bidx)` — now
|
||||
`pub` in `pdfs-core::cache`, because it is the contract the read path shares with the cache
|
||||
rather than a detail of the cache. The assembly loop in `read_range_remote` re-checks the same
|
||||
thing and fails `EIO` rather than contributing fewer bytes than the range it covers, which is
|
||||
the line that actually turned a bad block into a truncated file.
|
||||
3. **A file that fails either check is repaired, not just refused.** `Core::repair_block` pulls
|
||||
the whole revision through `download_file_to` — the manifest-verified path, which does no
|
||||
boundary arithmetic and is why pinning worked — into a scratch file, checks it against
|
||||
`nodes.size`, and adopts it as the cached blob via `store_file`. Every later read of the file
|
||||
is then answered by `ContentCache::read_range` before the block path is consulted at all. One
|
||||
download per file, not per block: `content_repairs` holds a lock per file so the concurrent
|
||||
block reads that all discover the same bad table share it. Bounded by `REPAIR_MAX` (512 MiB) —
|
||||
past that one unlucky read would pull down gigabytes, so it fails `EIO` and says to pin.
|
||||
|
||||
Nothing short is cached or promoted: `read_block` only reaches `store_block` and the `block_ring`
|
||||
insert with bytes that passed. `store_block` itself deliberately does *not* check — its fixtures
|
||||
store 4 KiB "blocks" of notional 1 GiB files to keep the budget tests cheap, and the read side
|
||||
(`cached_block`, `has_block`) already rejects a wrong-length block as a miss. Validation is the
|
||||
reader's job, at the point of fetch.
|
||||
|
||||
`getattr` needs no change after all: `pdfs pin` proved `nodes.size` is the correct one and the
|
||||
reader's belief is the wrong one, so the fix is to stop serving the reader's belief — not to
|
||||
publish it through `stat`.
|
||||
|
||||
**Verified live (2026-08-16).** Four multi-block PNGs under `Pictures/wallpaper/` were read whole
|
||||
through the mount on the running 1.8.1 daemon; every one delivered exactly its `stat` size
|
||||
(5558846, 1098729, 2015463, 2291122 bytes), and no `fuser::reply … Invalid argument` appeared. The
|
||||
journal shows the second net firing and the repair path serving each of them:
|
||||
|
||||
```
|
||||
WARN block came back shorter than the file's size implies bidx=0 blen=4194304 got=4939506 …
|
||||
INFO served a short block from a repaired whole-file download bidx=0 …
|
||||
```
|
||||
|
||||
**Answered, and not as expected.** The open question was which rung of `rank_block_sizes` these
|
||||
revisions take. The evidence says the reader is not the confused party: `reader.size()` equals
|
||||
`fsize` in every case — the gate in step 1 never fires — and the blocks it returns are *larger*
|
||||
than 4 MiB, not smaller (4939506 for a whole 4.9 MB file; 5239804 and 5670228 as first blocks).
|
||||
This client's fixed 4 MiB block geometry is the thing that is wrong. Filed as **B85**; the repair
|
||||
path above is what keeps those files readable in the meantime, at the cost of a whole-file
|
||||
download on first read.
|
||||
|
||||
## B85 — The read path assumes 4 MiB blocks; some revisions do not have them
|
||||
|
||||
**Status:** Open — **data correctness**, currently masked by B84's repair path
|
||||
**Found:** 2026-08-16, while live-verifying B84 (see its journal evidence)
|
||||
**Where:** `crates/pdfs-fuse/src/reads.rs`, `crates/pdfs-core/src/cache.rs` (`BLOCK_SIZE`,
|
||||
`block_len`)
|
||||
|
||||
**Cause:** The whole range path is built on one assumption — that a revision is a series of
|
||||
`BLOCK_SIZE` (4 MiB) blocks with a remainder at the end. `block_len(size, idx)` encodes it, the
|
||||
on-disk block cache is keyed by that index, and `read_block` asks the SDK's `RevisionReader` for
|
||||
`[idx * BLOCK_SIZE, block_len)`.
|
||||
|
||||
Some revisions on this account are not laid out that way, and B84's diagnostic caught it in the
|
||||
act. The warning it logs reads "shorter", but the numbers say the opposite:
|
||||
|
||||
```
|
||||
bidx=0 blen=4194304 got=4939506 fsize=4939506 # one block holding the whole file
|
||||
bidx=1 blen=745202 got=0 fsize=4939506 # …so there is no block 1
|
||||
bidx=0 blen=4194304 got=5239804 fsize=11537412 # first block is ~5.0 MiB, not 4
|
||||
bidx=1 blen=4194304 got=5670228 fsize=11106988 # …and they are not even uniform
|
||||
bidx=2 blen=4194304 got=5233034 fsize=19932790
|
||||
```
|
||||
|
||||
`reader.size()` agrees with `fsize` in every one of these, so B84's primary gate — the one that
|
||||
catches a block table that does not add up — correctly does *not* fire. The file is intact and
|
||||
the reader knows its real boundaries; it is this client that is asking for the wrong ones.
|
||||
|
||||
**Why it is not currently losing data:** B84's length check catches every one of these and routes
|
||||
the file through the whole-file manifest-verified download, so reads are correct. The cost is
|
||||
real, though: any file with non-4-MiB blocks is downloaded whole on first read, however few bytes
|
||||
were asked for, and one above `REPAIR_MAX` (512 MiB) fails `EIO` and can only be read by pinning
|
||||
it.
|
||||
|
||||
**Fix direction:** take the geometry from the revision instead of assuming it.
|
||||
`RevisionReader::block_sizes()` already exposes it and B84 already logs it. That means block
|
||||
offsets become a prefix sum over the real sizes rather than `idx * BLOCK_SIZE`, and — the part
|
||||
that makes this its own change rather than a patch — the on-disk block cache is keyed and
|
||||
length-validated on the 4 MiB assumption too (`block_len`), so its key scheme has to follow the
|
||||
file's own geometry or the cache has to hold ranges rather than blocks.
|
||||
|
||||
**Open question:** what produced these revisions. Uniform-but-not-4-MiB would suggest another
|
||||
client's chunk size; these are *not* uniform (5239804, 5670228, 5233034), which looks more like a
|
||||
size recorded per block after some transformation. Worth answering before choosing the fix, since
|
||||
"blocks are whatever the revision says" and "blocks are a different constant" lead to different
|
||||
designs.
|
||||
|
||||
---
|
||||
|
||||
## B83 — An access-deferred op retries forever and is reported as an ordinary queued upload
|
||||
|
||||
**Status:** Fixed (partly verified) 2026-08-16 — both halves are in and
|
||||
unit-tested. The reporting half was confirmed against the live 28-op backlog on
|
||||
1.8.0 (schema migrated to v27, all 28 rows window-stamped, `WARN` lines per op);
|
||||
the resolution half has not yet been observed draining that backlog.
|
||||
**Found:** 2026-08-16, running `scripts/fuse-acceptance.sh --live` on a
|
||||
throwaway CLI-made mount. Four regression cases (B69, B70, B74, B79) failed with
|
||||
`TimeoutError: daemon mutation queue did not drain` — the suite's
|
||||
`wait_for_queue()` polls the *global* `status.mount.pending_uploads`, which had
|
||||
been sitting at 28 for 30 days on the development machine. `pdfs status` called
|
||||
them 28 queued uploads holding 18.4 GiB; `pdfs transfers` showed nothing;
|
||||
`failing_ops` and `parked_uploads` were both 0.
|
||||
**Where:** `run_pending_drain` / `run_authorized_drain`
|
||||
(`crates/pdfs-fuse/src/drain.rs`), `require_uid_writable`
|
||||
(`crates/pdfs-fuse/src/lib.rs`), `Db::effective_node_access`
|
||||
(`crates/pdfs-core/src/db/share_access.rs`),
|
||||
`Db::defer_op_without_attempt` (`crates/pdfs-core/src/db/ops.rs`).
|
||||
|
||||
**Root cause.** `effective_node_access` returns `Ok(None)` when the uid has no
|
||||
row in `nodes`, and `require_uid_access` maps that to `EACCES` — "I have never
|
||||
heard of this node" and "you may not write this node" are the same answer. The
|
||||
drain reads `EACCES` as `DrainDisposition::AccessDeferred` and calls
|
||||
`defer_op_without_attempt`, which by design touches neither `attempts` nor
|
||||
`last_error`. So the op was re-deferred every `DRAIN_ACCESS_RECHECK` (5s)
|
||||
indefinitely, and every channel that could have surfaced it stayed empty:
|
||||
`attempts` 0, `last_error` NULL, `parked` counts only `PARK_UNTIL` rows,
|
||||
`failing` counts only `attempts >= FAILING_ATTEMPTS`, and the one log line was
|
||||
`debug!`. On the machine that found it, all 28 uids were absent from `nodes`:
|
||||
|
||||
```sql
|
||||
select (select count(*) from pending_op p
|
||||
where exists(select 1 from nodes n where n.uid = p.uid)) as in_nodes,
|
||||
(select count(*) from pending_op) as total;
|
||||
-- 0 | 28
|
||||
```
|
||||
|
||||
**Fix, part 1 — stop guessing.** `WriteAuthority { Writable, Denied, Unknown }`
|
||||
replaces the `Result<(), Errno>` the drain's access check used to return
|
||||
(`Core::uid_write_authority`). `require_uid_writable` still collapses both
|
||||
refusals to `EACCES`, which is right for a syscall: a stale handle must not be
|
||||
admitted because the tree forgot the node. The drain does not collapse them —
|
||||
`Unknown` becomes `DrainDisposition::AuthorityUnknown(uid)`, and
|
||||
`Core::resolve_unknown_authority` asks the remote:
|
||||
|
||||
- node still there → `upsert_node` re-interns it and the op is deferred for one
|
||||
recheck, so the next pass gets a real access answer;
|
||||
- node gone (`Ok(None)` / `is_gone`) → `record_op_failure` **immediately**, with
|
||||
a message naming the vanished uid, rather than waiting out the 5-minute window
|
||||
to say something vaguer;
|
||||
- could not ask → ordinary deferral.
|
||||
|
||||
The authority asked about is the op's own (`pending_op_authorities`), so a
|
||||
`create`/`mkdir` refetches its *parent*, not the node it has not made yet. The
|
||||
row and its staged blob survive every branch.
|
||||
|
||||
**Fix, part 2 — say something.** Schema v27 adds `pending_op.access_deferred_since`.
|
||||
`Db::defer_op_for_access` stamps it on the first deferral of a run and returns
|
||||
the stamp on every one; `Core::defer_for_access` warns on the first deferral and,
|
||||
once the run passes `DRAIN_ACCESS_DEFER_LIMIT` (5 min), reports it through
|
||||
`record_op_failure` so it enters the ordinary backoff and shows up in
|
||||
`failing_ops` and the status error text. `record_op_failure` clears the window so
|
||||
each escalation re-arms it rather than firing every recheck, and an attempt the
|
||||
access check admits clears it too (`clear_op_access_deferral`). The row and its
|
||||
staged blob are untouched on every path — this changes only what the user can
|
||||
see.
|
||||
|
||||
The deferral window stays as the backstop for everything that does not resolve,
|
||||
including a refetch that keeps not helping, and `AccessDeferral` now carries the
|
||||
reason into both the log line and the recorded `last_error`.
|
||||
|
||||
**Still open:** the acceptance runner waits on the *global* queue
|
||||
(`ManagedSyncPair.wait_for_queue`), so any unrelated stuck op fails those four
|
||||
cases on a machine that has one — that is what turned this backlog into four
|
||||
apparent FUSE regressions.
|
||||
|
||||
## B82 — A `parent_uid` cycle hangs the daemon on any short search
|
||||
|
||||
**Status:** Fixed (unverified) 2026-08-12 — unit-tested, not driven against a
|
||||
@@ -1504,12 +1741,26 @@ silently undoing the hole at commit.
|
||||
|
||||
## B22 — Synchronous `block_on` inside Mutex Guard (HIGH-02)
|
||||
|
||||
**Status:** Open / In Progress
|
||||
**Status:** Fixed (unverified under load) 2026-08-16 — the systematic audit this was waiting on
|
||||
has been done and found nothing left.
|
||||
**Found:** 2026-07-21, multi-agent concurrency audit (`audit_bugs.md` HIGH-02)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `sync.rs`, `devices.rs`
|
||||
|
||||
**Cause:** Invoking `rt.block_on(...)` while holding `state.lock()` deadlocks worker threads and the main FUSE loop under high concurrency. Most major handlers have been refactored to drop `state.lock()` before invoking async runtime methods, but a full systematic audit across all background tasks and FUSE callbacks is required to eliminate all instances.
|
||||
|
||||
**Audit (2026-08-16).** All 131 `block_on` sites in `pdfs-fuse` were scanned mechanically: for
|
||||
each, every guard binding (`self.state()` or any `.lock()`) still lexically live at that point,
|
||||
accounting for explicit `drop(..)` and scope exits. Two hits, both false positives —
|
||||
`devices.rs:570` binds `taken`, the `Option<Mount>` *taken out of* the map (the guard is a
|
||||
temporary, and `_guard` is explicitly dropped above), and `lib.rs:1917/1927` binds `resident`,
|
||||
a `bool` read out of a temporary guard. `pdfs-core` contains no `block_on` at all. The reverse
|
||||
direction — a guard held across an `.await` — is covered by `clippy::await_holding_lock`, which
|
||||
is on in the CI gate.
|
||||
|
||||
The scan is lexical, so it cannot see a guard passed into a callee; it is evidence, not proof,
|
||||
which is why this is *unverified*. What would settle it is the concurrency half of the acceptance
|
||||
suite run against a heavily loaded drain.
|
||||
|
||||
---
|
||||
|
||||
## B23 — Un-fsynced Scratch Wipe on Daemon Restart (HIGH-03)
|
||||
@@ -1544,12 +1795,44 @@ silently undoing the hole at commit.
|
||||
|
||||
## B25 — Weak Conflict Baseline Detection `(mtime, size)` (HIGH-05)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16 — the FUSE-write half was closed by **B69**; the
|
||||
mirror half is closed here.
|
||||
**Found:** 2026-07-21, multi-agent sync engine audit (`audit_bugs.md` HIGH-05)
|
||||
**Where:** `crates/pdfs-core/src/cache.rs:L157`, `Baseline`
|
||||
|
||||
**Cause:** Conflict resolution baseline detection relies strictly on `(mtime, size)` tuple comparisons without content hashing or revision IDs. Concurrent edits landing within the same second that produce identical file sizes match baselines, causing offline edits to overwrite remote changes silently without creating `(sync-conflict <ts>)` copies.
|
||||
|
||||
**Fix (2026-08-16), part one — the FUSE write path.** Closed by **B69**: `Baseline` gained
|
||||
`revision_id`, and `revision_changed` keys on the server revision id — the true identity, which
|
||||
advances if and only if a new revision was sealed — falling back to `(mtime, size)` only for a
|
||||
sidecar written before the field existed.
|
||||
|
||||
**Fix (2026-08-16), part two — the mirror sync path.** B69 did not reach it: a mirror folder's
|
||||
baseline is `sync_entry`, and its local side was still whole seconds. An edit made in the same
|
||||
second as the last sync that left the file the same length — a fixed-size record rewritten in
|
||||
place, an image re-exported, a database page updated — read as *unchanged* and was never
|
||||
uploaded, leaving the remote silently behind until something else about the file moved.
|
||||
|
||||
`sync_entry` gains `local_mtime_ns` (schema **28**), and the comparison moved into
|
||||
`LocalSig::same_content`, which uses nanoseconds when both sides have them and whole seconds
|
||||
otherwise. The column is nullable rather than a converted `local_mtime * 1e9` on purpose: the
|
||||
sub-second 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 "compare seconds", which is exactly the behaviour that row was written under; each row gains
|
||||
a real value the next time its path syncs.
|
||||
|
||||
Covered by `db::tests::migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline`
|
||||
and `sync::planner::file_plan_tests::a_same_second_same_size_edit_is_still_a_local_change`.
|
||||
|
||||
**Still open (deliberately):** the *remote* side of a mirror baseline is still `(mtime, size)`
|
||||
(`remote_rev`/`remote_hash`). Re-keying it onto `active_revision_id` the way B69 did for the FUSE
|
||||
path is the same idea and the same size of change, but the stored column already holds
|
||||
mtime-shaped strings, and telling an old row from a new one would need a heuristic on the value's
|
||||
shape. That deserves its own column and its own pass rather than a guess.
|
||||
|
||||
**To verify:** on a mirror folder, `printf %s aaaa > f && sync && printf %s bbbb > f` inside one
|
||||
second, then a reconcile pass — expect an upload, and the remote content to be `bbbb`.
|
||||
|
||||
---
|
||||
|
||||
## B26 — Broken Open-Unlinked File Semantics (HIGH-06)
|
||||
@@ -1620,32 +1903,92 @@ queueing when it retires the last handle of an unlinked inode.
|
||||
|
||||
## B30 — SQLite Write Lock & Mutex Contention under Heavy Drain (MED-03)
|
||||
|
||||
**Status:** Open / In Progress
|
||||
**Status:** Fixed (unverified under load) 2026-08-16 — closed by the work filed under the
|
||||
performance audit rather than by a change made for this entry.
|
||||
**Found:** 2026-07-21, multi-agent database audit (`audit_bugs.md` MED-03)
|
||||
**Where:** `crates/pdfs-core/src/db/ops.rs`, `crates/pdfs-core/src/db/mod.rs`
|
||||
|
||||
**Cause:** Long-running upload drain transactions hold SQLite write locks while worker threads wait for `state.lock()`, causing periodic FUSE response latency spikes during heavy sync operations.
|
||||
|
||||
**Audit (2026-08-16).** Each of the three mechanisms behind that sentence is gone:
|
||||
|
||||
1. **No transaction spans a network call.** Every `Db` method opens its transaction, commits and
|
||||
returns; nothing holds one open across calls (the invariant is stated on `Db::read`). A drain
|
||||
op's upload happens between database calls, not inside one.
|
||||
2. **Reads no longer queue behind the writer.** The read-only connection pool (`Db::read`) gives
|
||||
`SELECT`-only methods their own connections, so a FUSE metadata call does not wait on whatever
|
||||
is committing. Asserted by `db::tests::a_read_does_not_wait_for_the_write_connection`, which
|
||||
holds the write connection for the whole measurement.
|
||||
3. **The queue is no longer read whole to pick one op.** `Db::claim_next_due_op` selects and
|
||||
claims a single row in the database, which is what made a long queue quadratic to drain while
|
||||
holding the shared connection.
|
||||
|
||||
The `state.lock()` half is covered from the other side: **B75** moved network work off fuser's
|
||||
dispatch thread, and **B22**'s audit found no `block_on` under a `State` guard.
|
||||
|
||||
Unverified because "periodic latency spikes" is a load property, not a code property: it is
|
||||
closed by construction and wants the concurrency acceptance case under a loaded drain to confirm
|
||||
it in the field.
|
||||
|
||||
---
|
||||
|
||||
## B31 — Unimplemented Special Files & Attribute Modifications (MED-04)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Not a bug — by design, verified 2026-08-16
|
||||
**Found:** 2026-07-21, multi-agent POSIX audit (`audit_bugs.md` MED-04)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`
|
||||
|
||||
**Cause:** Symlinks (`symlink`/`readlink`), hardlinks (`link`), FIFOs/sockets (`mknod`), and `chmod`/`chown` attribute modifications return `ENOSYS` or are silently ignored.
|
||||
|
||||
**Verdict (2026-08-16).** Each of these is the correct answer, not a gap, and each errno is now a
|
||||
deliberate choice with a comment saying why:
|
||||
|
||||
- `symlink` → **EPERM**. The operation is meaningful; Drive simply cannot represent it. Callers
|
||||
treat `EPERM` as a definite "no" instead of retrying against an older interface.
|
||||
- `link` → **EPERM**. A node has exactly one parent on Drive, so there are no hard links. git
|
||||
falls back to `rename()` on `EPERM`, which this filesystem supports.
|
||||
- `mknod` → **ENOSYS**. Devices, FIFOs and sockets have no representation at all. Regular files
|
||||
arrive through `create`, never here.
|
||||
- `chmod`/`chown`/`utimes` → accepted and ignored, so tools that chmod or restamp after writing
|
||||
succeed rather than failing on a filesystem with no per-file mode to set.
|
||||
|
||||
Implementing any of them would mean inventing a local-only representation that no other Proton
|
||||
client can read — a worse outcome than a clean refusal. The errno contract is asserted by the
|
||||
acceptance suite's clean-refusal case so it cannot drift, and **B73** removed the per-probe WARN
|
||||
that made these look like incidents in the journal.
|
||||
|
||||
---
|
||||
|
||||
## B32 — Race Condition in Concurrent Handle Release and Open (MED-05)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16
|
||||
**Found:** 2026-07-21, multi-agent concurrency audit (`audit_bugs.md` MED-05)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs:L4042, L4574`
|
||||
|
||||
**Cause:** Opening a file for writing while a previous handle release is draining creates overlapping scratch write handles and out-of-order remote revision uploads.
|
||||
|
||||
**Fix (2026-08-16).** The concrete hole was in `serve_open`: it sampled the queued revision
|
||||
(`Core::pending`), then copied that blob into a fresh scratch file, then took the `State` lock to
|
||||
install the handle. A `release` publishing a newer revision anywhere inside that gap — which spans
|
||||
a copy the size of the whole file — left the new handle carrying the *older* base, and its own
|
||||
release then published content that silently discarded the revision closed a moment earlier.
|
||||
|
||||
The install is now guarded: the pending blob path (which changes on every publication, so it
|
||||
identifies the revision) is re-read immediately before the `State` lock, and if it moved, the
|
||||
scratch file is discarded and the open goes around again — up to `OPEN_BASE_ATTEMPTS` (3), after
|
||||
which it fails with `EIO` rather than spinning a FUSE worker. Only a handle the open is about to
|
||||
*create* is checked; if one already exists, that handle is authoritative and the scratch is
|
||||
discarded regardless.
|
||||
|
||||
The re-read is deliberately **not** taken under the `State` lock: no site in the daemon holds
|
||||
`pending` and `state` at once (the invariant is stated on `stamp_pending_sizes`) and this is not
|
||||
the place to become the first. What that costs is a residual window of a few instructions instead
|
||||
of a lock-tight comparison; what it removes is the multi-gigabyte window, which is the one that
|
||||
actually loses data.
|
||||
|
||||
**To verify:** the acceptance suite's concurrency case, with a large file, closing and reopening
|
||||
it for write from two processes while the drain is loaded.
|
||||
|
||||
---
|
||||
|
||||
## B33 — Lack of Partial Transfer Resumption & Head-of-Line Queue Blocking (MED-06)
|
||||
@@ -1704,12 +2047,25 @@ second-account and live FUSE acceptance have not been run and remain required in
|
||||
|
||||
## B36 — Unicode Normalization Discrepancy (NFC vs NFD) (LOW-01)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Open (verified 2026-08-16, deliberately not fixed)
|
||||
**Found:** 2026-07-21, multi-agent sync audit (`audit_bugs.md` LOW-01)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sync.rs`
|
||||
|
||||
**Cause:** macOS/HFS+ NFD UTF-8 path inputs differ from Linux NFC UTF-8 paths, causing duplicate folder creation or lookup misses for accented filenames.
|
||||
|
||||
**Verified (2026-08-16), not fixed.** The claim is accurate: names are compared as bytes
|
||||
throughout — the mirror engine's rel paths, `by_uid`/`children` interning, and the planner's
|
||||
classification union — so `café` written by a Proton client on macOS or iOS (NFD) and `café`
|
||||
written here (NFC) are two different paths, and a mirror folder holding both would sync both.
|
||||
|
||||
Not fixed here on purpose. A normalizing comparison changes what "the same file" *means* across
|
||||
the whole daemon, and it changes it in the unsafe direction: two names that are genuinely distinct
|
||||
on the remote would start colliding locally, and a collision in the sync engine is a deletion or
|
||||
an overwrite, not a warning. Doing it properly means a normalization dependency, a decision about
|
||||
which form is canonical on the wire, and a migration for baselines keyed on the other form — its
|
||||
own change with its own acceptance case, not a line in a sweep. Left LOW because it needs
|
||||
cross-platform authorship of the *same* accented name to bite at all.
|
||||
|
||||
---
|
||||
|
||||
## B37 — Failed Remote Trash Removed the Local Dentry (CRIT-04)
|
||||
@@ -1745,7 +2101,7 @@ pass from being treated as successfully settled.
|
||||
|
||||
## B39 — Sync Download Can Overwrite a Concurrent Local Edit (HIGH-09)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16
|
||||
**Found:** 2026-07-22, sync concurrency audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, download classification and apply path
|
||||
|
||||
@@ -1759,11 +2115,28 @@ revalidate immediately before rename. Preserve a conflict copy on mismatch. A
|
||||
deterministic test should pause the download, edit the target, resume it, and
|
||||
verify that neither version is lost.
|
||||
|
||||
**Fix (2026-08-16).** `Pending::Download` now carries `local: Option<LocalSig>` — what the
|
||||
planning walk actually saw at that path, or `None` for nothing there — and `download_file` calls
|
||||
`keep_racing_local_edit` in the last moment before the rename that publishes the download. A
|
||||
destination that no longer matches the plan is moved aside by `preserve_conflict_copy` first, so
|
||||
the racing bytes survive and re-upload as a new file on the next pass: the same resolution a
|
||||
both-sides-changed conflict already gets. A path that is *empty* when something was expected is
|
||||
not a conflict — the download is about to recreate it and there is nothing to preserve.
|
||||
|
||||
`Pending::Conflict` passes `None` deliberately: it has just moved the local copy aside itself, so
|
||||
the state its download expects to find is "nothing", and anything that appears while it runs is a
|
||||
new racing write that gets its own copy.
|
||||
|
||||
Covered by `sync::tests::a_download_keeps_a_local_edit_it_did_not_plan_for` (unchanged destination
|
||||
overwritten silently; changed destination preserved). Unverified against a real timing race —
|
||||
the deterministic pause-mid-download test the entry asks for is not in place; the unit test drives
|
||||
the guard directly instead.
|
||||
|
||||
---
|
||||
|
||||
## B40 — Sync Upload Can Stream Torn Live Content (HIGH-10)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16
|
||||
**Found:** 2026-07-22, sync concurrency audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, upload apply and baseline update
|
||||
|
||||
@@ -1775,11 +2148,32 @@ and falsely declare them synchronized.
|
||||
**Required fix/test:** Upload an immutable staged snapshot, or validate an open
|
||||
descriptor before and after streaming and refuse baseline settlement on change.
|
||||
|
||||
**Fix (2026-08-16).** The second option, and the correction is structural rather than a check.
|
||||
`record_file_baseline` no longer stats the path when it is done; it is *given* the identity the
|
||||
transferred content corresponds to, and both upload paths pass the `LocalSig` taken from the
|
||||
descriptor they streamed. So a file that changed mid-stream records a baseline describing the
|
||||
bytes that went up, not the bytes on disk now — which makes the very next pass classify the path
|
||||
as locally changed and upload a whole, clean revision over the torn one. The old behaviour stated
|
||||
the opposite: it declared the torn revision synchronized and left it as the file's content on
|
||||
Drive.
|
||||
|
||||
`warn_if_torn` compares before and after purely so the journal explains the extra revision; the
|
||||
correction does not depend on it. Downloads are unaffected — there the file on disk *is* the
|
||||
transferred content, published by a rename and stamped, so its stat is the identity.
|
||||
|
||||
Note the pre-existing mitigation this backs up: the `/proc/*/fd` scan already defers any file held
|
||||
open for writing (`FilePlan::Deferred`), so this covers the residual — a writer that opens, writes
|
||||
and closes entirely inside one upload.
|
||||
|
||||
**To verify:** upload a large file from a mirror folder while appending to it, then check that the
|
||||
following pass uploads a new revision and the final remote content matches the local file.
|
||||
|
||||
---
|
||||
|
||||
## B41 — Sync Folder Removal Races Active Reconciliation (HIGH-11)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (verified by inspection) 2026-08-16 — already correct in the code by the time
|
||||
this was revisited
|
||||
**Found:** 2026-07-22, sync lifecycle audit
|
||||
**Where:** `crates/pdfs-fuse/src/devices.rs`, `remove_sync_folder`
|
||||
|
||||
@@ -1791,11 +2185,26 @@ baseline state.
|
||||
**Required fix/test:** Serialize removal with reconciliation, cancel/disable the
|
||||
folder, await active work, then unmount and remove durable state.
|
||||
|
||||
**Audit (2026-08-16).** `Core::remove_sync_folder` opens by taking `self.sync_lock(id)` and holds
|
||||
it across the whole removal, and `reconcile_pass` takes the same lock for the whole pass — so a
|
||||
removal requested mid-pass blocks until that pass finishes, which is the serialization this asked
|
||||
for. The ordering inside the lock is also the one specified: unmount the on-demand session first
|
||||
(before trashing the remote tree it serves, which would otherwise leave it answering for deleted
|
||||
nodes), then drop the row.
|
||||
|
||||
The one step outside the lock is the remote trash, and that is safe by construction rather than by
|
||||
accident: the row is already gone, and `reconcile_pass` re-reads `sync_folder_get` **under the
|
||||
lock** and returns when it finds nothing, so no pass can start against a folder being removed. The
|
||||
mode re-check on that same re-read is what makes this hold for a switch racing a removal too.
|
||||
|
||||
No code change. Recorded as verified-by-inspection: a live removal during an active pass has not
|
||||
been driven.
|
||||
|
||||
---
|
||||
|
||||
## B42 — Local Delete Failure Is Recorded as Sync Success (HIGH-12)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16.
|
||||
**Found:** 2026-07-22, sync error-path audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, local file/directory deletion branches
|
||||
|
||||
@@ -1806,11 +2215,23 @@ next pass and can resurrect content that was deleted remotely.
|
||||
**Required fix/test:** Treat `ENOENT` as success; for every other removal error,
|
||||
retain the baseline and increment `Outcome.errors`.
|
||||
|
||||
**Fix (2026-08-16).** The error half was already in place by the time this was revisited: both
|
||||
`FilePlan::DeleteLocal` and the deferred folder removals log the failure, increment
|
||||
`outcome.errors` and `continue` without touching `sync_entry_remove`, so the baseline survives
|
||||
and the survivor is not mistaken for a new local file next pass.
|
||||
|
||||
The `ENOENT` half was not, and it failed the opposite way: a path that was *already* gone was
|
||||
counted as an error and kept its baseline, so the same removal was retried and re-reported on
|
||||
every pass forever. Both call sites now go through `removed_locally`, which reads `NotFound` as
|
||||
the state the engine was asking for. Covered by
|
||||
`sync::tests::an_already_absent_path_counts_as_removed`. Unverified because no live sync pass has
|
||||
been driven over an externally-deleted file yet.
|
||||
|
||||
---
|
||||
|
||||
## B43 — FUSE Directory Cookies Are Not Stable (MED-09)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16
|
||||
**Found:** 2026-07-22, FUSE/POSIX audit
|
||||
**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::serve_readdir`
|
||||
|
||||
@@ -1822,11 +2243,31 @@ indexes and cause entries to be skipped or repeated.
|
||||
directory handle, or assign stable per-entry cookies. Test with deliberately
|
||||
small reply pages and mutation between calls.
|
||||
|
||||
**Fix (2026-08-16).** The first option. `opendir` is now implemented: it enumerates the folder if
|
||||
it is cold, freezes the listing, and publishes it under a fresh `fh` in `State::dir_snapshots`;
|
||||
`readdir` serves from that snapshot; `releasedir` drops it. The kernel pairs each `opendir` with
|
||||
exactly one `releasedir`, so the snapshot lives exactly as long as the enumeration it belongs to,
|
||||
and a create or a trash landing between two pages can no longer shift the indexes the continuation
|
||||
cookie is expressed in.
|
||||
|
||||
The `fh` comes from the same `State::next_fh` counter write handles use, so a directory handle can
|
||||
never collide with a file handle. Serving from the snapshot is a pure in-memory walk with no
|
||||
chance of a remote call, so `readdir` no longer needs a worker at all; the live path
|
||||
(`serve_readdir`) stays as a fallback for a handle that has no snapshot, and both build their
|
||||
listing through the same `build_listing` so they cannot disagree.
|
||||
|
||||
Enumeration cost is unchanged — it moved from the first `readdir` to `opendir`, one call earlier —
|
||||
and a folder that cannot be enumerated now fails at `opendir`, where the caller can still act on
|
||||
the errno.
|
||||
|
||||
**To verify:** the acceptance suite with deliberately small reply pages, creating and trashing
|
||||
entries between `getdents` calls, asserting no entry is skipped or repeated.
|
||||
|
||||
---
|
||||
|
||||
## B44 — FUSE Background Workers Have No Coordinated Shutdown (MED-10)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed (unverified) 2026-08-16
|
||||
**Found:** 2026-07-22, daemon lifecycle audit
|
||||
**Where:** `crates/pdfs-fuse/src/mount.rs`, mount lifecycle; drain/index/sync loops
|
||||
|
||||
@@ -1839,6 +2280,31 @@ continue DB/client activity after teardown.
|
||||
handles, and ordered shutdown. Repeated mount/unmount tests should return thread
|
||||
counts to baseline and observe no post-unmount DB work.
|
||||
|
||||
**Fix (2026-08-16).** All four, in `crates/pdfs-fuse/src/shutdown.rs` plus the loops:
|
||||
|
||||
1. **Shared cancellation.** `Shutdown` — a flag and a condvar, set once and never cleared — lives
|
||||
on `Core`, so every clone a worker was given carries it.
|
||||
2. **Interruptible waits.** Every loop that called `thread::sleep` in a `loop {}` with no exit now
|
||||
calls `Shutdown::sleep`, which returns `false` the moment teardown starts: the conflict sweep
|
||||
(warmup *and* interval), the online probe, the local indexer, and both sync poll threads. The
|
||||
drain workers check between ops — never inside one, so an op is always either retired or
|
||||
released — and the sync engine gained a `SyncMsg::Stop` that ends its loop and abandons any
|
||||
burst it was settling.
|
||||
3. **Owned join handles.** `run_mount` collects every long-lived thread it starts into one
|
||||
`workers` vec.
|
||||
4. **Ordered shutdown.** `stop_workers` sets the flag, wakes the two waits that need waking — the
|
||||
drain's own condvar via `wake_drain`, and the control listener, which blocks inside `accept`
|
||||
and is woken by a connection from us before the socket is unlinked — then joins them all. It
|
||||
runs after the mounts are down, because nothing a worker can still do is harmful at that point,
|
||||
and it also runs on the startup-failure path, which abandons the mount just as completely.
|
||||
|
||||
A join that takes a while is expected and correct: a drain worker part-way through an upload
|
||||
finishes it rather than abandoning the user's bytes mid-flight.
|
||||
|
||||
Covered by `shutdown::tests` (a stop cuts an hour-long wait short; an already-stopped signal does
|
||||
not wait at all). Unverified for the property that motivated the entry — repeated in-process
|
||||
mount/unmount returning thread counts to baseline — which wants the acceptance suite.
|
||||
|
||||
---
|
||||
|
||||
## B45 — Truncate After a Queued Rewrite Corrupts or Returns EIO
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# 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.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).
|
||||
|
||||
## [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.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
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.7.0
|
||||
pkgver=1.8.1
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user