diff --git a/Cargo.lock b/Cargo.lock index 401ac36..b42a492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index d2d1d1c..f40ac45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "] diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs index 946e355..8e7ccec 100644 --- a/crates/pdfs-core/src/cache.rs +++ b/crates/pdfs-core/src/cache.rs @@ -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); diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index ee88712..1195027 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -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))); diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index a6bff92..8f6e1a7 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -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(); } } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 03be44b..ff1ad4d 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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 diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index a70b626..d7b0b6a 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -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(); diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 4cba4ce..7727daf 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -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) => { diff --git a/docs/BUGS.md b/docs/BUGS.md index f548bd1..dce2599 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -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.