mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(core,fuse,cli): harden writes, trash and cleanup found by account acceptance runs
The FUSE acceptance suite now runs on the signed-in account by default and cleans up after itself; its first live runs found three daemon bugs. - B101: a pwrite past EOF now authors the hole it leaves, so truncated bytes no longer come back from the base. A write attached to a create while that create uploads becomes a revision of the real uid instead of being dropped. - B102: removing a folder drops the queued revisions of files below it, found through the node tree, with their blobs and in-flight uploads. `pdfs rm` now drops queued ops too. - B103: each server call of a trash refresh times out after 120 s, so one hung call can no longer freeze the trash listing until restart. Acceptance suite: account mode with a manifest, lock and reaper; cleanup by uid; queued ops naming a test root count as leftovers; new edge-case, throughput and B100/B101 cases; the wide-directory case is split from the deep tree and gets a doubled budget. Adds `pdfs trash --json`.
This commit is contained in:
1 parent
84c9ade8e3
commit
7b67db5ced
13 files changed
+1785
-126
No files matched your search
@@ -30,7 +30,7 @@ struct Cli {
|
||||
/// Emit machine-readable JSON instead of formatted text.
|
||||
///
|
||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`). Commands that
|
||||
/// perform an action keep their human output — a script that needs to know
|
||||
/// whether one succeeded has the exit code.
|
||||
#[arg(long, global = true)]
|
||||
@@ -2551,7 +2551,11 @@ fn cmd_diagnostics() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_trash() -> Result<()> {
|
||||
match control_request(CtlRequest::ListTrash)? {
|
||||
let response = control_request(CtlRequest::ListTrash)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
|
||||
CtlResponse::Entries { entries } => {
|
||||
for e in entries {
|
||||
|
||||
+111
-39
@@ -66,6 +66,52 @@ pub fn op_supersedes(kind: &str) -> bool {
|
||||
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
|
||||
}
|
||||
|
||||
/// Delete the ops that removing `uid` takes with it, returning the staged blobs
|
||||
/// they held.
|
||||
///
|
||||
/// That is every op for `uid` or for anything queued beneath it, plus the
|
||||
/// revisions of files the node tree places beneath it. A revision op carries
|
||||
/// no `parent_uid`, so the queue alone cannot tell that its file sits inside
|
||||
/// a removed folder: the upload outlived the folder, failed forever against a
|
||||
/// trashed node, and kept its blob on disk (`docs/BUGS.md` B102).
|
||||
///
|
||||
/// Only revisions are taken from the node tree. A queued rename that moved a
|
||||
/// node *into* the folder has to land, or the node would stay where it was
|
||||
/// on the server instead of going to the trash with the folder.
|
||||
fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> {
|
||||
const DOOMED: &str = "
|
||||
WITH RECURSIVE
|
||||
queued(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN queued q ON p.parent_uid = q.uid
|
||||
),
|
||||
below(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN below b ON n.parent_uid = b.uid
|
||||
),
|
||||
doomed(id) AS (
|
||||
SELECT id FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM queued)
|
||||
OR (kind = 'revision' AND uid IN (SELECT uid FROM below))
|
||||
)";
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{DOOMED}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE id IN (SELECT id FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{DOOMED} DELETE FROM pending_op WHERE id IN (SELECT id FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
/// The volume id given to a node that exists only on this machine, so far. A
|
||||
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
|
||||
/// round-trips through the same `Display`/parse path as any other uid.
|
||||
@@ -252,27 +298,9 @@ impl Db {
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, Vec<String>)> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||
@@ -326,6 +354,67 @@ impl Db {
|
||||
Ok(Some(AttachedBlob { id, superseded }))
|
||||
}
|
||||
|
||||
/// Retire a queued create that has just landed as `real`, and repoint
|
||||
/// everything that named its placeholder, in one transaction.
|
||||
///
|
||||
/// The drain uploads the blob the create held when it was picked up. A
|
||||
/// write that closes while that upload is on the wire attaches a newer blob
|
||||
/// to the *same row* (`attach_blob_to_create`), so deleting the row by id
|
||||
/// threw that write away: the server kept the older bytes and nothing was
|
||||
/// left queued to replace them. When the row no longer holds `uploaded`, it
|
||||
/// becomes a revision of `real` instead, with its sidecar passed through
|
||||
/// `rewrite` so it names the real node. Returns that blob and sidecar, or
|
||||
/// `None` when the create is simply done.
|
||||
pub fn finish_create(
|
||||
&self,
|
||||
id: i64,
|
||||
uploaded: Option<&str>,
|
||||
local: &str,
|
||||
real: &str,
|
||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<(Option<String>, Option<String>)> = tx
|
||||
.query_row(
|
||||
"SELECT blob_path, meta_json FROM pending_op WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let newer = match row {
|
||||
Some((Some(blob), Some(meta))) if uploaded != Some(blob.as_str()) => {
|
||||
rewrite(&meta).map(|meta| (blob, meta))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match &newer {
|
||||
Some((_, meta)) => {
|
||||
tx.execute(
|
||||
"UPDATE pending_op
|
||||
SET kind = ?2, uid = ?3, parent_uid = NULL, name = NULL, meta_json = ?4,
|
||||
attempts = 0, last_error = NULL, next_attempt_at = 0
|
||||
WHERE id = ?1",
|
||||
params![id, OP_REVISION, real, meta],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
|
||||
tx.commit()?;
|
||||
Ok(newer)
|
||||
}
|
||||
|
||||
/// Replace the sidecar of a queued op, for a baseline that has moved under
|
||||
/// it.
|
||||
///
|
||||
@@ -428,29 +517,12 @@ impl Db {
|
||||
/// and in the user's pending count, for the life of the database.
|
||||
///
|
||||
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
|
||||
/// recursion finds nothing and costs one query.
|
||||
/// recursion finds nothing and costs one query. Revisions of files the
|
||||
/// node tree places beneath `uid` go too; see [`drop_doomed_ops`].
|
||||
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.commit()?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
@@ -2498,6 +2498,71 @@ fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A revision op names no parent, so the queue alone cannot see that its file
|
||||
/// sits inside a folder being removed. The node tree can: trashing the folder
|
||||
/// has to take the uploads of the files below it along, or they fail forever
|
||||
/// against a trashed node and keep their bytes on disk (B102). A rename that
|
||||
/// moved a node into the folder still has to land.
|
||||
#[test]
|
||||
fn trashing_a_folder_drops_the_queued_revisions_of_files_below_it() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_nodes(&[
|
||||
folder("root", None, "My Files"),
|
||||
folder("doomed", Some("root"), "doomed"),
|
||||
folder("sub", Some("doomed"), "sub"),
|
||||
file("deep", "sub", "deep.bin", 1),
|
||||
file("moved", "doomed", "moved.txt", 1),
|
||||
file("kept", "root", "kept.bin", 1),
|
||||
])
|
||||
.unwrap();
|
||||
let op = |kind: &str, link: &str, blob: Option<&str>| PendingOp {
|
||||
id: 0,
|
||||
kind: kind.to_string(),
|
||||
uid: uid(link).to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep")))
|
||||
.unwrap();
|
||||
db.enqueue_op(&op(OP_RENAME, "moved", None)).unwrap();
|
||||
db.enqueue_op(&op(OP_REVISION, "kept", Some("/staging/kept")))
|
||||
.unwrap();
|
||||
|
||||
let doomed = uid("doomed").to_string();
|
||||
let (_, blobs) = db.replace_ops_with_trash(&doomed, "doomed", 2).unwrap();
|
||||
assert_eq!(blobs, vec!["/staging/deep"]);
|
||||
|
||||
let mut left: Vec<(String, String)> = db
|
||||
.pending_ops()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|op| (op.kind, op.uid))
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
vec![
|
||||
(OP_RENAME.to_string(), uid("moved").to_string()),
|
||||
(OP_REVISION.to_string(), uid("kept").to_string()),
|
||||
(OP_TRASH.to_string(), doomed.clone()),
|
||||
]
|
||||
);
|
||||
|
||||
// Deleting outright follows the same tree.
|
||||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep2")))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.delete_ops_for_uid(&uid("sub").to_string()).unwrap(),
|
||||
vec!["/staging/deep2"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Deleting a folder that was created offline must take the ops queued
|
||||
/// underneath it with it: they name a placeholder parent that will now never
|
||||
/// become real, so nothing could ever drain them and nothing is left to
|
||||
@@ -2699,6 +2764,99 @@ fn attaching_to_an_already_drained_create_reports_it_is_gone() {
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_attached_while_its_create_uploads_becomes_a_revision() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~abc";
|
||||
let real = uid("real").to_string();
|
||||
let create = |blob: Option<&str>| PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some("new.txt".to_string()),
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: blob.map(|_| "{\"uid\":\"local~abc\"}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
let (id, _) = db.enqueue_op(&create(None)).unwrap();
|
||||
// The drain picked the create up with no blob; the file's first write
|
||||
// closes while the empty upload is on the wire.
|
||||
db.attach_blob_to_create(local, "/staging/written", "{\"uid\":\"local~abc\"}")
|
||||
.unwrap()
|
||||
.expect("create is still queued");
|
||||
|
||||
let newer = db
|
||||
.finish_create(id, None, local, &real, |json| {
|
||||
Some(json.replace("local~abc", "vol~real"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
newer,
|
||||
Some((
|
||||
"/staging/written".to_string(),
|
||||
"{\"uid\":\"vol~real\"}".to_string()
|
||||
))
|
||||
);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1, "the write is still queued");
|
||||
assert_eq!(ops[0].kind, OP_REVISION);
|
||||
assert_eq!(ops[0].uid, real);
|
||||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/written"));
|
||||
assert_eq!(ops[0].parent_uid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_create_whose_blob_landed_is_retired() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~abc";
|
||||
let real = uid("real").to_string();
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some("new.txt".to_string()),
|
||||
blob_path: Some("/staging/sent".to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: "local~child".to_string(),
|
||||
parent_uid: Some(local.to_string()),
|
||||
name: Some("inside.txt".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 2,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let newer = db
|
||||
.finish_create(id, Some("/staging/sent"), local, &real, |_| {
|
||||
panic!("an unchanged create has no sidecar to rewrite")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(newer, None);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1, "only the child is left");
|
||||
assert_eq!(ops[0].parent_uid.as_deref(), Some(real.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draining_a_folder_repoints_its_queued_children() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -41,7 +41,7 @@ use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeMoveItem};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::state::Intervals;
|
||||
use super::state::{Intervals, PendingRevision};
|
||||
use super::transfers::CountingReader;
|
||||
use super::{
|
||||
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, DRAIN_REVISION_DEBOUNCE,
|
||||
@@ -886,15 +886,20 @@ impl Core {
|
||||
// Retire the op before touching anything else: if we crash here the node
|
||||
// exists remotely and the local placeholder is reconciled by the event
|
||||
// sync, whereas a surviving op would create the file a second time.
|
||||
self.db.delete_op(op.id)?;
|
||||
let newer = self.retire_create(op, &local, &real)?;
|
||||
self.adopt_real_uid(&local, &real)?;
|
||||
// The feed will report this create back to us; the tree already has it
|
||||
// under its real uid, so that event is ours to ignore (`Core::self_changes`).
|
||||
self.note_self_change(&real);
|
||||
// The uploaded blob is done with either way: a write that replaced it
|
||||
// while it was on the wire already discarded it when it attached.
|
||||
if let Some(blob) = op.blob_path.as_deref() {
|
||||
self.cache.discard_staged(Path::new(blob));
|
||||
}
|
||||
self.pending.lock().remove(&local);
|
||||
if newer {
|
||||
info!(%local, %real, name, "a write landed during the create; queued it as a revision");
|
||||
self.wake_drain();
|
||||
}
|
||||
self.log_activity(ActivityKind::Upload, &name, "created", true);
|
||||
info!(%local, %real, name, kind = %op.kind, "pending create landed");
|
||||
Ok(())
|
||||
@@ -963,6 +968,65 @@ impl Core {
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Retire a landed create's op and move its queued state to the real uid.
|
||||
/// True when a newer write attached to the create while it was uploading,
|
||||
/// which is now queued as a revision of `real`.
|
||||
///
|
||||
/// Runs under the `pending` lock, which `Core::enqueue_staged_write` also
|
||||
/// holds from attaching a blob to a create until it records the pending
|
||||
/// entry. So a write either attached before this ran and is carried over,
|
||||
/// or finds the create gone and queues against the real uid itself. The
|
||||
/// open write handles are repointed right after, for the same reason:
|
||||
/// a handle released later must not queue against the placeholder.
|
||||
fn retire_create(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
local: &NodeUid,
|
||||
real: &NodeUid,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let real_str = real.to_string();
|
||||
let newer = {
|
||||
let mut pending = self.pending.lock();
|
||||
let newer = self.db.finish_create(
|
||||
op.id,
|
||||
op.blob_path.as_deref(),
|
||||
&local.to_string(),
|
||||
&real_str,
|
||||
|json| {
|
||||
let mut meta: StagedWrite = serde_json::from_str(json).ok()?;
|
||||
meta.uid = real_str.clone();
|
||||
serde_json::to_string(&meta).ok()
|
||||
},
|
||||
)?;
|
||||
pending.remove(local);
|
||||
if let Some((blob, json)) = &newer {
|
||||
let meta: StagedWrite = serde_json::from_str(json)?;
|
||||
pending.insert(
|
||||
real.clone(),
|
||||
PendingRevision {
|
||||
path: PathBuf::from(blob),
|
||||
meta,
|
||||
},
|
||||
);
|
||||
}
|
||||
newer.is_some()
|
||||
};
|
||||
self.for_each_state(|st| {
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
}
|
||||
}
|
||||
for aw in st.active_writes.values_mut() {
|
||||
if aw.uid == *local {
|
||||
aw.uid = real.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(newer)
|
||||
}
|
||||
|
||||
/// Swap a placeholder uid for the real one across everything that keyed off
|
||||
/// it: queued children, the DB, the in-memory tree, and the caches.
|
||||
///
|
||||
@@ -985,14 +1049,17 @@ impl Core {
|
||||
.fetch_node(real)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
// Repoints queued children and node rows, and drops the placeholder row.
|
||||
// A no-op after `retire_create`, which has already done both.
|
||||
self.db
|
||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||
|
||||
self.for_each_state(|st| {
|
||||
let Some(ino) = st.by_uid.remove(local) else {
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
}
|
||||
let Some(&ino) = st.by_uid.get(real) else {
|
||||
return;
|
||||
};
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
e.node = node.clone();
|
||||
@@ -1024,6 +1091,9 @@ impl Core {
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||
}
|
||||
// A write carried over from the create was made against no revision at
|
||||
// all; the one just created is what it now replaces.
|
||||
self.rebaseline_pending(real, &node);
|
||||
if node.is_folder() {
|
||||
// It was recorded as listed while local (it was empty and had nothing
|
||||
// to enumerate). That still holds: its queued children re-intern under
|
||||
|
||||
@@ -442,10 +442,7 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
let end = offset + data.len() as u64;
|
||||
aw.written.add(offset, end);
|
||||
aw.len = aw.len.max(end);
|
||||
aw.dirty = true;
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino.0, len);
|
||||
len
|
||||
|
||||
+127
-39
@@ -168,6 +168,10 @@ const DRAIN_BACKOFF_MAX: Duration = Duration::from_secs(300);
|
||||
/// directly on a new write or a reconnect, so this only bounds how late a
|
||||
/// backoff can fire.
|
||||
const DRAIN_IDLE_POLL: Duration = Duration::from_secs(30);
|
||||
/// How long a write whose create just landed waits for the drain to repoint
|
||||
/// its inode (`Core::landed_uid`): 200 polls of 10 ms, two seconds in all.
|
||||
const LANDED_UID_POLLS: u32 = 200;
|
||||
const LANDED_UID_POLL: Duration = Duration::from_millis(10);
|
||||
/// Grace period before a queued revision becomes eligible for draining.
|
||||
///
|
||||
/// Tools like aria2c preallocate a file (truncate to target size) and then write
|
||||
@@ -261,6 +265,15 @@ const TRASH_MATERIALIZE_CHUNK: usize = 150;
|
||||
/// outlives that timeout is a hang from the user's side: the client gives up,
|
||||
/// the user asks again, and the daemon accumulates another refresh.
|
||||
const TRASH_FIRST_WAIT: Duration = Duration::from_secs(20);
|
||||
/// How long one server call of a trash refresh may take before the refresh
|
||||
/// gives up.
|
||||
///
|
||||
/// Refreshes are single-flight, so a call that never returns wedges the trash
|
||||
/// listing for the life of the daemon: every later request joins the stuck
|
||||
/// refresh, times out, and answers with the listing from before it. That hid
|
||||
/// a just-trashed folder from a permanent delete for as long as the daemon ran
|
||||
/// (`docs/BUGS.md` B103). One call is one request, so this is generous.
|
||||
const TRASH_CALL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// `sync_state` keys for the freshness stamps of the two persisted listings, and
|
||||
/// for whether the account has a photos volume at all (so an account without one
|
||||
@@ -3098,35 +3111,57 @@ impl Core {
|
||||
Errno::EIO
|
||||
}
|
||||
})?;
|
||||
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.
|
||||
let attached = self
|
||||
.db
|
||||
.attach_blob_to_create(&uid.to_string(), &path.to_string_lossy(), &meta_json)
|
||||
.map_err(|e| {
|
||||
error!(%uid, error = %e, "attaching write to queued create failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
match attached {
|
||||
Some(a) => a.superseded,
|
||||
None => {
|
||||
// The create drained between `release` and here, so the node
|
||||
// has a real uid now and this handle's is stale. The bytes are
|
||||
// safe in staging, but nothing here can address them.
|
||||
let mut uid = uid.clone();
|
||||
let len = meta.len;
|
||||
let superseded = 'queued: {
|
||||
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. Attached and
|
||||
// recorded under one hold of `pending`, which is what the drain
|
||||
// takes to retire a landed create: it either sees this blob and
|
||||
// carries it over, or has already finished and this write
|
||||
// queues against the real uid below (`Core::retire_create`).
|
||||
let meta_json = serde_json::to_string(&meta).unwrap_or_default();
|
||||
let mut pending = self.pending.lock();
|
||||
let attached = self
|
||||
.db
|
||||
.attach_blob_to_create(&uid.to_string(), &path.to_string_lossy(), &meta_json)
|
||||
.map_err(|e| {
|
||||
error!(%uid, error = %e, "attaching write to queued create failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
if let Some(attached) = attached {
|
||||
pending.insert(uid.clone(), PendingRevision { path, meta });
|
||||
break 'queued attached.superseded;
|
||||
}
|
||||
drop(pending);
|
||||
// The create landed while this handle was open. Its uid is the
|
||||
// one the drain has just given the inode.
|
||||
let Some(real) = self.landed_uid(ino) else {
|
||||
error!(%uid, staged = %path.display(),
|
||||
"queued create vanished under a write; bytes kept in staging");
|
||||
return Err(Errno::EIO);
|
||||
};
|
||||
debug!(local = %uid, %real, "create landed under an open write; queueing a revision");
|
||||
uid = real;
|
||||
meta.uid = uid.to_string();
|
||||
let previous = self.pending.lock().get(&uid).cloned();
|
||||
meta.based_on = previous.as_ref().and_then(|p| p.meta.based_on.clone());
|
||||
if !meta.complete
|
||||
&& let Some(previous) = previous
|
||||
&& let Err(error) = merge_over_pending(&mut meta, &path, &previous)
|
||||
{
|
||||
error!(%uid, %error, staged = %path.display(),
|
||||
"folding in the write the create carried failed; bytes kept in staging");
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let op = PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
@@ -3134,25 +3169,25 @@ impl Core {
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some(path.to_string_lossy().into_owned()),
|
||||
meta_json: Some(meta_json),
|
||||
meta_json: Some(serde_json::to_string(&meta).unwrap_or_default()),
|
||||
created_at: now_millis(),
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: now_millis() + self.revision_debounce(uid).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");
|
||||
Errno::EIO
|
||||
})?;
|
||||
self.pending
|
||||
.lock()
|
||||
.insert(uid.clone(), PendingRevision { path, meta });
|
||||
superseded
|
||||
};
|
||||
if let Some(old) = superseded {
|
||||
self.cache.discard_staged(Path::new(&old));
|
||||
}
|
||||
let len = meta.len;
|
||||
self.pending
|
||||
.lock()
|
||||
.insert(uid.clone(), PendingRevision { path, meta });
|
||||
let uid = &uid;
|
||||
// Reflect the write in the tree straight away: `ls` must show the new
|
||||
// size and mtime even though the remote still holds the old revision.
|
||||
let now = now_secs();
|
||||
@@ -3187,6 +3222,23 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The real uid the drain gave `ino` when its queued create landed.
|
||||
///
|
||||
/// Called by a write that found the create already retired. The drain
|
||||
/// repoints the inode right after it drops `pending`, with no network in
|
||||
/// between, so a short wait covers the gap; `None` means the inode is gone
|
||||
/// or never stopped being a placeholder.
|
||||
fn landed_uid(&self, ino: u64) -> Option<NodeUid> {
|
||||
for _ in 0..LANDED_UID_POLLS {
|
||||
let uid = self.state().entries.get(&ino).map(|e| e.uid.clone())?;
|
||||
if !is_local_uid(&uid) {
|
||||
return Some(uid);
|
||||
}
|
||||
std::thread::sleep(LANDED_UID_POLL);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Queue the new content of a path-based truncate — `> file`, or any
|
||||
/// `setattr(size=…)` arriving without a write handle.
|
||||
///
|
||||
@@ -3457,10 +3509,7 @@ impl Core {
|
||||
error!(%uid, error = %e, "queueing trash failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
for blob in blobs {
|
||||
self.cache.discard_staged(Path::new(&blob));
|
||||
}
|
||||
self.pending.lock().remove(uid);
|
||||
self.release_dropped_ops(uid, blobs);
|
||||
self.hidden.lock().insert(uid.clone());
|
||||
// Withdrawn from every mount, not just the one the unlink came through:
|
||||
// a sync folder maps a remote folder that also exists under My Files, so
|
||||
@@ -3486,11 +3535,36 @@ impl Core {
|
||||
error!(%uid, error = %e, "dropping queued ops failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
self.release_dropped_ops(uid, blobs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Let go of what the ops just dropped for `uid` held: their pending
|
||||
/// entries, any upload still reading their bytes, and the bytes.
|
||||
///
|
||||
/// Removing a folder also drops the queued revisions of the files below it
|
||||
/// (`docs/BUGS.md` B102), so the entries to forget are found by blob, not
|
||||
/// only by `uid`.
|
||||
fn release_dropped_ops(&self, uid: &NodeUid, blobs: Vec<String>) {
|
||||
let below: Vec<NodeUid> = {
|
||||
let mut pending = self.pending.lock();
|
||||
pending.remove(uid);
|
||||
let below: Vec<NodeUid> = pending
|
||||
.iter()
|
||||
.filter(|(_, p)| blobs.iter().any(|b| p.path == Path::new(b)))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for k in &below {
|
||||
pending.remove(k);
|
||||
}
|
||||
below
|
||||
};
|
||||
for k in &below {
|
||||
self.cancel_upload(k);
|
||||
}
|
||||
for blob in blobs {
|
||||
self.cache.discard_staged(Path::new(&blob));
|
||||
}
|
||||
self.pending.lock().remove(uid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which staged blob is currently queued for `uid`, if any.
|
||||
@@ -4317,6 +4391,11 @@ impl Core {
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.and_then(batch::into_unit)
|
||||
.map_err(|e| CoreError::from_api(&e, "trash"))?;
|
||||
// Uploads queued for the node or anything below it would now only fail
|
||||
// against the trash, forever, holding their bytes (`docs/BUGS.md` B102).
|
||||
if let Err(error) = self.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||
}
|
||||
let name = self
|
||||
.state
|
||||
.lock()
|
||||
@@ -4483,10 +4562,8 @@ impl Core {
|
||||
/// end. Every batch is a usable listing.
|
||||
async fn refresh_trash(&self) -> CoreResult<()> {
|
||||
let started = Instant::now();
|
||||
let uids = self
|
||||
.client
|
||||
.enumerate_trash_node_uids()
|
||||
.await
|
||||
let uids = within_trash_call("enumerate trash", self.client.enumerate_trash_node_uids())
|
||||
.await?
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate trash"))?;
|
||||
info!(
|
||||
count = uids.len(),
|
||||
@@ -4512,10 +4589,8 @@ impl Core {
|
||||
let mut items: Vec<StoredTrash> = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(TRASH_MATERIALIZE_CHUNK) {
|
||||
let chunk_started = Instant::now();
|
||||
let nodes = self
|
||||
.client
|
||||
.enumerate_nodes(chunk)
|
||||
.await
|
||||
let nodes = within_trash_call("enumerate nodes", self.client.enumerate_nodes(chunk))
|
||||
.await?
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
items.extend(nodes.into_iter().map(|node| StoredTrash {
|
||||
uid: node.uid.to_string(),
|
||||
@@ -5556,6 +5631,19 @@ fn node_size(node: &Node) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `call`, failed with a remote error once it has run for
|
||||
/// [`TRASH_CALL_TIMEOUT`].
|
||||
async fn within_trash_call<T>(what: &str, call: impl Future<Output = T>) -> CoreResult<T> {
|
||||
tokio::time::timeout(TRASH_CALL_TIMEOUT, call)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CoreError::new(
|
||||
ErrorKind::Remote,
|
||||
format!("{what}: no answer within {}s", TRASH_CALL_TIMEOUT.as_secs()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// When a node a trash refresh found went to the trash, as far as the daemon
|
||||
/// can tell. A node the stored listing already had keeps the time it had. A new
|
||||
/// one went there since the last refresh, so now is close, but only when that
|
||||
@@ -7848,7 +7936,7 @@ mod tests {
|
||||
assert_before(function, "require_uid_writable", first_side_effect);
|
||||
}
|
||||
let trash = function_source(source, "fn queue_trash(");
|
||||
assert_before(trash, "replace_ops_with_trash", "discard_staged");
|
||||
assert_before(trash, "replace_ops_with_trash", "release_dropped_ops");
|
||||
|
||||
// §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
|
||||
|
||||
@@ -128,6 +128,23 @@ pub(crate) struct WriteHandle {
|
||||
pub(crate) open_count: usize,
|
||||
}
|
||||
|
||||
impl WriteHandle {
|
||||
/// Account for `[offset, end)` having been written through this handle.
|
||||
///
|
||||
/// A write past the end leaves a hole that reads as zeros, exactly like a
|
||||
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
|
||||
/// hole below the base size was refilled from the base at commit: a file
|
||||
/// shrunk and then written past its new end got its old bytes back.
|
||||
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
|
||||
if offset > self.len {
|
||||
self.written.add(self.len, offset);
|
||||
}
|
||||
self.written.add(offset, end);
|
||||
self.len = self.len.max(end);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
||||
///
|
||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||
@@ -1863,4 +1880,50 @@ mod tests {
|
||||
let shared = st.intern(owned_root, shared);
|
||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||
}
|
||||
|
||||
fn handle(len: u64, base_size: u64) -> WriteHandle {
|
||||
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
WriteHandle {
|
||||
ino: 2,
|
||||
uid: uid("file"),
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: Intervals::default(),
|
||||
len,
|
||||
base_size,
|
||||
base_mtime: 0,
|
||||
base_revision_id: None,
|
||||
dirty: false,
|
||||
open_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_past_the_end_authors_the_hole_it_leaves() {
|
||||
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
|
||||
// 10..50 are zeros now, not the base's bytes 10..50.
|
||||
let mut h = handle(100, 100);
|
||||
h.written.clip(10);
|
||||
h.len = 10;
|
||||
h.record_write(50, 60);
|
||||
assert_eq!(h.len, 60);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 60),
|
||||
vec![(0, 10, false), (10, 60, true)],
|
||||
"only the head still comes from the base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
|
||||
let mut h = handle(100, 100);
|
||||
h.record_write(40, 50);
|
||||
assert_eq!(h.len, 100);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 100),
|
||||
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
+100
-1
@@ -12,6 +12,105 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B103 — One stuck trash refresh freezes the trash listing until the daemon restarts
|
||||
|
||||
**Status:** Fixed (unverified). The hang itself has not been reproduced on purpose.
|
||||
**Found:** 2026-09-29, while timing the acceptance suite's wide-directory case. Its cleanup trashed
|
||||
the test folder, then waited for it to show up in the trash so it could delete it permanently. It
|
||||
never did. From 06:55 UTC on, every trash listing logged `trash refresh still running; answering
|
||||
with what has materialized` and returned the same 362 entries. No `trash refresh: enumerated` line
|
||||
followed, so the first server call of the refresh never returned.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::refresh_trash`, `within_trash_call`).
|
||||
|
||||
**Cause.** Trash refreshes are single-flight: a request joins the refresh already running instead
|
||||
of starting another. The refresh awaited its server calls with no deadline, so one call that
|
||||
never returned kept the flag set forever. Every later request joined it, waited out its 20 s
|
||||
budget, and answered with the stale listing.
|
||||
|
||||
**Fix.** Each server call of a refresh (`enumerate_trash_node_uids`, and each `enumerate_nodes`
|
||||
chunk) fails after `TRASH_CALL_TIMEOUT` (120 s). The refresh then ends with an error, clears the
|
||||
flag, and the next request starts a fresh one.
|
||||
|
||||
---
|
||||
|
||||
## B102 — Removing a folder leaves the queued uploads of the files inside it
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has run the acceptance suite yet.
|
||||
**Found:** 2026-09-29, after the account run that found B101. Its cleanup removed both test roots
|
||||
and reported "nothing left behind", but `pdfs sync queue` still listed two `revision` ops for
|
||||
`pdfs-acceptance-…/boundaries/reshaped`, each holding about 8 MiB of staged bytes. They retried
|
||||
against the trash forever ("the share has not been writable for 300s").
|
||||
|
||||
**Where:** `crates/pdfs-core/src/db/ops.rs` (`drop_doomed_ops`, used by
|
||||
`Db::replace_ops_with_trash` and `Db::delete_ops_for_uid`), `crates/pdfs-fuse/src/lib.rs`
|
||||
(`Core::delete`, `Core::release_dropped_ops`).
|
||||
|
||||
**Cause.** Two gaps.
|
||||
|
||||
- **The queue could not see which files a folder holds.** Removing a node dropped the ops for it
|
||||
and for anything *queued* beneath it, found through `pending_op.parent_uid`. Only `create` and
|
||||
`mkdir` ops carry a parent, so a `revision` of a file that already existed remotely was never
|
||||
found, however deep in the removed folder it sat.
|
||||
- **`pdfs rm` dropped nothing at all.** The control socket's delete trashed the node remotely but
|
||||
left every op queued for it, unlike an unlink through the mount.
|
||||
|
||||
**Fix.** Removal also follows the node tree (`nodes.parent_uid`) and drops the `revision` ops of
|
||||
every file below the node, with their blobs, pending entries and in-flight uploads. Queued renames
|
||||
below it are kept: one that moved a node into the folder still has to land for the node to go to
|
||||
the trash with it. `pdfs rm` now drops the queued ops once the remote trash succeeds. The
|
||||
acceptance janitor lists the daemon queue after cleanup and reports any op still naming one of its
|
||||
roots as a leftover.
|
||||
Regression test: `trashing_a_folder_drops_the_queued_revisions_of_files_below_it`.
|
||||
|
||||
---
|
||||
|
||||
## B101 — A shrunk file written past its end gets old bytes back, or fails with `EIO`; a write during a create's upload is lost
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has run the acceptance suite yet.
|
||||
**Found:** 2026-09-29, by the new acceptance case "block boundaries and overwrites that change
|
||||
the block count". It failed with `Input/output error` in My files and in an on-demand folder, every
|
||||
time. The journal had `the revision's block table disagrees with the node size ... fsize=8388611
|
||||
reader_size=0`, then `pending upload failed; will retry ... gap-fill from base failed: Errno(5)`
|
||||
on every retry.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/state.rs` (`WriteHandle::record_write`),
|
||||
`crates/pdfs-fuse/src/drain.rs` (`Core::drain_local_node`, `Core::retire_create`),
|
||||
`crates/pdfs-fuse/src/lib.rs` (`Core::enqueue_staged_write`),
|
||||
`crates/pdfs-core/src/db/ops.rs` (`Db::finish_create`).
|
||||
|
||||
**Cause.** Two defects.
|
||||
|
||||
- **The hole of a write past the end was not authored.** A write handle tracks which ranges it
|
||||
wrote; everything else below the base size is filled from the base when it is committed. A
|
||||
grow through `setattr` marks its new tail as written, but a `pwrite` past the end did not mark
|
||||
the hole it leaves. After `ftruncate(fd, 4 MiB)` and `pwrite(fd, data, 4 MiB + 10)`, bytes
|
||||
4 MiB to 4 MiB + 10 were refilled from the old base instead of reading as zeros. That silently
|
||||
restored truncated bytes. When the base was itself a queued revision the remote did not have
|
||||
yet, the fill read a revision of the wrong size, and B84's check failed it: `EIO` on read, and
|
||||
a drain that retried forever.
|
||||
- **A write that closed while its file's create uploaded was lost.** The create of a new file
|
||||
drains on its own, often while the file is still open. A write that closes meanwhile attaches
|
||||
its blob to the create's queue row. When the create landed, the drain deleted that row by id,
|
||||
so the server kept the older bytes (often an empty file) and nothing was queued to replace
|
||||
them. A handle still open after the create landed carried the placeholder uid, found no create
|
||||
to attach to, and failed its `close` with `EIO`.
|
||||
|
||||
**Fix.** A write past the end authors the hole (`WriteHandle::record_write`).
|
||||
`Db::finish_create` retires a landed create in one transaction: if the row now holds a newer blob
|
||||
than the one uploaded, it becomes a revision of the real uid instead of being deleted. The drain
|
||||
does this under the `pending` lock, which `enqueue_staged_write` holds from attaching a blob until
|
||||
it records the pending entry, and then repoints open write handles to the real uid. A write that
|
||||
still finds the create gone waits for its inode's real uid (`Core::landed_uid`) and queues a
|
||||
revision against it.
|
||||
Regression tests: `a_write_past_the_end_authors_the_hole_it_leaves`,
|
||||
`a_write_attached_while_its_create_uploads_becomes_a_revision`,
|
||||
`a_create_whose_blob_landed_is_retired`, and the live acceptance cases "block boundaries and
|
||||
overwrites that change the block count" (now also read back after the queue drains) and
|
||||
`regression B101`.
|
||||
|
||||
---
|
||||
|
||||
## B100 — A freshly copied file reads as `EIO` or 53 bytes too big
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has read a file with a provisional
|
||||
@@ -48,7 +147,7 @@ network). A folder past the cap is now queued instead of dropped. When a running
|
||||
its thread takes over the oldest queued folder, so at most 8 threads still run.
|
||||
Regression tests: `a_folder_past_the_cap_is_queued_not_dropped`,
|
||||
`a_finished_batch_hands_its_thread_the_oldest_queued_one`. The read and open paths need a live
|
||||
mount and have no unit test.
|
||||
mount and have no unit test; the acceptance case `regression B100` covers them on a real mount.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,29 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`pdfs trash --json`** prints the trash listing as JSON.
|
||||
- **The FUSE acceptance suite tests the signed-in account by default.**
|
||||
`scripts/fuse-acceptance.sh` with no arguments creates test folders in My files and new
|
||||
on-demand and mirror sync folders, runs every case in each, and removes all of it afterwards,
|
||||
also on a failure or interrupt. A run that dies is cleaned up by the next one. New cases cover
|
||||
block boundaries, unusual names, a deep tree, a wide directory, rename patterns, handle coherency,
|
||||
throughput and B100. `--offline-only` runs the account-free suite as before.
|
||||
|
||||
### Fixed
|
||||
- **Truncating and then writing past the end no longer brings old bytes back.** The gap
|
||||
between the new end and the write now reads as zeros, as it should, instead of the file's
|
||||
earlier content. When that earlier content had not been uploaded yet, the file failed with
|
||||
`Input/output error` and its upload retried forever (B101).
|
||||
- **A write made while a new file is first uploading is no longer lost.** Drive could keep an
|
||||
empty or older copy of a file written right after it was created, and closing a file held open
|
||||
across its first upload could fail (B101).
|
||||
- **Removing a folder also cancels the pending uploads of the files inside it.** Before, an
|
||||
edit that had not reached Drive yet kept retrying against the trash forever and kept its
|
||||
staged copy on disk. `pdfs rm` now also drops what was queued for what it removes (B102).
|
||||
- **The trash no longer freezes after a server call hangs.** Until the daemon restarted, the
|
||||
trash kept showing the same entries, and a folder just deleted could not be found there to
|
||||
delete permanently (B103).
|
||||
- **Freshly listed files read correctly.** A file whose real size had not been fetched yet
|
||||
reported its encrypted size, about 53 bytes too big, and reading it failed with
|
||||
`Input/output error`. This happened most often right after copying a large folder. Reads and
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
## Scripting with `--json`
|
||||
|
||||
The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`,
|
||||
`devices list`, `locations`, `transfers`, `activity` and `cache inspect`. Commands that change
|
||||
`devices list`, `locations`, `transfers`, `activity`, `trash` and `cache inspect`. Commands that change
|
||||
something keep their human-readable output; check their exit code.
|
||||
|
||||
```bash
|
||||
|
||||
+86
-10
@@ -25,18 +25,80 @@ Proton:
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
Kernel/FUSE behavior and remote convergence need a real mount. Live testing is
|
||||
explicitly opt-in and always runs the account-free contract first. It runs
|
||||
destructive POSIX operations only below a fresh `pdfs-acceptance-*` directory.
|
||||
It refuses paths that are not FUSE mounts and removes its directory on success,
|
||||
failure, or interruption.
|
||||
Kernel/FUSE behavior and remote convergence need a real mount. With no
|
||||
arguments, the script tests the account the running service is logged in to:
|
||||
|
||||
Use a dedicated test account without irreplaceable data:
|
||||
```console
|
||||
scripts/fuse-acceptance.sh # every case, every mount kind
|
||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four
|
||||
```
|
||||
|
||||
An account run does this, in order:
|
||||
|
||||
1. Runs the account-free contract as the reference.
|
||||
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and
|
||||
diffs it against the reference.
|
||||
3. Registers two new sync folders under `~/.cache/pdfs-acceptance/run-<id>/`
|
||||
and runs the contract and the move cases in every on-demand/mirror pairing.
|
||||
4. Removes everything it created, locally and remotely, and reports a
|
||||
`cleanup` result. The run fails if anything is left behind.
|
||||
|
||||
Cleanup identifies remote folders by uid, never by name alone, so nothing of
|
||||
yours is touched. It removes each sync folder with `sync rm --delete-remote`,
|
||||
trashes the My files folder, and then deletes both permanently from the trash.
|
||||
It only counts a folder as gone once a fresh trash listing no longer shows it.
|
||||
It then reads `pdfs sync queue` and counts any queued op that still names one
|
||||
of its folders as left behind too, since such an op keeps its staged bytes and
|
||||
retries forever (B102).
|
||||
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs,
|
||||
two more interrupts are ignored; a third abandons it.
|
||||
|
||||
Each run records what it is about to create in
|
||||
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and
|
||||
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a
|
||||
hard timeout, a power cut), the next run finds the unlocked manifest and
|
||||
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>`
|
||||
folders that no live run claims: in My files (older than
|
||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and
|
||||
in the trash. A run does not start while that reap leaves anything behind.
|
||||
|
||||
The run creates and destroys real remote folders, so a dedicated test account
|
||||
without irreplaceable data is still the safest target.
|
||||
|
||||
`--live PATH...` tests mounts you set up yourself and leaves the sync folder
|
||||
configuration alone. It runs destructive POSIX operations only below a fresh
|
||||
`pdfs-acceptance-*` directory, refuses paths that are not FUSE mounts, and
|
||||
removes its directory on success, failure, or interruption:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||
```
|
||||
|
||||
### Edge cases and performance
|
||||
|
||||
Besides the syscall contract, the suite covers:
|
||||
|
||||
- **Block boundaries.** Files of 0 bytes up to two 4 MiB blocks plus 3 bytes,
|
||||
including every size next to a boundary, then overwrites that grow, shrink
|
||||
and reshape them.
|
||||
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text,
|
||||
newline, tab, emoji, characters Windows forbids, leading and trailing
|
||||
spaces, and a case-only rename.
|
||||
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the
|
||||
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`.
|
||||
Online, each create is a round trip to the server, and the kernel runs
|
||||
creates in one directory one at a time. So the wide case gets twice the
|
||||
per-case timeout and notes the time per create as `wide.create_ms`.
|
||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an
|
||||
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out
|
||||
and back.
|
||||
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND`
|
||||
writers, 30 rewrite generations, and a truncate under a reader.
|
||||
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes,
|
||||
and metadata operations on 100 files. The rates are printed. The case fails
|
||||
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS`
|
||||
(default 20 operations per second).
|
||||
|
||||
### The mount is diffed against an ordinary filesystem
|
||||
|
||||
The local reference run is not only a self-test of the runner. Every case
|
||||
@@ -78,6 +140,16 @@ in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||
renamed after close must keep its content) need a real mount and a reachable
|
||||
daemon; they are skipped, not failed, without one.
|
||||
|
||||
**B100** (a copied tree reads back at its exact sizes) copies 48 files of
|
||||
assorted sizes into the mount at once, as `cp -r` does. On a live mount it
|
||||
waits for the queue, checks every size and byte, runs `pdfs refresh` on each
|
||||
folder to drop the cached listing, and checks again. The refresh is what
|
||||
brought back the provisional sizes the bug reported.
|
||||
|
||||
**B101** (a write during its create's upload reaches Drive) holds new files open for
|
||||
different times before writing, so their empty creates land before, during and after the
|
||||
bytes arrive, then checks every file after the queue drains.
|
||||
|
||||
B74 was found by exactly this mechanism: the B70 case failed, and narrowing it
|
||||
produced a smaller reproduction that got its own case. The defect is fixed and
|
||||
live-validated (2026-07-26); the case now guards against its return.
|
||||
@@ -136,13 +208,17 @@ stops at the first failure.
|
||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
||||
it; that has happened, and it was caught only by reading the journal by hand.
|
||||
|
||||
Abandoned `pdfs-acceptance-*` roots from a crashed run are removed at startup,
|
||||
but only if they match the exact generated name and are over an hour old
|
||||
(`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never disturbed.
|
||||
With `--live`, abandoned `pdfs-acceptance-*` roots from a crashed run are
|
||||
removed at startup, but only if they match the exact generated name and are
|
||||
over an hour old (`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never
|
||||
disturbed. An account run instead uses the manifest and reaper described
|
||||
above.
|
||||
|
||||
### Automated mode matrix
|
||||
|
||||
The managed live runner accepts two existing, empty, unmounted directories. It
|
||||
An account run drives this matrix itself, in folders it creates. To use
|
||||
directories of your choice instead, the managed live runner accepts two
|
||||
existing, empty, unmounted directories. It
|
||||
registers both as new sync folders, waits for initial synchronization, then
|
||||
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
|
||||
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
|
||||
|
||||
+1013
-17
File diff suppressed because it is too large.
Load diff
@@ -4,20 +4,28 @@ set -Eeuo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: scripts/fuse-acceptance.sh [--offline-only] [OPTION ...]
|
||||
usage: scripts/fuse-acceptance.sh [OPTION ...]
|
||||
scripts/fuse-acceptance.sh --offline-only [OPTION ...]
|
||||
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] [OPTION ...]
|
||||
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR [OPTION ...]
|
||||
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
||||
|
||||
The local reference suite always runs first and requires no account; the mount
|
||||
is then diffed against its recorded behaviour. --live runs the same contract on
|
||||
each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only when all live mountpoints
|
||||
With no mode, everything runs on the signed-in account: My files, then two
|
||||
sync folders it registers itself, through every pairing of on-demand and
|
||||
mirror, with moves and copies between them. Everything it creates is removed
|
||||
afterwards, on failure and on Ctrl-C too, and permanently deleted from the
|
||||
trash; a run that was killed is cleaned up by the next one.
|
||||
|
||||
--offline-only runs only the local reference suite, which needs no account.
|
||||
It always runs first; every mount is diffed against its recorded behaviour.
|
||||
--live runs the same contract on each given mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only when all live mountpoints
|
||||
show the same remote folder.
|
||||
|
||||
Options are forwarded to the Python runner. The useful ones:
|
||||
--list print every case and the targets it runs against
|
||||
--timeout SECONDS per-case limit (default 180); a hung case dumps stacks
|
||||
--fail-fast stop at the first failure instead of continuing
|
||||
--quick one on-demand/mirror pairing instead of all four
|
||||
--report-json PATH machine-readable results and recorded observations
|
||||
--report-junit PATH JUnit XML for CI
|
||||
--budget SECONDS report any case slower than this
|
||||
@@ -45,8 +53,11 @@ collect_paths() {
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
--offline-only|"")
|
||||
shift || true
|
||||
"")
|
||||
exec "${runner[@]}" --account
|
||||
;;
|
||||
--offline-only)
|
||||
shift
|
||||
exec "${runner[@]}" "$@"
|
||||
;;
|
||||
--live)
|
||||
@@ -60,10 +71,13 @@ case "${1:-}" in
|
||||
first="$1"; second="$2"; shift 2
|
||||
exec "${runner[@]}" --managed-live "$first" "$second" "$@"
|
||||
;;
|
||||
--*)
|
||||
# Bare options with no mode: an offline run with those options.
|
||||
--account)
|
||||
exec "${runner[@]}" "$@"
|
||||
;;
|
||||
--*)
|
||||
# Bare options with no mode: an account run with those options.
|
||||
exec "${runner[@]}" --account "$@"
|
||||
;;
|
||||
*)
|
||||
collect_paths "$@"
|
||||
;;
|
||||
|
||||
Reference in new issue
Block a user