fixed rm before online del, mirror bug

This commit is contained in:
nvrl committed 2026-07-22 01:52:10 +02:00
1 parent 1cd6a26be3
commit bd17d506f8
9 files changed
+288 -144

No files matched your search

Generated
+4 -4
View File
@@ -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
View File
@@ -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>"]
+6 -2
View File
@@ -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);
+9 -13
View File
@@ -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)));
+17 -23
View File
@@ -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
View File
@@ -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
+8 -54
View File
@@ -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();
+8 -2
View File
@@ -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
View File
@@ -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.