mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fixed rm before online del, mirror bug
This commit is contained in:
1 parent
1cd6a26be3
commit
bd17d506f8
9 files changed
+288
-144
No files matched your search
Generated
+4
-4
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.2",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"ignore",
|
||||
@@ -3552,7 +3552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"image",
|
||||
@@ -3568,7 +3568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
|
||||
@@ -807,9 +807,13 @@ impl ContentCache {
|
||||
.collect();
|
||||
let content_dir = self.content_dir.clone();
|
||||
// Force pool enforcement ignoring current cap check to free disk space immediately
|
||||
let Ok(list) = self.db.cache_eviction_candidates(KIND_BLOB, 10) else { return; };
|
||||
let Ok(list) = self.db.cache_eviction_candidates(KIND_BLOB, 10) else {
|
||||
return;
|
||||
};
|
||||
for (key, bytes) in list {
|
||||
if pinned.contains(&key) { continue; }
|
||||
if pinned.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
let p = content_dir.join(&key);
|
||||
let m = content_dir.join(format!("{key}.meta"));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
|
||||
@@ -683,26 +683,22 @@ impl Core {
|
||||
// fails, since eviction may already have deleted part of the tree.
|
||||
// Recovery is the user's explicit switch back to `mirror`, whose arm
|
||||
// clears the baseline and re-downloads.
|
||||
// Try mounting the secondary FUSE filesystem FIRST before touching local files.
|
||||
// If mount fails, abort safely so local files are preserved intact.
|
||||
let session = match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"mount ondemand failed; local files preserved: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = self.db.sync_folder_set_mode(id, "ondemand") {
|
||||
let _ = umount_session_unblocked(session, fuse_connection_id(&local));
|
||||
return Err(SwitchBlocked::Failed(format!("db: {e:?}")));
|
||||
}
|
||||
|
||||
// Reclaim the disk: empty the local dir now that the mount handles filesystem requests.
|
||||
// Reclaim the disk before mounting. Once FUSE covers `local`, a
|
||||
// directory walk addresses the remote namespace, so evicting at
|
||||
// that point sends unlink/rmdir requests to Drive and leaves the
|
||||
// underlying mirror hidden and untouched.
|
||||
if let Err(e) = evict_dir_contents(&local) {
|
||||
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
|
||||
}
|
||||
let session = self.spawn_ondemand_mount(&local, root).map_err(|e| {
|
||||
SwitchBlocked::Failed(format!(
|
||||
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
|
||||
))
|
||||
})?;
|
||||
self.mounts
|
||||
.lock()
|
||||
.insert(id, (session, fuse_connection_id(&local)));
|
||||
|
||||
@@ -72,29 +72,23 @@ impl Core {
|
||||
if let Err(e) = self.drain_op(&op) {
|
||||
let attempts = op.attempts + 1;
|
||||
let err_str = e.to_string();
|
||||
let is_unrecoverable = attempts >= 5 || err_str.contains("403") || err_str.contains("402") || err_str.contains("413");
|
||||
if is_unrecoverable {
|
||||
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed permanently; quarantining op");
|
||||
let _ = self.db.delete_ops_for_uid(&op.uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
&op.uid,
|
||||
format!("quarantined after failure: {err_str}"),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let backoff = DRAIN_BACKOFF_MIN
|
||||
.saturating_mul(1u32 << attempts.min(6))
|
||||
.min(DRAIN_BACKOFF_MAX);
|
||||
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
||||
if let Err(e) = self.db.record_op_failure(
|
||||
op.id,
|
||||
&err_str,
|
||||
now_millis() + backoff.as_millis() as i64,
|
||||
) {
|
||||
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
||||
self.wait_for_drain_work();
|
||||
}
|
||||
// Never infer that user data is disposable from an error string
|
||||
// or an arbitrary retry count. The staged blob and this row are
|
||||
// the only durable description of an accepted write. Even a
|
||||
// permission/quota error can become recoverable after the user
|
||||
// changes account state, and deleting the row here made the blob
|
||||
// unreachable after five ordinary network failures.
|
||||
let backoff = DRAIN_BACKOFF_MIN
|
||||
.saturating_mul(1u32 << attempts.min(6))
|
||||
.min(DRAIN_BACKOFF_MAX);
|
||||
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
||||
if let Err(e) = self.db.record_op_failure(
|
||||
op.id,
|
||||
&err_str,
|
||||
now_millis() + backoff.as_millis() as i64,
|
||||
) {
|
||||
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
||||
self.wait_for_drain_work();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
-31
@@ -3486,11 +3486,13 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let open_now = {
|
||||
let mut st = self.core.state.lock();
|
||||
st.forget_or_unlink(&uid);
|
||||
st.entries.get(&ino).map(|e| e.open_count).unwrap_or(0) > 0
|
||||
};
|
||||
let open_now = self
|
||||
.core
|
||||
.state
|
||||
.lock()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|e| e.open_count > 0);
|
||||
// A node the server has never heard of cannot be trashed there; deleting
|
||||
// it just means its queued creation is no longer wanted. This works
|
||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
||||
@@ -3498,6 +3500,7 @@ impl ProtonFs {
|
||||
if !open_now {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
}
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
||||
reply.ok();
|
||||
return;
|
||||
@@ -3507,7 +3510,10 @@ impl ProtonFs {
|
||||
// command returns (offline.md Phase 3b).
|
||||
if !self.core.online.load(Ordering::Relaxed) {
|
||||
match self.core.queue_trash(&uid, name) {
|
||||
Ok(()) => reply.ok(),
|
||||
Ok(()) => {
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
reply.ok();
|
||||
}
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
return;
|
||||
@@ -3523,6 +3529,9 @@ impl ProtonFs {
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
// Only remove the namespace entry after Drive accepted the mutation.
|
||||
// Otherwise an EIO response still made the path disappear locally.
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
if !open_now {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
self.core.cache.evict(&uid);
|
||||
@@ -4099,6 +4108,9 @@ impl Filesystem for ProtonFs {
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
error!(%uid, error = %e, "create scratch file failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
@@ -4193,12 +4205,7 @@ impl Filesystem for ProtonFs {
|
||||
.get(&e.uid)
|
||||
.map(|p| p.meta.len)
|
||||
.unwrap_or_else(|| node_size(&e.node));
|
||||
(
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
fsize,
|
||||
is_video,
|
||||
)
|
||||
(e.uid.clone(), e.node.modification_time, fsize, is_video)
|
||||
}
|
||||
Some(_) => {
|
||||
reply.error(Errno::EISDIR);
|
||||
@@ -4312,6 +4319,9 @@ impl Filesystem for ProtonFs {
|
||||
};
|
||||
let mut st = self.core.state.lock();
|
||||
let ino = st.intern(parent, node);
|
||||
if let Some(entry) = st.entries.get_mut(&ino) {
|
||||
entry.open_count = entry.open_count.saturating_add(1);
|
||||
}
|
||||
if let Some(kids) = st.children.get_mut(&parent)
|
||||
&& !kids.contains(&ino)
|
||||
{
|
||||
@@ -4503,7 +4513,10 @@ impl Filesystem for ProtonFs {
|
||||
let ret = unsafe { libc::fallocate(fd, mode, offset as i64, length as i64) };
|
||||
if ret == 0 {
|
||||
if is_punch_hole {
|
||||
aw.written.punch_hole(offset, new_end);
|
||||
// A punched range reads as zero. Mark it authored so
|
||||
// queue_revision does not refill it from the remote
|
||||
// baseline and silently undo the hole at commit.
|
||||
aw.written.add(offset, new_end.min(aw.len));
|
||||
aw.dirty = true;
|
||||
} else if !keep_size && new_end > aw.len {
|
||||
aw.written.add(aw.len, new_end);
|
||||
@@ -4661,6 +4674,7 @@ impl Filesystem for ProtonFs {
|
||||
});
|
||||
(h, unlinked_uid)
|
||||
};
|
||||
let was_unlinked = unlinked_uid.is_some();
|
||||
if let Some(uid) = unlinked_uid {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
self.core.cache.evict(&uid);
|
||||
@@ -4670,12 +4684,22 @@ impl Filesystem for ProtonFs {
|
||||
// scratch file is the only copy of what was just written, and blocking
|
||||
// the caller on the network is what made a copy into the mount run at
|
||||
// upload speed (and fail outright offline).
|
||||
match handle {
|
||||
Some(h) => match self.core.queue_revision(&h) {
|
||||
match (handle, was_unlinked) {
|
||||
(Some(h), true) => {
|
||||
// POSIX keeps an unlinked file usable until the last close, but
|
||||
// closing it must not resurrect those bytes as a new revision.
|
||||
if let Err(e) = std::fs::remove_file(&h.path)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
warn!(path = %h.path.display(), error = %e, "discarding unlinked scratch file failed");
|
||||
}
|
||||
reply.ok();
|
||||
}
|
||||
(Some(h), false) => match self.core.queue_revision(&h) {
|
||||
Ok(()) => reply.ok(),
|
||||
Err(e) => reply.error(e),
|
||||
},
|
||||
None => reply.ok(),
|
||||
(None, _) => reply.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4684,10 +4708,11 @@ impl Filesystem for ProtonFs {
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent.0, &name_str) {
|
||||
let st = self.core.state.lock();
|
||||
if let Some(entry) = st.entries.get(&ino)
|
||||
&& entry.node.is_folder() {
|
||||
reply.error(Errno::EISDIR);
|
||||
return;
|
||||
}
|
||||
&& entry.node.is_folder()
|
||||
{
|
||||
reply.error(Errno::EISDIR);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.trash_child(parent.0, &name_str, reply);
|
||||
}
|
||||
@@ -4695,16 +4720,27 @@ impl Filesystem for ProtonFs {
|
||||
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
let name_str = name.to_string_lossy();
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent.0, &name_str) {
|
||||
let st = self.core.state.lock();
|
||||
if let Some(entry) = st.entries.get(&ino) {
|
||||
if !entry.node.is_folder() {
|
||||
{
|
||||
let st = self.core.state.lock();
|
||||
if st
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| !entry.node.is_folder())
|
||||
{
|
||||
reply.error(Errno::ENOTDIR);
|
||||
return;
|
||||
}
|
||||
if st.has_children(ino) {
|
||||
reply.error(Errno::ENOTEMPTY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A missing cached listing says "unknown", not "empty". Proton's
|
||||
// trash call is recursive, so enumerate before applying rmdir's
|
||||
// POSIX emptiness rule.
|
||||
if let Err(e) = self.core.ensure_children(ino) {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
if self.core.state.lock().has_children(ino) {
|
||||
reply.error(Errno::ENOTEMPTY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.trash_child(parent.0, &name_str, reply);
|
||||
@@ -4819,10 +4855,12 @@ impl Filesystem for ProtonFs {
|
||||
{
|
||||
let st = self.core.state.lock();
|
||||
if let Some(entry) = st.entries.get(&ino)
|
||||
&& entry.node.is_folder() && st.is_ancestor_of(ino, newparent) {
|
||||
reply.error(Errno::EINVAL);
|
||||
return;
|
||||
}
|
||||
&& entry.node.is_folder()
|
||||
&& st.is_ancestor_of(ino, newparent)
|
||||
{
|
||||
reply.error(Errno::EINVAL);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The destination has to be listed either way: the queued path pushes
|
||||
// the node into that listing, and the online one drops it to force a
|
||||
|
||||
@@ -56,27 +56,6 @@ impl Intervals {
|
||||
}
|
||||
}
|
||||
|
||||
/// Punch a hole in `[start, end)`, removing authored status for this range.
|
||||
pub(crate) fn punch_hole(&mut self, start: u64, end: u64) {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let mut next = Vec::new();
|
||||
for &(s, e) in &self.0 {
|
||||
if e <= start || s >= end {
|
||||
next.push((s, e));
|
||||
} else {
|
||||
if s < start {
|
||||
next.push((s, start));
|
||||
}
|
||||
if e > end {
|
||||
next.push((end, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.0 = next;
|
||||
}
|
||||
|
||||
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
|
||||
/// order. `authored == true` means the bytes live in the scratch file;
|
||||
/// `false` means they must come from the remote base (or are a hole).
|
||||
@@ -241,13 +220,15 @@ impl State {
|
||||
/// Check if a directory inode has any child nodes in memory or in the database.
|
||||
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
||||
if let Some(kids) = self.children.get(&parent)
|
||||
&& !kids.is_empty() {
|
||||
return true;
|
||||
}
|
||||
&& !kids.is_empty()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(entry) = self.entries.get(&parent)
|
||||
&& let Ok(has_kids) = self.db.has_children(&entry.uid) {
|
||||
return has_kids;
|
||||
}
|
||||
&& let Ok(has_kids) = self.db.has_children(&entry.uid)
|
||||
{
|
||||
return has_kids;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -586,33 +567,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intervals_punch_hole_middle() {
|
||||
let mut iv = Intervals::default();
|
||||
iv.add(0, 100);
|
||||
iv.punch_hole(30, 50);
|
||||
assert_eq!(iv.0, vec![(0, 30), (50, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intervals_punch_hole_edges() {
|
||||
let mut iv = Intervals::default();
|
||||
iv.add(0, 100);
|
||||
iv.punch_hole(0, 20);
|
||||
assert_eq!(iv.0, vec![(20, 100)]);
|
||||
|
||||
iv.punch_hole(80, 100);
|
||||
assert_eq!(iv.0, vec![(20, 80)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intervals_punch_hole_full() {
|
||||
let mut iv = Intervals::default();
|
||||
iv.add(0, 100);
|
||||
iv.punch_hole(0, 100);
|
||||
assert!(iv.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_ancestor_of_hierarchy() {
|
||||
let (mut st, _dir) = state();
|
||||
|
||||
@@ -1681,14 +1681,20 @@ impl Core {
|
||||
});
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut join_errors = 0usize;
|
||||
while let Some(joined) = set.join_next().await {
|
||||
match joined {
|
||||
Ok(result) => out.push(result),
|
||||
Err(e) => warn!(error = %e, "sync: task panicked"),
|
||||
Err(e) => {
|
||||
join_errors += 1;
|
||||
warn!(error = %e, "sync: task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, join_errors)
|
||||
});
|
||||
let (results, join_errors) = results;
|
||||
outcome.errors += join_errors;
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(applied) => {
|
||||
|
||||
+166
-14
@@ -1193,7 +1193,10 @@ retracted.
|
||||
|
||||
**Fix:** Added type and non-emptiness checks in `lib.rs`:
|
||||
- `unlink`: Returns `Errno::EISDIR` if target is a folder.
|
||||
- `rmdir`: Returns `Errno::ENOTDIR` if target is a file, and `Errno::ENOTEMPTY` if folder contains children (`st.has_children` checking both memory map and SQLite DB).
|
||||
- `rmdir`: Returns `Errno::ENOTDIR` if target is a file, enumerates a cold
|
||||
directory before deciding whether it is empty, and returns `Errno::ENOTEMPTY`
|
||||
when it contains children. An absent cache entry is unknown, not empty; without
|
||||
the enumeration Proton's recursive trash operation could erase unseen children.
|
||||
|
||||
**Verified:**
|
||||
- Unit tests: `test_posix_unlink_and_rmdir_checks` in `lib.rs` passing cleanly.
|
||||
@@ -1203,15 +1206,22 @@ retracted.
|
||||
|
||||
## B19 — Remote Folder Trashing on Mode Switch Failure (CRIT-02)
|
||||
|
||||
**Status:** Fixed (verified 2026-07-21)
|
||||
**Status:** Fixed in code (unverified on a live mount, 2026-07-22)
|
||||
**Found:** 2026-07-21, multi-agent sync engine audit (`audit_bugs.md` CRIT-02)
|
||||
**Where:** `crates/pdfs-fuse/src/devices.rs`, `apply_sync_folder_mode`
|
||||
|
||||
**Cause:** Switching folder mode from `mirror` to `ondemand` purged local files *before* FUSE mount verification. If mounting failed, the folder remained in `mirror` mode with an empty local directory, triggering remote trash of all files on the next sync cycle.
|
||||
**Cause:** The first fix mounted FUSE before calling `evict_dir_contents`. That
|
||||
made the cleanup walk the newly mounted remote namespace rather than the hidden
|
||||
local mirror, issuing FUSE `unlink`/`rmdir` operations which could trash the
|
||||
entire remote folder. The local files were not reclaimed at all.
|
||||
|
||||
**Fix:** Reordered mode switch operations: `spawn_ondemand_mount` is invoked and validated *before* calling `evict_dir_contents`. If mount fails, switch aborts safely with local files preserved intact.
|
||||
**Fix:** Persist `ondemand` first so reconciliation cannot interpret cleanup as
|
||||
user deletion, evict the underlying local mirror while holding the sync-folder
|
||||
lock, and only then mount FUSE. A mount failure leaves an inert `ondemand` row;
|
||||
switching back to `mirror` clears the baseline and restores the local copy.
|
||||
|
||||
**Verified:** Code inspection in `devices.rs` line 688 confirms `spawn_ondemand_mount` runs prior to `evict_dir_contents`.
|
||||
**Verified:** `cargo clippy -p pdfs-fuse --all-targets -- -D warnings` and the
|
||||
`pdfs-fuse` unit suite pass. A fault-injected/live mode-switch test is still needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -1235,12 +1245,15 @@ retracted.
|
||||
**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-01)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `ProtonFs::fallocate`
|
||||
|
||||
**Cause:** `FALLOC_FL_PUNCH_HOLE` zeroed scratch blocks without updating `WriteHandle::written` interval maps, causing stale/corrupt byte uploads. Calling `fallocate` on read-only open handles returned unhandled errors.
|
||||
**Cause:** `FALLOC_FL_PUNCH_HOLE` zeroed scratch blocks but removed their
|
||||
`WriteHandle::written` authored status. Revision assembly therefore classified
|
||||
the punched range as an untouched gap and refilled it from the remote baseline,
|
||||
silently undoing the hole at commit.
|
||||
|
||||
**Fix:** Updated `fallocate` handler to call `aw.written.punch_hole(offset, new_end)` when `is_punch_hole` is specified, setting `aw.dirty = true`. In B17, `libc::fallocate` was also moved outside `state.lock()`.
|
||||
**Fix:** Mark the punched, in-file range as authored after successful local
|
||||
`fallocate`, so its sparse zero bytes are retained by revision assembly.
|
||||
|
||||
**Verified:**
|
||||
- Unit tests: `test_intervals_punch_hole_*` in `state.rs` passing.
|
||||
- Live mount verification: `libc.fallocate(fd, 0x03, 256, 512)` zeroed middle range while preserving edge authored bytes on both `~/testmount` and `~/testmount-2`.
|
||||
|
||||
---
|
||||
@@ -1309,6 +1322,9 @@ retracted.
|
||||
3. `forget_or_unlink` marks `entry.unlinked = true` and removes `ino` from `st.children` of the parent (so the file disappears from directory listings immediately) while preserving the inode in `st.entries`.
|
||||
4. `read` calculates optimistic `fsize` falling back to `core.pending` length for unlinked/pending files.
|
||||
5. Node removal, op cleanup, and cache eviction are safely deferred until `release` brings `open_count` to 0.
|
||||
6. `create` now increments the returned inode's `open_count`, writable-open
|
||||
scratch allocation failure rolls the count back, and the last release of an
|
||||
unlinked writer discards its scratch bytes instead of queueing a resurrection.
|
||||
|
||||
**Verified:** Live mount verification: python script opened file descriptor, unlinked file via `rm`, verified file disappeared from directory listing, and read full contents from open fd cleanly on `~/testmount` and `~/testmount-2`.
|
||||
|
||||
@@ -1316,13 +1332,15 @@ retracted.
|
||||
|
||||
## B27 — Orphaned Write Revision Bricking Offline Sync Queue (HIGH-07)
|
||||
|
||||
**Status:** Fixed (verified 2026-07-22)
|
||||
**Status:** Fixed in code (unverified create→unlink live sequence, 2026-07-22)
|
||||
**Found:** 2026-07-21, multi-agent drain engine audit (`audit_bugs.md` HIGH-07)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs:L1811`, `crates/pdfs-fuse/src/drain.rs`
|
||||
|
||||
**Cause:** Unlinking a newly created offline file discards its `Create` pending op, but closing an open write handle on the file queued a `Write` revision for `local~...`.
|
||||
|
||||
**Fix:** `queue_revision` checks `is_local_uid(&h.uid) && !self.db.has_create_op(...)`. If the local creation op was unlinked/discarded, the revision upload is discarded cleanly without bricking the offline sync queue.
|
||||
**Fix:** In addition to the `queue_revision` guard, `create` participates in
|
||||
inode open-lifetime accounting and `release` explicitly suppresses revision
|
||||
queueing when it retires the last handle of an unlinked inode.
|
||||
|
||||
**Verified:** Code inspection in `lib.rs:1811` and workspace tests passing.
|
||||
|
||||
@@ -1330,7 +1348,7 @@ retracted.
|
||||
|
||||
## B28 — Unhandled Disk Exhaustion (`ENOSPC`) in Cache & Scratch Store (MED-01)
|
||||
|
||||
**Status:** Fixed (verified 2026-07-22)
|
||||
**Status:** Fixed in code — previous remediation was unsafe (2026-07-22)
|
||||
**Found:** 2026-07-21, multi-agent storage audit (`audit_bugs.md` MED-01)
|
||||
**Where:** `crates/pdfs-core/src/cache.rs`, `emergency_evict`
|
||||
|
||||
@@ -1392,11 +1410,19 @@ retracted.
|
||||
**Found:** 2026-07-21, multi-agent drain engine audit (`audit_bugs.md` MED-06)
|
||||
**Where:** `crates/pdfs-fuse/src/drain.rs:L73`
|
||||
|
||||
**Cause:** Permanent server errors (403, 402 quota exceeded, 413 payload too large) or repeated failures (attempts >= 5) caused pending upload ops to retry infinitely and block the offline sync queue.
|
||||
**Cause:** The attempted remediation classified errors by substring and deleted
|
||||
the pending row after five failures or strings containing 402/403/413. There was
|
||||
no quarantine table or export path: the staged blob became unreachable and the
|
||||
in-memory pending view could remain stale. Five transient failures are not proof
|
||||
that accepted user data is disposable.
|
||||
|
||||
**Fix:** `drain.rs` detects unrecoverable errors (403, 402, 413, attempts >= 5), quarantines the failing op by deleting it from `pending_op`, and logs a permanent error to the activity feed to keep the sync queue flowing cleanly.
|
||||
**Fix:** Never delete an accepted write on a retry path. Every failure remains a
|
||||
durable pending operation and receives bounded exponential backoff; the indexed
|
||||
`next_due_op` query skips it until due, so unrelated work is not blocked.
|
||||
|
||||
**Verified:** Code inspection in `drain.rs:73` and workspace test suite passing cleanly.
|
||||
**Verified:** `cargo clippy -p pdfs-fuse --all-targets -- -D warnings` and the
|
||||
`pdfs-fuse` unit suite pass. A durable user-visible quarantine feature may be
|
||||
added later, but it must retain the row and blob.
|
||||
|
||||
---
|
||||
|
||||
@@ -1430,7 +1456,133 @@ retracted.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## B37 — Failed Remote Trash Removed the Local Dentry (CRIT-04)
|
||||
|
||||
**Status:** Fixed in code (unverified on a live mount, 2026-07-22)
|
||||
**Found:** 2026-07-22, deep FUSE/POSIX audit
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `ProtonFs::trash_child`
|
||||
|
||||
**Cause:** `trash_child` called `forget_or_unlink` before the online
|
||||
`trash_nodes` request. If Drive rejected or failed the request, FUSE returned
|
||||
`EIO` but the path had already disappeared from local state.
|
||||
|
||||
**Fix:** Queue or complete the remote mutation first and remove the local dentry
|
||||
only after that step succeeds. Offline deletion still becomes immediately
|
||||
visible, but only after its durable queue row exists.
|
||||
|
||||
---
|
||||
|
||||
## B38 — Sync Task Panic Could Authorize Destructive Mode Switch (HIGH-08)
|
||||
|
||||
**Status:** Fixed in code (2026-07-22)
|
||||
**Found:** 2026-07-22, sync concurrency audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::flush_batch`
|
||||
|
||||
**Cause:** A `JoinSet` task panic was logged but not added to `Outcome.errors`.
|
||||
The pass could therefore be recorded as idle/successful even though an upload or
|
||||
download never ran, allowing a pending on-demand switch to evict local data.
|
||||
|
||||
**Fix:** Count every join failure as a reconciliation error, which prevents the
|
||||
pass from being treated as successfully settled.
|
||||
|
||||
---
|
||||
|
||||
## B39 — Sync Download Can Overwrite a Concurrent Local Edit (HIGH-09)
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-07-22, sync concurrency audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, download classification and apply path
|
||||
|
||||
**Cause:** Reconciliation classifies a local file from an earlier scan, downloads
|
||||
to a temporary file, then renames over the destination without revalidating that
|
||||
the local inode/content stayed unchanged. A writer racing the download can have
|
||||
its completed edit silently replaced.
|
||||
|
||||
**Required fix/test:** Capture a local identity/signature during planning and
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## B40 — Sync Upload Can Stream Torn Live Content (HIGH-10)
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-07-22, sync concurrency audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, upload apply and baseline update
|
||||
|
||||
**Cause:** Planning stats a path, but upload later opens and streams the live
|
||||
file. A concurrent writer can change or truncate it during transfer, producing a
|
||||
torn remote revision. Baseline settlement may then stat still newer local bytes
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## B41 — Sync Folder Removal Races Active Reconciliation (HIGH-11)
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-07-22, sync lifecycle audit
|
||||
**Where:** `crates/pdfs-fuse/src/devices.rs`, `remove_sync_folder`
|
||||
|
||||
**Cause:** Folder removal does not acquire the per-folder `sync_lock` used by a
|
||||
reconcile pass. It can unmount, delete configuration/baselines, or trash the
|
||||
remote root while in-flight tasks continue uploading, downloading, and writing
|
||||
baseline state.
|
||||
|
||||
**Required fix/test:** Serialize removal with reconciliation, cancel/disable the
|
||||
folder, await active work, then unmount and remove durable state.
|
||||
|
||||
---
|
||||
|
||||
## B42 — Local Delete Failure Is Recorded as Sync Success (HIGH-12)
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-07-22, sync error-path audit
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, local file/directory deletion branches
|
||||
|
||||
**Cause:** Some local removal errors are ignored while the baseline is removed
|
||||
and success is logged. The surviving path becomes a new untracked item on the
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## B43 — FUSE Directory Cookies Are Not Stable (MED-09)
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-07-22, FUSE/POSIX audit
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `ProtonFs::serve_readdir`
|
||||
|
||||
**Cause:** `readdir` rebuilds a live vector for every call and uses its array
|
||||
index as the continuation cookie. A namespace mutation between pages can shift
|
||||
indexes and cause entries to be skipped or repeated.
|
||||
|
||||
**Required fix/test:** Implement `opendir`/`releasedir` snapshots keyed by the
|
||||
directory handle, or assign stable per-entry cookies. Test with deliberately
|
||||
small reply pages and mutation between calls.
|
||||
|
||||
---
|
||||
|
||||
## B44 — FUSE Background Workers Have No Coordinated Shutdown (MED-10)
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-07-22, daemon lifecycle audit
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, mount lifecycle; drain/index/sync loops
|
||||
|
||||
**Cause:** Long-lived threads and tasks retain `Core` clones but share no
|
||||
cancellation token or owned join set. Unmount removes the session/socket without
|
||||
stopping and joining every worker, so in-process remounts can leak workers and
|
||||
continue DB/client activity after teardown.
|
||||
|
||||
**Required fix/test:** Add shared cancellation, interruptible waits, owned join
|
||||
handles, and ordered shutdown. Repeated mount/unmount tests should return thread
|
||||
counts to baseline and observe no post-unmount DB work.
|
||||
|
||||
|
||||
Reference in new issue
Block a user