From 39be354bd286acf4ec8a2fa2ee524b144e21ee84 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 6 Oct 2026 19:29:02 +0200 Subject: [PATCH] docs(bugs): give the ledger an index and one order The ledger ran B193 down to B78, then B1 up to B75, with T1 to T4 in the middle and a stray top-level heading inside B14. Status lines used a dozen wordings for the same few states, so finding what is still open meant reading 6,600 lines. Add a header: what the numbers promise (permanent, never reused; B76 and B77 were never assigned), the status vocabulary, and how to add an entry, with a template. Add an index sorted by status, with what is left for every open or partly fixed entry. Order all entries newest first under one Entries section, and move the acceptance-runner findings to their own section. Titles are in sentence case; anchors are unchanged. Entry text is kept as written, except B89: the file it described, proton-dev/CLAUDE.md, no longer exists, so it is closed. --- docs/BUGS.md | 6804 +++++++++++++++++++++++++++----------------------- 1 file changed, 3620 insertions(+), 3184 deletions(-) diff --git a/docs/BUGS.md b/docs/BUGS.md index ce3c3b4..a4de421 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -1,18 +1,302 @@ -# Bugs & side findings +# Bug ledger -Running tracker for bugs and loose ends spotted while working on something else — -so they don't get lost when the session that found them ends. +Every defect and side finding tracked for this client: how it was found, what caused it, how it +was fixed, and how the fix was checked. The [index](#index) sorts the entries by status. -Conventions: +To report a problem, [open an issue](https://github.com/narrrl/proton-drive-linux/issues/new/choose) +and include what [TROUBLESHOOTING.md](TROUBLESHOOTING.md#reporting-a-problem) lists. An issue that +turns out to be a defect gets an entry here. -- **Open** / **Fixed (unverified)** / **Fixed** / **Won't fix** -- "Fixed (unverified)" means the code change is in but nobody has driven the real - flow against it yet. It stays that way until someone does. -- Record how it was *found*, not just what it is. The repro is the expensive part. +- [Numbers](#numbers) +- [Statuses](#statuses) +- [Adding an entry](#adding-an-entry) +- [Index](#index) +- [Entries](#entries) +- [Acceptance harness findings](#acceptance-harness-findings) ---- +## Numbers -## B193 — Deleting a file whose create failed can trash a file of the user's on Drive +Code comments, tests and acceptance cases refer to entries by number, for example +`docs/BUGS.md B123` or the case `regression B123`. A number is therefore permanent: an entry is +never renumbered or removed, and a number is never reused. B76 and B77 were never assigned. +Findings about the acceptance runner itself are numbered `T`. + +## Statuses + +| Status | Meaning | +|---|---| +| **Open** | Not fixed | +| **Partly fixed** | Part of it is fixed; the entry says what is left | +| **Fixed (unverified)** | The change is in and has unit or simulation coverage, but nobody has driven the real flow against a live account since. It stays so until someone does | +| **Fixed** | Fixed and checked: on a live account, by the acceptance suite, or by the test the entry names. A date says when | +| **Won't fix** | Understood and accepted on purpose; the entry says why | +| **Not a bug** | The behaviour is correct or the report was mistaken. Kept as a record | +| **Closed** | No longer applies | + +Older entries use other words for the same states, such as "fixed in code", "fixed in-tree" or +"live-validated". The index sorts each entry into one of the states above; the entry keeps its +original wording. + +## Adding an entry + +1. Take the next free number and add the entry at the top of [Entries](#entries). +2. Record how the bug was **found**, not only what it is. The reproduction is the expensive part. +3. Name the test that covers the fix: a unit test, a simulation seed, or an acceptance case named + `regression B` (see [TESTING.md](TESTING.md#regressions-and-open-bugs)). +4. Add the entry to the [index](#index). When its status changes, update the status line and move + it in the index in the same change. + +```markdown +### B194 — What the user sees, in one line + +**Status:** Open +**Found:** 2026-10-06, how it was found +**Where:** `crates/pdfs-fuse/src/drain.rs`, `Core::function` + +**Repro:** The steps, and what happens. + +**Cause:** Why it happens. + +**Fix:** What changed, and why that is the right layer. + +**Verified:** The test, run or check that shows it, with its date. +``` + +## Index + +### Open + +| Entry | Title | What is left | +|---|---|---| +| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off | +| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted | +| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about | +| [B66](#b66--local-only-pending-data-is-not-protected-at-shutdown-high-21) | Local-only pending data is not protected at shutdown (HIGH-21) | No shutdown protection and no `pdfs sync flush` | +| [B65](#b65--release-artifacts-lack-supply-chain-verification-med-11) | Release artifacts lack supply-chain verification (MED-11) | No SBOM, provenance or checksums | +| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | Keyring write failures are only logged | +| [B15](#b15--an-empty-but-listed-folder-and-duplicated-folder-uids) | An empty-but-`listed` folder, and duplicated folder uids | Cause not attributed | +| [B10](#b10--glib-critical-when-the-launcher-closes-over-an-in-flight-open) | GLib critical when the launcher closes over an in-flight open | | + +### Partly fixed + +| Entry | Title | What is left | +|---|---|---| +| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions | +| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured | +| [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry | +| [B68](#b68--profile-backup-cannot-be-written-to-the-device-root) | Profile backup cannot be written to the device root | Backup health is not shown in the app; the restore was not re-run | +| [B64](#b64--stable-publishing-lacks-data-safety-and-recovery-gates-crit-12) | Stable publishing lacks data-safety and recovery gates (CRIT-12) | Recovery approval before a release | +| [B63](#b63--release-version-identity-is-inconsistent-and-unenforced-high-20) | Release version identity is inconsistent and unenforced (HIGH-20) | Protocol version identity | +| [B57](#b57--event-cursor-can-advance-past-unapplied-state-high-14) | Event cursor can advance past unapplied state (HIGH-14) | Database and apply fault tests | +| [B56](#b56--database-schema-version-parsing-fails-open-high-13) | Database schema version parsing fails open (HIGH-13) | Malformed schema versions | +| [B12](#b12--cold-enumeration-is-slow-per-entry-and-goes-superlinear-past-500) | Cold enumeration is slow per entry, and goes superlinear past ~500 | The attribute-invalidation follow-up is unverified | +| [B8](#b8--no-way-to-complete-a-captcha-so-a-gated-sign-in-is-unrecoverable) | No way to complete a CAPTCHA, so a gated sign-in is unrecoverable | Not tested against a real verification request | +| [B2](#b2--the-reported-mkv-went-to-trash-the-repro-vanished-entirely) | The reported .mkv went to trash, the repro vanished entirely | Where the trash came from is still unattributed | + +### Fixed, not verified live + +The change is in and has unit or simulation coverage. Nobody has driven the real flow against +a live account since. + +| Entry | Title | +|---|---| +| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive | +| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive | +| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive | +| [B189](#b189--a-change-that-failed-on-a-short-outage-waits-out-its-retry-long-after-the-link-is-back) | A change that failed on a short outage waits out its retry long after the link is back | +| [B188](#b188--a-restart-can-leave-a-queued-write-retrying-forever-on-a-file-deleted-while-open) | A restart can leave a queued write retrying forever on a file deleted while open | +| [B187](#b187--an-uploaded-file-can-leave-a-copy-of-itself-in-the-cache-until-the-next-start) | An uploaded file can leave a copy of itself in the cache until the next start | +| [B186](#b186--a-file-deleted-offline-while-open-fails-a-partial-write-with-inputoutput-error) | A file deleted offline while open fails a partial write with "Input/output error" | +| [B185](#b185--a-new-file-written-again-just-as-it-starts-uploading-waits-out-a-retry) | A new file written again just as it starts uploading waits out a retry | +| [B184](#b184--a-file-renamed-after-its-create-lost-its-answer-stays-on-drive-under-its-old-name) | A file renamed after its create lost its answer stays on Drive under its old name | +| [B183](#b183--a-file-deleted-after-a-restart-stays-on-drive-when-its-create-lost-its-answer) | A file deleted after a restart stays on Drive when its create lost its answer | +| [B182](#b182--a-write-after-a-restart-over-a-create-never-read-back-lands-as-a-conflict-copy) | A write after a restart over a create never read back lands as a conflict copy | +| [B179](#b179--a-file-read-just-as-its-create-lands-reads-empty) | A file read just as its create lands reads empty | +| [B178](#b178--trashing-a-folder-while-sync-is-paused-keeps-a-create-in-it-as-a-trash) | Trashing a folder while sync is paused keeps a create in it as a trash | +| [B177](#b177--a-folder-listed-just-as-its-create-lands-answers-inputoutput-error) | A folder listed just as its create lands answers "Input/output error" | +| [B176](#b176--a-write-closed-just-as-its-files-create-lands-is-dropped) | A write closed just as its file's create lands is dropped | +| [B175](#b175--a-file-replaced-by-a-rename-just-as-its-create-lands-stays-on-drive) | A file replaced by a rename just as its create lands stays on Drive | +| [B174](#b174--a-file-renamed-just-as-its-create-lands-is-refused-with-permission-denied) | A file renamed just as its create lands is refused with "Permission denied" | +| [B173](#b173--a-file-deleted-just-as-its-create-lands-stays-on-drive) | A file deleted just as its create lands stays on Drive | +| [B172](#b172--a-file-renamed-while-its-upload-is-read-back-shows-its-old-name) | A file renamed while its upload is read back shows its old name | +| [B171](#b171--a-file-deleted-while-its-upload-fails-stays-on-drive-for-the-uploads-backoff) | A file deleted while its upload fails stays on Drive for the upload's backoff | +| [B170](#b170--a-write-opened-as-an-upload-lands-fails-the-next-write-with-eio) | A write opened as an upload lands fails the next write with `EIO` | +| [B165](#b165--a-stop-waits-on-a-stuck-thread-until-systemd-kills-the-daemon) | A stop waits on a stuck thread until systemd kills the daemon | +| [B159](#b159--a-write-right-after-a-restart-fails-with-resource-temporarily-unavailable) | A write right after a restart fails with "Resource temporarily unavailable" | +| [B158](#b158--a-file-renamed-twice-in-a-row-ends-up-under-the-first-name-again) | A file renamed twice in a row ends up under the first name, again | +| [B157](#b157--a-second-write-to-a-file-whose-upload-was-not-read-back-fails-with-eio) | A second write to a file whose upload was not read back fails with `EIO` | +| [B156](#b156--a-file-written-again-after-its-create-lost-its-answer-lands-as-a-conflict-copy) | A file written again after its create lost its answer lands as a conflict copy | +| [B155](#b155--a-write-whose-upload-lost-its-answer-lands-as-a-conflict-copy-of-itself) | A write whose upload lost its answer lands as a conflict copy of itself | +| [B154](#b154--a-file-made-under-the-name-of-a-create-on-the-wire-lands-as-a-conflict-copy) | A file made under the name of a create on the wire lands as a conflict copy | +| [B153](#b153--an-op-waiting-for-a-name-sat-out-its-backoff-after-the-name-was-free) | An op waiting for a name sat out its backoff after the name was free | +| [B152](#b152--a-rename-made-as-its-create-landed-was-undone-in-the-tree) | A rename made as its create landed was undone in the tree | +| [B151](#b151--a-file-deleted-after-its-create-lost-its-answer-stays-on-drive) | A file deleted after its create lost its answer stays on Drive | +| [B150](#b150--a-conflict-copy-whose-folder-is-gone-retries-forever) | A conflict copy whose folder is gone retries forever | +| [B149](#b149--pdfs-rename-or-pdfs-move-right-after-a-rename-through-the-mount-is-undone) | `pdfs rename` or `pdfs move` right after a rename through the mount is undone | +| [B148](#b148--a-folder-pinned-before-it-has-reached-drive-loses-its-pin) | A folder pinned before it has reached Drive loses its pin | +| [B147](#b147--a-new-file-made-under-a-name-just-renamed-away-can-share-the-first-files-node) | A new file made under a name just renamed away can share the first file's node | +| [B146](#b146--a-file-renamed-just-as-its-upload-lands-can-go-back-to-its-old-name) | A file renamed just as its upload lands can go back to its old name | +| [B145](#b145--renaming-a-just-made-download-to-its-final-name-can-fail-with-device-or-resource-busy) | Renaming a just-made download to its final name can fail with "Device or resource busy" | +| [B144](#b144--a-rename-can-land-under-a-conflict-name-although-its-target-was-freed) | A rename can land under a conflict name although its target was freed | +| [B143](#b143--a-write-to-a-file-whose-create-landed-as-the-link-went-down-lands-as-a-conflict-copy) | A write to a file whose create landed as the link went down lands as a conflict copy | +| [B142](#b142--renaming-a-new-file-as-its-upload-lands-can-fail-with-device-or-resource-busy) | Renaming a new file as its upload lands can fail with "Device or resource busy" | +| [B141](#b141--a-file-renamed-twice-in-a-row-can-end-up-under-the-first-name) | A file renamed twice in a row can end up under the first name | +| [B140](#b140--files-in-a-folder-that-lands-as-the-daemon-stops-are-gone-after-a-restart) | Files in a folder that lands as the daemon stops are gone after a restart | +| [B139](#b139--a-file-closed-while-its-first-upload-lands-keeps-its-last-write-in-staging) | A file closed while its first upload lands keeps its last write in staging | +| [B138](#b138--a-file-created-where-another-is-leaving-lands-as-a-conflict-copy) | A file created where another is leaving lands as a conflict copy | +| [B137](#b137--a-queued-change-can-be-deleted-by-an-older-one-landing) | A queued change can be deleted by an older one landing | +| [B136](#b136--a-file-renamed-while-its-first-upload-is-on-the-wire-goes-back-to-its-old-name) | A file renamed while its first upload is on the wire goes back to its old name | +| [B135](#b135--a-file-just-moved-is-sometimes-not-found-by-its-new-path) | A file just moved is sometimes not found by its new path | +| [B131](#b131--a-write-to-a-file-whose-create-could-not-be-read-back-lands-as-a-conflict-copy) | A write to a file whose create could not be read back lands as a conflict copy | +| [B124](#b124--after-a-replacing-rename-fails-the-file-it-was-replacing-is-gone-from-the-mount) | After a replacing rename fails, the file it was replacing is gone from the mount | +| [B123](#b123--a-second-write-open-on-a-file-just-written-fails-with-eio) | A second write open on a file just written fails with `EIO` | +| [B122](#b122--a-rename-across-folders-fails-with-eio-when-the-source-folder-holds-the-new-name) | A rename across folders fails with `EIO` when the source folder holds the new name | +| [B121](#b121--a-move-right-after-a-rename-fails-with-eio-and-a-rename-across-folders-takes-seconds) | A move right after a rename fails with `EIO`, and a rename across folders takes seconds | +| [B120](#b120--a-file-queued-in-a-folder-that-has-just-landed-is-put-off-by-five-seconds) | A file queued in a folder that has just landed is put off by five seconds | +| [B119](#b119--reading-a-file-just-written-through-the-mount-needs-the-network) | Reading a file just written through the mount needs the network | +| [B118](#b118--creating-a-file-or-folder-through-the-mount-can-fail-with-enoent-although-it-was-made) | Creating a file or folder through the mount can fail with `ENOENT` although it was made | +| [B112](#b112--folders-shared-by-the-same-person-disappear-from-shared-with-me) | Folders shared by the same person disappear from Shared with me | +| [B111](#b111--renaming-a-file-back-right-after-renaming-it-fails-with-eio) | Renaming a file back right after renaming it fails with EIO | +| [B110](#b110--a-read-that-settles-a-provisional-size-deadlocks-on-its-own-page-lock) | A read that settles a provisional size deadlocks on its own page lock | +| [B109](#b109--a-re-listing-shrinks-a-file-that-is-open-for-writing-and-sqlite-reads-it-as-malformed) | A re-listing shrinks a file that is open for writing, and SQLite reads it as malformed | +| [B108](#b108--a-file-deleted-during-a-mirror-pass-puts-the-sync-folder-in-error) | A file deleted during a mirror pass puts the sync folder in `error` | +| [B107](#b107--one-read-on-a-stalled-connection-hangs-the-reading-program-for-half-an-hour) | One read on a stalled connection hangs the reading program for half an hour | +| [B106](#b106--saving-over-a-file-that-was-just-read-fails-with-already-exists) | Saving over a file that was just read fails with "already exists" | +| [B105](#b105--a-file-deleted-while-its-upload-is-in-flight-comes-back-as-a-conflict-copy) | A file deleted while its upload is in flight comes back as a conflict copy | +| [B104](#b104--reading-a-file-that-was-replaced-by-a-rename-fails-with-eio) | Reading a file that was replaced by a rename fails with `EIO` | +| [B103](#b103--one-stuck-trash-refresh-freezes-the-trash-listing-until-the-daemon-restarts) | One stuck trash refresh freezes the trash listing until the daemon restarts | +| [B102](#b102--removing-a-folder-leaves-the-queued-uploads-of-the-files-inside-it) | Removing a folder leaves the queued uploads of the files inside it | +| [B101](#b101--a-shrunk-file-written-past-its-end-gets-old-bytes-back-or-fails-with-eio-a-write-during-a-creates-upload-is-lost) | A shrunk file written past its end gets old bytes back, or fails with `EIO`; a write during a create's upload is lost | +| [B100](#b100--a-freshly-copied-file-reads-as-eio-or-53-bytes-too-big) | A freshly copied file reads as `EIO` or 53 bytes too big | +| [B99](#b99--conflict-copies-in-synced-folders-are-logged-again-on-every-start-and-shown-as-resolved) | Conflict copies in synced folders are logged again on every start and shown as "Resolved" | +| [B98](#b98--a-superseded-upload-spins-its-drain-thread-forever-and-shows-as-uploading-at-0-bytes) | A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes | +| [B97](#b97--photo-refreshes-stack-up-on-the-control-socket-and-shutdown-turns-the-resolve-pass-into-a-burst-of-undecryptable-photo-warnings) | Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings | +| [B96](#b96--staged-bytes-that-name-no-node-are-never-retired-and-are-re-announced-on-every-boot) | Staged bytes that name no node are never retired, and are re-announced on every boot | +| [B95](#b95--a-real-indextmp-is-treated-as-a-browser-tempfile-and-parked-forever) | A real `index.tmp` is treated as a browser tempfile and parked forever | +| [B94](#b94--a-parked-create-has-no-exit-but-the-rename-so-an-abandoned-one-is-stuck-forever) | A parked create has no exit but the rename, so an abandoned one is stuck forever | +| [B93](#b93--a-photo-whose-node-would-not-resolve-lost-its-name-and-with-it-its-tab-and-its-group) | A photo whose node would not resolve lost its name, and with it its tab and its group | +| [B92](#b92--refreshing-the-gallery-reported-resource-temporarily-unavailable) | Refreshing the gallery reported "Resource temporarily unavailable" | +| [B90](#b90--the-local-file-indexer-walks-into-foreign-fuse-mounts-and-a-dead-one-makes-the-daemon-unkillable) | The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable | +| [B86](#b86--an-on-demand-mount-serves-a-listing-that-predates-the-mirror-engines-own-uploads) | An on-demand mount serves a listing that predates the mirror engine's own uploads | +| [B85](#b85--the-read-path-assumes-4-mib-blocks-some-revisions-do-not-have-them) | The read path assumes 4 MiB blocks; some revisions do not have them | +| [B83](#b83--an-access-deferred-op-retries-forever-and-is-reported-as-an-ordinary-queued-upload) | An access-deferred op retries forever and is reported as an ordinary queued upload | +| [B82](#b82--a-parent_uid-cycle-hangs-the-daemon-on-any-short-search) | A `parent_uid` cycle hangs the daemon on any short search | +| [B81](#b81--prompt-labels-every-drive-hit-my-files-including-device-folders) | Prompt labels every Drive hit "My files", including device folders | +| [B80](#b80--29-of-the-account-is-absent-from-the-search-index) | 29% of the account is absent from the search index | +| [B62](#b62--debian-artifact-omits-required-service-and-autostart-units-high-19) | Debian artifact omits required service and autostart units (HIGH-19) | +| [B60](#b60--config-writes-are-non-atomic-and-parse-errors-are-overwritten-high-17) | Config writes are non-atomic and parse errors are overwritten (HIGH-17) | +| [B59](#b59--private-state-permission-enforcement-fails-open-high-16) | Private state permission enforcement fails open (HIGH-16) | +| [B58](#b58--control-socket-has-unbounded-frames-and-connections-high-15) | Control socket has unbounded frames and connections (HIGH-15) | +| [B48](#b48--successful-unlink-can-reappear-from-a-stale-remote-listing) | Successful unlink can reappear from a stale remote listing | +| [B42](#b42--local-delete-failure-is-recorded-as-sync-success-high-12) | Local delete failure is recorded as sync success (HIGH-12) | +| [B38](#b38--sync-task-panic-could-authorize-destructive-mode-switch-high-08) | Sync task panic could authorize destructive mode switch (HIGH-08) | +| [B37](#b37--failed-remote-trash-removed-the-local-dentry-crit-04) | Failed remote trash removed the local dentry (CRIT-04) | +| [B34](#b34--writable-posix-exposure-of-read-only-shared-folders-med-07) | Writable POSIX exposure of read-only shared folders (MED-07) | +| [B30](#b30--sqlite-write-lock--mutex-contention-under-heavy-drain-med-03) | SQLite write lock & mutex contention under heavy drain (MED-03) | +| [B28](#b28--unhandled-disk-exhaustion-enospc-in-cache--scratch-store-med-01) | Unhandled disk exhaustion (`ENOSPC`) in cache & scratch store (MED-01) | +| [B27](#b27--orphaned-write-revision-bricking-offline-sync-queue-high-07) | Orphaned write revision bricking offline sync queue (HIGH-07) | +| [B22](#b22--synchronous-block_on-inside-mutex-guard-high-02) | Synchronous `block_on` inside mutex guard (HIGH-02) | +| [B19](#b19--remote-folder-trashing-on-mode-switch-failure-crit-02) | Remote folder trashing on mode switch failure (CRIT-02) | +| [B14](#b14--provisional-ciphertext-sizes-make-rsync-read-short-and-abort-the-file) | Provisional (ciphertext) sizes make rsync read short and abort the file | +| [B13](#b13--rename-over-an-existing-destination-fails-instead-of-replacing-it) | `rename` over an existing destination fails instead of replacing it | + +### Won't fix, not a bug, closed + +| Entry | Title | What is left | +|---|---|---| +| [B89](#b89--the-recorded-sdk-dependency-is-three-minor-versions-out-of-date-in-the-working-memory) | The recorded SDK dependency is three minor versions out of date in the working memory | Closed: the file it described no longer exists | +| [B87](#b87--a-revisions-block-geometry-is-learned-a-read-too-late) | A revision's block geometry is learned a read too late | Won't fix: a bounded cost, accepted | +| [B36](#b36--unicode-normalization-discrepancy-nfc-vs-nfd-low-01) | Unicode normalization discrepancy (NFC vs NFD) (LOW-01) | Won't fix: NFC and NFD names stay distinct | +| [B31](#b31--unimplemented-special-files--attribute-modifications-med-04) | Unimplemented special files & attribute modifications (MED-04) | Not a bug: by design | +| [B3](#b3--activity-log-timestamps-written-in-seconds-read-as-milliseconds) | ~~Activity log timestamps written in seconds, read as milliseconds~~ | Not a bug: investigator error | + +### Fixed + +| Entry | Title | +|---|---| +| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later | +| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English | +| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it | +| [B169](#b169--a-stop-lets-a-mirror-pass-go-on-until-the-runtime-ends-under-it) | A stop lets a mirror pass go on until the runtime ends under it | +| [B168](#b168--a-file-removed-with-its-folder-while-uploading-is-sent-again-to-the-root) | A file removed with its folder while uploading is sent again, to the root | +| [B167](#b167--the-mount-takes-seconds-to-come-up-on-a-large-drive) | The mount takes seconds to come up on a large Drive | +| [B166](#b166--a-folder-drops-or-empties-a-file-whose-create-lands-while-it-is-listed) | A folder drops or empties a file whose create lands while it is listed | +| [B164](#b164--a-folder-lists-a-new-file-twice-while-its-create-lands) | A folder lists a new file twice while its create lands | +| [B163](#b163--a-file-read-right-after-its-create-landed-is-empty) | A file read right after its create landed is empty | +| [B162](#b162--a-folder-removed-and-made-again-at-once-loses-what-goes-into-it) | A folder removed and made again at once loses what goes into it | +| [B161](#b161--a-file-moved-while-it-was-being-created-shows-up-under-its-old-name) | A file moved while it was being created shows up under its old name | +| [B160](#b160--a-new-folder-reaches-drive-only-half-a-minute-later) | A new folder reaches Drive only half a minute later | +| [B133](#b133--a-file-deleted-while-its-upload-is-in-flight-can-still-come-back-as-a-recovered-copy) | A file deleted while its upload is in flight can still come back as a recovered copy | +| [B132](#b132--a-folder-whose-mkdir-is-still-queued-is-gone-after-a-restart) | A folder whose mkdir is still queued is gone after a restart | +| [B130](#b130--a-read-of-a-file-that-a-remote-change-invalidates-can-hang-the-daemon) | A read of a file that a remote change invalidates can hang the daemon | +| [B129](#b129--a-file-deleted-or-replaced-while-its-create-is-on-the-wire-stays-on-drive) | A file deleted or replaced while its create is on the wire stays on Drive | +| [B128](#b128--a-file-created-offline-over-one-deleted-offline-can-be-lost) | A file created offline over one deleted offline can be lost | +| [B127](#b127--a-file-created-offline-lands-twice-when-the-answer-to-its-upload-is-lost) | A file created offline lands twice when the answer to its upload is lost | +| [B126](#b126--a-file-created-offline-under-a-name-an-offline-rename-freed-lands-as-a-conflict-copy) | A file created offline under a name an offline rename freed lands as a conflict copy | +| [B125](#b125--a-folder-made-offline-fails-with-eio-once-its-listing-is-invalidated) | A folder made offline fails with `EIO` once its listing is invalidated | +| [B117](#b117--an-upload-queued-for-a-file-pdfs-move-moved-is-listed-by-its-raw-id-and-put-off-once) | An upload queued for a file `pdfs move` moved is listed by its raw id and put off once | +| [B116](#b116--trashing-a-folder-keeps-the-queued-creates-in-its-subfolders) | Trashing a folder keeps the queued creates in its subfolders | +| [B115](#b115--pdfs-ls-shows-a-files-encrypted-size) | `pdfs ls` shows a file's encrypted size | +| [B114](#b114--a-name-pdfs-rm-or-pdfs-rename-took-away-still-resolves-for-up-to-30-seconds) | A name `pdfs rm` or `pdfs rename` took away still resolves for up to 30 seconds | +| [B113](#b113--a-new-file-moved-before-its-upload-drains-lands-as-a-conflict-copy-of-itself) | A new file moved before its upload drains lands as a conflict copy of itself | +| [B84](#b84--a-block-read-that-comes-back-short-truncates-the-file-silently) | A block read that comes back short truncates the file, silently | +| [B79](#b79--every-write-in-an-on-demand-device-folder-mount-fails-eacces) | Every write in an on-demand device-folder mount fails EACCES | +| [B78](#b78--profile-backup-fails-cannot-create-file-at-the-root-of-a-device) | Profile backup fails: Cannot create file at the root of a device | +| [B75](#b75--network-work-on-fusers-single-dispatch-thread-freezes-the-whole-mount) | Network work on fuser's single dispatch thread freezes the whole mount | +| [B74](#b74--a-drained-create-never-reaches-a-sync-folder-mounts-inode-space-so-the-file-reads-as-empty) | A drained create never reaches a sync-folder mount's inode space, so the file reads as empty | +| [B73](#b73--unimplemented-fuse-handlers-log-a-warn-per-probe) | Unimplemented FUSE handlers log a WARN per probe | +| [B70](#b70--browser-in-flight-temp-files-uploaded-and-conflict-forked-on-the-on-demand-mount) | Browser in-flight temp files uploaded and conflict-forked on the on-demand mount | +| [B69](#b69--spurious-sync-conflict-copies-from-mtime-keyed-revision-identity-b16b25-root-fix) | Spurious `(sync-conflict)` copies from mtime-keyed revision identity (B16/B25 root fix) | +| [B55](#b55--incomplete-local-scans-are-interpreted-as-deletions-crit-11) | Incomplete local scans are interpreted as deletions (CRIT-11) | +| [B54](#b54--total-wipe-guard-does-not-protect-a-one-entry-baseline-crit-10) | Total-wipe guard does not protect a one-entry baseline (CRIT-10) | +| [B53](#b53--superseding-a-pending-operation-is-not-transactional-crit-09) | Superseding a pending operation is not transactional (CRIT-09) | +| [B52](#b52--staged-write-publication-is-not-crash-safe-crit-08) | Staged-write publication is not crash-safe (CRIT-08) | +| [B51](#b51--scratch-sidecar-does-not-establish-a-durable-recovery-record-crit-07) | Scratch sidecar does not establish a durable recovery record (CRIT-07) | +| [B50](#b50--failed-orphan-staging-deletes-the-sole-scratch-copy-crit-06) | Failed orphan staging deletes the sole scratch copy (CRIT-06) | +| [B49](#b49--failed-conflict-preservation-still-overwrites-the-local-file-crit-05) | Failed conflict preservation still overwrites the local file (CRIT-05) | +| [B47](#b47--fuse-accepts-path-components-longer-than-name_max) | FUSE accepts path components longer than NAME_MAX | +| [B46](#b46--combined-cross-directory-move-and-rename-can-fail-out-of-date) | Combined cross-directory move and rename can fail out of date | +| [B45](#b45--truncate-after-a-queued-rewrite-corrupts-or-returns-eio) | Truncate after a queued rewrite corrupts or returns EIO | +| [B44](#b44--fuse-background-workers-have-no-coordinated-shutdown-med-10) | FUSE background workers have no coordinated shutdown (MED-10) | +| [B43](#b43--fuse-directory-cookies-are-not-stable-med-09) | FUSE directory cookies are not stable (MED-09) | +| [B41](#b41--sync-folder-removal-races-active-reconciliation-high-11) | Sync folder removal races active reconciliation (HIGH-11) | +| [B40](#b40--sync-upload-can-stream-torn-live-content-high-10) | Sync upload can stream torn live content (HIGH-10) | +| [B39](#b39--sync-download-can-overwrite-a-concurrent-local-edit-high-09) | Sync download can overwrite a concurrent local edit (HIGH-09) | +| [B35](#b35--ipc-unix-socket-creation-permission-race-med-08) | IPC Unix socket creation permission race (MED-08) | +| [B33](#b33--lack-of-partial-transfer-resumption--head-of-line-queue-blocking-med-06) | Lack of partial transfer resumption & head-of-line queue blocking (MED-06) | +| [B32](#b32--race-condition-in-concurrent-handle-release-and-open-med-05) | Race condition in concurrent handle release and open (MED-05) | +| [B29](#b29--missing-fuse-forget-method--memory-leak-med-02) | Missing FUSE `forget` method / memory leak (MED-02) | +| [B26](#b26--broken-open-unlinked-file-semantics-high-06) | Broken open-unlinked file semantics (HIGH-06) | +| [B25](#b25--weak-conflict-baseline-detection-mtime-size-high-05) | Weak conflict baseline detection `(mtime, size)` (HIGH-05) | +| [B24](#b24--directory-hierarchy-loop-in-rename-high-04) | Directory hierarchy loop in `rename` (HIGH-04) | +| [B23](#b23--un-fsynced-scratch-wipe-on-daemon-restart-high-03) | Un-fsynced scratch wipe on daemon restart (HIGH-03) | +| [B21](#b21--untracked-hole-punching-in-fallocate-high-01) | Untracked hole punching in `fallocate` (HIGH-01) | +| [B20](#b20--un-fsynced-temp-file-atomic-rename-in-content-cache-crit-03) | Un-fsynced temp file atomic rename in content cache (CRIT-03) | +| [B18](#b18--subtree-erasure-via-posix-unlink--rmdir-crit-01) | Subtree erasure via POSIX `unlink` / `rmdir` (CRIT-01) | +| [B17](#b17--fuse-mount-lock-contention-missing-next_attempt_at-in-db-enqueue-and-ioctl-enotty-fix) | FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix | +| [B16](#b16--aria2c-preallocation--rapid-sequential-writes-trigger-false-self-conflict-copies) | aria2c preallocation / rapid sequential writes trigger false self-conflict copies | +| [B11](#b11--a-file-moved-while-its-create-is-still-queued-reads-as-empty) | A file moved while its create is still queued reads as empty | +| [B9](#b9--enter-did-not-open-the-selected-result-in-the-launcher) | Enter did not open the selected result in the launcher | +| [B7](#b7--renamed-directory-reads-as-missing-until-the-entry-ttl-expires) | Renamed directory reads as missing until the entry TTL expires | +| [B6](#b6--daemon-sets-no-file-modes-controlsock-is-an-unguarded-authority) | Daemon sets no file modes: `control.sock` is an unguarded authority | +| [B5](#b5--ls--l-costs-a-network-round-trip-per-file-thumbnail-xattr-probes) | `ls -l` costs a network round trip per file (thumbnail xattr probes) | +| [B4](#b4--invalidate_listing-silently-skipped-non-resident-folders) | `invalidate_listing` silently skipped non-resident folders | +| [B1](#b1--fuse-rename-loses-the-file-data-loss) | FUSE rename loses the file (data loss) | + +### Acceptance harness + +| Entry | Title | +|---|---| +| [T1](#t1--managed-acceptance-raced-daemon-startup) | Managed acceptance raced daemon startup | +| [T2](#t2--acceptance-cleanup-raced-queued-namespace-replay) | Acceptance cleanup raced queued namespace replay | +| [T3](#t3--mode-matrix-preservation-check-raced-mirror-restoration) | Mode-matrix preservation check raced mirror restoration | +| [T4](#t4--successful-managed-cleanup-left-its-own-sentinel-behind) | Successful managed cleanup left its own sentinel behind | + +## Entries + +Newest first. + +### B193 — Deleting a file whose create failed can trash a file of the user's on Drive **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 1436c5f (`one_client_on_a_flaky_link` seed 6; @@ -35,7 +319,9 @@ has since put in the tree stays on Drive, where the user can see and delete it. **Test:** simulation test `a_withdrawn_create_leaves_the_file_renamed_to_the_name_it_was_sent_under` (failed before the fix: Drive was empty). -## B192 — A new file closed empty reaches Drive only half a minute later +--- + +### B192 — A new file closed empty reaches Drive only half a minute later **Status:** Fixed. **Found:** 2026-10-05, while writing the test for B193. An empty file made on an idle mount was @@ -52,7 +338,9 @@ attaches nothing, so its create waited for the workers' 30 s idle poll. **Test:** simulation test `a_file_made_empty_while_the_drain_is_idle_reaches_drive_at_once` (failed before the fix: the file was still queued after 10 s). -## B191 — A file deleted after its create failed and was written again can stay on Drive +--- + +### B191 — A file deleted after its create failed and was written again can stay on Drive **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 1436c5f (`one_client_on_a_flaky_link` seed 11, @@ -71,7 +359,9 @@ it whole. `a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted` in `crates/pdfs-core/src/db/tests.rs`. -## B190 — A file saved just before the daemon stops can keep its old content on Drive +--- + +### B190 — A file saved just before the daemon stops can keep its old content on Drive **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 2baad67 (`one_client_on_a_flaky_link` seed 9, @@ -92,7 +382,9 @@ written in the same way. released, as its release would have. Test `a_stop_keeps_what_a_handle_wrote_before_its_release` in `crates/pdfs-fuse/src/sim/daemon.rs`. -## B189 — A change that failed on a short outage waits out its retry long after the link is back +--- + +### B189 — A change that failed on a short outage waits out its retry long after the link is back **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 2baad67 (`three_clients_one_writer_each` @@ -111,7 +403,9 @@ minutes. Test `a_write_that_failed_on_a_short_outage_goes_out_soon_after_the_link_returns` in `crates/pdfs-fuse/src/sim/daemon.rs`. -## B188 — A restart can leave a queued write retrying forever on a file deleted while open +--- + +### B188 — A restart can leave a queued write retrying forever on a file deleted while open **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 2baad67 (`three_clients_one_writer_each` @@ -131,7 +425,9 @@ a blob that was gone. B186's fix brought this in, so no release has it. have. Test `a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open` in `crates/pdfs-fuse/src/sim/daemon.rs`. -## B187 — An uploaded file can leave a copy of itself in the cache until the next start +--- + +### B187 — An uploaded file can leave a copy of itself in the cache until the next start **Status:** Fixed (unverified). **Found:** 2026-10-05, writing the test for B186: after its file was deleted, the cache still @@ -148,7 +444,9 @@ start swept it, even after the file was evicted or deleted. **Fix.** The temp name is removed after the rename. Test `store_file_twice_from_the_same_blob_leaves_no_temp_file` in `crates/pdfs-core/src/cache.rs`. -## B186 — A file deleted offline while open fails a partial write with "Input/output error" +--- + +### B186 — A file deleted offline while open fails a partial write with "Input/output error" **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 3073052 (`three_clients_one_writer_each` @@ -167,7 +465,9 @@ and offline it could not. once the delete has landed, and the delete landing lets go of them once the file is closed. Sim test `a_file_deleted_offline_while_open_still_reads_and_takes_writes`. -## B185 — A new file written again just as it starts uploading waits out a retry +--- + +### B185 — A new file written again just as it starts uploading waits out a retry **Status:** Fixed (unverified). **Found:** 2026-10-05, in the confirmation run on 3073052 (`one_client_on_a_good_link` seed 6, @@ -186,7 +486,9 @@ ready, but the create waited out the backoff. renamed while it ran already was. Test `renaming_a_backing_off_create_makes_it_due` in `crates/pdfs-core/src/db/tests.rs`. -## B184 — A file renamed after its create lost its answer stays on Drive under its old name +--- + +### B184 — A file renamed after its create lost its answer stays on Drive under its old name **Status:** Fixed (unverified). **Found:** 2026-10-05, replaying `one_client_on_a_flaky_link` seed 11 with the B183 fix. `a.txt`'s @@ -209,7 +511,9 @@ and `a_file_renamed_and_deleted_after_its_create_lost_its_answer_leaves_nothing` `what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart` in `crates/pdfs-core/src/db/tests.rs`. -## B183 — A file deleted after a restart stays on Drive when its create lost its answer +--- + +### B183 — A file deleted after a restart stays on Drive when its create lost its answer **Status:** Fixed (unverified). **Found:** 2026-10-05, by the known-bug confirmation run (`PDFS_SIM_KNOWN=fail`) on da013c2: @@ -232,7 +536,9 @@ turn into a trash and across restarts. The in-memory map is gone. Tests `crates/pdfs-core/src/db/tests.rs`, and sim test `a_file_deleted_after_a_restart_leaves_drive_with_what_its_create_sent`. -## B182 — A write after a restart over a create never read back lands as a conflict copy +--- + +### B182 — A write after a restart over a create never read back lands as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-10-05, by the known-bug confirmation run on da013c2: @@ -251,7 +557,9 @@ based on that. The drain found Drive's revision in its way and took it for someo B143 does, after a restart too. Sim test `a_write_after_a_restart_over_a_create_never_read_back_is_not_a_conflict`. -## B181 — The app shows a queued edit kept as a conflict copy in English +--- + +### B181 — The app shows a queued edit kept as a conflict copy in English **Status:** Fixed. **Found:** 2026-10-05, checking that every name collision and conflict the drain resolves is @@ -278,7 +586,7 @@ reason it does not know still gets a sentence without the why. Translated in eve --- -## B180 — A stop waits 10 s for a drain worker that slept through it +### B180 — A stop waits 10 s for a drain worker that slept through it **Status:** Fixed. **Found:** 2026-10-04, by the simulation suite on 0f3c06f: `a_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending` @@ -304,7 +612,7 @@ flag before it takes that lock, so a worker that finds it unset is asleep when t --- -## B179 — A file read just as its create lands reads empty +### B179 — A file read just as its create lands reads empty **Status:** Fixed (unverified). **Found:** 2026-10-04, by the account run on 378667f, on on-demand sync folders: the unusual @@ -327,7 +635,7 @@ landed, the read goes on under the uid Drive gave it. --- -## B178 — Trashing a folder while sync is paused keeps a create in it as a trash +### B178 — Trashing a folder while sync is paused keeps a create in it as a trash **Status:** Fixed (unverified). **Found:** 2026-10-04, by the account run on 378667f, on an on-demand sync folder: B116's @@ -348,7 +656,7 @@ create go wakes the workers, so a trash in that moment kept a create nothing had --- -## B177 — A folder listed just as its create lands answers "Input/output error" +### B177 — A folder listed just as its create lands answers "Input/output error" **Status:** Fixed (unverified). **Found:** 2026-10-04, by the account run on 378667f, on an on-demand sync folder: B100's @@ -370,7 +678,7 @@ stand-in, so the lookup found no row, and the listing went to Drive with the sta --- -## B176 — A write closed just as its file's create lands is dropped +### B176 — A write closed just as its file's create lands is dropped **Status:** Fixed (unverified). **Found:** 2026-10-04, by `one_client_on_a_good_link` (seed 12) on 378667f, run three at a time @@ -391,7 +699,7 @@ missing create op is checked against the row, and a landed file's write is queue --- -## B175 — A file replaced by a rename just as its create lands stays on Drive +### B175 — A file replaced by a rename just as its create lands stays on Drive **Status:** Fixed (unverified). **Found:** 2026-10-04, by `one_client_on_a_good_link` (seed 8) on 378667f, run three at a time @@ -413,7 +721,7 @@ has a real uid. If it has, the file is trashed on Drive like any other replaced --- -## B174 — A file renamed just as its create lands is refused with "Permission denied" +### B174 — A file renamed just as its create lands is refused with "Permission denied" **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation suite on 378667f (`one_client_on_a_good_link`, seed 6): @@ -434,7 +742,7 @@ create lands. --- -## B173 — A file deleted just as its create lands stays on Drive +### B173 — A file deleted just as its create lands stays on Drive **Status:** Fixed (unverified). **Found:** 2026-10-04, by `one_client_on_a_good_link` (seed 12) on d850265, run three at a time @@ -456,7 +764,7 @@ uid. If it has, the create landed first, and the file is trashed on Drive like a --- -## B172 — A file renamed while its upload is read back shows its old name +### B172 — A file renamed while its upload is read back shows its old name **Status:** Fixed (unverified). **Found:** 2026-10-04, by `one_client_on_a_good_link` (seed 8) on 2502af6, run three at a time @@ -481,7 +789,7 @@ rename was queued then or is now, or if the file moved meanwhile. --- -## B171 — A file deleted while its upload fails stays on Drive for the upload's backoff +### B171 — A file deleted while its upload fails stays on Drive for the upload's backoff **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation suite (`one_client_on_a_good_link`, seed 9). The queue @@ -506,7 +814,7 @@ ran as (`Db::record_attempt_failure`). --- -## B170 — A write opened as an upload lands fails the next write with `EIO` +### B170 — A write opened as an upload lands fails the next write with `EIO` **Status:** Fixed (unverified). **Found:** 2026-10-04, by CI on 9bbc994 and by three parallel copies of @@ -537,7 +845,7 @@ writes at an offset, and writes again after it. Without the fix the second write --- -## B169 — A stop lets a mirror pass go on until the runtime ends under it +### B169 — A stop lets a mirror pass go on until the runtime ends under it **Status:** Fixed. **Found:** 2026-10-04, in the journal of the account run on the working tree after B165. A @@ -563,7 +871,7 @@ deeper folder was not sent and the stop did not run into its limit. --- -## B168 — A file removed with its folder while uploading is sent again, to the root +### B168 — A file removed with its folder while uploading is sent again, to the root **Status:** Fixed. **Found:** 2026-10-04, by the journal check of the account run on the working tree after B165: @@ -595,7 +903,7 @@ half of the fix). `FakeClient::hold_next_create` is new for it. --- -## B167 — The mount takes seconds to come up on a large Drive +### B167 — The mount takes seconds to come up on a large Drive **Status:** Fixed. **Found:** 2026-10-04, looking into the cleanup of the account run on the working tree after @@ -621,7 +929,7 @@ listing. --- -## B166 — A folder drops or empties a file whose create lands while it is listed +### B166 — A folder drops or empties a file whose create lands while it is listed **Status:** Fixed. **Found:** 2026-10-04, by the account run on the working tree after B165 (a wide directory, on @@ -656,7 +964,7 @@ fix). --- -## B165 — A stop waits on a stuck thread until systemd kills the daemon +### B165 — A stop waits on a stuck thread until systemd kills the daemon **Status:** Fixed (unverified). **Found:** 2026-10-04, by the account run on 2.10.0 (cleanup after on-demand pairing 2). The @@ -684,7 +992,7 @@ blocking task cannot hold the exit either. The pass itself still has no deadline --- -## B164 — A folder lists a new file twice while its create lands +### B164 — A folder lists a new file twice while its create lands **Status:** Fixed. **Found:** 2026-10-04, by the account run on 2.10.0 ("a wide directory", on-demand pairing 2). @@ -711,7 +1019,7 @@ before the fix: the folder listed `c.bin` twice). --- -## B163 — A file read right after its create landed is empty +### B163 — A file read right after its create landed is empty **Status:** Fixed. **Found:** 2026-10-04, by the account run on 2.10.0 ("unusual but legal names", on-demand @@ -744,7 +1052,7 @@ file's next reads as Drive lists a file that is not committed yet --- -## B162 — A folder removed and made again at once loses what goes into it +### B162 — A folder removed and made again at once loses what goes into it **Status:** Fixed. **Found:** 2026-10-04, by the simulation's slow-link profile (`one_client_on_a_slow_link`, @@ -770,7 +1078,7 @@ at once if the trash has landed (B144). --- -## B161 — A file moved while it was being created shows up under its old name +### B161 — A file moved while it was being created shows up under its old name **Status:** Fixed. **Found:** 2026-10-04, by the simulation's slow-link profile (`one_client_on_a_slow_link`, @@ -795,7 +1103,7 @@ answer, taken when the read arrives, until the test releases it --- -## B160 — A new folder reaches Drive only half a minute later +### B160 — A new folder reaches Drive only half a minute later **Status:** Fixed. **Found:** 2026-10-04, by the simulation's slow-link profile (`one_client_on_a_slow_link`, @@ -816,7 +1124,7 @@ before the fix: the folder was still queued after 10 s). --- -## B159 — A write right after a restart fails with "Resource temporarily unavailable" +### B159 — A write right after a restart fails with "Resource temporarily unavailable" **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's flaky-link profile (`one_client_on_a_flaky_link`, @@ -841,7 +1149,7 @@ so the race itself has no regression test. --- -## B158 — A file renamed twice in a row ends up under the first name, again +### B158 — A file renamed twice in a row ends up under the first name, again **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's Wi-Fi profile (`one_client_on_a_slow_link`, seed 3, @@ -866,7 +1174,7 @@ Seeds 2 and 3 of `one_client_on_a_slow_link` pass eight replays each. --- -## B157 — A second write to a file whose upload was not read back fails with `EIO` +### B157 — A second write to a file whose upload was not read back fails with `EIO` **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's Wi-Fi profile (`one_client_on_a_slow_link`, seed 4, @@ -896,7 +1204,7 @@ fake Drive's new `lose_read_back_of_next_revision`. Without the fix the second w --- -## B156 — A file written again after its create lost its answer lands as a conflict copy +### B156 — A file written again after its create lost its answer lands as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's Wi-Fi profile (`one_client_on_a_slow_link`, seed 4, @@ -915,7 +1223,9 @@ any of them is ours. One holding older bytes than the op's is adopted, and the o as a revision of it. Kept on the op's row since B183, so a restart no longer forgets them. Sim test `a_file_written_again_after_its_create_lost_its_answer_lands_once`; the fake Drive's held and lost create answers now combine. -## B155 — A write whose upload lost its answer lands as a conflict copy of itself +--- + +### B155 — A write whose upload lost its answer lands as a conflict copy of itself **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's Wi-Fi profile (`one_client_on_a_slow_link`, seed 2, @@ -935,7 +1245,9 @@ conflict: it is sent again as a revision of the same bytes. Sim test `a_write_whose_answer_was_lost_lands_once`, with the fake Drive's new `lose_reply_to_next_revision`. -## B154 — A file made under the name of a create on the wire lands as a conflict copy +--- + +### B154 — A file made under the name of a create on the wire lands as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-10-04, by CI on 7269cb5 (`one_client_on_a_good_link`, seed 9, every run). `c.bin` @@ -955,7 +1267,9 @@ lands, or hands it to the rename queued behind it, which wakes the waiters when test `a_file_made_under_the_name_of_a_create_on_the_wire_waits_for_it`, with the fake Drive's new `hold_reply_to_next_create`. -## B153 — An op waiting for a name sat out its backoff after the name was free +--- + +### B153 — An op waiting for a name sat out its backoff after the name was free **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's LAN profile (`one_client_on_a_good_link`, seed 4, @@ -975,7 +1289,9 @@ more after it. and the op wakes the waiters for its name again once its failure is recorded. Unit test `a_held_name_is_a_wait_and_other_failures_are_not`; the seed is the regression check. -## B152 — A rename made as its create landed was undone in the tree +--- + +### B152 — A rename made as its create landed was undone in the tree **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's LAN profile (`one_client_on_a_good_link`, seed 4, @@ -995,7 +1311,9 @@ renames the entry under that lock, so a renamed entry always has its op. The nod through the lock's outbox, in order with the rename's own write. No test: the window is inside one call and no fake hook reaches it; the seed is the regression check. -## B151 — A file deleted after its create lost its answer stays on Drive +--- + +### B151 — A file deleted after its create lost its answer stays on Drive **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation's Wi-Fi profile in CI (`one_client_on_a_slow_link`, @@ -1018,7 +1336,9 @@ blob's bytes. It trashes that node, and then discards the blob. A new create of that folder waits for the trash. Sim test `a_file_deleted_after_its_create_lost_its_answer_leaves_drive`. -## B150 — A conflict copy whose folder is gone retries forever +--- + +### B150 — A conflict copy whose folder is gone retries forever **Status:** Fixed (unverified). **Found:** 2026-10-04, by the journal check of the account run on a6f36d5: 33 warnings, one @@ -1038,7 +1358,9 @@ location's root otherwise. Sim test `a_queued_write_whose_folder_was_trashed_lan **Left open.** How the B79 case's bytes stayed in `staging/` after the file was deleted, so that the next start queued them again ("reconciled orphaned staged writes queued=1"), is not known yet. -## B149 — `pdfs rename` or `pdfs move` right after a rename through the mount is undone +--- + +### B149 — `pdfs rename` or `pdfs move` right after a rename through the mount is undone **Status:** Fixed (unverified). **Found:** 2026-10-04, by the account run on a6f36d5: the B121 case failed on every on-demand @@ -1058,7 +1380,9 @@ as for a rename through the mount, and a taken name is refused up front. A node still queued has that create's target rewritten instead. A move between two locations still goes straight to Drive. -## B148 — A folder pinned before it has reached Drive loses its pin +--- + +### B148 — A folder pinned before it has reached Drive loses its pin **Status:** Fixed (unverified). **Found:** 2026-10-04, while planning the move of queued ops to local ids: every table keyed by @@ -1075,7 +1399,9 @@ longer kept offline and the pin list showed an entry for nothing. **Fix.** The landing moves the pin to the real uid in the same transaction. A pin the real uid already has wins, and the placeholder's is dropped. -## B147 — A new file made under a name just renamed away can share the first file's node +--- + +### B147 — A new file made under a name just renamed away can share the first file's node **Status:** Fixed (unverified). **Found:** 2026-10-04, by `sim::run::tests::three_clients_one_writer_each`, seed 2, in about one @@ -1094,7 +1420,9 @@ it waits for that one to land, as for any name a queued change holds. Nor does i already in the tree, which was answered for. An unanswered twin the tree has already listed lands as a conflict copy, which loses nothing. -## B146 — A file renamed just as its upload lands can go back to its old name +--- + +### B146 — A file renamed just as its upload lands can go back to its old name **Status:** Fixed (unverified). **Found:** 2026-10-04, by the CI simulation run (`sim::run`, profile one-client-echoes, seed 4). @@ -1113,7 +1441,9 @@ by the time it holds the lock, a rename came in between, and the tree keeps it. **Test:** none. The window is between two statements and is not hit on purpose. -## B145 — Renaming a just-made download to its final name can fail with "Device or resource busy" +--- + +### B145 — Renaming a just-made download to its final name can fail with "Device or resource busy" **Status:** Fixed (unverified). **Found:** 2026-10-04, by the account acceptance run (B70 check). The browser-style rename @@ -1134,7 +1464,9 @@ Before that it is kept, like any other park. **Test:** the unit test `a_parked_create_is_dropped_only_once_its_file_is_gone_and_closed`. The window is a few milliseconds wide and is not hit on purpose. -## B144 — A rename can land under a conflict name although its target was freed +--- + +### B144 — A rename can land under a conflict name although its target was freed **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation runs (`sim::run`, profile one-client-echoes, seed 4). @@ -1155,7 +1487,9 @@ before falling back to a conflict name. **Test:** seed 4 of one-client-echoes passes repeatedly. The window is too narrow to hit on purpose. -## B143 — A write to a file whose create landed as the link went down lands as a conflict copy +--- + +### B143 — A write to a file whose create landed as the link went down lands as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation runs (`sim::run`, profile three-clients, seed 1). @@ -1184,7 +1518,9 @@ later, as for any other failed request. fake Drive takes the link down as soon as it has answered the create. Without the fix, the test fails on the conflict copy. -## B142 — Renaming a new file as its upload lands can fail with "Device or resource busy" +--- + +### B142 — Renaming a new file as its upload lands can fail with "Device or resource busy" **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation runs (`sim::run`, profile three-clients, seed 3). @@ -1202,7 +1538,9 @@ any other file. **Test:** seed 3 of three-clients passes repeatedly. The window is too narrow to hit on purpose. -## B141 — A file renamed twice in a row can end up under the first name +--- + +### B141 — A file renamed twice in a row can end up under the first name **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation runs (`sim::run`, profile one-client-echoes, seed 2). @@ -1223,7 +1561,9 @@ fails, it is dropped instead of being retried after the newer one. **Test:** the unit test `a_rename_on_the_wire_lands_before_the_one_that_replaces_it`. Seed 2 of one-client-echoes passes repeatedly. -## B140 — Files in a folder that lands as the daemon stops are gone after a restart +--- + +### B140 — Files in a folder that lands as the daemon stops are gone after a restart **Status:** Fixed (unverified). **Found:** 2026-10-04, by the simulation runs (`sim::run`, profile one-client-flaky, seed 4), @@ -1245,7 +1585,9 @@ folder. **Test:** the unit test `a_landed_folder_hands_its_children_over_in_the_stored_node_too`. Seed 4 of one-client-flaky passes ten replays in a row. -## B139 — A file closed while its first upload lands keeps its last write in staging +--- + +### B139 — A file closed while its first upload lands keeps its last write in staging **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 1), at the @@ -1269,7 +1611,9 @@ which survives the landing, and queues the write against the real uid when the n **Test:** the unit tests `a_landed_placeholder_keeps_its_lid` and `an_inode_gives_back_the_lid_it_was_made_from`. Seed 1 of three-clients passes. -## B138 — A file created where another is leaving lands as a conflict copy +--- + +### B138 — A file created where another is leaving lands as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 9). @@ -1294,7 +1638,9 @@ on, whether the rename came before the failure was recorded or after. **Test:** the unit test `a_queued_rename_away_from_a_name_holds_it_until_it_lands`. Seed 9 of one-client-lan passes. -## B137 — A queued change can be deleted by an older one landing +--- + +### B137 — A queued change can be deleted by an older one landing **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 9), at the @@ -1314,7 +1660,9 @@ again. **Test:** the unit test `a_dropped_op_id_is_never_given_out_again`. -## B136 — A file renamed while its first upload is on the wire goes back to its old name +--- + +### B136 — A file renamed while its first upload is on the wire goes back to its old name **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 3), at @@ -1343,7 +1691,9 @@ that rename is queued. When a node lands, its children's parent is changed to it **Test:** the unit test `a_rename_made_while_its_create_uploads_is_queued_for_the_landed_node`. Seed 3 of one-client-flaky passes. -## B135 — A file just moved is sometimes not found by its new path +--- + +### B135 — A file just moved is sometimes not found by its new path **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 1), once @@ -1367,7 +1717,9 @@ reply. **Test:** the unit test `an_online_rename_writes_the_move_through_before_it_answers`. With the fix, seed 1 of three-clients passes repeatedly with `PDFS_SIM_KNOWN=fail`. -## B134 — A file deleted online while the drain checks its write is kept as a conflict copy +--- + +### B134 — A file deleted online while the drain checks its write is kept as a conflict copy **Status:** Open, with `local_first` off only. By default an `unlink` drops the write and queues the trash in one step, and the drain sends the trash. @@ -1389,7 +1741,9 @@ staged blob, it lands next to where the file was. **Test:** none. The simulation runs mount with `local_first` on, so they no longer reach it. -## B133 — A file deleted while its upload is in flight can still come back as a recovered copy +--- + +### B133 — A file deleted while its upload is in flight can still come back as a recovered copy **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-deletes, seed 3). @@ -1417,7 +1771,9 @@ after a delete never takes the dropped op's id. fail on any conflict copy the drain keeps of it. They counted it instead until the run on 812eb3d passed every profile on twelve seeds without the count. -## B132 — A folder whose mkdir is still queued is gone after a restart +--- + +### B132 — A folder whose mkdir is still queued is gone after a restart **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 4). @@ -1446,7 +1802,9 @@ second cause, B140. **Test:** the unit test `queued_children_are_what_drive_does_not_list_yet`. Seed 4 of one-client-flaky passes ten replays in a row. -## B131 — A write to a file whose create could not be read back lands as a conflict copy +--- + +### B131 — A write to a file whose create could not be read back lands as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 1). @@ -1483,7 +1841,9 @@ them again, keyed as Drive has the node, before it rebases anything onto it. the conflict copy when the create landed in a later second than it was made, which depends on timing, and then on the refused write. -## B130 — A read of a file that a remote change invalidates can hang the daemon +--- + +### B130 — A read of a file that a remote change invalidates can hang the daemon **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 1). @@ -1517,7 +1877,9 @@ stacks (a `tokio-rt-worker` in state `D` inside `NotifyBatch::flush`); with `PDFS_SIM_KNOWN=fail`, seed 1 of one-client-flaky fails on it, though it no longer reaches it since phase 3. -## B129 — A file deleted or replaced while its create is on the wire stays on Drive +--- + +### B129 — A file deleted or replaced while its create is on the wire stays on Drive **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 4). @@ -1547,7 +1909,9 @@ whose create never landed is done without asking Drive. **Test:** the simulation runs fail on it. They counted it instead until the run on 812eb3d passed every profile on twelve seeds without the count. -## B128 — A file created offline over one deleted offline can be lost +--- + +### B128 — A file created offline over one deleted offline can be lost **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 3). @@ -1574,7 +1938,9 @@ queued trash puts it in, and the create waits for the trash to free the name (B1 **Test:** the simulation runs fail on it. They counted it instead until the run on 812eb3d passed every profile on twelve seeds without the count. -## B127 — A file created offline lands twice when the answer to its upload is lost +--- + +### B127 — A file created offline lands twice when the answer to its upload is lost **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 1). @@ -1597,7 +1963,9 @@ replaced the blob after the lost upload still forks a copy: the node holds the o **Test:** the simulation runs fail on it. They counted it instead until the run on 812eb3d passed every profile on twelve seeds without the count. -## B126 — A file created offline under a name an offline rename freed lands as a conflict copy +--- + +### B126 — A file created offline under a name an offline rename freed lands as a conflict copy **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 3). @@ -1621,7 +1989,9 @@ name (`Core::name_is_held`). **Test:** the simulation runs fail on it. They counted it instead until the run on 812eb3d passed every profile on twelve seeds without the count. -## B125 — A folder made offline fails with `EIO` once its listing is invalidated +--- + +### B125 — A folder made offline fails with `EIO` once its listing is invalidated **Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seeds 3 @@ -1653,7 +2023,9 @@ show neither again. refresh drops its listing, and it is listed again. With `PDFS_SIM_KNOWN=fail` the flaky profile fails on it in the simulation runs; seeds 3 and 4 no longer reach it since phase 3. -## B124 — After a replacing rename fails, the file it was replacing is gone from the mount +--- + +### B124 — After a replacing rename fails, the file it was replacing is gone from the mount **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 1). A @@ -1675,7 +2047,9 @@ next lookup lists the folder again and finds the node. **Test:** the unit test `a_replaced_node_put_back_shows_in_the_mount_again`. Since B122 is fixed, the simulation runs no longer reach it through a rename across folders. -## B123 — A second write open on a file just written fails with `EIO` +--- + +### B123 — A second write open on a file just written fails with `EIO` **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seeds 5 and @@ -1725,7 +2099,9 @@ rebases open handles, and lets go of the staged blob only after. `the_echo_of_our_own_rename_keeps_the_cached_bytes` in `sim::daemon`. The simulation runs no longer tolerate it. -## B122 — A rename across folders fails with `EIO` when the source folder holds the new name +--- + +### B122 — A rename across folders fails with `EIO` when the source folder holds the new name **Status:** Fixed (unverified). **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seeds 1, 7 @@ -1747,7 +2123,9 @@ and the drain uses it in place of its own `move_renaming`. **Test:** the FUSE test `a_rename_across_folders_ignores_the_new_name_in_the_old_folder` in `sim::daemon`. The simulation runs no longer tolerate it. -## B121 — A move right after a rename fails with `EIO`, and a rename across folders takes seconds +--- + +### B121 — A move right after a rename fails with `EIO`, and a rename across folders takes seconds **Status:** Fixed (unverified). **Found:** 2026-10-03, in the journal of the account run on flaky Wi-Fi. Each change answered @@ -1779,7 +2157,9 @@ request Drive called out of date. The drain's `move_renaming` drops it once its `every_remote_rename_and_move_rides_out_a_stale_hash`. On the account run, "regression B111" and "regression B113" should show no `status=422` warnings. -## B120 — A file queued in a folder that has just landed is put off by five seconds +--- + +### B120 — A file queued in a folder that has just landed is put off by five seconds **Status:** Fixed (unverified). **Found:** 2026-10-02, in the journal of the account run on flaky Wi-Fi. A folder made while the @@ -1808,7 +2188,9 @@ because a mkdir online is made at once even while sync is paused. Instead, `--jo now counts `re-interned an authority missing from the local tree` as an error, so a run that goes offline catches this race, and B117, if either comes back. -## B119 — Reading a file just written through the mount needs the network +--- + +### B119 — Reading a file just written through the mount needs the network **Status:** Fixed (unverified since the second fix below; "regression B119" passed on one account run of 2026-10-03 and failed on the next). @@ -1852,7 +2234,9 @@ and `revision_conflict` would keep it as a conflict copy. in `lib.rs`, and the acceptance case "regression B119", which writes two files, waits for the queue and expects `pdfs ls` to list both as cached. -## B118 — Creating a file or folder through the mount can fail with `ENOENT` although it was made +--- + +### B118 — Creating a file or folder through the mount can fail with `ENOENT` although it was made **Status:** Fixed (unverified). **Found:** 2026-10-02, by the account run on flaky Wi-Fi. In the first on-demand folder, case 14 @@ -1876,7 +2260,9 @@ exist. **Test:** none that replays it on demand, since the gap depends on Drive. The affected acceptance cases ("application workloads" and "block boundaries") fail when it comes back. -## B117 — An upload queued for a file `pdfs move` moved is listed by its raw id and put off once +--- + +### B117 — An upload queued for a file `pdfs move` moved is listed by its raw id and put off once **Status:** Fixed (verified 2026-10-03: "regression B117" passed in My files on the account run, and its journal had no deferral). @@ -1908,7 +2294,9 @@ the acceptance case "regression B117". It pauses sync, rewrites an uploaded file `pdfs move`. It checks the path `pdfs sync queue` shows for the upload, and looks in the journal for a deferral of the node after the resume. -## B116 — Trashing a folder keeps the queued creates in its subfolders +--- + +### B116 — Trashing a folder keeps the queued creates in its subfolders **Status:** Fixed (verified 2026-10-03: "regression B116" passed in My files on the account run). **Found:** 2026-10-02, by the first live run of the acceptance case "regression B113". That @@ -1942,7 +2330,9 @@ queued and due. It then trashes the folder with `pdfs rm` and expects the create the folder before sync resumes, so a create that wrongly stayed lands where the file was written, and fails if the file shows up in the root anyway. -## B115 — `pdfs ls` shows a file's encrypted size +--- + +### B115 — `pdfs ls` shows a file's encrypted size **Status:** Fixed (verified 2026-10-03: "regression B115" passed in My files on the account run). **Found:** 2026-10-02, while verifying B88 live. `pdfs ls ~/Downloads/pdfs-b88-test` listed the @@ -1970,7 +2360,9 @@ does not wait, since the fetch cannot succeed. "regression B115". It compares the sizes `pdfs ls` reports with the written ones, after the upload and again after `pdfs refresh`. -## B114 — A name `pdfs rm` or `pdfs rename` took away still resolves for up to 30 seconds +--- + +### B114 — A name `pdfs rm` or `pdfs rename` took away still resolves for up to 30 seconds **Status:** Fixed (verified 2026-10-03: "regression B114" passed in My files on the account run). **Found:** 2026-10-02, while verifying B88 live. After `pdfs rm ~/Downloads/pdfs-b88-test` the @@ -1999,7 +2391,9 @@ released. with `pdfs rename`, then checks at once that neither old name resolves. It reproduced in My files and in an on-demand folder before the fix. -## B113 — A new file moved before its upload drains lands as a conflict copy of itself +--- + +### B113 — A new file moved before its upload drains lands as a conflict copy of itself **Status:** Fixed (verified 2026-10-02). **Found:** 2026-10-02, on the production account. aria2 downloaded two seasons into the on-demand @@ -2036,7 +2430,9 @@ create lands. Only the unit test covers that half. The acceptance case "regressi the online order through the mount, with a rename and a move, and passed in My files and in an on-demand folder. -## B112 — Folders shared by the same person disappear from Shared with me +--- + +### B112 — Folders shared by the same person disappear from Shared with me **Status:** Fixed (unverified). **Found:** 2026-09-30, reported in #25. An account with several accepted shares from one other @@ -2057,7 +2453,9 @@ decrypted with the first root's share key (proton-sdk-rs#2). No app code changes. The app-side workaround in #25, one SDK call per root, is not needed. Unverified: no account with several shares from one owner has listed through 0.7.2 yet. -## B111 — Renaming a file back right after renaming it fails with EIO +--- + +### B111 — Renaming a file back right after renaming it fails with EIO **Status:** Fixed (unverified). **Found:** 2026-09-29, by the acceptance case "unusual but legal names", on My files and on the @@ -2081,7 +2479,9 @@ rename already uses. Any other error still returns at once. **Test:** the acceptance case "regression B111" renames a file there and back eight times through the mount, and three times with `pdfs rename`, and checks its name and content after each round. -## B110 — A read that settles a provisional size deadlocks on its own page lock +--- + +### B110 — A read that settles a provisional size deadlocks on its own page lock **Status:** Fixed (unverified). **Found:** 2026-09-29, by the acceptance case "application workloads", which timed out on My @@ -2103,7 +2503,9 @@ issued the read hangs, and so does anything else that touches those pages. change content, so the page cache has nothing stale to drop. A daemon already stuck this way needs a restart. -## B109 — A re-listing shrinks a file that is open for writing, and SQLite reads it as malformed +--- + +### B109 — A re-listing shrinks a file that is open for writing, and SQLite reads it as malformed **Status:** Fixed (unverified). **Found:** 2026-09-29, by the acceptance case "application workloads" on My files. It failed @@ -2126,7 +2528,9 @@ back as empty pages. Queued writes were already protected (B11); open handles we a listing, a size upgrade or a create landing replaces its entry. After the last close, the queued write takes over as before. -## B108 — A file deleted during a mirror pass puts the sync folder in `error` +--- + +### B108 — A file deleted during a mirror pass puts the sync folder in `error` **Status:** Fixed (unverified). **Found:** 2026-09-29, by the account run of `scripts/fuse-acceptance.sh --quick`. It stopped with @@ -2145,7 +2549,9 @@ ended in `error` and logged "item(s) failed; will retry" for files nobody wanted does not count as an error. The next pass sees the deletion and handles it like any other. Other open failures are still errors. -## B107 — One read on a stalled connection hangs the reading program for half an hour +--- + +### B107 — One read on a stalled connection hangs the reading program for half an hour **Status:** Fixed (unverified). **Found:** 2026-09-29, by the acceptance case "application workloads" on My files, which hit its @@ -2174,7 +2580,7 @@ inside `block_on`, and a unit test runs that pattern from a thread without a run --- -## B106 — Saving over a file that was just read fails with "already exists" +### B106 — Saving over a file that was just read fails with "already exists" **Status:** Fixed (unverified). **Found:** 2026-09-29, by the acceptance case "application workloads" on an on-demand folder, @@ -2200,7 +2606,7 @@ withdrawn checks that the op still exists before it sends any bytes. --- -## B105 — A file deleted while its upload is in flight comes back as a conflict copy +### B105 — A file deleted while its upload is in flight comes back as a conflict copy **Status:** Fixed (unverified). **Found:** 2026-09-29, in the daemon journal of an acceptance run on the account. During the @@ -2225,7 +2631,7 @@ it is dropped. The check goes by op id, which the next op queued can take over ( --- -## B104 — Reading a file that was replaced by a rename fails with `EIO` +### B104 — Reading a file that was replaced by a rename fails with `EIO` **Status:** Fixed (unverified). **Found:** 2026-09-29, by the acceptance case "rename patterns" on My files and on an on-demand @@ -2249,7 +2655,7 @@ waited for the final close; B106 moved them back to the trash. --- -## B103 — One stuck trash refresh freezes the trash listing until the daemon restarts +### 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 @@ -2271,7 +2677,7 @@ flag, and the next request starts a fresh one. --- -## B102 — Removing a folder leaves the queued uploads of the files inside it +### 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 @@ -2305,7 +2711,7 @@ Neither revision may stay queued. --- -## 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 +### 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 @@ -2351,7 +2757,7 @@ overwrites that change the block count" (now also read back after the queue drai --- -## B100 — A freshly copied file reads as `EIO` or 53 bytes too big +### 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 size yet. @@ -2391,7 +2797,7 @@ mount and have no unit test; the acceptance case `regression B100` covers them o --- -## B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved" +### B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved" **Status:** Fixed (unverified). No daemon carrying the change has been restarted against the affected copies yet. @@ -2427,7 +2833,7 @@ Regression test: `activity_has_finds_only_matching_kind_and_target`. --- -## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes +### B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes **Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a superseded upload yet. @@ -2455,7 +2861,7 @@ upload finishes before it is seen. --- -## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings +### B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings **Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real library yet. @@ -2495,7 +2901,7 @@ out. --- -## B96 — Staged bytes that name no node are never retired, and are re-announced on every boot +### B96 — Staged bytes that name no node are never retired, and are re-announced on every boot **Status:** Fixed (unverified). **Found:** 2026-09-19, in the journal: the same "staged bytes belong to no queued upload" warning @@ -2516,7 +2922,7 @@ reported as one aggregated warning with one example path, not one line each. --- -## B95 — A real `index.tmp` is treated as a browser tempfile and parked forever +### B95 — A real `index.tmp` is treated as a browser tempfile and parked forever **Status:** Fixed (unverified). **Found:** 2026-09-19, while working out why 43 uploads were parked. Most of them were @@ -2541,7 +2947,7 @@ the queue, which fails at once on a parked op. --- -## B94 — A parked create has no exit but the rename, so an abandoned one is stuck forever +### B94 — A parked create has no exit but the rename, so an abandoned one is stuck forever **Status:** Fixed (unverified). **Found:** 2026-09-19, while reading the journal: 43 rows in `pending_op` carried the @@ -2571,7 +2977,7 @@ driven. --- -## B93 — A photo whose node would not resolve lost its name, and with it its tab and its group +### B93 — A photo whose node would not resolve lost its name, and with it its tab and its group **Status:** Fixed (unverified) — found and fixed while making the timeline refresh incremental (B92); no library with an undecryptable photo in it has been driven against the fix. @@ -2597,7 +3003,7 @@ another device finally loses its `main_uid` — a carry could never express "the --- -## B92 — Refreshing the gallery reported "Resource temporarily unavailable" +### B92 — Refreshing the gallery reported "Resource temporarily unavailable" **Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real library yet. @@ -2649,7 +3055,7 @@ size, so it is nowhere near as expensive, but the technique that fixed this woul --- -## B91 — The daemon hung overnight, and nothing could say what it was waiting on +### B91 — The daemon hung overnight, and nothing could say what it was waiting on **Status:** Open — the hang itself is undiagnosed. What is fixed (unverified) is the *undiagnosability*: the daemon now reports what it is doing, warns when it stops making progress, @@ -2719,7 +3125,7 @@ one-line answer from `pdfs diagnostics` or from the stall WARN. --- -## B90 — The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable +### B90 — The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable **Status:** Fixed (unverified) — the walker no longer stats a nested mountpoint. The stuck-daemon half cannot be fixed in userspace at all; see "Why there is no second line of defence". @@ -2792,9 +3198,9 @@ be reaped and frees the `cache.db` lock. A reboot also clears it. --- -## B89 — The recorded SDK dependency is three minor versions out of date in the working memory +### B89 — The recorded SDK dependency is three minor versions out of date in the working memory -**Status:** Open — documentation only, but it misdirects every reader of the SDK code. +**Status:** Closed (2026-10-06). `proton-dev/CLAUDE.md` no longer exists, so there is nothing left to correct. It was open as documentation only. **Found:** 2026-08-17, while checking B85's evidence against the reader implementation. **Where:** `proton-dev/CLAUDE.md` (the container-level file, not in this repo). @@ -2816,7 +3222,7 @@ in every version — but that was luck, and the next question of this shape may --- -## B88 — `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount +### B88 — `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount **Status:** Partly fixed. B86's fix covers `ls` and `refresh`. `move` takes paths in any location through `Core::move_between`. `rm`, `rename` and `mkdir` were fixed and verified live 2026-10-02. @@ -2872,7 +3278,7 @@ none. The acceptance case "regression B88" repeats this on every location of a r --- -## B87 — A revision's block geometry is learned a read too late +### B87 — A revision's block geometry is learned a read too late **Status:** Open — a bounded, self-correcting cost, accepted deliberately in 1.8.2. **Found:** 2026-08-17, while fixing B85. This is the residue that fix leaves. @@ -2902,7 +3308,7 @@ anything to remove. --- -## B86 — An on-demand mount serves a listing that predates the mirror engine's own uploads +### B86 — An on-demand mount serves a listing that predates the mirror engine's own uploads **Status:** Fixed in code (2026-08-17); live re-run of the repro pending. **Found:** 2026-08-16, while trying to drive B42's remote-delete path from one machine. @@ -2938,7 +3344,7 @@ creates a remote node, the same way the FUSE write path does; and let `refresh`/ path under any mount the daemon owns, not just the primary. Test: the repro above, asserting the new files appear immediately after the mode switch. -### Fix (2026-08-17) +#### Fix (2026-08-17) Two halves, matching the two halves of the report. @@ -2975,7 +3381,116 @@ on-demand, which is the stale case. `--quick` does not get there. --- -## B84 — A block read that comes back short truncates the file, silently +### B85 — The read path assumes 4 MiB blocks; some revisions do not have them + +**Status:** Fixed in code (2026-08-17) — the geometry now comes from the revision. See +"Correction to the diagnosis" below: the premise that this was *losing* data does not survive +reading the SDK, and the fix is a cost fix, not a correctness one. +**Found:** 2026-08-16, while live-verifying B84 (see its journal evidence) +**Where:** `crates/pdfs-fuse/src/reads.rs`, `crates/pdfs-core/src/cache.rs` (`BLOCK_SIZE`, +`block_len`) + +**Cause:** The whole range path is built on one assumption — that a revision is a series of +`BLOCK_SIZE` (4 MiB) blocks with a remainder at the end. `block_len(size, idx)` encodes it, the +on-disk block cache is keyed by that index, and `read_block` asks the SDK's `RevisionReader` for +`[idx * BLOCK_SIZE, block_len)`. + +Some revisions on this account are not laid out that way, and B84's diagnostic caught it in the +act. The warning it logs reads "shorter", but the numbers say the opposite: + +``` +bidx=0 blen=4194304 got=4939506 fsize=4939506 # one block holding the whole file +bidx=1 blen=745202 got=0 fsize=4939506 # …so there is no block 1 +bidx=0 blen=4194304 got=5239804 fsize=11537412 # first block is ~5.0 MiB, not 4 +bidx=1 blen=4194304 got=5670228 fsize=11106988 # …and they are not even uniform +bidx=2 blen=4194304 got=5233034 fsize=19932790 +``` + +`reader.size()` agrees with `fsize` in every one of these, so B84's primary gate — the one that +catches a block table that does not add up — correctly does *not* fire. The file is intact and +the reader knows its real boundaries; it is this client that is asking for the wrong ones. + +**Why it is not currently losing data:** B84's length check catches every one of these and routes +the file through the whole-file manifest-verified download, so reads are correct. The cost is +real, though: any file with non-4-MiB blocks is downloaded whole on first read, however few bytes +were asked for, and one above `REPAIR_MAX` (512 MiB) fails `EIO` and can only be read by pinning +it. + +**Fix direction:** take the geometry from the revision instead of assuming it. +`RevisionReader::block_sizes()` already exposes it and B84 already logs it. That means block +offsets become a prefix sum over the real sizes rather than `idx * BLOCK_SIZE`, and — the part +that makes this its own change rather than a patch — the on-disk block cache is keyed and +length-validated on the 4 MiB assumption too (`block_len`), so its key scheme has to follow the +file's own geometry or the cache has to hold ranges rather than blocks. + +**Open question:** what produced these revisions. Uniform-but-not-4-MiB would suggest another +client's chunk size; these are *not* uniform (5239804, 5670228, 5233034), which looks more like a +size recorded per block after some transformation. Worth answering before choosing the fix, since +"blocks are whatever the revision says" and "blocks are a different constant" lead to different +designs. + +#### Correction to the diagnosis (2026-08-17) + +**The logged numbers cannot have come from `read_at`, and the "this client asks for the wrong +range" reading is wrong.** `RevisionReader::read_at(offset, length)` does not assume any geometry: +it computes `end = min(offset + length, file_size)`, plans over the *real* block sizes +(`plan_blocks`), and splices each block clamped to `[offset, end)` (`splice_block`). Both are +present and identical in the version the client actually builds against — `Cargo.lock` pins +`proton-drive-rs` **0.6.0**, not the 0.1.11 the top-level `CLAUDE.md` still describes — and in +0.1.11 as well. So a `read_at(bstart, blen)` can return at most `blen` bytes, and every quoted +line has `got > blen`: + +``` +bidx=0 blen=4194304 got=4939506 # got exceeds the length requested +bidx=1 blen=4194304 got=5670228 # …as does this one +``` + +The only site in the client that logs this shape (`reads.rs`, the `blen`/`got`/`fsize` warning) +reaches it solely on the `bytes.len() != blen` branch of a `read_at(bstart, blen)`. Those two +facts cannot both hold. Either the numbers were transcribed with `blen` and `got` swapped, or they +came from a build that is not in the history. **This wants re-capturing from the journal before +anything is concluded from it** — and in particular, the entry's claim that B84's repair path is +"currently masking data loss" here is not supported. + +What *is* true, and is what got fixed: the client assumed the geometry. `idx * BLOCK_SIZE` and +`block_len(size, idx)` encoded "4 MiB blocks with a remainder" in the range planner, the in-memory +ring, the in-flight fetch key and the on-disk block cache. When a revision is not shaped that way, +nothing breaks — but every client block straddles two server blocks, so the reader fetches and +decrypts two to answer for one, and the block written to disk lines up with nothing the next read +asks for. That is a real and unbounded cost on exactly the files the entry found, and it defeats +the property the block cache was built around. + +#### Fix (2026-08-17) + +`BlockGeometry` (`pdfs-core/src/cache.rs`) is a prefix sum over a revision's block sizes, with +`BlockGeometry::uniform(size)` reproducing the old constant-block arithmetic exactly. Blocks are +now addressed by `BlockSpan { idx, start, len }` rather than by index, everywhere: the range +planner (`spans(offset, end)`), the in-memory ring, the in-flight-fetch key, and the on-disk block +cache. The span is the identity because the index alone stopped naming one byte range — block 1 +under the fallback and block 1 under the real geometry are different bytes — so the block +sidecar records the block's plaintext `start` and a re-planned read misses rather than being +served the wrong offset. Sidecars written before that field existed read as "wherever the 4 MiB +assumption put it", which keeps the existing cache intact for every file this client uploaded. + +The geometry itself is learned as a side effect of the first read (which has to open a +`RevisionReader` anyway) and written to a `.geom` sidecar under the block cache; +`Core::block_geometry` reads it back and falls back to `uniform` when it is absent. Resolving a +reader just to *plan* would reintroduce the per-read key derivation the block cache exists to +avoid (B12), so the first read of a file still plans on the fallback and self-corrects. A recorded +table whose sizes do not sum to the node's size is refused outright, and eviction takes the +geometry with the blocks it describes. + +The open question above is still open, and no longer blocks anything: "blocks are whatever the +revision says" is now what the code does, so a different constant is just another table. + +Tests: `the_uniform_geometry_is_the_old_block_arithmetic`, +`a_non_uniform_geometry_places_blocks_by_prefix_sum` (built on the exact sizes from the log +above), `a_cached_block_is_not_served_to_a_different_geometry`, +`a_recorded_geometry_is_returned_only_for_the_revision_it_describes`. + +--- + +### B84 — A block read that comes back short truncates the file, silently **Status:** Fixed and **live-verified 2026-08-16**. **Data correctness** — an application reading a large file through the mount could get a truncated copy that reported success. The truncation is now @@ -3089,116 +3604,9 @@ This client's fixed 4 MiB block geometry is the thing that is wrong. Filed as ** path above is what keeps those files readable in the meantime, at the cost of a whole-file download on first read. -## B85 — The read path assumes 4 MiB blocks; some revisions do not have them - -**Status:** Fixed in code (2026-08-17) — the geometry now comes from the revision. See -"Correction to the diagnosis" below: the premise that this was *losing* data does not survive -reading the SDK, and the fix is a cost fix, not a correctness one. -**Found:** 2026-08-16, while live-verifying B84 (see its journal evidence) -**Where:** `crates/pdfs-fuse/src/reads.rs`, `crates/pdfs-core/src/cache.rs` (`BLOCK_SIZE`, -`block_len`) - -**Cause:** The whole range path is built on one assumption — that a revision is a series of -`BLOCK_SIZE` (4 MiB) blocks with a remainder at the end. `block_len(size, idx)` encodes it, the -on-disk block cache is keyed by that index, and `read_block` asks the SDK's `RevisionReader` for -`[idx * BLOCK_SIZE, block_len)`. - -Some revisions on this account are not laid out that way, and B84's diagnostic caught it in the -act. The warning it logs reads "shorter", but the numbers say the opposite: - -``` -bidx=0 blen=4194304 got=4939506 fsize=4939506 # one block holding the whole file -bidx=1 blen=745202 got=0 fsize=4939506 # …so there is no block 1 -bidx=0 blen=4194304 got=5239804 fsize=11537412 # first block is ~5.0 MiB, not 4 -bidx=1 blen=4194304 got=5670228 fsize=11106988 # …and they are not even uniform -bidx=2 blen=4194304 got=5233034 fsize=19932790 -``` - -`reader.size()` agrees with `fsize` in every one of these, so B84's primary gate — the one that -catches a block table that does not add up — correctly does *not* fire. The file is intact and -the reader knows its real boundaries; it is this client that is asking for the wrong ones. - -**Why it is not currently losing data:** B84's length check catches every one of these and routes -the file through the whole-file manifest-verified download, so reads are correct. The cost is -real, though: any file with non-4-MiB blocks is downloaded whole on first read, however few bytes -were asked for, and one above `REPAIR_MAX` (512 MiB) fails `EIO` and can only be read by pinning -it. - -**Fix direction:** take the geometry from the revision instead of assuming it. -`RevisionReader::block_sizes()` already exposes it and B84 already logs it. That means block -offsets become a prefix sum over the real sizes rather than `idx * BLOCK_SIZE`, and — the part -that makes this its own change rather than a patch — the on-disk block cache is keyed and -length-validated on the 4 MiB assumption too (`block_len`), so its key scheme has to follow the -file's own geometry or the cache has to hold ranges rather than blocks. - -**Open question:** what produced these revisions. Uniform-but-not-4-MiB would suggest another -client's chunk size; these are *not* uniform (5239804, 5670228, 5233034), which looks more like a -size recorded per block after some transformation. Worth answering before choosing the fix, since -"blocks are whatever the revision says" and "blocks are a different constant" lead to different -designs. - -### Correction to the diagnosis (2026-08-17) - -**The logged numbers cannot have come from `read_at`, and the "this client asks for the wrong -range" reading is wrong.** `RevisionReader::read_at(offset, length)` does not assume any geometry: -it computes `end = min(offset + length, file_size)`, plans over the *real* block sizes -(`plan_blocks`), and splices each block clamped to `[offset, end)` (`splice_block`). Both are -present and identical in the version the client actually builds against — `Cargo.lock` pins -`proton-drive-rs` **0.6.0**, not the 0.1.11 the top-level `CLAUDE.md` still describes — and in -0.1.11 as well. So a `read_at(bstart, blen)` can return at most `blen` bytes, and every quoted -line has `got > blen`: - -``` -bidx=0 blen=4194304 got=4939506 # got exceeds the length requested -bidx=1 blen=4194304 got=5670228 # …as does this one -``` - -The only site in the client that logs this shape (`reads.rs`, the `blen`/`got`/`fsize` warning) -reaches it solely on the `bytes.len() != blen` branch of a `read_at(bstart, blen)`. Those two -facts cannot both hold. Either the numbers were transcribed with `blen` and `got` swapped, or they -came from a build that is not in the history. **This wants re-capturing from the journal before -anything is concluded from it** — and in particular, the entry's claim that B84's repair path is -"currently masking data loss" here is not supported. - -What *is* true, and is what got fixed: the client assumed the geometry. `idx * BLOCK_SIZE` and -`block_len(size, idx)` encoded "4 MiB blocks with a remainder" in the range planner, the in-memory -ring, the in-flight fetch key and the on-disk block cache. When a revision is not shaped that way, -nothing breaks — but every client block straddles two server blocks, so the reader fetches and -decrypts two to answer for one, and the block written to disk lines up with nothing the next read -asks for. That is a real and unbounded cost on exactly the files the entry found, and it defeats -the property the block cache was built around. - -### Fix (2026-08-17) - -`BlockGeometry` (`pdfs-core/src/cache.rs`) is a prefix sum over a revision's block sizes, with -`BlockGeometry::uniform(size)` reproducing the old constant-block arithmetic exactly. Blocks are -now addressed by `BlockSpan { idx, start, len }` rather than by index, everywhere: the range -planner (`spans(offset, end)`), the in-memory ring, the in-flight-fetch key, and the on-disk block -cache. The span is the identity because the index alone stopped naming one byte range — block 1 -under the fallback and block 1 under the real geometry are different bytes — so the block -sidecar records the block's plaintext `start` and a re-planned read misses rather than being -served the wrong offset. Sidecars written before that field existed read as "wherever the 4 MiB -assumption put it", which keeps the existing cache intact for every file this client uploaded. - -The geometry itself is learned as a side effect of the first read (which has to open a -`RevisionReader` anyway) and written to a `.geom` sidecar under the block cache; -`Core::block_geometry` reads it back and falls back to `uniform` when it is absent. Resolving a -reader just to *plan* would reintroduce the per-read key derivation the block cache exists to -avoid (B12), so the first read of a file still plans on the fallback and self-corrects. A recorded -table whose sizes do not sum to the node's size is refused outright, and eviction takes the -geometry with the blocks it describes. - -The open question above is still open, and no longer blocks anything: "blocks are whatever the -revision says" is now what the code does, so a different constant is just another table. - -Tests: `the_uniform_geometry_is_the_old_block_arithmetic`, -`a_non_uniform_geometry_places_blocks_by_prefix_sum` (built on the exact sizes from the log -above), `a_cached_block_is_not_served_to_a_different_geometry`, -`a_recorded_geometry_is_returned_only_for_the_revision_it_describes`. - --- -## B83 — An access-deferred op retries forever and is reported as an ordinary queued upload +### B83 — An access-deferred op retries forever and is reported as an ordinary queued upload **Status:** Fixed (partly verified) 2026-08-16 — both halves are in and unit-tested. The reporting half was confirmed against the live 28-op backlog on @@ -3274,7 +3682,9 @@ reason into both the log line and the recorded `last_error`. cases on a machine that has one — that is what turned this backlog into four apparent FUSE regressions. -## B82 — A `parent_uid` cycle hangs the daemon on any short search +--- + +### B82 — A `parent_uid` cycle hangs the daemon on any short search **Status:** Fixed (unverified) 2026-08-12 — unit-tested, not driven against a real corrupted database (nobody has one). @@ -3307,7 +3717,9 @@ write side. Cycles also stay out of the index (see B80). **Still open:** `pin_is_pinned`'s ancestor walk (`db/pins.rs`) has the same shape and no guard. Not reached from search, so it was left alone. -## B81 — Prompt labels every Drive hit "My files", including device folders +--- + +### B81 — Prompt labels every Drive hit "My files", including device folders **Status:** Fixed (unverified) 2026-08-12 — needs a GUI/dmenu run. **Found:** 2026-08-12, reading the prompt while fixing B80: with B80 fixed, a @@ -3331,7 +3743,9 @@ resolved from the `nodes` row by `pin_list`, serde-defaulted so an older daemon (which omits it) still parses; front-ends fall back to `recursive` only when the node is not cached. -## B80 — 29% of the account is absent from the search index +--- + +### B80 — 29% of the account is absent from the search index **Status:** Fixed (unverified) 2026-08-12 — migration replayed against a read-only copy of the live DB; not yet run by the daemon itself. @@ -3391,7 +3805,9 @@ whole-query prefix lane ahead of the existing single-character one. rewrites it, and waits for `pdfs search` to find it. In an on-demand sync folder, that file sits in the device-folder subtree the index used to lose. -## B79 — Every write in an on-demand device-folder mount fails EACCES +--- + +### B79 — Every write in an on-demand device-folder mount fails EACCES **Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1 package since) @@ -3467,7 +3883,7 @@ Empty the directory and restart the unit. --- -## B78 — Profile backup fails: Cannot create file at the root of a device +### B78 — Profile backup fails: Cannot create file at the root of a device **Status:** Fixed (verified live 2026-07-31). Same defect as **B68**, which was found first on the managed-live matrix; B68's second half — making backup health @@ -3518,2910 +3934,77 @@ replacement-machine / fresh-state restore to be re-run end to end. --- -## B1 — FUSE rename loses the file (data loss) - -**Status:** Fixed (verified 2026-07-20) -**Found:** 2026-07-19, user reported `mv file.mkv dir/` on the mount deleted the file -**Verified:** the exact repro below on the new daemon — `mv` returns 0, the file is -present in the destination listing and reads back its content. Previously it was in -neither directory. -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `Filesystem::rename` - -`rename` ended with: - -```rust -st.forget(&uid); -st.children.remove(&newparent); // memory only -``` - -`forget()` deletes the node's **DB row** (`db.delete_node`). `children.remove()` -only drops the **in-memory** listing, leaving `nodes.listed = 1` on the -destination folder. The next `ls` goes through `ensure_children`, which takes the -DB fast path (`children_if_listed`, lib.rs:722) and rebuilds the destination from -the database — where the node no longer has a row. - -Net effect: file gone from the source, absent from the destination, and -`rename(2)` returned **0**. Silent loss with a success code. - -**Severity is worse than "stale cache":** `listed` is a **DB** column, so the bad -state survives a daemon restart. Nothing clears it on its own — the affected -folder keeps serving the listing that omits the file indefinitely, until an event -invalidation or a manual refresh happens to hit it. Confirmed on the repro -folders, which still read `listed=1` with no child rows minutes later. - -The data itself is believed intact server-side (the move succeeded; only the -local view is wrong), but that was **not** verifiable without a daemon restart — -see the status line. - -Also hits plain in-place renames (`mv a.txt b.txt`), same mechanism — the parent -is both source and destination there. - -The control-socket `Core::move_to` path was always correct; it used -`invalidate_listing`, which clears the DB flag as well. The FUSE path didn't. - -**Fix:** use the same helper. - -```rust -st.forget(&uid); -st.invalidate_listing(newparent); -``` - -**Repro:** - -``` -cd && mkdir d && echo hi > f.txt && mv f.txt d/ -# renameat2(...) = 0, but f.txt is in neither directory, -# not in trash, and absent from the `nodes` table -``` - -**Note on `invalidate_listing`:** it early-returns when the folder isn't in the -in-memory `children` map, so it won't clear a stale DB `listed` flag in that -case. Safe on this path (both `lookup_child` and the explicit -`ensure_children(newparent)` guarantee the listing is resident), but it's a sharp -edge for other callers. See B4. - ---- - -## B2 — The reported .mkv went to trash, the repro vanished entirely - -**Status:** Partly resolved — `mv` exonerated; trash origin still unattributed -**Found:** 2026-07-19, while chasing B1 - -### What was settled (2026-07-19) - -**`mv` never trashes.** Three repros on the mount, all with the old (pre-B1-fix) -daemon: - -| repro | node state | destination | result | -|---|---|---|---| -| `zz-claude-repro-src.txt` | create still queued | fresh folder | vanished, **not** trashed | -| `zz-b2-settled.txt` | settled, real uid, landed | listed folder | vanished, **not** trashed | -| `zz-b3 Movie Title.mkv` | settled | folder named as the file's stem (the user's exact shape) | vanished, **not** trashed | - -So B1 fully explains the *disappearance*, and nothing in the rename path -trashes. The `.mkv` reaching the trash has a different cause. - -**The listing the user acted on was almost certainly stale.** B1/B4 make stale -listings *persistent* — `listed = 1` lives in the DB and survives restarts — so -`ls` showing the file at 15:57 is not evidence it still existed remotely. This -is the most likely reading: it had been trashed earlier and the mount kept -showing it. - -**Ruled out:** - -- *Sync engine.* `reconcile_folder` gates on `mode == "mirror"`; `~/Videos` was - `ondemand` from 15:39:27. All three sync trash sites log, and no such rows exist. -- *Log pruning hiding the evidence.* `ACTIVITY_KEEP = 2000`; the table is at the - cap but its oldest row is Jul 17 18:03, so the whole window is covered. The - `.mkv`'s uid appears exactly once — an `Upload` at 06:07:56 — and never again. -- *Conflict machinery.* `keep_as_conflict_copy` uploads a new file under an - alternate name; it never trashes the original. -- *Eviction through the mount.* Was the best theory: `mirror→ondemand` calls - `evict_dir_contents`, which would issue one `unlink(2)` per file, and FUSE - `unlink` trashes remotely. But `apply_sync_folder_mode` evicts **before** - `spawn_ondemand_mount`, so the deletes hit the plain local directory. Dead. -- *Control-socket delete.* `CtlRequest::Delete` logs on both success and failure. - -**What remains:** `trash_child` (backing FUSE `unlink`/`rmdir`) was the only -trash path that wrote no activity row — so an ordinary `rm` on the mount, at any -point after 15:39:27 when `~/Videos` became a FUSE mount, would produce exactly -what we see and leave no trace. Note there is also a *trashed folder* of the same -name (`Evangelion 1.11 - You Are (Not) Alone`, `is_dir=1`, content mtime -04:57:50), which suggests this mkdir-and-move dance had been attempted earlier — -plausibly followed by a manual cleanup while B1 was making files appear to vanish. - -That is a hypothesis, not a finding. It is not provable from the evidence that -survives. - -### Fix applied - -`trash_child` now logs to the activity feed on both success (`"trashed from the -mount"`) and failure, matching every other trash site. The next occurrence will -be attributable — which is the part that actually mattered here. - -### If it recurs - -The activity log is now the first place to look. `trash` rows carry no -trashed-at timestamp (the `mtime` column is the node's *content* mtime — for the -`.mkv` it read 06:07:56, matching its upload, not its deletion), so the activity -feed is the only ordering evidence there is. - -Two different endings for what looked like the same operation, so B1 may not be -the whole story: - -- User's `Evangelion 1.11 - You Are (Not) Alone.mkv` → **trash**, full size - (1768670449) intact, recoverable via `pdfs restore`. -- Clean repro (`zz-claude-repro-src.txt`) → **gone entirely**. Not in trash, no - `nodes` row, nothing in the search index. - -B1 explains the second. It does not explain a node reaching the trash — nothing -in the FUSE `rename` path calls `trash_nodes`. - -Leading theory: the trash came from the sync-conflict machinery, which *is* -logged (`ActivityKind::Trash` rows exist with `(sync-conflict )` details). -The `~/Videos` folder is registered as a sync folder in `ondemand` mode, and had -just been switched to `ondemand` shortly before. `reconcile_folder` gates on -`mode == "mirror"`, so it should have been inert — worth confirming that gate -actually held, and that no pass was already in flight across the switch. - -Note the file was a **conflict-copy sibling** of two files already carrying -`-003` / `-004` suffixes, so the conflict path had definitely been active in that -directory. - -**Next step:** find the trash event's origin. The activity log had no row at the -15:58 mv, which points away from a logged (control-socket / sync) path — but the -timestamp bug in B3 made that table hard to read, so re-check with correct -scaling before trusting the absence. - ---- - -## B3 — ~~Activity log timestamps written in seconds, read as milliseconds~~ - -**Status:** Not a bug — investigator error, kept as a record -**Found / retracted:** 2026-07-19 - -Originally filed because every activity row rendered as `1970-01-21 …`. That was -an artifact of the ad-hoc debugging query, which divided by 1000; the code never -does. Seconds are consistent end to end: - -- writer — `log_activity` uses `now_secs()` (`pdfs-fuse/src/lib.rs`) -- schema / `activity_list` — pass the value through untouched -- reader — `activity_time` calls `glib::DateTime::from_unix_local(secs)` - -The correct manual query, for next time: - -```sql -select datetime(time,'unixepoch','localtime'), kind, target, detail, ok -from activity order by time desc limit 40; -``` - -Worth noting because the bad query is what made the activity log look empty -around the reported `mv` — which is evidence B2 leans on. That absence has since -been re-checked with correct scaling and it does hold: no row at 15:58. - ---- - -## B4 — `invalidate_listing` silently skipped non-resident folders - -**Status:** Fixed (verified 2026-07-20, via B1's repro — the FUSE rename path that -depends on this helper now behaves correctly end to end) -**Found:** 2026-07-19, reviewing the B1 fix -**Where:** `crates/pdfs-fuse/src/state.rs:257` - -```rust -if self.children.remove(&ino).is_none() { - return; // never clears the DB `listed` flag -} -``` - -The early return assumed "not in the hot cache ⇒ nothing to invalidate", which -stopped being true once listings became DB-backed. A folder trimmed from the -in-memory map but still `listed = 1` in the DB could not be invalidated at all — -callers thought they had dropped the listing, and `ensure_children` would happily -rebuild the stale one. - -**Fix:** drop the early return; always clear the flag. Costs one redundant -`UPDATE` when nothing was cached. - -`Core::refresh_dir` had been hand-rolling a workaround for exactly this (clearing -the DB flag itself, then reaching into `state.children` directly, with a comment -explaining why it couldn't use the helper). It now just calls -`invalidate_listing`. **Behaviour change worth knowing:** `refresh_dir` used to -propagate a DB write failure to the caller of `CtlRequest::Refresh`; it now warns -and reports success, matching every other invalidation site. - -**Test:** `a_deleted_child_leaves_a_listed_parent_serving_a_stale_listing` in -`pdfs-core/src/db/tests.rs` pins the B1/B4 mechanism at the DB layer — a deleted -child plus a still-`listed` parent yields a listing that silently omits it. - ---- - -## B5 — `ls -l` costs a network round trip per file (thumbnail xattr probes) - -**Status:** Fixed (verified 2026-07-20) -**Found:** 2026-07-19, investigating "`exa -l` is slow in the mounts, `exa` is fast" - -### Reproduced (2026-07-19) - -All runs cold (35 s wait to clear the 30 s attr TTL). `exa` needs only `readdir`; -`exa -l` stats every entry, so the delta is per-entry metadata cost: - -| mount | entries | `exa` | `exa -l` | delta per entry | -|---|---|---|---|---| -| primary `~/ProtonDrive/Installer` | 34 | 10 ms | 11 ms | **0.03 ms** | -| on-demand `~/Documents` | 45 | 27 ms | 54 ms | **0.6 ms** | -| on-demand `~/Videos/[Reaktor] FMA …` | 65 | 73 ms | 193 ms | **1.85 ms** | - -### Cause (2026-07-20): a network round trip per file per xattr name - -`strace -f -T` on a cold `exa -l` of the 65-file `.mkv` directory: - -``` -lgetxattr(".../E01 ....mkv", "user.proton.thumbnail", NULL, 0) = -1 ENODATA <0.186435> -``` - -129 `lgetxattr` and 258 `llistxattr` calls for 65 entries, each `lgetxattr` -~186 ms. Three things compounded: - -1. **`listxattr` advertised `user.proton.thumbnail` + `user.proton.preview` for - every file**, regardless of whether that file could have one. An xattr-aware - lister then asks for each advertised name — two `getxattr` per entry. -2. **`Core::thumbnail` cached only success.** `download_thumbnail` returning - `None` — the normal answer for a `.mkv` — was never remembered, so every - listing re-asked the API and was re-told nothing, forever. -3. **`getxattr` ran inline on fuser's dispatch loop**, not on the `Workers` - pool. `lookup` and `readdir` had been moved off it (PERF #1.0); this one was - missed. So each 186 ms miss stalled *every* other op on the mount, which is - why the concurrency `exa` does have bought nothing. - -**The mount-kind correlation was a red herring.** It tracks file *type*, not the -fork: `~/Videos` is `.mkv` (Proton generates no thumbnail — always a miss), -`~/ProtonDrive/Installer` is installers whose probes were already warm. The -`fork_state` theory in the previous write-up is wrong; forked and primary mounts -run the same code and neither is at fault. Worth noting as a lesson — three -measurements on different directories looked like a mount-kind effect because -nobody had varied file type independently. - -### Fix - -- `listxattr` advertises the names only for `image/*` and `video/*` media types. - `getxattr` still honours an explicit request for an unadvertised name, so - nothing becomes unreachable. Everything else — documents, installers, archives - — now costs zero round trips per listing. -- `Core::thumbnail` remembers misses in `no_thumbnail`, keyed `(uid, type)` and - validated against the node's mtime exactly as the positive side is. Bounded by - clearing at `MAX_THUMBNAIL_MISSES` (8192). -- `getxattr` hands off to `Lane::Meta`, so a miss can never block the dispatch - loop. - -A video directory still pays its misses once per revision (image/video is where -a thumbnail plausibly exists, so the probe is legitimate) — but in parallel, and -never again. - -### Verified (2026-07-20) - -Cold runs (40 s wait each) on the restarted daemon: - -| measurement | before | after | -|---|---|---| -| `~/Videos/…FMA…` (65 `.mkv`) cold `exa -l` | 193 ms | **14 ms** | -| `~/Documents` (45 entries) cold `exa -l` | 54 ms | **13 ms** | -| `user.proton.*` probes over the 65 `.mkv` | 130 | **0** | -| `~/Documents` probe latency, 1st listing | 124–395 ms | 124–395 ms | -| `~/Documents` probe latency, 2nd listing | 124–395 ms | **0.26–0.67 ms** | - -Each part of the fix is separately visible in the traces: - -- **The advertising gate.** The `.mkv` directory now issues *zero* `user.proton.*` - probes; the only remaining `lgetxattr` is exa's own `security.selinux` at - ~0.3 ms. Note this means Proton does **not** report those files as `video/*` — - the gate is excluding them by media type, not because they are known to lack a - thumbnail. A video whose media type is generic no longer advertises one even if - it has it; `getxattr` still serves an explicit request, so it stays reachable. - Worth revisiting if thumbnails ever go missing in a file manager. -- **The negative cache.** `~/Documents` holds 10 `.png` files, correctly still - advertised, so it probes 20 times on both passes — but the second pass answers - in 0.26–0.67 ms instead of going to the wire. That is the whole point: the - probes that are legitimate stop being expensive after the first. -- **The worker handoff.** Pass 1 on `~/Documents` shows interleaved - `<... lgetxattr resumed>` lines across several tids, i.e. the misses now - overlap instead of serializing behind the dispatch loop. - -### Secondary finding (still open): `lookup` is O(n) per name - -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `serve_lookup` - -`serve_lookup` linear-scans the parent's children comparing names, and with no -`readdirplus` that is one `lookup` per child — O(n²) name comparisons under the -global state lock for a listing. - -Real, but **not** what made this slow: it costs the same on both mount kinds and -at 65 entries a linear scan is nanoseconds against a 186 ms round trip. Two -candidate optimizations were considered and deliberately *not* applied, and that -judgement still holds now the real cause is known: - -- **Per-directory `name -> ino` map.** A second structure that must stay in sync - with `children` — the two-halves-of-one-cache shape that caused B1 and B4. -- **`readdirplus`** (`fuser` 0.17 supports it; we return `ENOSYS`). The right - shape long-term, but it is a rewrite of the directory-read path and it would - have hidden this bug rather than explained it. - -### Measurement notes for next time - -- Cold requires a 35 s wait per run (30 s attr TTL). Warm runs show ~6 ms - regardless and prove nothing. -- `strace -c` on `exa` **needs `-f`** — exa is multithreaded, and without it the - trace captures only main-thread startup (3 `statx` calls, no `getdents`). -- `-T` (per-call durations) is what cracked this; `-c` summaries attribute time - across threads in a way that hid the 186 ms constant. - ---- - -## B6 — Daemon sets no file modes: `control.sock` is an unguarded authority - -**Status:** Fixed (verified 2026-07-20) -**Found:** 2026-07-19, while writing the plaintext-at-rest threat model in -`docs/ARCHITECTURE.md` §8. Not from a report — from checking a claim before -asserting it in a doc. I had written "their default 0700 permissions protect -them", went to verify, and found we set no modes at all. -**Where:** `crates/pdfs-fuse/src/mount.rs` (`UnixListener::bind(control_socket)`), -`crates/pdfs-core/src/config.rs` (`create_dir_all` on state/cache dirs) - -`grep -rn "set_permissions\|from_mode" crates/pdfs-core/src crates/pdfs-fuse/src` -returns exactly one hit, and it is `shell.rs` setting `0755` on a generated -script. Nothing else sets a mode. So: - -- state and cache directories are created at `0777 & ~umask` — typically `0755` -- `control.sock` is bound at the same, typically `0755` - -Anything that can connect to the socket drives the daemon with its authenticated -session: enumerate the tree, read file contents, upload, trash, create public -share links. No credential is required — the keyring is never consulted, because -the daemon already holds the session. - -The only thing preventing this today is that `~/.cache` and `~/.local/state` are -conventionally `0700`. That is a property of the user's system, not something we -establish or check. It does not hold on a machine with a permissive umask, a -group-shared home, or a home restored from a backup that flattened modes. - -**Severity:** low on a single-user desktop, real on a shared or multi-user host. -It is a privilege boundary rather than a data-at-rest issue, which is what makes -it worth more than the cache-plaintext point it was found next to. - -**Fix:** `chmod 0600` on the socket immediately after `bind` (before the listener -thread starts accepting), and `0700` on the state and cache directories at -creation. Both are a few lines. Worth also asserting the socket mode in a test, -since a regression here is silent. - -**Fixed 2026-07-19:** `AppDirs::ensure` now sets `0700` on the state, cache, and -config directories on *every* start (not just at creation, so an existing -permissive directory is tightened), and `config::restrict_socket` sets `0600` on -both the control socket and the tray socket immediately after `bind`. A socket -whose mode cannot be set takes the daemon down rather than serving unguarded; -the tray's is best-effort, since it only guards single-instancing. Unit tests in -`config.rs` assert both modes. - -**Verified 2026-07-20** on the restarted daemon — all three directories now read -`drwx------` and both sockets `srw-------`, against `drwxr-xr-x` / `srwxr-xr-x` -before. - -**Measured exposure at the time of the fix:** the directories really were -`drwxr-xr-x` and the live socket `srwxr-xr-x`, but `~/.cache` and `~/.local` -were both `0700` on this machine, so nothing was reachable in practice. The bug -was a latent dependency on those parents, not a live hole. Removing the -dependency is the point. - ---- - -## B7 — renamed directory reads as missing until the entry TTL expires - -**Status:** Fixed (verified 2026-07-20) -**Verified:** `mkdir "zz-b7 Old Name"` with a file inside, `mv` to a new name, then -an immediate `ls` and `cat` through the new name — both succeed with no wait. The -directory used to return ENOENT until the entry TTL expired. -**Found:** 2026-07-19, user renamed a folder on the mount with `mv`. `ls` of the -parent listed the new name, but `ls /` returned ENOENT — repeatedly, so -not a one-shot race. It self-healed on its own a few minutes later, and the -files were all intact: this is a visibility bug, not data loss. -**Where:** `crates/pdfs-fuse/src/state.rs`, `State::relocate` - -**Cause:** the online rename path ended with `relocate`, which called `forget` on -the moved node. `forget` drops the node's `by_uid` mapping, so when the -invalidated parent listing re-enumerated, `intern_mem` allocated it a **fresh** -inode. But the kernel had already carried the renamed dentry over to the *old* -inode number, and it holds that dentry for the entry TTL. Every lookup, getattr -and opendir through it resolved to an inode `entries` no longer held: - - entries.get(old_ino) -> None -> ENOENT - -`readdir` of the parent went the other way — it walks `children` and reports the -*new* inode — which is exactly why the directory listed fine but could not be -entered. Once the TTL expired the kernel re-looked-up the name, got the new -inode, and everything worked again. - -**Fix:** `relocate` now rewrites the node in place (`rename_in_place`) and -invalidates both parents' listings, instead of forgetting it. The inode is -preserved, so the kernel's dentry stays valid and the re-enumeration reuses the -same `by_uid` slot. Both `relocate` tests now assert inode stability; that -property was untested, which is how this got through the B1 fix. - -**Note:** dropping the `forget` also stops the moved node's DB row from being -deleted and re-created on every move — the row is updated instead, which is what -the B1 fix was working around from the other side. - ---- - -## B8 — no way to complete a CAPTCHA, so a gated sign-in is unrecoverable - -**Status:** Implemented, partially verified — the bridge is proven, a real gate is not -**Found:** 2026-07-19, user hit `proton api error Unknown (http 422): For -security reasons, please complete CAPTCHA` and had no way to answer it. -**Where:** SDK `api.rs`/`http.rs`/`session.rs`, `pdfs-core/src/auth.rs`, -`pdfs-gui/src/app/pages/verify.rs` - -**Cause:** three gaps stacked, none of which is a bug on its own. - -1. `ResponseCode` had no `9001`, so the gate deserialized to `Unknown` — which - is why the error read as an opaque failure rather than a recoverable prompt. -2. Nothing read `Details.HumanVerificationToken`, and the client never sent the - `x-pm-human-verification-token{,-type}` headers the retry needs. -3. No UI could render Proton's hosted verification page. - -**Fix:** `HumanVerification` (challenge) + `HumanVerificationCredential` -(answer) in the SDK, header plumbing on the session-less login calls, and -`ProtonApiSession::begin_verified`. `pdfs-core` promotes a solvable gate to -`Error::HumanVerificationRequired` and `auth::login_interactive` re-runs the -login with the earned token — the retry lives in core because the gated attempt -burns its SRP handshake, which no front-end should have to know. The GUI hosts -the page in a `WebKitWebView` (webkit6 0.4, pairs with the existing gtk4 0.9) -and bridges the page's `postMessage` to a script message handler. - -**Deliberately not done:** `email`/`sms` verification methods (the token arrives -out of band, so the webview cannot complete them — such a gate stays a plain API -error rather than opening a page the user cannot finish), HV on *authenticated* -endpoints (only the login path is plumbed), and re-gating of the retry (a second -challenge means the token was rejected; looping would trap the user). - -**Verified:** the WebKit bridge end-to-end with a throwaway probe — handler -registers, the page's `postMessage` arrives, non-completion messages are -filtered, the completion token round-trips. Unit tests cover 9001 parsing, the -challenge/`Details` shape, URL escaping, and message filtering. - -**Not verified:** a real gated login against verify.proton.me. It cannot be -triggered on demand, so the exact message shape Proton's page posts is taken -from its documented contract, not observed. If verification appears to hang with -the page solved, that is the first thing to check — `extract_token` in -`verify.rs` is the single place that decides what counts as completion. - -**Note:** the SDK half shipped as proton-sdk / proton-drive-rs **0.1.11**. The -workspace requirement was widened to `"0.1"` at the same time, so later 0.1.x -releases are picked up by `cargo update` without an edit here. - ---- - -## B9 — Enter did not open the selected result in the launcher - -**Status:** Fixed (verified) -**Found:** 2026-07-19, user reported Enter doing nothing in `pdfs-prompt` while -the arrow keys and Escape worked normally. -**Where:** `crates/pdfs-gui/src/prompt.rs`, the window key controller - -**Cause:** key-event *phase*, not key handling. `gtk4::Entry` — really its inner -`GtkText` — binds Return to its own `activate` and consumes it. The launcher's -`EventControllerKey` is attached to the **window** in the default **bubble** -phase, so the focused entry saw Return first and the window handler was never -reached. Escape and Up/Down worked precisely because `GtkText` has no bindings -for them and they bubbled up as intended. - -That asymmetry is the tell: when some keys reach a window-level controller and -others silently don't, the ones that don't are being claimed by the focused -widget. - -**Fix:** Return moved off the window controller and onto -`entry.connect_activate`. Enter while focus is in the results list was already -handled by `row_activated`, so the two together cover both focus positions. - -**Rejected alternative:** setting the window controller to -`PropagationPhase::Capture`. It fixes the symptom by putting the handler ahead -of the entry — but ahead of it for *every* keystroke, not just Return, which -puts text input and IME composition behind a handler that has no business -seeing them. The narrow fix has no such blast radius. - -**Verified:** live. Pressing Enter logged `opening path=…`, the daemon hydrated -the file, and `xdg_open` ran. - ---- - -## B10 — GLib critical when the launcher closes over an in-flight open - -**Status:** Open (side finding) -**Found:** 2026-07-19, seen in `pdfs-prompt` output immediately after a -successful Enter-to-open while verifying B9. - -``` -GLib-GIO-CRITICAL: g_list_store_remove: assertion '!g_sequence_iter_is_end (it)' failed -``` - -**What is known:** it fires right after the `opening path=…` log line, i.e. -during `xdg_open` + `window.close()`. It is a warning, not a crash — the open -itself succeeded. - -**What it is not:** the launcher's own row handling. `Section::set_rows` walks -`GtkListBox` children (`first_child`/`remove`) and touches no `GListStore`, so -the failing store belongs to GTK/libadwaita internals, most likely something -teardown-ordering related in the app/window bookkeeping as the window is closed -while a launch is still settling. - -**Why it was not chased:** it appeared while verifying an unrelated fix and has -no user-visible effect. Worth revisiting if the launcher ever misbehaves on -close (a hang, a lost open, or a stale window), since an assertion firing during -teardown is exactly the shape of bug that later turns into one of those. - -**Where to start:** run `pdfs-prompt` under `G_DEBUG=fatal-criticals` to turn -the warning into an abort and get a real backtrace naming the store. - -## B11 — a file moved while its create is still queued reads as empty - -**Status:** Fixed (verified 2026-07-20) -**Found:** 2026-07-20, while verifying B1 on the restarted daemon. - -The B1 repro is `echo hi > f.txt && mv f.txt d/`, i.e. the move lands while the -create is still queued for upload. Immediately after the `mv`, the file is -present in the destination listing — B1 is genuinely fixed — but stats as **0 -bytes** and `cat` returns nothing: - -``` --rw-r--r-- 1 narl narl 0 Jul 20 00:29 zz-b1-dst/zz-b1-src.txt -``` - -A few seconds later the same file reads correctly (`3` bytes, `hi`). A control -file created and read *without* an intervening `mv` was correct immediately. - -**Cause:** `State::intern_mem` replaces an existing entry's node wholesale -(`e.node = node`). A move invalidates both parents' listings (that is B7's fix, -and it is correct), so the next `ls` re-enumerates — and the node that comes back, -from the remote or from its DB row, carries the size of the revision the *server* -holds. For a file whose write is still queued that is the pre-write size, usually -0. Interning it reverts the optimistic size `record_pending_write` had stamped. - -**Why an empty read rather than a short one:** a file that stats as 0 bytes gets -**no `read` from the kernel at all**. `read_range` would have served the staged -blob quite happily; it is never asked. So the file reads as empty for as long as -the stale size stands, and "empty file" is indistinguishable from "file whose -contents were lost" to whatever is reading. - -`Core::hydrate` already solved exactly this for the *restart* case — it stamps -each pending write's size onto the node as entries materialize. The protection -was simply missing on every live re-enumeration path. - -**Fix:** `Core::stamp_pending_sizes` re-applies the optimistic size to a batch of -nodes, called from both arms of `ensure_children` (the DB fast path and the -network path) before the state lock is taken. It snapshots the pending map first -and returns: no site in the daemon holds `pending` and `state` at once, and this -is not the place to become the first — `hydrate` established that pattern for the -same reason. - -Scoped to `ensure_children` deliberately. The other `intern` sites are -single-node and all authoritative by construction (mkdir, create, an upload that -just landed); `drain.rs`'s post-upload adoption is *supposed* to take the -server's node, and already handles the queued case through `rebaseline_pending`. - -**Tests:** `pending_size_tests` in `pdfs-fuse/src/lib.rs` covers the queued file -keeping its size, a settled sibling keeping the server's, folders being left -alone, and an empty pending map changing nothing. - -**Verified 2026-07-20** on the rebuilt daemon: `echo hi > f.txt && mv f.txt d/` -then an immediate `ls -l` and `cat` gives 3 bytes and `hi`, against 0 bytes and -empty output before. - ---- - -## B12 — cold enumeration is slow per entry, and goes superlinear past ~500 - -**Status:** Both causes fixed and verified (2026-07-20); attr-invalidation follow-up unverified -**Found:** 2026-07-20, measuring improvements.md P2.8 ("pipeline -`enumerate_nodes_detail`") before implementing it, to decide whether the -bottleneck was network or crypto. - -### Measured (cold; `pdfs refresh ` then a timed `ls -1`) - -| entries | time | per entry | -|---|---|---| -| 1–9 | 350–550 ms | fixed cost | -| 34 | 629 ms | — | -| 147 | 1.92 s | 12.5 ms | -| 251 | 3.18 s | 12.4 ms | -| 484 | 6.25 s | 12.5 ms | -| **793** | **38.8 s** | **48.9 ms** | - -The 793 figure reproduces to ±100 ms across three runs. Marginal cost from 484 to -793 is **106.7 ms/entry**, 8.5× the baseline. - -`perf record` against the running daemon shows **~90 % of cycles inside the -`pdfs` binary** in both cases, and sample counts matching wall time (1022 samples -≈ 5.1 s, 7538 ≈ 37.9 s at 199 Hz) — so this is CPU-bound throughout, not waiting -on the network. Symbols are unavailable (the installed binary is stripped), which -is what blocks attribution. - -### Cause 1 (found, ~half the baseline): every FTS row is deleted by a full scan - -`nodes_fts` declares `uid` as **UNINDEXED**: - -```sql -CREATE VIRTUAL TABLE nodes_fts USING fts5(uid UNINDEXED, name, tokenize='trigram') -``` - -and `Db::upsert_nodes` (`pdfs-core/src/db/nodes.rs`) deletes by exactly that -column on every node, because "FTS5 has no UPSERT": - -```sql -DELETE FROM nodes_fts WHERE uid = ?1 -``` - -An UNINDEXED FTS5 column is not searchable, so the predicate cannot use an index. -`EXPLAIN QUERY PLAN` confirms it: - -``` -`--SCAN nodes_fts VIRTUAL TABLE INDEX 0: -``` - -One full scan of the FTS index per node written. Measured on a `.backup` copy of -the live 171 MB DB (17 443 indexed nodes): - -| deletes | time | per delete | -|---|---|---| -| 484 | 3.09 s | 6.4 ms | -| 793 | 5.26 s | 6.6 ms | - -So **~6.5 ms of the 12.5 ms per-entry baseline is this one statement**, and it -gets worse for every user as their node count grows — the scan is over the whole -index, so the cost of writing *any* listing scales with the size of the *account*. -It is also paid by every sync pass and every event-driven refresh, not just `ls`. - -**Fix direction:** delete by rowid instead. FTS5 deletes by rowid efficiently, so -map `nodes.rowid` to the FTS rowid and drop the `uid` column from the index -(or keep it UNINDEXED purely for retrieval). Needs a schema migration on a live -171 MB DB, so it wants its own change rather than being smuggled into this one. - -### Cause 2 (the dominant one): an S2K key derivation per file, to list a folder - -**Symbols came from the local unstripped build, not a new install.** `strip` -preserves both the build id and the text addresses, so when `/usr/bin/pdfs` and -`target/release/pdfs` reported the *same* build id, the stripped binary was just -a copy of the local one and `perf buildid-cache -a target/release/pdfs` was -enough to symbolise a recording of the running daemon. Worth remembering — it -turned a "needs another install + restart" into a five-second step. - -Hot functions over a 793-entry cold enumerate: - -| % | symbol | +### B75 — Network work on fuser's single dispatch thread freezes the whole mount + +**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 while +chasing what was thought to be a B74 regression. +**Impact:** any burst of concurrent writes froze the entire mount — not the +files being written, the *mount*. `ls` on an unrelated directory of it timed out +for minutes. The daemon itself stayed healthy throughout: the primary mount and +the control socket answered normally while a sync-folder mount was unreachable. + +**Root cause.** `mount.rs` and `devices.rs` both build their session from +`Config::default()`, which leaves `n_threads` unset, and fuser 0.17 defaults it +to 1 (`session.rs:254`). One dispatch thread per mount reads a request, runs the +handler to completion, and only then reads the next. Six handlers were handed to +the [`Workers`] pool — `lookup`, `getattr`, `readdir`, `read`, `getxattr` and the +slow `serve_lookup` — and everything else ran inline, including handlers that +block on the API: + +| handler | blocking work | |---|---| -| 64.4 | `sha2::sha256::x86::digest_blocks` | -| 6.0 | `::update` | -| 5.7 | `sqlite3VdbeExec` (cause 1, above) | -| 4.0 | `sha2::sha256::compress256` | -| 1.5 | `pgp::types::s2k::StringToKey::derive_key` | +| `create` | `upload_file` to mint the node, then `fetch_node` — two round trips | +| `mkdir` | `create_folder`, then `fetch_node` | +| `rename` | `rename_node`, then `move_node`, plus a trash on the replaced-destination path | +| `unlink` / `rmdir` | `trash_nodes`; `rmdir` enumerates the folder first | +| `setattr` (path truncate) | `queue_truncate` → `fill_gaps`, which reads the kept prefix from the remote | -**~74 % of the listing is SHA-256 inside PGP's S2K**, i.e. the per-file node-key -unlock that `build_node` does under `NodeDetail::Full`. `derive_key` itself shows -1.5 % because the time lands in its SHA leaf. Nothing downloads during an `ls`, so -PGP is the only plausible source of that SHA. +So eight applications each creating a file served one create at a time, and +every `read`, `open` and `lookup` on that mount queued behind them. -### The "cliff past ~500" framing was wrong +**Measured**, acceptance suite's `independent and shared-file concurrency` (8 +threads × 16 files), and a 3-second `ls` of the mount root sampled every 4 s +throughout: -That was an inference from a single folder, not a measurement. An S2K's cost is -set by the iteration count in the key packet, which is chosen by *the client that -uploaded the file* — so it varies per file, not per listing size. Every folder -measured sits at 12–16 ms/entry except `Music/aC_ID.dll` at 49 ms, and -`Music/ELDEN RING SOUNDTRACK` (67 files, same parent tree) is ~16 ms/entry, so it -is not "Music files are expensive" either. A 4× jump for a 1.6× change in n fits -"those files were uploaded by a client using a costlier S2K" far better than a -size threshold. n and folder identity are confounded in the data — there was only -ever one folder above 500 entries. - -Left unresolved deliberately: the fix below removes the per-file S2K from the -listing path entirely, so which of the two explanations held stopped mattering. -To settle it anyway, log the S2K iteration count per file across one enumerate of -each folder. - -### Fix: enumerate cheap, upgrade sizes in the background - -`ensure_children` now calls `enumerate_nodes_light`, which skips the file -node-key unlock (folders are still unlocked — their keys are what the children -decrypt with). The listing is served from that immediately. - -`Light` returns no `claimed_size`, and `ls -l` wants sizes, so a naive split would -just move the same S2K onto the first `stat`. Instead `Core::spawn_size_upgrade` -fetches the full nodes for that folder on a worker, batched, after the listing has -been answered, and adopts *only* the size — re-interning wholesale would clobber a -name or parent that a concurrent rename/move had changed. It is single-flight per -folder, because a `stat` of one entry in a fresh listing means a `stat` of all of -them. Queued writes keep their optimistic size through it, via the same -`stamp_pending_sizes` that B11 added. - -Three entry points cover every way a provisional listing can appear: the network -enumeration, the DB fast path (rows persisted before an upgrade ran), and -`getattr` itself (a listing restored by `hydrate` on mount, which `ensure_children` -returns early for). - -**Known tradeoff — sizes are provisional until the upgrade lands.** `node_size` -falls back to `total_size_on_storage`, the ciphertext size, so a `stat` in that -window reads slightly *too large*. Reads are unaffected: the revision reader -carries its own authoritative size. Deliberately not the B11 shape — that -reported **0**, which made the kernel skip reads entirely; too-large is cosmetic -and self-corrects. - -**The window was longer than "a round trip", and that took a correction.** The -daemon has the real sizes quickly, but the *kernel* keeps the provisional attrs -for the full 30 s entry TTL, so `ls -l` kept reporting them long after the DB was -right. This nearly caused a misdiagnosis during verification: two successive -`ls -l` runs agreed with each other and both disagreed with the truth by a -constant +59 bytes, which reads exactly like "the upgrade never ran". Querying the -DB directly is what separated "not computed" from "computed but not visible". - -Closed by having `spawn_size_upgrade` call `notifier.inval_inode` for the inodes -it corrected, after the DB write so a provoked re-`getattr` cannot race the -persistence. That needed a `Notifier` on `Core` (a `OnceLock`, since the session -is built *from* the `Core`), and a fresh one per on-demand fork — each fork has -its own inode space, so notifying through the primary mount's channel would name -inodes that session has never heard of. - -### Verified (2026-07-20) - -| folder | before | after | +| | before | after | |---|---|---| -| `Music/aC_ID.dll` (793) | 38.8 s | **4.48 s** (8.7×) | -| `InstantUpload/Camera` (484) | 6.25 s | **2.91 s** (2.1×) | -| `Pictures/old` (417) | 5.10 s | **2.25 s** (2.3×) | +| concurrency case | 200 s watchdog ×2, 37 s ×1 | 16.9 / 24.1 / 29.1 s, all pass | +| `ls` of the mount during the run | 33 of 55 probes timed out | 0 of 85 | +| `readdir stability` | 148.8 s | 62.9 s | +| `namespace operations` | 31.3 s | 15.7 s | -Schema v15 migrated the live 171 MB DB on start (17 443 rows indexed). All 793 -sizes converge to exactly their pre-change values, checked against a `.backup` -copy taken before any of this landed. The +59-byte ciphertext delta is a precise -probe for a provisional size, and is what the attr-invalidation follow-up should -be tested with. +**Fix:** each of those handlers now parses its arguments inline — cheap, and it +keeps the error paths prompt — and hands the body to the worker pool as a +`serve_*` method, the pattern `lookup`/`readdir` already used. Metadata work +takes `Lane::Meta`; the truncate path takes `Lane::Transfer` because it can pull +a whole file. -### Original cause-2 ruling-out (kept — all still true) +**`release` was deliberately left inline, and this is load-bearing.** Moving it +too made all three concurrency runs fail with `concurrent file 1 mismatch`. The +kernel does not wait for a `release` reply before letting `close(2)` return, so +the only thing sequencing the staging of the written bytes against the `open` + +`read` an application issues immediately afterwards is that both are served by +the same dispatch loop. From a worker, the read overtakes the staging and is +answered from the remote's older revision. Taking `release` off the loop — worth +doing, since `queue_revision` gap-fills a partial write from the network — needs +a per-node "staging in flight" barrier that reads wait on, not the dispatch +loop's accidental ordering. -Backing cause 1 out of the totals leaves per-node non-FTS cost at **6.2 ms at -n=484 but 42 ms at n=793**. Something gets ~7× more expensive per node, sharply, -somewhere around 500–800 entries. - -Ruled out so far: - -- *Chunking / network.* `MAX_BATCH_COUNT = 150`, so cost would rise in visible - steps at 150/300/450; it is flat at 12.5 ms/entry straight through 484. The - marginal cost would imply ~16 s per POST, which is absurd. -- *Composition.* Both the 484 and 793 folders are 100 % files, no subfolders. -- *`FOLDER_KEY_CACHE_CAP` (512).* Suspicious number, but `folder_keys` only holds - *folder* keys and these children are all files, so nothing is inserted during - the walk. `resolve_parent_key_ctx` caches each ancestor it derives, and all - children share one parent, so it is a hit after the first child. -- *The entity cache.* `InMemoryCacheRepository` is `HashMap`-backed, O(1) `set`. -- *FTS (cause 1).* Linear — 3.1 s → 5.3 s where the cliff is 6.1 s → 38.8 s. - -**Next step:** symbol-level profile. Needs an unstripped `pdfs` installed and the -daemon restarted, then `perf record -p ` across a 793-entry enumerate. Every -cheaper avenue above has been spent. Note a second daemon is **not** an option -for this: Proton refresh tokens are single-use, so a parallel session would fight -the running daemon for them. - -**Do not implement improvements.md P2.8 before this is understood.** Pipelining -would parallelize the cliff rather than remove it, and at 793 entries the cliff is -33 s of the 38.8 s — far more than concurrency could win back. +**Not fixed here.** `n_threads` is still 1; with the blocking handlers moved off, +the loop is short enough that raising it is a separate question. Related findings +from the same audit, still open: cache/reader validity is keyed on +`(mtime, size)` while the revision identity is `active_revision_id` +(`cache.rs:84` — two same-size revisions within one mtime second collide, which +is what a SQLite page rewrite looks like); `local_finish_scan` holds the daemon's +one SQLite connection across a full FTS5 trigram rebuild (`db/local.rs:79`); +`earliest_due_at` has no `<= now` filter where `next_due_op` does, so the drain +busy-spins when offline with a due op (`db/ops.rs:385` vs `:421`); and +`drain_local_node` deletes its op before the fallible `adopt_real_uid` +(`drain.rs:346`). --- -## B13 — `rename` over an existing destination fails instead of replacing it - -**Status:** Fixed (unverified — needs a real rsync against the rebuilt daemon) -**Found:** 2026-07-20, user ran `rsync -rauLP ~/ProtonDrive/Music/... ~/Music/` -(both are protondrive mounts) and every single file failed at the end of its -transfer: - -``` -rsync: [receiver] rename ".../.Buunshin - heimwee (Original Mix).wav.b0akm3" - -> ".../Buunshin - heimwee (Original Mix).wav": Input/output error (5) -``` - -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `Filesystem::rename` - -Daemon log, one per failed file: - -``` -ERROR pdfs_fuse: rename failed uid=… error=proton api error AlreadyExists (http 422): - A file or folder with that name already exists -WARN pdfs_fuse::drain: pending upload failed; will retry uid=… attempts=1 - error=proton api error DoesNotExist (http 422): File or folder not found -``` - -**Cause:** `rename(2)` is specified to *atomically replace* an existing -destination. Our handler just calls `client.rename_node`, and Proton refuses a -name that already exists — so the 422 becomes a blanket `EIO`. Nothing in the -path ever looks up the destination name. - -This breaks every write-to-temp-then-rename tool, which is most of them: rsync, -editors doing atomic saves, `mv -f`, package managers. The failure is at the -*end* of the transfer, so the bytes are uploaded and then thrown away — the user -pays full upload cost for nothing. - -**Second-order damage:** the failed rename leaves the temp node behind and its -queued upload then fails with `DoesNotExist` and retries forever. So each failed -file also leaves a poisoned entry in the drain queue. - -**Fix:** `rename` now looks up `newname` under `newparent` and, if it resolves to -a different node, removes it before the API call. - -The order is forced by Proton: the name has to be free before `rename_node` will -take it, so it is trash-then-rename, which means the operation is **not atomic**. -`Core::remove_replaced` does the trashing and `Core::restore_replaced` puts the -node back if the rename then fails — on every failing path, including the queued -ones. If the restore *also* fails the node stays in the trash and says so -loudly, because the alternative is a file the user believes was merely renamed -sitting somewhere they will not look. - -Deliberate details: - -- **Refusals happen before anything is trashed.** `check_replaceable` is a pure - function for exactly this reason: every `Err` it returns is a case where the - destination must survive, and a mistake there turns a refusal into deletion. - `EISDIR` / `ENOTDIR` when the two ends disagree about being a directory, and - `ENOTEMPTY` for a non-empty destination directory — Proton trashes a folder - with its whole subtree, so allowing that would discard every file underneath - without ever naming them. -- **`RenameFlags` is now read** (it was `_flags`). `RENAME_NOREPLACE` returns - `EEXIST` instead of replacing; `RENAME_EXCHANGE` returns `EINVAL`, since no - Proton primitive swaps two names and emulating it would leave a window in - which one of them does not exist. -- A node whose own create is still queued never reached the server, so replacing - it just discards its queued ops — no API call, and it works offline. -- Renaming a node onto its own name stays a no-op rather than a self-replace. - -**Tests:** `replace_tests` in `pdfs-fuse/src/lib.rs` covers all five decisions. -The trash/restore half needs a live server and is not covered. - -**Repro:** `cd && echo a > x && echo b > y && mv y x` → was EIO, should -now succeed with `x` holding `b`. - ---- - -## B14 — provisional (ciphertext) sizes make rsync read short and abort the file - -**Status:** Fixed (unverified — needs a cold folder on the rebuilt daemon) -**Found:** 2026-07-20, same rsync run as B13. Alongside the receiver errors, the -sender failed to read its own source files: - -``` -rsync: [sender] read errors mapping "/home/narl/ProtonDrive/Music/…/ - Buunshin - i think i feel... (Original Mix).wav": No data available (61) -``` - -**Where:** `node_size` (`pdfs-fuse/src/lib.rs:3480`) + `Core::spawn_size_upgrade` - -**Cause:** this is B12's known tradeoff surfacing as a real transfer failure. -Until the background size upgrade lands, `node_size` falls back to -`total_size_on_storage` — the **ciphertext** size, which is larger than the -plaintext (measured at +59 bytes on this account). B12 called that "cosmetic and -self-corrects" because it only affects `ls -l`. - -It does not only affect `ls -l`. `ENODATA` (61) is what **rsync sets on a short -read**: `map_ptr` in `fileio.c` asks for the bytes `stat` promised, gets fewer, -and marks the mapping `ENODATA`. So a reader that trusts `st_size` — rsync, -anything using `mmap`, `sendfile`, or a sized `read` loop — sees a truncated -file and errors out. That is the same class of bug as B11, just from the other -direction: B11 reported **too small** (0) so the kernel skipped reads entirely; -this reports **too large** so reads run off the end. - -The 30 s attr TTL makes the window much wider than the upgrade's own latency -(B12 documented this), so a cold `rsync` of a large tree is essentially -guaranteed to hit it. - -**Why it looked fine afterwards:** re-listing the same directory now shows every -size correct, because the upgrade has long since landed. The bug is only visible -cold. Reproducing it needs `pdfs refresh ` immediately before the read. - -**Fix:** a provisional size is never published. `getattr` on a file whose -`claimed_size` is unknown now resolves it *before* replying, instead of replying -with the ciphertext size and upgrading afterwards. - -The cost is one batched round trip per folder, not one per file: `ls -l` is one -`getattr` per entry, and they collapse onto a single upgrade. B12's split is -still doing its job — a plain `ls` never reaches this path at all, which is -where the 8.7× came from. - -That required making the upgrade *awaitable*, which is most of the change: - -- `size_upgrades` went from `HashSet` to `HashMap>`, - a condvar per folder. Followers wait on it; there may be hundreds for one - folder, so it releases all of them. -- **The leader does the fetch on its own thread rather than handing it to a - worker.** This is the part to not undo: `getattr` waits on `Lane::Meta`, so a - leader that queued its fetch onto that same lane could have a wide enough - `ls -l` fill the lane with threads waiting for a job that can never be - scheduled. -- `SizeUpgrade::WAIT` caps the wait at 10 s, falling back to the provisional - size. A `stat` that never returns is worse than one that is briefly wrong. -- `upgrade_sizes` owns the single-flight bookkeeping and has one exit path; - `apply_size_upgrade` holds the body it used to inline, so a failed fetch still - releases the waiters and leaves the folder retryable. - -**Tests:** `size_upgrade_tests` covers the follower being released, a waiter -arriving after `finish` (it checks the flag, not the notification, which it -would miss), and all waiters waking. - -**Not covered, worth watching:** `Lane::Meta` can now hold waiting `getattr`s. -There is no deadlock — leaders never queue their own work — but a cold `ls -l` -across many folders at once could occupy the lane. If metadata ops start -feeling sticky under heavy cold listing, this is the first suspect. - -### Verified (2026-07-20) — correct, but expensive, and not completely closed - -Two further holes turned up during verification; both are fixed, and the second -is the one that mattered. - -1. **`upgrade_sizes_for_parent` early-returned** when the parent listing was not - resident in `children` — and a rename invalidates exactly that, so a freshly - renamed file always landed in the dead branch. `stat` read 67 bytes for a - 16-byte file, and `cat` returned the 16 real bytes followed by 51 NULs. The - same shape as B4: an early return that assumed the hot cache was - authoritative. Now falls back to resolving the single node, keyed by its own - inode. -2. **`lookup` replies with attrs and a TTL too.** With no `readdirplus`, `ls -l` - is one `lookup` per entry and *zero* `getattr` calls — so fixing only - `getattr` fixed a path `ls -l` never takes. This is worth remembering: the - first fix looked right and did nothing for the reported symptom. - `serve_lookup` now resolves as well, taking an `off_loop` flag because - `lookup`'s warm path runs on the dispatch loop where it may not block (B5). - -| measurement | before fix | after fix | -|---|---|---| -| cold `ls -l`, 7-file wav folder | ciphertext sizes | **exact settled sizes** | -| renamed file `stat` / `cat` | 67 B, 51 NULs | **16 B, clean** | -| cold plain `ls`, 793 entries | 4.48 s | **4.58 s** (B12 intact) | -| cold `ls -l`, 793 entries | ~4.5 s, wrong sizes | **85.8 s**, 785/793 correct | - -### The cost, and the part still open - -**A cold `ls -l` of a 793-entry folder went from ~4.5 s to 85.8 s.** That is not -a flaw in the batching — it is B12's cause 2 arriving on schedule. A real -`claimed_size` requires the per-file node-key unlock, i.e. one S2K per file, and -that work is single-threaded. B12 removed it from the *listing* path; asking for -sizes puts it back, because sizes are what it produces. Plain `ls` is untouched, -which is why the split is still worth having. - -**8 of 793 entries still came back provisional**, caught by diffing the cold -listing against a settled one. Those are `SizeUpgrade::WAIT` timeouts: the leader -takes ~80 s for the batch and a waiter gives up at 10 s. So the bug is rarer but -not gone — it now needs a folder large enough that the batch outruns the cap. - -### Per-chunk wakeup (done, unverified) - -The timeout gap is closed by releasing waiters **per chunk** instead of once at -the end. `SizeUpgrade` carries a generation counter; `run_size_upgrade` fetches -in `SIZE_UPGRADE_CHUNK` (150, the SDK's own `MAX_BATCH_COUNT`, so one chunk is -one request), applies each chunk, and bumps the generation. A waiter re-checks -whether *its own* node now has a real size and returns if so — it no longer -waits on the other 792. - -Two structural points worth keeping: - -- **The batch runs on its own thread, not the `Workers` pool.** Callers wait on - `Lane::Meta`, so queueing the batch there could let a wide `ls -l` fill the - lane with threads waiting for a job that can never be scheduled. - `Lane::Transfer` would swap that deadlock for starvation behind bulk reads. - One short-lived thread per folder, bounded by the single-flight, avoids both. -- **`wait_for` evaluates its predicate with no `SizeUpgrade` lock held.** The - predicate reads `state`, and the applying thread holds `state` before it - signals; taking them in the other order would close the cycle. - -This does **not** make a full cold `ls -l` faster — every size still has to be -computed. It makes each individual `stat` return after its own chunk rather than -after the whole folder, and removes the provisional-size fallback that the 10 s -cap was producing. - -**Tests:** `size_upgrade_tests` covers the resolving chunk releasing a waiter, -an unrelated chunk *not* releasing one, `finish` releasing an unresolved waiter, -the already-resolved and post-`finish` arrivals, all waiters waking, and the -timeout backstop still firing. - ---- - -## Plan: parallelise the per-file S2K (the 85 s) - -This is the only item that reduces the work rather than redistributing it, and -it is the same bottleneck as improvements.md PERF #5 — approached from sizes -rather than from cold navigation. Filed here because B12/B14 are what produced -the measurements it needs. - -**The claim to be tested first, before any code.** B12's profile of a 793-entry -cold enumerate attributed **64.4 % of cycles to `sha2::sha256::x86::digest_blocks` -and ~74 % overall to SHA-256 inside PGP's S2K**, i.e. the per-file node-key -unlock in `build_node` under `NodeDetail::Full`. If that still holds for the -*size upgrade* path specifically — it is the same `enumerate_nodes` call, so it -should — then the work is CPU-bound, embarrassingly parallel across files, and -currently serialised on one thread. - -**Step 0 — confirm, do not assume.** Re-profile against the size-upgrade path -now that it is the thing on the critical path: - -``` -perf buildid-cache -a target/release/pdfs # symbols; see B12's note on strip -perf record -p -- sleep 90 -# meanwhile: pdfs refresh Music/aC_ID.dll && ls -l Music/aC_ID.dll -``` - -Expect the same SHA-256 leaf. If it is *not* dominant, stop — the rest of this -plan is aimed at the wrong thing. - -**Step 1 — establish the ceiling.** The upgrade is `enumerate_nodes` over 793 -uids. Measure single-node unlock cost directly (log the S2K iteration count and -elapsed time per file across one folder — this also settles the question B12 left -open, whether the 49 ms/entry folder is expensive because of *n* or because the -uploading client chose a costlier S2K). Ideal speedup is bounded by core count -and by how uneven those per-file costs are. - -**Step 2 — move the crypto off the async runtime.** The decryption currently runs -inline in the SDK's `enumerate_nodes` future. Blocking CPU work on a Tokio worker -starves everything else sharing that runtime. Wrap the per-node unlock in -`spawn_blocking`, or hand the whole chunk to a `rayon` pool and await the result. -This is the change PERF #5 describes and is a **`proton-drive-rs` change, not a -`pdfs` one** — which is why it was deferred: it needs an SDK release, and the -workspace pins `"0.1"` so a `cargo update` picks it up. - -**Step 3 — parallelise across files within a chunk.** 150 nodes per chunk, each -independent once the parent key is resolved. `resolve_parent_key_ctx` caches the -ancestor key and all children of a folder share one parent, so the parallel -region needs read-only access to it — take the folder key once, then fan out. -Watch `FOLDER_KEY_CACHE_CAP`/`folder_keys` (PERF #9 made it an LRU) for -contention if the fan-out takes that lock per node. - -**Step 4 — re-measure the same three folders**, which have before/after numbers -already recorded above and in B12: - -| folder | cold plain `ls` | cold `ls -l` (today) | -|---|---|---| -| `Music/aC_ID.dll` (793) | 4.58 s | 85.8 s | -| `InstantUpload/Camera` (484) | 2.91 s | not measured | -| `Pictures/old` (417) | 2.25 s | not measured | - -Fill in the middle column's `ls -l` first — there is only one data point for the -regression right now, and B12's "cliff past ~500" framing was wrong precisely -because it generalised from a single folder. **Do not repeat that mistake here.** - -**What success looks like:** cold `ls -l` on the 793-entry folder within ~2–3× of -the plain `ls`, rather than 19×. Perfect scaling is not available — the request -round trips are serial per chunk and only the crypto parallelises. - -**Explicitly out of scope:** `readdirplus`. It would cut the *number* of calls -(one reply per directory instead of one `lookup` per entry) and is the right -long-term shape for the directory-read path, but it does not remove a single S2K -— the sizes still have to be computed. It is a separate change and should not be -bundled in, for the same reason B5 declined it: it would hide this bug rather -than fix it. - -**Do not** attempt to derive the plaintext size from `total_size_on_storage` -instead. The delta is not a constant (+564 B on a 39 MB file, +51 B on a 16-byte -one — it scales with block count), so the arithmetic would have to model Proton's -block framing exactly, and being wrong in the *small* direction reproduces B11, -where a short size made the kernel skip reads entirely. - -**Verify with:** the ciphertext delta is the probe (+564 B on a 39 MB file, -+51 B on a 16-byte one — it scales with block count, so it is not a constant). -`pdfs refresh` a folder, then `ls -l` immediately and diff against a settled -listing. - ---- - -## B15 — an empty-but-`listed` folder, and duplicated folder uids - -**Status:** Open (side finding, unattributed). Originally filed as "a sync-folder -listing came back empty"; that part was investigated and retracted — see below. -**Found:** 2026-07-20, while confirming B13. - -`ls ~/Music/Buunshin/not everythin is your fault (wav)/` returns `total 0` — no -entries — even though the rename failures in B13 prove files existed under those -names minutes earlier (Proton rejected the rename *because* the name was taken). -The same folder read through the primary mount -(`~/ProtonDrive/Music/Buunshin/…`) lists all seven `.wav` files correctly. - -**Checked (2026-07-20) — the "stale empty listing" reading was wrong.** The -folder `not everythin is your fault (wav)` that read empty has **`listed = 0`** -in `nodes`, so its `ls` was a real network enumeration, not a cached empty one. -The files genuinely are not there: every rename in B13 failed and rsync -discarded its temp files. B15 as originally filed does not exist. - -**What the query did turn up, and is worth keeping:** - -1. **A real B1-shaped row.** `not everything is your fault` (no `(wav)`) reads - `listed = 1, trashed = 0` with **zero child rows** — the exact - empty-but-listed state B1 was about, still present in the live DB, reached by - some route the B1 fix does not cover. This one is worth chasing. -2. **Duplicated folders.** `Music` and `Buunshin` each exist under **two - distinct uids**, and the `(wav)` folder name appears twice as well — one copy - populated (7 children), one empty. So the remote tree has picked up duplicate - directories somewhere. Related to the self-conflict duplication already - recorded in memory, but not yet attributed here. - -Both are folder-identity problems rather than the mount disagreement originally -suspected. Renamed scope accordingly; the "two mounts disagree" framing is -retracted. - ---- - -## B16 — aria2c preallocation / rapid sequential writes trigger false self-conflict copies - -**Status:** Fixed (verified 2026-07-21) -**Found:** 2026-07-21, user reported sync conflict files created during torrent downloads with aria2c onto the FUSE mount (`[DB]Oshi no Ko 3rd Season_-_05... (sync-conflict 1784644482).mkv`). -**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/drain.rs`, `crates/pdfs-core/src/db/ops.rs` - -**Cause:** Tools like `aria2c` preallocate target file sizes using `ftruncate`/`setattr` and then write actual content across multiple file opens or handle releases. -1. First close (e.g. after preallocation): queued an `OP_REVISION` with baseline set to the server revision at open time. -2. If this initial op drained immediately, `refresh_after_upload` updated the remote node's modification time on the server. -3. Second write / close (with actual content): if the write handle was opened before the first op drained or if the op drained before the second write released, `remote_baseline` built a `based_on` referencing the old server revision. -4. When the second op drained, `revision_conflict` compared `based_on` against the new remote revision mtime, detected a mismatch, and treated it as a remote edit by another device — uploading the local data as a `(sync-conflict )` duplicate file. - -**Fix:** A two-part defense: -1. **Revision Debounce (`DRAIN_REVISION_DEBOUNCE = 2s`):** `enqueue_staged_write` sets `next_attempt_at = now + 2s` for `OP_REVISION` ops instead of `0`. Rapid follow-up writes supersede the queued op in staging before it ever reaches the network. Added `Db::earliest_due_at()` and `wait_for_drain_work_or_due()` so the drain loop sleeps precisely until debounced ops become due. -2. **Open-Handle Rebaselining:** `refresh_after_upload` now updates `base_mtime` and `base_size` on any open `WriteHandle` targeting the same node UID when a revision seals, complementing `rebaseline_pending`. - -**Verified:** Clean build and 200/200 workspace tests passing. - -**Residual (2026-07-24):** the two-part defense narrowed the window but did not close it — the baseline was still keyed on `(mtime, size)`, which drifts when the server re-stamps the *same* revision. Reproduced again on a plain application save (`test_export.xml`): base mtime `1784897777` vs sealed `1784897780`, identical size, no other device involved. The root cause is that mtime is not a revision identity. Fixed properly by **B69**. - ---- - -## B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix - -**Status:** Fixed (verified 2026-07-21) -**Found:** 2026-07-21, deep code audit of `fix/ioctl-handler` branch. -**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-core/src/db/ops.rs` - -**Cause:** -1. `Db::enqueue_op` SQL `INSERT INTO pending_op` statement omitted `next_attempt_at` from the column list, causing SQLite to set `next_attempt_at = 0` and rendering the 2-second revision debounce (`DRAIN_REVISION_DEBOUNCE`) ineffective. -2. `ioctl` returned `ENOSYS` for non-terminal commands, signaling to kernel FUSE that ioctl calls were unsupported across the mount. -3. `fallocate` executed `libc::fallocate` while holding `self.core.state.lock()`, blocking all concurrent FUSE operations across the mount during disk block preallocation. -4. `open`/`create` invoked `create_scratch()` prior to checking `st.active_writes`, leaking scratch files on disk during concurrent opens. - -**Fix:** -1. Updated `Db::enqueue_op` in `ops.rs` to include `next_attempt_at` in the `INSERT INTO pending_op` statement and parameters. -2. Changed `ioctl` handler in `lib.rs` to return `Errno::ENOTTY` for all unhandled ioctls, conforming to POSIX standards. -3. Moved `libc::fallocate` outside the `state` lock by cloning the `Arc` reference first, and mapped system OS errors to `fuser::Errno`. -4. Refactored `open` and `create` to check `st.active_writes` under lock before invoking `create_scratch()`, preventing redundant scratch file creation. - -**Verified:** Workspace unit tests (200/200) and `clippy` passing cleanly. - ---- - -## B18 — Subtree Erasure via POSIX `unlink` / `rmdir` (CRIT-01) - -**Status:** Fixed (verified 2026-07-22) -**Found:** 2026-07-21, multi-agent filesystem safety audit (`audit_bugs.md` CRIT-01) -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::unlink`, `ProtonFs::rmdir` - -**Cause:** `unlink` on directories and `rmdir` on non-empty directories bypassed POSIX checks and called `trash_child` directly, silently deleting remote subtrees. - -**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, 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. -- Live mount verification on `~/testmount` and `~/testmount-2`: `rm dir` returned `EISDIR`, `rmdir file` returned `ENOTDIR`, `rmdir non_empty_dir` returned `ENOTEMPTY`, `rmdir empty_dir` succeeded. - ---- - -## B19 — Remote Folder Trashing on Mode Switch Failure (CRIT-02) - -**Status:** Fixed; all four managed-live mode pairs verified 2026-07-22, -fault-injected mount failure still pending -**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:** 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:** 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:** `cargo clippy -p pdfs-fuse --all-targets -- -D warnings`, the -`pdfs-fuse` unit suite, and the complete managed-live matrix passed against the -1.0.0 release binary. A fault-injected mount-failure test is still needed. - ---- - -## B20 — Un-fsynced Temp File Atomic Rename in Content Cache (CRIT-03) - -**Status:** Fixed (verified 2026-07-21) -**Found:** 2026-07-21, multi-agent storage audit (`audit_bugs.md` CRIT-03) -**Where:** `crates/pdfs-core/src/cache.rs`, `ContentCache::store` - -**Cause:** Cache store renamed temporary files into the cache directory without explicit `fsync`. Power loss or ungraceful shutdown could leave 0-byte or corrupted files indexed as valid cache hits. - -**Fix:** Added `f.sync_all()?` calls on open file handles prior to executing `std::fs::rename`. - -**Verified:** `cargo test -p pdfs-core` suite passing cleanly. - ---- - -## B21 — Untracked Hole Punching in `fallocate` (HIGH-01) - -**Status:** Fixed (verified 2026-07-22) -**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-01) -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::fallocate` - -**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:** Mark the punched, in-file range as authored after successful local -`fallocate`, so its sparse zero bytes are retained by revision assembly. - -**Verified:** -- Live mount verification: `libc.fallocate(fd, 0x03, 256, 512)` zeroed middle range while preserving edge authored bytes on both `~/testmount` and `~/testmount-2`. - ---- - -## B22 — Synchronous `block_on` inside Mutex Guard (HIGH-02) - -**Status:** Fixed (unverified under load) 2026-08-16 — the systematic audit this was waiting on -has been done and found nothing left. -**Found:** 2026-07-21, multi-agent concurrency audit (`audit_bugs.md` HIGH-02) -**Where:** `crates/pdfs-fuse/src/lib.rs`, `sync.rs`, `devices.rs` - -**Cause:** Invoking `rt.block_on(...)` while holding `state.lock()` deadlocks worker threads and the main FUSE loop under high concurrency. Most major handlers have been refactored to drop `state.lock()` before invoking async runtime methods, but a full systematic audit across all background tasks and FUSE callbacks is required to eliminate all instances. - -**Audit (2026-08-16).** All 131 `block_on` sites in `pdfs-fuse` were scanned mechanically: for -each, every guard binding (`self.state()` or any `.lock()`) still lexically live at that point, -accounting for explicit `drop(..)` and scope exits. Two hits, both false positives — -`devices.rs:570` binds `taken`, the `Option` *taken out of* the map (the guard is a -temporary, and `_guard` is explicitly dropped above), and `lib.rs:1917/1927` binds `resident`, -a `bool` read out of a temporary guard. `pdfs-core` contains no `block_on` at all. The reverse -direction — a guard held across an `.await` — is covered by `clippy::await_holding_lock`, which -is on in the CI gate. - -The scan is lexical, so it cannot see a guard passed into a callee; it is evidence, not proof, -which is why this is *unverified*. What would settle it is the concurrency half of the acceptance -suite run against a heavily loaded drain. - ---- - -## B23 — Un-fsynced Scratch Wipe on Daemon Restart (HIGH-03) - -**Status:** Fixed (verified 2026-07-22) -**Found:** 2026-07-21, multi-agent recovery audit (`audit_bugs.md` HIGH-03) -**Where:** `crates/pdfs-core/src/cache.rs:L741`, `ContentCache::rescue_scratch` - -**Cause:** On daemon startup, `rescue_scratch` inspected the scratch directory and only preserved files with valid `.json` sidecars created by `fsync`. Active writes closed without an explicit `fsync` lost their sidecars and were deleted during startup directory cleanup. - -**Fix:** Updated `rescue_scratch` in `cache.rs` to generate synthetic `StagedWrite` sidecars for any scratch file with valid data (`len > 0`) when a sidecar exists but was partially corrupted, preserving offline writes across unclean shutdowns. - -**Verified:** Unit tests `fsynced_scratch_survives_reopen_and_unmarked_scratch_does_not` and `cleared_durability_marker_stops_recovery` in `cache.rs` passing 100%. - ---- - -## B24 — Directory Hierarchy Loop in `rename` (HIGH-04) - -**Status:** Fixed (verified 2026-07-21) -**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-04) -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::rename`, `crates/pdfs-fuse/src/state.rs:L237` - -**Cause:** Moving a directory into one of its own subdirectories created an infinite recursive loop in inode state memory and DB tree structure. - -**Fix:** Added `State::is_ancestor_of` helper in `state.rs` that walks up parent chains. `rename` checks `st.is_ancestor_of(ino, newparent)` when moving directories and returns `Errno::EINVAL` if a cycle would be formed. - -**Verified:** -- Unit tests: `test_is_ancestor_of_hierarchy` in `state.rs` passing. -- Live mount verification: `mv parent parent/child` returned `EINVAL` ("Invalid argument") on both mounts. - ---- - -## B25 — Weak Conflict Baseline Detection `(mtime, size)` (HIGH-05) - -**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 — the FUSE-write half was closed by -**B69**; the mirror half is closed here. - -**Live verification (2026-08-16, `~/pdfs-live-mirror`, folder 6).** `b25.txt` was written with -mtime `12:00:10.100000000`, synced (baseline `local_mtime=1786874410`, -`local_mtime_ns=1786874410100000000`), then rewritten with sixteen different bytes and stamped -`12:00:10.900000000` — identical whole second, identical length. The next pass classified it as -locally changed and uploaded `b25.txt new version`; the baseline advanced to -`…410900000000`, and reading the file back through an on-demand mount of the same folder returned -`BBBBBBBBBBBBBBBB`. Under the old whole-second comparison this edit was invisible. -**Found:** 2026-07-21, multi-agent sync engine audit (`audit_bugs.md` HIGH-05) -**Where:** `crates/pdfs-core/src/cache.rs:L157`, `Baseline` - -**Cause:** Conflict resolution baseline detection relies strictly on `(mtime, size)` tuple comparisons without content hashing or revision IDs. Concurrent edits landing within the same second that produce identical file sizes match baselines, causing offline edits to overwrite remote changes silently without creating `(sync-conflict )` copies. - -**Fix (2026-08-16), part one — the FUSE write path.** Closed by **B69**: `Baseline` gained -`revision_id`, and `revision_changed` keys on the server revision id — the true identity, which -advances if and only if a new revision was sealed — falling back to `(mtime, size)` only for a -sidecar written before the field existed. - -**Fix (2026-08-16), part two — the mirror sync path.** B69 did not reach it: a mirror folder's -baseline is `sync_entry`, and its local side was still whole seconds. An edit made in the same -second as the last sync that left the file the same length — a fixed-size record rewritten in -place, an image re-exported, a database page updated — read as *unchanged* and was never -uploaded, leaving the remote silently behind until something else about the file moved. - -`sync_entry` gains `local_mtime_ns` (schema **28**), and the comparison moved into -`LocalSig::same_content`, which uses nanoseconds when both sides have them and whole seconds -otherwise. The column is nullable rather than a converted `local_mtime * 1e9` on purpose: the -sub-second part of an existing baseline is genuinely unknown, and inventing zero for it would make -every already-synced file compare as changed and re-upload an entire mirror on first start. `NULL` -means "compare seconds", which is exactly the behaviour that row was written under; each row gains -a real value the next time its path syncs. - -Covered by `db::tests::migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline` -and `sync::planner::file_plan_tests::a_same_second_same_size_edit_is_still_a_local_change`. - -**Still open (deliberately):** the *remote* side of a mirror baseline is still `(mtime, size)` -(`remote_rev`/`remote_hash`). Re-keying it onto `active_revision_id` the way B69 did for the FUSE -path is the same idea and the same size of change, but the stored column already holds -mtime-shaped strings, and telling an old row from a new one would need a heuristic on the value's -shape. That deserves its own column and its own pass rather than a guess. - -**To verify:** on a mirror folder, `printf %s aaaa > f && sync && printf %s bbbb > f` inside one -second, then a reconcile pass — expect an upload, and the remote content to be `bbbb`. - ---- - -## B26 — Broken Open-Unlinked File Semantics (HIGH-06) - -**Status:** Fixed (verified 2026-07-22) -**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-06) -**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/state.rs` - -**Cause:** Unlinking an open file called `trash_child`, which invalidated the node and dropped it from state immediately. Subsequent `read` calls on open file handles failed with `ENOENT` / `EIO`. - -**Fix:** -1. Added `open_count` and `unlinked` fields to `Entry` in `state.rs`. -2. `open` increments `open_count`, `release` decrements `open_count`. -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`. - ---- - -## B27 — Orphaned Write Revision Bricking Offline Sync Queue (HIGH-07) - -**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:** 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. - ---- - -## B28 — Unhandled Disk Exhaustion (`ENOSPC`) in Cache & Scratch Store (MED-01) - -**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` - -**Cause:** Running out of local disk space caused unhandled `EIO` errors without triggering emergency eviction of unpinned cache blobs. - -**Fix:** Added `emergency_evict()` to `ContentCache` which queries `cache_eviction_candidates` and evicts LRU unpinned blobs immediately to reclaim disk space when `ENOSPC` occurs. - -**Verified:** `cargo test -p pdfs-core` suite passing cleanly. - ---- - -## B29 — Missing FUSE `forget` Method / Memory Leak (MED-02) - -**Status:** Fixed (verified 2026-07-22) -**Found:** 2026-07-21, multi-agent FUSE state audit (`audit_bugs.md` MED-02) -**Where:** `crates/pdfs-fuse/src/lib.rs:L3983`, `crates/pdfs-fuse/src/state.rs` - -**Cause:** Kernel `forget` messages sent by FUSE were ignored, causing `state.entries` and `state.by_uid` maps to grow monotonically in RAM over long daemon uptimes. - -**Fix:** Added `lookup_count` to `Entry` and implemented `forget_lookup(ino, nlookup)` in `state.rs`. `ProtonFs::forget` now invokes `st.forget_lookup(ino.0, nlookup)` to prune unreferenced inodes from memory. - -**Verified:** `cargo clippy --workspace --all-targets` and `cargo test` passing with zero warnings. - ---- - -## B30 — SQLite Write Lock & Mutex Contention under Heavy Drain (MED-03) - -**Status:** Fixed (unverified under load) 2026-08-16 — closed by the work filed under the -performance audit rather than by a change made for this entry. -**Found:** 2026-07-21, multi-agent database audit (`audit_bugs.md` MED-03) -**Where:** `crates/pdfs-core/src/db/ops.rs`, `crates/pdfs-core/src/db/mod.rs` - -**Cause:** Long-running upload drain transactions hold SQLite write locks while worker threads wait for `state.lock()`, causing periodic FUSE response latency spikes during heavy sync operations. - -**Audit (2026-08-16).** Each of the three mechanisms behind that sentence is gone: - -1. **No transaction spans a network call.** Every `Db` method opens its transaction, commits and - returns; nothing holds one open across calls (the invariant is stated on `Db::read`). A drain - op's upload happens between database calls, not inside one. -2. **Reads no longer queue behind the writer.** The read-only connection pool (`Db::read`) gives - `SELECT`-only methods their own connections, so a FUSE metadata call does not wait on whatever - is committing. Asserted by `db::tests::a_read_does_not_wait_for_the_write_connection`, which - holds the write connection for the whole measurement. -3. **The queue is no longer read whole to pick one op.** `Db::claim_next_due_op` selects and - claims a single row in the database, which is what made a long queue quadratic to drain while - holding the shared connection. - -The `state.lock()` half is covered from the other side: **B75** moved network work off fuser's -dispatch thread, and **B22**'s audit found no `block_on` under a `State` guard. - -Unverified because "periodic latency spikes" is a load property, not a code property: it is -closed by construction and wants the concurrency acceptance case under a loaded drain to confirm -it in the field. - ---- - -## B31 — Unimplemented Special Files & Attribute Modifications (MED-04) - -**Status:** Not a bug — by design, verified 2026-08-16 -**Found:** 2026-07-21, multi-agent POSIX audit (`audit_bugs.md` MED-04) -**Where:** `crates/pdfs-fuse/src/lib.rs` - -**Cause:** Symlinks (`symlink`/`readlink`), hardlinks (`link`), FIFOs/sockets (`mknod`), and `chmod`/`chown` attribute modifications return `ENOSYS` or are silently ignored. - -**Verdict (2026-08-16).** Each of these is the correct answer, not a gap, and each errno is now a -deliberate choice with a comment saying why: - -- `symlink` → **EPERM**. The operation is meaningful; Drive simply cannot represent it. Callers - treat `EPERM` as a definite "no" instead of retrying against an older interface. -- `link` → **EPERM**. A node has exactly one parent on Drive, so there are no hard links. git - falls back to `rename()` on `EPERM`, which this filesystem supports. -- `mknod` → **ENOSYS**. Devices, FIFOs and sockets have no representation at all. Regular files - arrive through `create`, never here. -- `chmod`/`chown`/`utimes` → accepted and ignored, so tools that chmod or restamp after writing - succeed rather than failing on a filesystem with no per-file mode to set. - -Implementing any of them would mean inventing a local-only representation that no other Proton -client can read — a worse outcome than a clean refusal. The errno contract is asserted by the -acceptance suite's clean-refusal case so it cannot drift, and **B73** removed the per-probe WARN -that made these look like incidents in the journal. - ---- - -## B32 — Race Condition in Concurrent Handle Release and Open (MED-05) - -**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 - -**Live verification (2026-08-16).** Three 64 MiB random files were written over the same mount -path (`ProtonDrive/pdfs-live-20260816/b32.bin`) back to back, each `cp` closing before the next -began, while the drain was still working. The queue coalesced them into a single op (29 queued = -28 known-stuck + 1). After the drain retired it, `pdfs refresh` dropped the cached listing and a -full re-read from Drive returned `4ad41482f5199a401e425381d7621626` — the md5 of the *third* -write, byte for byte. No open re-used a stale staged base, and the retry warning never had to -fire. -**Found:** 2026-07-21, multi-agent concurrency audit (`audit_bugs.md` MED-05) -**Where:** `crates/pdfs-fuse/src/lib.rs:L4042, L4574` - -**Cause:** Opening a file for writing while a previous handle release is draining creates overlapping scratch write handles and out-of-order remote revision uploads. - -**Fix (2026-08-16).** The concrete hole was in `serve_open`: it sampled the queued revision -(`Core::pending`), then copied that blob into a fresh scratch file, then took the `State` lock to -install the handle. A `release` publishing a newer revision anywhere inside that gap — which spans -a copy the size of the whole file — left the new handle carrying the *older* base, and its own -release then published content that silently discarded the revision closed a moment earlier. - -The install is now guarded: the pending blob path (which changes on every publication, so it -identifies the revision) is re-read immediately before the `State` lock, and if it moved, the -scratch file is discarded and the open goes around again — up to `OPEN_BASE_ATTEMPTS` (3), after -which it fails with `EIO` rather than spinning a FUSE worker. Only a handle the open is about to -*create* is checked; if one already exists, that handle is authoritative and the scratch is -discarded regardless. - -The re-read is deliberately **not** taken under the `State` lock: no site in the daemon holds -`pending` and `state` at once (the invariant is stated on `stamp_pending_sizes`) and this is not -the place to become the first. What that costs is a residual window of a few instructions instead -of a lock-tight comparison; what it removes is the multi-gigabyte window, which is the one that -actually loses data. - -**To verify:** the acceptance suite's concurrency case, with a large file, closing and reopening -it for write from two processes while the drain is loaded. - ---- - -## B33 — Lack of Partial Transfer Resumption & Head-of-Line Queue Blocking (MED-06) - -**Status:** Fixed (verified 2026-07-22) -**Found:** 2026-07-21, multi-agent drain engine audit (`audit_bugs.md` MED-06) -**Where:** `crates/pdfs-fuse/src/drain.rs:L73` - -**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:** 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:** `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. - ---- - -## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07) - -**Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed -second-account and live FUSE acceptance remain pending P7 -**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07) -**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs` - -**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors. - -**Fix:** P2 persists share authority in schema V17 `share_access`, inherits it through resident -entries, exposes read-only POSIX modes, and gates mutations at handler, queue, and drain boundaries. -Downgrades fail closed and are not acknowledged past failed durable state updates. - -**Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied, -and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus -mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed -second-account and live FUSE acceptance have not been run and remain required in P7. - ---- - -## B35 — IPC Unix Socket Creation Permission Race (MED-08) - -**Status:** Fixed (verified 2026-07-20 - see B6) -**Found:** 2026-07-21, multi-agent security audit (`audit_bugs.md` MED-08) -**Where:** `crates/pdfs-fuse/src/control.rs`, `crates/pdfs-core/src/config.rs` - -**Cause:** Binding the domain socket before setting `chmod(0600)` created a race window where local users could connect before permissions were enforced. - -**Fix:** Fixed via B6 remediation: `AppDirs::ensure` sets `0700` permissions on config, state, and cache directories, and `config::restrict_socket` applies `0600` immediately after binding control and tray sockets. - ---- - -## B36 — Unicode Normalization Discrepancy (NFC vs NFD) (LOW-01) - -**Status:** Open (verified 2026-08-16, deliberately not fixed) -**Found:** 2026-07-21, multi-agent sync audit (`audit_bugs.md` LOW-01) -**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sync.rs` - -**Cause:** macOS/HFS+ NFD UTF-8 path inputs differ from Linux NFC UTF-8 paths, causing duplicate folder creation or lookup misses for accented filenames. - -**Verified (2026-08-16), not fixed.** The claim is accurate: names are compared as bytes -throughout — the mirror engine's rel paths, `by_uid`/`children` interning, and the planner's -classification union — so `café` written by a Proton client on macOS or iOS (NFD) and `café` -written here (NFC) are two different paths, and a mirror folder holding both would sync both. - -Not fixed here on purpose. A normalizing comparison changes what "the same file" *means* across -the whole daemon, and it changes it in the unsafe direction: two names that are genuinely distinct -on the remote would start colliding locally, and a collision in the sync engine is a deletion or -an overwrite, not a warning. Doing it properly means a normalization dependency, a decision about -which form is canonical on the wire, and a migration for baselines keyed on the other form — its -own change with its own acceptance case, not a line in a sweep. Left LOW because it needs -cross-platform authorship of the *same* accented name to bite at all. - ---- - -## B37 — Failed Remote Trash Removed the Local Dentry (CRIT-04) - -**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/filesystem.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:** Fixed and **live-verified 2026-08-16** on 1.8.1 - -**Live verification (2026-08-16).** Folder 6 was switched to on-demand (evicting its local -copies) and then back to mirror, which queues a download of the 128 MiB `b40.bin`. Three seconds -into that download, `RACING-LOCAL-EDIT-B39` was written to the destination path. The pass logged - -``` -WARN pdfs_fuse::sync: sync: local file changed while downloading; keeping it as a conflict copy - path=/home/narl/pdfs-live-mirror/b40.bin expected=None - actual=Some(LocalSig { mtime: 1786916720, mtime_ns: Some(1786916720414151922), size: 21 }) -``` - -and finished with the remote copy at `b40.bin` and the racing 21 bytes intact at -`b40 (sync-conflict 1786916794).bin` — which the following pass then uploaded as a file of its -own. Before the fix the rename would have destroyed those bytes with no trace. - -**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. - -**Fix (2026-08-16).** `Pending::Download` now carries `local: Option` — what the -planning walk actually saw at that path, or `None` for nothing there — and `download_file` calls -`keep_racing_local_edit` in the last moment before the rename that publishes the download. A -destination that no longer matches the plan is moved aside by `preserve_conflict_copy` first, so -the racing bytes survive and re-upload as a new file on the next pass: the same resolution a -both-sides-changed conflict already gets. A path that is *empty* when something was expected is -not a conflict — the download is about to recreate it and there is nothing to preserve. - -`Pending::Conflict` passes `None` deliberately: it has just moved the local copy aside itself, so -the state its download expects to find is "nothing", and anything that appears while it runs is a -new racing write that gets its own copy. - -Covered by `sync::tests::a_download_keeps_a_local_edit_it_did_not_plan_for` (unchanged destination -overwritten silently; changed destination preserved). Unverified against a real timing race — -the deterministic pause-mid-download test the entry asks for is not in place; the unit test drives -the guard directly instead. - ---- - -## B40 — Sync Upload Can Stream Torn Live Content (HIGH-10) - -**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 - -**Live verification (2026-08-16).** A 128 MiB `b40.bin` was dropped into folder 6 and a pass -forced; six seconds into the upload, sixteen bytes were overwritten at offset 100,000,000. The -pass logged - -``` -WARN pdfs_fuse::sync: sync: file changed while uploading; the remote revision may be torn and - will be replaced on the next pass rel="b40.bin" - streamed=LocalSig { mtime: 1786916599, mtime_ns: Some(1786916599336211782), size: 134217728 } - now=Some(LocalSig { mtime: 1786916605, mtime_ns: Some(1786916605686365985), size: 134217728 }) -``` - -and — this is the part that matters — recorded the baseline against the *streamed* signature -(`…599`), not the file on disk. The very next pass therefore read the file as locally changed and -uploaded `b40.bin new version`. Round-tripping the folder through on-demand and back later -downloaded the remote copy at md5 `cfb45003003fb2ad50c2c365f8313849` — the post-mutation local -content, exactly. The torn revision healed itself without anyone asking. - -**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. - -**Fix (2026-08-16).** The second option, and the correction is structural rather than a check. -`record_file_baseline` no longer stats the path when it is done; it is *given* the identity the -transferred content corresponds to, and both upload paths pass the `LocalSig` taken from the -descriptor they streamed. So a file that changed mid-stream records a baseline describing the -bytes that went up, not the bytes on disk now — which makes the very next pass classify the path -as locally changed and upload a whole, clean revision over the torn one. The old behaviour stated -the opposite: it declared the torn revision synchronized and left it as the file's content on -Drive. - -`warn_if_torn` compares before and after purely so the journal explains the extra revision; the -correction does not depend on it. Downloads are unaffected — there the file on disk *is* the -transferred content, published by a rename and stamped, so its stat is the identity. - -Note the pre-existing mitigation this backs up: the `/proc/*/fd` scan already defers any file held -open for writing (`FilePlan::Deferred`), so this covers the residual — a writer that opens, writes -and closes entirely inside one upload. - -**To verify:** upload a large file from a mirror folder while appending to it, then check that the -following pass uploads a new revision and the final remote content matches the local file. - ---- - -## B41 — Sync Folder Removal Races Active Reconciliation (HIGH-11) - -**Status:** Fixed (verified by inspection) 2026-08-16 — already correct in the code by the time -this was revisited -**Found:** 2026-07-22, sync lifecycle audit -**Where:** `crates/pdfs-fuse/src/devices.rs`, `remove_sync_folder` - -**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. - -**Audit (2026-08-16).** `Core::remove_sync_folder` opens by taking `self.sync_lock(id)` and holds -it across the whole removal, and `reconcile_pass` takes the same lock for the whole pass — so a -removal requested mid-pass blocks until that pass finishes, which is the serialization this asked -for. The ordering inside the lock is also the one specified: unmount the on-demand session first -(before trashing the remote tree it serves, which would otherwise leave it answering for deleted -nodes), then drop the row. - -The one step outside the lock is the remote trash, and that is safe by construction rather than by -accident: the row is already gone, and `reconcile_pass` re-reads `sync_folder_get` **under the -lock** and returns when it finds nothing, so no pass can start against a folder being removed. The -mode re-check on that same re-read is what makes this hold for a switch racing a removal too. - -No code change. Recorded as verified-by-inspection: a live removal during an active pass has not -been driven. - ---- - -## B42 — Local Delete Failure Is Recorded as Sync Success (HIGH-12) - -**Status:** Fixed (unverified) 2026-08-16. -**Found:** 2026-07-22, sync error-path audit -**Where:** `crates/pdfs-fuse/src/sync.rs`, local file/directory deletion branches - -**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`. - -**Fix (2026-08-16).** The error half was already in place by the time this was revisited: both -`FilePlan::DeleteLocal` and the deferred folder removals log the failure, increment -`outcome.errors` and `continue` without touching `sync_entry_remove`, so the baseline survives -and the survivor is not mistaken for a new local file next pass. - -The `ENOENT` half was not, and it failed the opposite way: a path that was *already* gone was -counted as an error and kept its baseline, so the same removal was retried and re-reported on -every pass forever. Both call sites now go through `removed_locally`, which reads `NotFound` as -the state the engine was asking for. Covered by -`sync::tests::an_already_absent_path_counts_as_removed`. Unverified because no live sync pass has -been driven over an externally-deleted file yet. - -**Live verification attempted 2026-08-16, blocked.** Driving this needs the *remote* side of a -mirror folder to lose a file, and a mirror folder's remote lives in the device share, which no -mountpoint exposes — `pdfs rm` only accepts paths under a mountpoint, and the obvious workaround -(switch the folder to on-demand, trash the file through its own mount, switch back) runs into -**B86**: the on-demand mount serves a listing that predates the mirror engine's uploads, so the -file to be trashed is not visible to `rm` at all. Verifying B42 live therefore needs either a -second client on the account or B86 fixed first. The ENOENT branch remains covered only by its -unit test. - ---- - -## B43 — FUSE Directory Cookies Are Not Stable (MED-09) - -**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 - -**Live verification (2026-08-16).** A probe opened -`ProtonDrive/pdfs-live-20260816` (12 files) and called `getdents64` with a 160-byte buffer, so the -kernel had to page through the listing; between every page it created a new file in the directory -and unlinked an older one. Three pages, 14 entries returned (12 + `.` + `..`), zero duplicates, -zero pre-existing entries missed — and none of the files created mid-enumeration appeared, which -is the frozen snapshot behaving as specified. The same probe against the pre-fix index-cookie -readdir is what this entry was written about. - -**Found:** 2026-07-22, FUSE/POSIX audit -**Where:** `crates/pdfs-fuse/src/filesystem.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. - -**Fix (2026-08-16).** The first option. `opendir` is now implemented: it enumerates the folder if -it is cold, freezes the listing, and publishes it under a fresh `fh` in `State::dir_snapshots`; -`readdir` serves from that snapshot; `releasedir` drops it. The kernel pairs each `opendir` with -exactly one `releasedir`, so the snapshot lives exactly as long as the enumeration it belongs to, -and a create or a trash landing between two pages can no longer shift the indexes the continuation -cookie is expressed in. - -The `fh` comes from the same `State::next_fh` counter write handles use, so a directory handle can -never collide with a file handle. Serving from the snapshot is a pure in-memory walk with no -chance of a remote call, so `readdir` no longer needs a worker at all; the live path -(`serve_readdir`) stays as a fallback for a handle that has no snapshot, and both build their -listing through the same `build_listing` so they cannot disagree. - -Enumeration cost is unchanged — it moved from the first `readdir` to `opendir`, one call earlier — -and a folder that cannot be enumerated now fails at `opendir`, where the caller can still act on -the errno. - -**To verify:** the acceptance suite with deliberately small reply pages, creating and trashing -entries between `getdents` calls, asserting no entry is skipped or repeated. - ---- - -## B44 — FUSE Background Workers Have No Coordinated Shutdown (MED-10) - -**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 - -**Live verification (2026-08-16).** `systemctl --user restart proton-drive.service` on a daemon -with six live mounts, a busy drain and 18.4 GiB staged: stop to `Stopped` took 110 ms, the journal -shows the six unmounts followed by `sync engine stopping` and `daemon stopping`, and systemd never -reached its SIGKILL timeout. The old PID was gone immediately, and the fresh daemon settled at 42 -threads — the same count the outgoing one had, so no loop was left behind or spawned twice. A -second restart mid-session behaved identically. - -**Found:** 2026-07-22, daemon lifecycle audit -**Where:** `crates/pdfs-fuse/src/mount.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. - -**Fix (2026-08-16).** All four, in `crates/pdfs-fuse/src/shutdown.rs` plus the loops: - -1. **Shared cancellation.** `Shutdown` — a flag and a condvar, set once and never cleared — lives - on `Core`, so every clone a worker was given carries it. -2. **Interruptible waits.** Every loop that called `thread::sleep` in a `loop {}` with no exit now - calls `Shutdown::sleep`, which returns `false` the moment teardown starts: the conflict sweep - (warmup *and* interval), the online probe, the local indexer, and both sync poll threads. The - drain workers check between ops — never inside one, so an op is always either retired or - released — and the sync engine gained a `SyncMsg::Stop` that ends its loop and abandons any - burst it was settling. -3. **Owned join handles.** `run_mount` collects every long-lived thread it starts into one - `workers` vec. -4. **Ordered shutdown.** `stop_workers` sets the flag, wakes the two waits that need waking — the - drain's own condvar via `wake_drain`, and the control listener, which blocks inside `accept` - and is woken by a connection from us before the socket is unlinked — then joins them all. It - runs after the mounts are down, because nothing a worker can still do is harmful at that point, - and it also runs on the startup-failure path, which abandons the mount just as completely. - -A join that takes a while is expected and correct: a drain worker part-way through an upload -finishes it rather than abandoning the user's bytes mid-flight. - -Covered by `shutdown::tests` (a stop cuts an hour-long wait short; an already-stopped signal does -not wait at all). Unverified for the property that motivated the entry — repeated in-process -mount/unmount returning thread counts to baseline — which wants the acceptance suite. - ---- - -## B45 — Truncate After a Queued Rewrite Corrupts or Returns EIO - -**Status:** Fixed and live-verified (2026-07-22) -**Found by:** `scripts/fuse-acceptance.sh` -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, write-open scratch setup; -`crates/pdfs-fuse/src/lib.rs`, `Core::queue_truncate` - -**Repro:** Write a file, immediately replace its contents, then run -`truncate -s 4 file` before the queued replacement drains. Reading it returned -four NUL bytes instead of the first four replacement bytes. - -The expanded managed suite found the path-based variant: create and close a -10-byte file, then immediately call `truncate(path, 4)`. The close's complete -revision was still inside the two-second drain debounce. `queue_truncate` -described the shrink as an incomplete edit over the remote base, noticed the -pending edit, preserved an orphan staging file, and returned `EIO`. - -**Cause:** A new write handle always treated the server revision as its base. -When a newer complete revision was still queued locally, the handle's scratch -file began sparse and zero-filled. Shrinking it preserved those scratch zeros, -not the prefix of the locally authoritative queued revision. - -**Fix:** A write opened over a complete queued revision seeds its scratch file -from that staged blob and marks the copied range authored. Stacking a write over -an incomplete queued revision is refused rather than guessed. Path-based -truncate now composes the same way: it copies a complete pending blob, applies -the new length, marks the result complete, and inherits the last real remote -baseline. Live acceptance verified shrink, zero-filled growth, and sparse I/O. - ---- - -## B46 — Combined Cross-Directory Move and Rename Can Fail Out of Date - -**Status:** Fixed and live-verified (2026-07-22) -**Found by:** `scripts/fuse-acceptance.sh` -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::rename` - -**Repro:** `mv source victim`, replacing an existing file, followed immediately -by `mv victim dir/moved`. Proton accepted the move half of the second operation, -then rejected its rename half with `InvalidRequirements` (HTTP 422, "out of -date"), surfaced as `EIO`. - -**Cause:** Moving changes the node's encrypted-name requirements. The following -link-details read can briefly observe the pre-move state, so the new name is -signed against stale requirements. - -**Fix history:** A bounded retry of `InvalidRequirements` reduced the window but -did not close it; the managed matrix reproduced the same failure after all four -retries. Combined cross-directory move+rename now enters the durable rename -queue as a desired end state. Its drain re-fetches the node on every attempt, -skips either half that already landed, renames before moving, and resolves name -collisions without losing the source. Simple rename-only and move-only calls -remain synchronous. The managed live suite subsequently passed the replacement -and combined move/rename cases. - -**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the -move and the rename as one `move-multiple` request carrying the target name, -instead of renaming in the source folder and then moving. That removes the -second call the stale requirements could hit, and the SDK retries a remaining -`InvalidRequirements` once with the server's current name hash. A destination -name collision now moves under a conflict name in one step. Same-folder renames -are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames -then moves with its bounded retry and has not been converted. Needs a managed -live run of the combined move/rename cases. - ---- - -## B47 — FUSE Accepts Path Components Longer Than NAME_MAX - -**Status:** Fixed and managed-live verified (2026-07-22) -**Found:** 2026-07-22, managed FUSE acceptance suite -**Where:** `crates/pdfs-fuse/src/filesystem.rs`, name-taking callbacks - -**Repro:** `open(/<256 ASCII bytes>, O_CREAT)` succeeded. A conventional -Linux filesystem must reject a pathname component longer than 255 bytes with -`ENAMETOOLONG`. - -**Cause:** Every callback converted `OsStr` with `to_string_lossy` and passed it -straight to Drive. This imposed neither Linux's component limit nor Drive's -UTF-8 requirement, and could silently change non-UTF-8 names. - -**Fix:** Route lookup, create, mkdir, unlink, rmdir, and both rename components -through one validator. It enforces the 255-byte limit, rejects reserved/empty -components, rejects invalid UTF-8 with `EILSEQ`, and preserves valid Unicode. -The managed live suite is the verification gate. - ---- - -## T1 — Managed Acceptance Raced Daemon Startup - -**Status:** Fixed (2026-07-22) -**Found:** repeated install/restart/acceptance loop -**Where:** `scripts/fuse-acceptance.py`, managed setup - -**Repro:** Run `systemctl --user restart proton-drive.service` and immediately -start `--managed-live`. The offline reference completed before the daemon had -recreated its control socket, so the first `pdfs --json sync list` failed with -`ENOENT` and setup aborted. - -**Fix:** Managed validation polls the supported `sync list` API until the daemon -is ready or `PDFS_ACCEPTANCE_SYNC_TIMEOUT` expires. This is tracked as a test -harness defect rather than an application bug. - ---- - -## T2 — Acceptance Cleanup Raced Queued Namespace Replay - -**Status:** Fixed (2026-07-22) -**Found:** managed on-demand/on-demand matrix after every functional test passed -**Where:** `scripts/fuse-acceptance.py`, per-contract cleanup - -**Repro:** A combined move+rename was correctly accepted into the durable queue. -Cleanup started recursively deleting its test tree while the rename drain was -still landing. The moved entry appeared in `dir-b` after `rmtree` enumerated it -but before `rmdir(dir-b)`, producing `ENOTEMPTY`. - -**Fix:** Before cleanup, poll `pdfs --json status` until both pending counters -are zero. Retry `rmtree` only for `ENOTEMPTY` inside the uniquely owned test -root, then wait for the queued deletions before changing modes. Other errors are -still immediate failures. - ---- - -## T3 — Mode-Matrix Preservation Check Raced Mirror Restoration - -**Status:** Fixed (2026-07-22) -**Found:** managed on-demand/on-demand → on-demand/mirror transition -**Where:** `scripts/fuse-acceptance.py`, managed mode switching - -**Repro:** Switch a populated managed folder from `ondemand` to `mirror`, wait -until `sync list` reports mirror/idle, and immediately read a preservation file. -The file can still be absent even though the subsequent restore pass downloads -it correctly. - -**Cause:** The application commits the new mode before scheduling reconciliation -and leaves the previous idle state and sync timestamp in the row. Those values -describe the completed on-demand state, not a completed mirror restoration. - -**Fix:** The harness records every transition to mirror and explicitly requests -and waits for a newer completed sync pass before validating local bytes. This is -tracked as a harness synchronization defect; transient absence before that pass -is expected asynchronous behavior, not data loss. - ---- - -## T4 — Successful Managed Cleanup Left Its Own Sentinel Behind - -**Status:** Fixed (2026-07-22) -**Found:** second consecutive managed acceptance run -**Where:** `scripts/fuse-acceptance.py`, managed cleanup - -**Repro:** Complete the mode matrix in mirror/mirror, then immediately start a -new managed run. Registration cleanup removed the sync-folder records and remote -test folders, but the next run's empty-directory precheck found the harness's -128 KiB preservation sentinel in each local directory. - -**Cause:** Removing a mirror registration intentionally preserves the local -copy. The harness treated unregistering as if it also emptied local storage. - -**Fix:** After successful unregister, cleanup deletes only the sentinel whose -contents match the per-run bytes generated by this harness. Unknown files remain -untouched and continue to fail the strict precheck. - ---- - -## B48 — Successful Unlink Can Reappear From a Stale Remote Listing - -**Status:** Fixed (unverified) -**Found:** 2026-07-22, managed acceptance cleanup after all on-demand I/O passed -**Where:** `crates/pdfs-fuse/src/lib.rs`, child enumeration and trash paths - -**Repro:** Unlink every child of a directory and immediately remove the -directory. `unlink` returned success, but a parent invalidation followed by an -eventually consistent remote enumeration returned the just-trashed child again. -`rmdir` then returned `ENOTEMPTY`; repeated recursive removal could reproduce it -for the entire acceptance timeout. - -**Cause:** Three stale-data paths combined. A queued cross-directory rename -updated `State` but did not invalidate the kernel's cached empty destination -listing, so recursive walkers never saw or unlinked the moved child. Removing a node recorded no -authoritative local tombstone, so an eventually consistent Drive listing could -intern it again. Separately, `State::has_children` treated a resident empty -listing as inconclusive and fell back to obsolete SQLite child rows. Thus -`readdir` could report empty while `rmdir` returned `ENOTEMPTY` forever. - -**Fix:** Every successful online or queued trash records its uid in a -session-shared hidden set. Both persisted and remote child enumeration filter -that set. Secondary on-demand mounts share it, and an explicit restore removes -the uid so restored content can appear normally. `has_children` now treats any -resident listing—including an empty one—as authoritative and consults SQLite -only when the listing is absent. After replying successfully to a queued rename, -the FUSE handler invalidates both exact dentries and both directory inodes; the -ordering matters because an invalidation sent before the rename reply is -overwritten by the kernel's response processing. Uids are immutable and never -reused, making a session-long tombstone safe. - -**Test:** the acceptance case "regression B48" lists a folder of 12 files, moves one more in, -unlinks them all, and on a mount asks Drive for the listing with `pdfs refresh`. The listing has to -be empty, and `rmdir` has to succeed at once. - ---- - -## B49 — Failed Conflict Preservation Still Overwrites the Local File (CRIT-05) - -**Status:** Fixed, with the injected-failure suite the entry asked for (2026-08-17). Only the -end-to-end live conflict remains unexercised. -**Found:** 2026-07-22, 1.0 data-safety audit -**Where:** `crates/pdfs-fuse/src/sync.rs`, download/conflict handling - -**Cause:** If renaming a concurrently changed local file to a conflict copy -fails, sync only logs the error and continues publishing the remote download -over the local version. Second-resolution conflict names can also collide. - -**Required fix/test:** Abort download publication unless preservation succeeds; -use collision-resistant names. Test existing names, permissions, `ENOSPC`, -`EIO`, and cross-filesystem behavior. This is B39's failure path; B25 remains -the separate weak-baseline problem. - -**Verification (2026-08-17).** `preserve_conflict_copy` was already correct — it copies to a -`create_new` destination, syncs data and directory, and removes the source last, so every failure -returns `Err` and `apply_one` never reaches the download. What was missing was proof, so: -`conflict_preservation_failures_all_keep_the_local_file` drives an unwritable directory -(`EACCES`), a source that vanished between planning and preservation (`ENOENT`), and a destination -name already taken by a directory — and asserts the local file is still there, byte-identical, -after each. `a_conflict_copy_is_byte_and_mtime_identical` covers the other half: the copy carries -the bytes, mtime and mode, so the next pass uploads the user's version rather than something that -looks freshly written. The existing collision test covers the suffix search. - -Not covered: a genuine `ENOSPC` or `EIO` from the storage layer, and the end-to-end live conflict. - ---- - -## B50 — Failed Orphan Staging Deletes the Sole Scratch Copy (CRIT-06) - -**Status:** Fixed, with injected filesystem-failure verification (2026-08-17). -**Found:** 2026-07-22, 1.0 data-safety audit -**Where:** `crates/pdfs-fuse/src/lib.rs`, `Core::stage_orphaned_write` - -**Cause:** If preserving an orphaned scratch write into staging fails, the -failure path removes the original even though no other durable copy exists. - -**Required fix/test:** Never unlink the source until a durable replacement and -metadata are committed. Inject `ENOSPC`, `EIO`, `EROFS`, permission, and -cross-device failures and assert a byte-identical copy remains discoverable. -Related: B23, B27, and B28. - -**Verification (2026-08-17).** Four new tests in `pdfs-core/src/cache.rs`. -`orphan_preservation_keeps_the_scratch_copy_when_staging_is_unwritable` makes staging mode `0500` -so both the rename and the copy fallback fail, and asserts the scratch file survives -byte-identical, is the path the error names, and carries its recovery marker. -`a_retained_orphan_write_is_discoverable_after_a_restart` reopens the cache after that failure and -asserts `recovered_writes` finds the bytes *with their metadata* — present is not the same as -discoverable, and discoverable is what the entry asked for. -`orphan_preservation_across_a_device_boundary_publishes_before_removing` and -`a_failed_cross_device_preservation_keeps_the_marked_source` drive the copy fallback across a real -device boundary (`/dev/shm` vs `/tmp`), asserting the ordering — destination and sidecar published -and synced first, source removed last — and that a publication which does not finish leaves the -marked source in place. The cross-device pair skips with a printed note on a machine that has only -one filesystem, rather than passing silently. - -Not covered: a genuine `ENOSPC`/`EIO` from the block layer; `EROFS` is approximated by the -unwritable-directory case rather than a read-only mount. - ---- - -## B51 — Scratch Sidecar Does Not Establish a Durable Recovery Record (CRIT-07) - -**Status:** Fixed, with crash-boundary and corrupt-sidecar verification (2026-08-17). True -power-loss testing (a machine actually losing power mid-write) is still not covered. -**Found:** 2026-07-22, 1.0 crash-consistency audit -**Where:** `crates/pdfs-core/src/cache.rs`, scratch durability markers - -**Cause:** A temporary sidecar is renamed without syncing the sidecar first or -the directory afterward. Power loss after successful user `fsync` can preserve -bytes but lose authored-range metadata; recovery may discard the write or treat -a sparse partial write as complete and replace untouched ranges with zeros. - -**Required fix/test:** Sync data, write and sync temporary metadata, rename it, -then sync the directory before acknowledging durability. Crash-test every -boundary and corrupt/partial sidecars. B20 and B23 do not cover this invariant. - -**Verification (2026-08-17).** `a_half_written_scratch_sidecar_is_never_read_as_a_record` puts on -disk exactly what a crash inside `mark_scratch_durable` leaves — a `.json.tmp` and no published -sidecar — and asserts the next open recovers nothing from it, so an unpublished temporary can -never be replayed as a write. `a_corrupt_scratch_sidecar_still_rescues_its_bytes` truncates a -published sidecar to a JSON prefix and asserts the blob is still moved to `recovery/` -unaccompanied: metadata loss must not take the user's bytes with it. -`remarking_a_scratch_file_replaces_its_record_atomically` covers the repeated-`fsync` case — each -call leaves one complete parseable record and no debris. - -Not covered: real power loss. Nothing short of a crashing VM tests that, and the entry should not -be closed as if it did. - ---- - -## B52 — Staged-Write Publication Is Not Crash-Safe (CRIT-08) - -**Status:** Fixed, with cross-filesystem verification against a real device boundary -(2026-08-17). True power-loss testing is still not covered. -**Found:** 2026-07-22, 1.0 crash-consistency audit -**Where:** `crates/pdfs-core/src/cache.rs`, staged-write publication - -**Cause:** Rename/copy and direct sidecar writes lack a complete sync protocol. -The cross-filesystem fallback can remove its source after an unsynced copy, -leaving no complete data/metadata pair after a crash. - -**Required fix/test:** Publish synced temporary data and metadata via atomic -renames and directory syncs; delete the source only after verification. Inject -failure after every write, sync, rename, DB enqueue, and deletion. Related: B50 -and B51. - -**Verification (2026-08-17).** `stage_write` gained a `stage_write_at` seam — the public entry -point picks a timestamped destination, which is right for production and impossible to arrange a -failure at, the same reason `preserve_write_at` exists. -`staging_across_a_device_boundary_removes_the_source_last` drives the copy path across a real -device boundary and asserts the destination and sidecar are both on disk before the source goes. -`a_failed_staging_sidecar_keeps_the_cross_device_source` blocks the sidecar's temporary name and -asserts the source survives — a staged blob nothing describes is not a substitute for the bytes. -`an_unaccompanied_staged_blob_survives_a_restart` asserts startup leaves an undescribed staging -blob (and a leftover `.json.tmp`) alone rather than tidying user data away. - -Not covered: real power loss, and injected failure at *every* one of the listed points — the -sidecar and source-removal boundaries are covered, the intermediate `fsync` calls are not. - ---- - -## B53 — Superseding a Pending Operation Is Not Transactional (CRIT-09) - -**Status:** Fixed, with rollback, restart and full-database verification (2026-08-17). -**Found:** 2026-07-22, 1.0 SQLite durability audit -**Where:** `crates/pdfs-core/src/db/ops.rs`, `Db::enqueue_op` - -**Cause:** The old durable row is deleted before its replacement is inserted, -outside a transaction. A crash, constraint error, full disk, or I/O failure can -erase acknowledged upload work and orphan its blob. - -**Required fix/test:** Select and replace in one transaction; retain the old -blob until commit and make cleanup recoverable. Inject insert/commit failures -and prove that old or new operation—never neither—remains queued. B27 concerns -a retained failing row; this issue erases the row. - -**Verification (2026-08-17).** The existing `failed_superseding_insert_keeps_the_old_pending_op` -proves the row rolls back. Two tests were added for the parts that make the rollback usable. -`a_rolled_back_supersede_keeps_its_blob_and_survives_reopen` asserts the failed call does *not* -report the old blob as superseded — that value is the caller's instruction to delete those bytes, -and the row that owns them is still queued — and that the rollback is still there after closing -and reopening a file-backed database, so it is not an artefact of the connection that failed. -`a_full_database_rolls_the_supersede_back_rather_than_erasing_it` caps `max_page_count` at the -current size, which is the same `SQLITE_FULL` an out-of-space filesystem produces, and asserts the -acknowledged upload is still queued and still owns its bytes. - ---- - -## B54 — Total-Wipe Guard Does Not Protect a One-Entry Baseline (CRIT-10) - -**Status:** Fixed, with one-entry, absent-root, unreadable-root and replaced-mountpoint -regressions (2026-08-17). The live mode matrix is still the only thing unexercised. -**Found:** 2026-07-22, 1.0 mirror-sync audit -**Where:** `crates/pdfs-fuse/src/sync/planner.rs` - -**Cause:** The safeguard activates only when the baseline has at least two -paths. A one-file local folder that is missing, unreadable, or wrongly mounted -can therefore trash its sole remote file. - -**Required fix/test:** Protect every non-empty baseline and require an -independently complete scan plus an explicit deliberate-delete signal for a -total wipe. Test absent roots, replaced mountpoints, permissions, and one-file -folders in every mode combination. - -**Verification (2026-08-17).** The guard reads `!baseline.is_empty()`, so every non-empty baseline -is protected; `guard_local_wipe_blocks_only_a_total_disappearance` already carried the one-entry -case. The "independently complete scan" half is B55's fix — an absent, unreadable or partially -readable root now fails the pass before the guard is reached, which those tests cover. - -What was missing is the case where nothing fails: a root that is readable but is no longer the -tree it was, an empty directory left where a mount used to be. -`a_replaced_mountpoint_trips_the_guard_at_every_baseline_size` walks a real empty root, confirms -the scan honestly reports nothing, and asserts the guard fires for baselines of one, two and three -paths — then that it steps aside the moment the tree is back, so a remounted folder resumes -instead of staying wedged. - -**Deliberate divergence from the required fix:** there is no explicit deliberate-delete signal. -The guard refuses a total wipe outright rather than offering a way to confirm one. That is -stricter than asked for, and it means a user who really did delete everything must remove and -re-add the sync folder. Worth revisiting if anyone hits it; not worth building a confirmation path -nobody has asked for yet. - -Not covered: the live mode matrix (`scripts/fuse-acceptance.sh --managed-live`). - ---- - -## B55 — Incomplete Local Scans Are Interpreted as Deletions (CRIT-11) - -**Status:** Fixed, with injected iterator/stat failure tests (2026-08-17). -**Found:** 2026-07-22, 1.0 mirror-sync audit -**Where:** `crates/pdfs-fuse/src/sync.rs`, local scan/reconciliation - -**Cause:** Directory-entry and metadata errors omit paths instead of marking the -snapshot incomplete. Reconciliation can treat omissions as intentional local -deletions and propagate them remotely. - -**Required fix/test:** Carry completeness/errors into planning; an incomplete -subtree must make the pass non-destructive and retain its prior baseline. Test -`readdir`, `stat`, permission, transient-I/O, and disappearing-entry failures. -B42 covers the inverse local-delete failure. - -**Verification (2026-08-17).** The scan body was lifted out of `Core::walk_local` into a free -`walk_local_tree`, so it can be driven against a real tree with a real unreadable directory in it -and no `Core` at all. Every failure in it is fatal to the pass by construction — a `read_dir` that -fails, an entry that cannot be stat-ed, a name that is not UTF-8 — because a skipped path is a -path reconciliation reads as a deletion and propagates to Drive. - -Tests: `an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it` (chmod `000` on a -subdirectory holding a file, asserting the error names the subtree), -`an_unreadable_root_fails_the_scan`, `an_absent_root_fails_the_scan` (the unmounted-device case), -`a_non_utf8_name_fails_the_scan`, and `a_readable_tree_scans_completely` as the complement — the -strictness must not cost a working pass, so a healthy tree still reports every entry and still -skips symlinks by design. - -Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`. -Both are races that would need a filesystem shim to force; the code path they take is the same -`symlink_metadata` failure the permission test drives. - ---- - -## B56 — Database Schema Version Parsing Fails Open (HIGH-13) - -**Status:** Partly fixed — future schemas are refused; malformed-version handling remains open -**Found:** 2026-07-22, 1.0 database audit -**Where:** `crates/pdfs-core/src/db/migrations.rs`, `Db::migrate` - -**Cause:** Missing, malformed, or non-numeric version values become zero, while -all future versions are accepted. Corruption can replay migrations, and an -older binary can open a newer incompatible database. - -**Required fix/test:** Distinguish a new DB from corrupt metadata; reject -malformed/future versions without mutation and validate required schema. Test -empty, malformed, negative, future, partial/interrupted migration, and upgrade -fixtures from every released schema. - ---- - -## B57 — Event Cursor Can Advance Past Unapplied State (HIGH-14) - -**Status:** Partly fixed — cursor now advances per successfully invalidated event; DB/apply fault tests remain -**Found:** 2026-07-22, 1.0 event-recovery audit -**Where:** `crates/pdfs-fuse/src/background.rs`, `run_event_sync` - -**Cause:** Cache invalidation failures are ignored while the batch cursor still -advances. Cursor-persistence failure also leaves the in-memory cursor moving; -first-seed persistence failure can later reseed at a newer head. Applying state -and committing its cursor have no atomic relationship. - -**Required fix/test:** Apply idempotently and persist derived DB state plus the -cursor atomically, or advance only through the last durable event. Retry failed -prerequisites. Crash/fault-test every event and cursor boundary, first seed, -duplicate replay, malformed events, and database-full conditions. - ---- - -## B58 — Control Socket Has Unbounded Frames and Connections (HIGH-15) - -**Status:** Fixed in code with frame-limit regressions; connection-flood verification pending -**Found:** 2026-07-22, 1.0 IPC audit -**Where:** `crates/pdfs-fuse/src/control.rs` - -**Cause:** The server uses unbounded `read_line`, a new thread per connection, -and no concurrency or idle limit. A local process can exhaust memory, file -descriptors, and threads, blocking filesystem control and safe shutdown. - -**Required fix/test:** Bound frames, clients, and idle lifetime. Test slow/no- -newline clients, oversized/truncated JSON, floods, and recovery. B6/B35 cover -socket authority and permissions, not resource bounds. - ---- - -## B59 — Private State Permission Enforcement Fails Open (HIGH-16) - -**Status:** Fixed in code with symlink regression; wrong-owner integration verification pending -**Found:** 2026-07-22, 1.0 local-privacy audit -**Where:** `crates/pdfs-core/src/config.rs`, `AppDirs::ensure` - -**Cause:** chmod/creation failures are warned about or ignored while decrypted -content, plaintext metadata, and the authenticated socket may remain accessible -to other local users. - -**Required fix/test:** Before loading credentials or serving data, verify every -sensitive directory is owned by the effective user, is not a symlink, and is -owner-only. Test wrong owner, symlink substitution, read-only filesystems, -permissive restored directories, and chmod failure. This tightens B6/B35. - ---- - -## B60 — Config Writes Are Non-Atomic and Parse Errors Are Overwritten (HIGH-17) - -**Status:** Fixed in code; fault/concurrent-save verification pending -**Found:** 2026-07-22, 1.0 configuration audit -**Where:** `crates/pdfs-core/src/config.rs`, `load_config` / `save_config` - -**Cause:** Save truncates in place without sync. Load treats read/parse failure -as absence and best-effort overwrites with defaults, silently losing mountpoint, -ignore policy, budget, and explicit device adoption. - -**Required fix/test:** Atomically publish a restricted, synced temporary file -and sync its directory. Preserve malformed input and report an actionable error. -Test invalid/truncated JSON, permissions, `ENOSPC`, crash points, and concurrent -saves. - ---- - -## B61 — Rotated Single-Use Credentials Can Be Lost (HIGH-18) - -**Status:** Open — 1.0 authentication/recovery blocker -**Found:** 2026-07-22, 1.0 authentication audit -**Where:** `crates/pdfs-core/src/auth.rs`, refresh callback - -**Cause:** Failure to serialize or persist rotated credentials is only logged -(and some setup failures are silent). The daemon continues in memory while the -keyring retains an invalid single-use refresh token, so restart loses login. - -**Required fix/test:** Expose unhealthy persistence, retry with bounded backoff, -notify the user, and define a safe re-login/shutdown path. Test locked/full/ -unavailable keyrings, callback races, repeated rotations, death, and restart. - ---- - -## B62 — Debian Artifact Omits Required Service and Autostart Units (HIGH-19) - -**Status:** Fixed in workflow; clean-install VM verification pending -**Found:** 2026-07-22, 1.0 packaging audit -**Where:** `.github/workflows/release.yml`; `packaging/` - -**Cause:** The generated package omits `proton-drive.service` and the tray -autostart entry even though normal login/GUI flows assume that lifecycle. - -**Required fix/test:** Package all units/resources with correct paths, modes, -and hooks. In clean supported-distribution VMs test install, login, reboot, -daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state. - ---- - -## B63 — Release Version Identity Is Inconsistent and Unenforced (HIGH-20) - -**Status:** Partly fixed — tag/workspace/PKGBUILD equality is enforced; protocol identity remains -**Found:** 2026-07-22, 1.0 release audit -**Where:** manifests, client identity constants, tags, release workflow - -**Cause:** Workspace/binary versions, protocol identity strings, and tags can -disagree; the workflow trusts any `v*` tag without checking package metadata or -installed `--version` output. - -**Required fix/test:** Establish one version source and fail CI on every tag, -manifest, package, and binary mismatch. Document any intentionally independent -protocol identity. - ---- - -## B64 — Stable Publishing Lacks Data-Safety and Recovery Gates (CRIT-12) - -**Status:** Partly fixed — automated gates and managed-live matrix pass; recovery approval remains open -**Found:** 2026-07-22, 1.0 release audit -**Where:** CI and `.github/workflows/release.yml` - -**Cause:** A tag can publish without required format/lint/tests, offline and -dedicated-account live acceptance, migrations, or crash/recovery drills. Fixes -still marked unverified can ship as stable. - -**Required fix/test:** Publish immutable RC artifacts only after all workspace -checks, full managed mode matrix, migration/power-loss recovery, clean install/ -upgrade tests, and explicit sign-off for every critical/high integrity issue. - ---- - -## B65 — Release Artifacts Lack Supply-Chain Verification (MED-11) - -**Status:** Open — 1.0 release/security blocker -**Found:** 2026-07-22, 1.0 supply-chain audit -**Where:** CI and release workflows - -**Cause:** Releases do not enforce advisory/license policy or publish signed -provenance, SBOMs, and verifiable checksums for exact distributed artifacts. - -**Required fix/test:** Add locked audit/license gates, SBOM and provenance, -checksums and signatures, documented verification, and an independent clean-job -verification/install test. - ---- - -## B66 — Local-Only Pending Data Is Not Protected at Shutdown (HIGH-21) - -**Status:** Open — 1.0 data-safety/UX blocker -**Found:** 2026-07-22, 1.0 recovery audit -**Where:** shutdown, CLI/GUI status, logout/unregister workflows - -**Cause:** Undrained writes may exist only on one machine, but there is no flush -command with a completion contract, prominent pending byte/count health, or -shutdown/logout warning/inhibition. - -**Required fix/test:** Add `pdfs sync flush`, expose pending operations/bytes -and last errors, and protect destructive lifecycle actions. Test offline stop, -logout, unregister, reboot, retry exhaustion, and drain. Credential removal must -never delete staged bytes. - ---- - -## B67 — Fresh-State Restore Can Silently Select a New Device (HIGH-22) - -**Status:** Open — 1.0 recovery blocker -**Found:** 2026-07-22, 1.0 disaster-recovery audit -**Where:** device discovery/adoption; `docs/RECOVERY.md` - -**Cause:** Restore depends on hostname and folder basename. After local-state -loss, a mismatch can silently create/select another device rather than attach -the intended remote roots, making recovery appear complete while data is absent. - -**Required fix/test:** Require explicit adoption/confirmation on ambiguity and -show old/proposed device and root IDs. Test state loss, hostname change, -duplicates, renamed folders, multiple devices, and byte-identical restoration. - ---- - -## B68 — Profile Backup Cannot Be Written to the Device Root - -**Status:** Partly fixed — the destination is fixed and verified live -(2026-07-31, see **B78**); backup health is still invisible in the UI and the -replacement-machine restore has not been re-run -**Found:** complete managed-live mode matrix against the 1.0.0 release binary -**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination - -**Repro:** Every sync-folder registration or mode transition attempted the -profile backup and received `NotEnoughPermissions` (HTTP 422): `Cannot create -file at the root of a device`. The filesystem matrix itself passed, but profile -backup never became durable remotely. - -**Impact:** The recovery feature appears active but does not preserve the device -profile, so a replacement machine still lacks modes, pins, mount settings, and -the explicit remote-folder mapping. Repeated retries also add noisy warnings. - -**Required fix/test:** Store the profile below a writable application folder -rather than directly at the device root, reuse that folder by stable identity, -and make backup health visible. Verify upload, replacement, restart restore, and -byte-identical fresh-state recovery on a dedicated account. - -## B69 — Spurious `(sync-conflict)` copies from mtime-keyed revision identity (B16/B25 root fix) - -**Status:** Fixed — offline-tested 2026-07-25, **live-validated 2026-07-26** (see below) -**Found:** 2026-07-24, user reported `test_export (sync-conflict 1784898786).xml` created after a normal save from an application into the on-demand mount. A full-mount scan turned up ~156 conflict copies; 151 were byte-identical to their live sibling. -**Where:** `crates/pdfs-fuse/src/{drain,filesystem,lib,state,sweep,sync,sync/planner}.rs`, `crates/pdfs-core/src/cache.rs`, `crates/pdfs-core/src/control.rs`; SDK `proton-drive-rs` `node.rs`/`client.rs`/`public_link.rs`. - -**Cause:** Two independent false-positive sources, both from `(mtime, size)` being used as a revision identity (the weakness B25 flagged; the recurrence B16 could not fully close): -1. **Re-stamped mtime.** `revision_conflict` compared the queued write's baseline mtime against the remote's. The server stamps a sealed revision with its own commit time (`…780`), a few seconds off the optimistic time the client baselined at (`…777`). Same bytes, same size, drifted mtime → the drain diverted the write into a conflict copy of its own base. -2. **First-sync of pre-existing files.** `plan_file` classified `(local, remote, no-baseline)` as `Conflict`, so the *first* reconcile of a folder cut a conflict copy of every file that already existed identically on both sides — the ~151-file storm, all in one ~40s window. - -**Fix:** Make conflict detection key on the server **revision id**, the true identity (it advances iff a new revision was sealed): -1. **SDK** exposes `active_revision_id` and `content_sha1` (plaintext SHA-1 from the revision's decrypted `XAttr`) on `NodeKind::File` — both already on the wire / already decrypted, no extra round trips. -2. **`Baseline`** gains `revision_id`, captured at write-open (`WriteHandle.base_revision_id`) and carried across supersede/rebaseline like the mtime. `revision_changed` (extracted, pure, unit-tested) trusts the id when both sides have it and only falls back to `(mtime, size)` for an old sidecar. A re-stamped mtime on the same id is no longer a conflict; a same-size edit by another device (new id) still is. -3. **Planner** adds `FilePlan::AdoptBaseline`: a no-baseline pair of equal size is recorded into the baseline instead of conflicted. Divergent sizes stay a real conflict. -4. **Auto-sweep** (`sweep.rs`, `pdfs-conflict-sweep` thread, 5-min cadence): removes conflict copies proven identical to their live sibling (equal size **and** equal `content_sha1`, trashing the copy remotely) and surfaces divergent/orphaned ones once as a new `ActivityKind::Conflict` entry. Never removes a copy it cannot prove is a duplicate. - -**Verified:** `proton-sdk-rs` 50/61 lib tests + clippy clean; client `cargo fmt`/`clippy -D warnings`/`cargo test --workspace --locked` all green (258 tests, incl. new `revision_changed`, planner `AdoptBaseline`, and `conflict_base_name` cases). The 151 identical copies were removed manually during triage; the 5 divergent ones are left for the user. - -**Live validation (2026-07-26):** 1.1.0 installed over the production account (6 -FUSE mounts of real data), daemon restarted, sweep observed across its warmup pass -and a full 5-minute interval. Pre-run snapshot: exactly 2 `(sync-conflict)` copies -left on the account, both divergent in size *and* `content_sha1`, so the correct -behaviour was to remove nothing. Result: **nothing removed** — the conflict -manifest was byte-identical before and after, the activity feed gained zero -`auto-removed duplicate` entries, and the copies were flagged `differs from …` as -intended. (The 40 `trash` entries in the feed for that window are -`trashed from the mount` — a previous FUSE-acceptance run's queued cleanup ops -replaying through the drain, unrelated to the sweep.) The sweep's *removal* path -therefore remains unexercised against a real account; it is now report-only by -default (B71) precisely so that stays true until there is field evidence. - -**Shipping note (resolved 2026-07-25):** the client's revision-id/sha1 use depends on new SDK surface (`proton-sdk`/`proton-drive-rs` **0.2.2**). That version is now **published to crates.io**; the client's workspace deps were bumped `"0.2"` → `"0.2.2"`, the `Cargo.lock` re-resolved to the registry crates, and the local `[patch.crates-io]` shim removed. No local patch remains. (Live validation of the fix against a real account is still pending.) - -## B70 — Browser in-flight temp files uploaded and conflict-forked on the on-demand mount - -**Status:** Layers A + B fixed in-tree 2026-07-25 — **live-validated 2026-07-26**. -Validation was blocked until B74 was fixed (its scenario ends in a rename, which -B74 made read back as empty); with that out of the way the acceptance case -`regression B70: transient download name is not sealed` passes live. -**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads` -(an on-demand mount) the complete files had been forked into `(sync-conflict)` -copies while their truncated partials kept the canonical name — e.g. -`teamspeak.tar.gz` (30 MB partial live vs a 3.77 GB conflict copy) and `nils.zip` -(17 MB vs 3.18 GB). The conflict copies were byte-exact to the user's independent -gdrive backups, proving the *conflict copy* was the finished download and the -*live* file the stub. The smoking gun: four `Unconfirmed NNNNN (sync-conflict …).crdownload` -copies — Brave's in-flight temp files, forked *mid-download*. User confirmed the -trigger: downloading with Brave directly into the on-demand folder, where a stall -+ "resume" is routine. -**Where:** on-demand write path — `crates/pdfs-fuse/src/filesystem.rs` `release` -(`queue_revision`, ~line 780) and `rename` (~line 933); drain seal in -`crates/pdfs-fuse/src/drain.rs`. The existing ignore system -(`crates/pdfs-core/src/syncignore.rs`, `DEFAULT_IGNORE_PATTERNS`) is wired **only** -into mirror reconcile (`sync.rs`), so it never sees on-demand writes. - -**Cause:** A browser downloading into the mount writes a growing temp file -(`*.crdownload`, or `*.part` for Firefox) and, on a stall, closes the fd. `release` -hands every closed write to `queue_revision`, so a *partial* revision gets sealed -as the canonical file. On resume the browser reopens and rewrites the full file; -its baseline revision has moved (the client sealed the partial itself), so the -drain diverts the finished bytes into a `(sync-conflict)` copy. Net: the stub wins -the name, the real file is exiled to a conflict copy, and every abandoned -`.crdownload` is uploaded as its own multi-hundred-MB node. Unlike B69 this is a -*genuine* two-revision divergence, so B69's revision-id detection correctly refuses -to auto-sweep it — the fix has to stop the fork from happening, not reconcile it -after. - -**Fix — two layers, both without an SDK release (A stops transient names ever -sealing; B stops a single-writer resume forking against its own seal):** - -- **A — transient names never seal a revision until finalized (done 2026-07-25).** - A new `syncignore::is_transient_name` recognises browser download temps - (`*.crdownload`/`*.part`/`*.partial`/`*.download`), generic scratch - (`*.tmp`/`*.temp`), editor swap/backup (`*.swp`/`*.swx`/`*~`), and office/lock - files (`.~lock.*`, `~$*`). On `create` (filesystem.rs) a transient name is kept - **purely local** — no empty remote node is minted — and its queued create is - **parked**: `next_attempt_at` is set to the new `PARK_UNTIL` sentinel so - `Db::next_due_op` never selects it. Written bytes still ride on that create via - the existing `attach_blob_to_create`, whose `next_attempt_at` reset now - *preserves* a park (SQL `CASE … >= PARK_UNTIL`), so a growing download attaches - revision after revision without ever waking the drain. The finalize `rename` - (`foo.crdownload → foo`) goes through the existing `is_local_uid` branch - (`rewrite_op_target` renames the queued create); when the old name was transient - and the new one is not, it calls `Db::set_create_hold(uid, false)` and wakes the - drain, so the *completed* file uploads exactly once. Net: no partial and no - abandoned temp ever reaches Drive, and nothing forks. Reuses the whole - create/attach/rename/drain pipeline — the only new surface is the predicate, the - `PARK_UNTIL` sentinel, `set_create_hold`, a `hold` arg on `queue_local_node`, - and the attach-preserves-park `CASE`. Tests: `syncignore` predicate cases + - `db::a_parked_transient_create_stays_off_the_drain_until_finalized`. No SDK - dependency. - - *Not yet covered by A:* apps that write the final name in place with **no** - temp suffix (rare), and Firefox multi-connection `.part` files that it may - rename per-segment — those still rely on B. An abandoned transient file (a - cancelled download left on disk) now stays local-only forever rather than - polluting Drive; a later janitor could reclaim its staged blob. -- **B — don't self-conflict a single-writer sequential rewrite (done 2026-07-25, - no SDK dep).** The safety net for names A does not recognise (an app writing the - final name in place, Firefox per-segment `.part` renames, or any resume the - in-memory rebase machinery missed). The daemon now records the server revision - id it *itself* seals per node (`Core.own_sealed_revs`, written in - `refresh_after_upload` where the sealed node is already fetched, dropped on - trash). When a queued write drains and `revision_changed` fires, - `Core::revision_conflict` checks whether the remote sits at one of *our own* - sealed revisions (pure `is_own_self_supersede`): if so, no other device touched - the file — it is a single-writer stall→resume — so it chains (supersedes) - instead of forking a `(sync-conflict)` copy. Gated on `meta.complete`: an - incomplete blob's gaps still refer to the stale base, so it keeps the - non-destructive conflict-copy path. This is a superset guard over the existing - `refresh_after_upload`/`rebaseline_pending` rebasers, which only fire while a - handle/op is live; B catches the windows they miss (fresh open during the - earlier upload's flight, a stale inherited baseline). Tests: `is_own_self_supersede` - cases in `drain.rs`. *Residual:* the map is in-memory only, so a daemon restart - between the partial seal and the resume drops the record and that one write can - still fork — narrow, and B69's revision-id keying already covers the mtime-drift - half. A persisted own-seal ledger would close it. - -**Triage of the copies already made is done (2026-07-25):** all completed files -were promoted back to their canonical names (metadata-only rename, no re-upload) -and the abandoned `.crdownload` stubs trashed. - -## B71 — Conflict sweep is not production-ready (auto-trash loop, CRIT-13) - -**Status:** Blockers 1–4 fixed in-tree 2026-07-26; items 5–10 and the remaining -cleanup still open. The sweep is safe to leave running (it is report-only by -default and cannot trash anything until explicitly switched to `enforce`). -**Where:** `crates/pdfs-fuse/src/sweep.rs`, spawn site `crates/pdfs-fuse/src/mount.rs`, -config surface `crates/pdfs-core/src/config.rs`. - -The B69 fix (see above) shipped a `pdfs-conflict-sweep` thread that **trashes user -files on the real Drive** from a background loop. The identity check itself is -conservative (equal size *and* equal `content_sha1`, missing digest treated as -divergent), but the machinery around it is not yet safe to run unattended. Review -found the following, ordered by severity. Each is a separate defect; they are -grouped because they gate the same feature. - -**Blockers (all fixed 2026-07-26 — the sweep may not trash anything without them):** - -1. **Data-loss race — trash-then-discard (CRIT).** `remove_conflict_copy` - (`sweep.rs:137-155`) trashes the node remotely and *then* calls - `discard_queued_ops(uid)`, which deletes the node's queued ops **and discards - their staged blobs** (`lib.rs:1544-1553`). The decision was made from the - `load_all()` snapshot taken at `sweep.rs:77`, with network round trips in - between. A user edit landing in that window is destroyed — the queued write is - dropped and its staged bytes, which may be the only copy, are discarded. This - violates the invariant that `staging/` is never purged. *Fix:* re-read the node - from the DB and re-verify `active_revision_id` immediately before trashing; - skip the node outright if `Core.pending` or the op table has any entry for it; - never let the sweep discard staged bytes. -2. **No kill-switch (CRIT).** `mount.rs:234-239` spawns the loop unconditionally. - There is no `AppConfig` field and no environment override, so the only way to - stop a destructive background loop is to uninstall. *Fix:* add - `AppConfig.conflict_sweep: Option` (same `Option` idiom as `cache_budget` - / `ignore_patterns`), a `PDFS_CONFLICT_SWEEP=off` override for support, and a - Settings toggle. -3. **Open handles ignored (HIGH).** Nothing checks for a live file handle before - `forget_or_unlink` + `evict_reader`, so the sweep can trash a node a reader - currently has open. *Fix:* skip nodes with open handles. -4. **Enforcing on day one (HIGH, process).** The sweep exists because B69 proved - the previous revision-identity check was wrong; betting deletion on the - *replacement* check in the same release, with no field evidence, is the wrong - order. *Fix:* ship report-only (log `would-trash` plus the existing - `ActivityKind::Conflict`), collect a release cycle of real logs, then flip to - enforcing. - -**Fix for 1–4 (landed 2026-07-26):** - -- **`SweepMode`** (`pdfs-core/src/config.rs`) — `Off` / `Report` / `Enforce`, - serialised into `AppConfig.conflict_sweep` (`#[serde(default)]`, so configs - predating the field still load). `Default` is **`Report`**, which is blocker 4: - an existing install that has never heard of the setting cannot get the - enforcing behaviour on upgrade. `AppConfig::resolved_conflict_sweep` layers - `PDFS_CONFLICT_SWEEP` over the stored value; the precedence is a pure - `resolve_sweep_mode(env, stored)` so it is testable without mutating process - state. An unparseable value falls through to the config rather than guessing a - mode in either direction. -- **Spawn gate** (`mount.rs`) — `SweepMode::Off` skips the thread entirely rather - than starting an idle one, so the setting is verifiable from outside the - process (`ls /proc//task/*/comm`). Both branches log the resolved mode at - startup. `mount` grew past clippy's argument limit, so `username` + - `sweep_mode` moved into a `MountOptions` struct resolved by the caller — - `mount` still reads neither config nor environment. -- **Interlocks** (`sweep.rs::duplicate_still_removable`) — re-checks, immediately - before the destructive call, everything the pass decided from its stale - snapshot: no queued op (new `Db::has_any_op`, a cheap `COUNT` — a queued op - means bytes are owed an upload and `discard_queued_ops` would throw away the - staged blob holding them), no open write handle or shared scratch file - (`Core::is_busy`, checking `active_writes` + `handles` under one `State` lock), - and the node still at the same revision id, size, digest, name, parent and - untrashed state. Any doubt — including a failed DB read — means leave it alone: - a copy that survives to the next pass costs nothing, a wrongly removed one - costs data. This is blockers 1 and 3. -- **Testability** — the decision moved into a pure - `decide(node, sibling, base, mode) -> SweepAction`, the same extraction used - for `revision_changed` and `is_own_self_supersede`; everything that can fail or - race stays in the caller. Tests now cover removal-when-enforcing, - report-mode-never-removes, size-differs, digest-differs, missing-digest in both - directions, and orphaned copies. Gate: `cargo fmt` / `clippy -D warnings` clean, - `cargo test --workspace` 282 passed (was 270). - -**Should fix:** - -5. **`load_all()` every pass (MED).** Each 300 s pass pulls the entire node table - into a `Vec` and builds a full `(parent, name)` `HashMap`. *Fix:* - query only conflict-named rows (`name LIKE '% (sync-conflict %'`) and look up - siblings per parent — O(conflicts), not O(drive). -6. **No batching or per-pass cap (MED).** The B69 incident produced 151 copies; - that is 151 sequential `trash_nodes` calls in one pass with no cap and no - backoff. `trash_nodes` takes a slice — batch it and cap per pass. -7. **`online` sampled once per pass (MED).** Read at `sweep.rs:100`, ahead of a - loop that does network I/O; going offline mid-pass yields a long run of failing - calls. *Fix:* bail on the first transport error. -8. **Ambiguous sibling index (MED).** `by_parent_name.insert` (`sweep.rs:97`) is - last-write-wins, so with duplicate `(parent, name)` rows the surviving entry - depends on `load_all` row order. *Fix:* make the choice deterministic or skip - ambiguous parents. -9. **Trashed ancestors (LOW).** A copy under a trashed folder finds no live - sibling and is flagged "orphaned", spamming the activity feed. *Fix:* skip - nodes with a trashed ancestor. -10. **SHA-1 provenance unverified (MED).** `is_identical` (`sweep.rs:181`) - compares digests without asserting they belong to the current - `active_revision_id`. A stale `XAttr` digest from an earlier revision plus a - size collision would delete real data. (The *missing* digest case is already - handled correctly.) - -**Also required to close:** - -- **Tests.** The *policy* is now covered via the pure `decide` (see above). Still - untested are the *interlocks*, which need a `Core`: pending-op→skip, - open-handle→skip, revision-moved→skip, offline→skip. Those want a test harness - that can build a `Core` against a temp DB and a stub client. -- **`conflict_notified` is unbounded.** `HashSet` that only grows except - on trash (`sweep.rs:157,170`). -- **Docs.** `docs/ARCHITECTURE.md` thread map does not list `pdfs-conflict-sweep`; - `docs/RECOVERY.md` needs a "the sweep trashed a file, recover it from Proton - trash" path. - -**Mitigating context from the 2026-07-26 pre-run snapshot:** the account had only -2 conflict copies left, both divergent in size *and* `content_sha1`, so the -correct behaviour for the first live pass is to trash nothing. - -## B72 — `pdfs trash` never returns - -**Status:** Hardened in-tree 2026-07-26 — the hang is gone by construction; the -underlying slowness of the first refresh is still unmeasured (live-pending). -**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash` -produced no output and was still running when killed at 120 s. Daemon was healthy -and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly. -**Where:** `Core::list_trash` (`crates/pdfs-fuse/src/lib.rs`). Not the CLI: the -client's 120 s `READ_TIMEOUT` (`pdfs-core/src/control.rs`) is what ends the call, -and the daemon never writes a response line. - -**Root cause.** The trash of this account had never been fetched successfully — -`trash` table empty, no `trash_synced_ms` in `sync_state`. `list_trash` therefore -always took its "never fetched → `rt.block_on(refresh_trash())`" branch, which: - -- materialized the *entire* trash in one `enumerate_nodes` call (one S2K unlock - per node) before persisting anything, so nothing was ever saved and no later - request could be served from the DB; -- had no single-flight guard on that branch (only `spawn_trash_refresh` did), so - every attempt started another full refresh; -- was never cancelled when the requester timed out, because the control handler - sits in `block_on` on its own thread and nothing signals it. - -So each look at the trash added a permanent, full-cost refresh to the daemon. -Measured on the live daemon after a handful of GUI/CLI attempts: **11 stale -`pdfs-control` threads**, RSS 2.9 GB. Concurrent with a drain backlog that was -uploading conflict copies continuously (1991 `Conflict` activity rows, multi-GB -uploads), none of the refreshes ever finished. - -**Fix (in-tree).** `list_trash` never blocks on the whole refresh: it kicks the -single-flighted background refresh, waits at most `TRASH_FIRST_WAIT` (20 s, well -under the front-end read timeout) on a `Notify`, and answers with whatever has -materialized. `refresh_trash` now materializes in `TRASH_MATERIALIZE_CHUNK` (150) -batches, persisting the cumulative listing after each one, and logs the uid count -plus per-batch and total elapsed at INFO/DEBUG — previously the whole path was -silent, which is why the stall could not be located from the journal. - -**Still open:** *why* one refresh is slow enough to matter. The new INFO lines -(`trash refresh: enumerated` / `batch` / `done`) answer that on the next run: -whether `enumerate_trash_node_uids` returns at all, how many nodes the trash -holds, and the per-batch cost. If the count is large, materializing with -`enumerate_nodes_light` (skips the per-file node-key S2K; `size` would fall back -to on-storage size) is the next lever. - -**Impact:** the trash listing is the user's recovery path after any destructive -operation, including the B71 sweep. It needs to work before the sweep is allowed -to trash anything. - -## B73 — Unimplemented FUSE handlers log a WARN per probe - -**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the -expanded acceptance suite. -**Found:** `scripts/fuse-acceptance.sh --live` step "unsupported operations refuse -cleanly". Each probe produced a multi-line `fuser` warning in the daemon journal: - -``` -WARN fuser: [Not Implemented] symlink(parent: INodeNo(0x2d), link_name: "symlink", target: "link-target") -WARN fuser: [Not Implemented] link(ino: INodeNo(0x94), newparent: INodeNo(0x2d), newname: "hardlink") -WARN fuser: [Not Implemented] mknod(parent: INodeNo(0x2d), name: "fifo", mode: 4516, umask: 0x12, rdev: 0) -WARN fuser: [Not Implemented] setxattr(ino: INodeNo(0x91), name: "user.pdfs.probe", flags: 0x0, position: 0) -WARN fuser: [Not Implemented] lseek(ino: INodeNo(0x93), fh: 0, offset: 0, whence: 3) -``` - -**Cause:** `ProtonFs` does not implement these callbacks, so `fuser`'s default -trait method answers `ENOSYS` *and logs at WARN*. The refusal is correct — the -acceptance suite accepts every one of them as a clean refusal — but the logging -is not conditional on anything the user did wrong. -**Impact:** cosmetic, but these are not exotic calls: `git` probes symlinks, -`cp -a` probes hard links, `tar` and `cp --sparse` probe `SEEK_HOLE`/`SEEK_DATA` -(`whence: 3` above is `SEEK_DATA`), and any xattr-aware tool probes `setxattr`. -Warnings that look like faults bury real ones and make both `--journal-check` -and manual triage noisier. -**Measured** across two full acceptance runs against one daemon lifetime (PID -887441, mount never remounted): - -| op | warnings | behaviour | -|---|---|---| -| `link` | 11 | repeats on every call | -| `symlink` | 4 | repeats on every call | -| `mknod` | 2 | repeats on every call | -| `setxattr` | 1 | kernel cached the `ENOSYS`; never asked again | -| `lseek` | 1 | cached | -| `copy_file_range` | 1 | cached | -| `fsyncdir` | 1 | cached | - -So the FUSE kernel module remembers `ENOSYS` for the file-level operations and -stops issuing them for the lifetime of the mount, but not for the namespace -operations, which are re-issued forever. - -`link` leads for a sharper reason than "tools probe it": git uses `link()` to -place **every loose object**, falling back to `rename()` when it fails. In one -workloads run, 9 of 10 `link` warnings carried a 38-hex-character name — the -`.git/objects/ab/<38 hex>` layout — from just 3 commits over 3 files. The volume -is therefore proportional to work done, not a fixed startup cost: a `git clone` -of a real repository emits one multi-line warning per object. The lone `symlink` -that is not the acceptance probe is git's `core.symlinks` detection at -`git init` (a random name pointing at `testing`). - -That moves this from cosmetic to a genuine operational problem: ordinary use of -git inside a mount can bury every other log line. - -**Fix (applied):** all seven callbacks are now implemented explicitly in -`crates/pdfs-fuse/src/filesystem.rs`, each answering with **the same errno -`fuser`'s default would** and nothing else — no WARN. Following `ioctl` -(which already used this pattern and says why in its comment), the errno -contract is unchanged, so the acceptance suite's clean-refusal assertions hold -byte for byte: - -| op | errno | why that one | -|---|---|---| -| `symlink`, `link` | `EPERM` | meaningful operation, unrepresentable on Drive; git falls back to `rename()` | -| `mknod` | `ENOSYS` | no devices/FIFOs/sockets; regular files arrive via `create` | -| `setxattr` | `ENOSYS` | surfaces as `EOPNOTSUPP`; reads stay implemented for the thumbnail interface | -| `fsyncdir` | `ENOSYS` | kernel absorbs it and reports success; dir changes are already durable in SQLite | -| `lseek`, `copy_file_range` | `ENOSYS` | **must stay** `ENOSYS` — it is what makes the kernel emulate `SEEK_HOLE`/`SEEK_DATA` and coreutils fall back to read/write instead of failing | - -Only the three namespace ops were strictly required (the other four silence -themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the -refusal explicit and documented at the call site rather than inherited. - -**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the -capability diff identical to the pre-fix run, and **0** `Not Implemented` lines -in the journal across the whole run (was 17, 10 of them `link`). -**Regression cover:** the acceptance suite's clean-refusal probes assert the -errno contract; they pass unchanged. - -## B74 — A drained create never reaches a sync-folder mount's inode space, so the file reads as empty +### B74 — A drained create never reaches a sync-folder mount's inode space, so the file reads as empty **Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the expanded acceptance suite (`regression B70` case), then isolated to a broader @@ -6556,70 +4139,2923 @@ behaves correctly in isolation; the empty read arrived with the rename, on the same path a plain temp file takes. B70's live validation was blocked on this and is now unblocked — its acceptance case passes. -## B75 — Network work on fuser's single dispatch thread freezes the whole mount +--- -**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 while -chasing what was thought to be a B74 regression. -**Impact:** any burst of concurrent writes froze the entire mount — not the -files being written, the *mount*. `ls` on an unrelated directory of it timed out -for minutes. The daemon itself stayed healthy throughout: the primary mount and -the control socket answered normally while a sync-folder mount was unreachable. +### B73 — Unimplemented FUSE handlers log a WARN per probe -**Root cause.** `mount.rs` and `devices.rs` both build their session from -`Config::default()`, which leaves `n_threads` unset, and fuser 0.17 defaults it -to 1 (`session.rs:254`). One dispatch thread per mount reads a request, runs the -handler to completion, and only then reads the next. Six handlers were handed to -the [`Workers`] pool — `lookup`, `getattr`, `readdir`, `read`, `getxattr` and the -slow `serve_lookup` — and everything else ran inline, including handlers that -block on the API: +**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the +expanded acceptance suite. +**Found:** `scripts/fuse-acceptance.sh --live` step "unsupported operations refuse +cleanly". Each probe produced a multi-line `fuser` warning in the daemon journal: -| handler | blocking work | -|---|---| -| `create` | `upload_file` to mint the node, then `fetch_node` — two round trips | -| `mkdir` | `create_folder`, then `fetch_node` | -| `rename` | `rename_node`, then `move_node`, plus a trash on the replaced-destination path | -| `unlink` / `rmdir` | `trash_nodes`; `rmdir` enumerates the folder first | -| `setattr` (path truncate) | `queue_truncate` → `fill_gaps`, which reads the kept prefix from the remote | +``` +WARN fuser: [Not Implemented] symlink(parent: INodeNo(0x2d), link_name: "symlink", target: "link-target") +WARN fuser: [Not Implemented] link(ino: INodeNo(0x94), newparent: INodeNo(0x2d), newname: "hardlink") +WARN fuser: [Not Implemented] mknod(parent: INodeNo(0x2d), name: "fifo", mode: 4516, umask: 0x12, rdev: 0) +WARN fuser: [Not Implemented] setxattr(ino: INodeNo(0x91), name: "user.pdfs.probe", flags: 0x0, position: 0) +WARN fuser: [Not Implemented] lseek(ino: INodeNo(0x93), fh: 0, offset: 0, whence: 3) +``` -So eight applications each creating a file served one create at a time, and -every `read`, `open` and `lookup` on that mount queued behind them. +**Cause:** `ProtonFs` does not implement these callbacks, so `fuser`'s default +trait method answers `ENOSYS` *and logs at WARN*. The refusal is correct — the +acceptance suite accepts every one of them as a clean refusal — but the logging +is not conditional on anything the user did wrong. +**Impact:** cosmetic, but these are not exotic calls: `git` probes symlinks, +`cp -a` probes hard links, `tar` and `cp --sparse` probe `SEEK_HOLE`/`SEEK_DATA` +(`whence: 3` above is `SEEK_DATA`), and any xattr-aware tool probes `setxattr`. +Warnings that look like faults bury real ones and make both `--journal-check` +and manual triage noisier. +**Measured** across two full acceptance runs against one daemon lifetime (PID +887441, mount never remounted): -**Measured**, acceptance suite's `independent and shared-file concurrency` (8 -threads × 16 files), and a 3-second `ls` of the mount root sampled every 4 s -throughout: - -| | before | after | +| op | warnings | behaviour | |---|---|---| -| concurrency case | 200 s watchdog ×2, 37 s ×1 | 16.9 / 24.1 / 29.1 s, all pass | -| `ls` of the mount during the run | 33 of 55 probes timed out | 0 of 85 | -| `readdir stability` | 148.8 s | 62.9 s | -| `namespace operations` | 31.3 s | 15.7 s | +| `link` | 11 | repeats on every call | +| `symlink` | 4 | repeats on every call | +| `mknod` | 2 | repeats on every call | +| `setxattr` | 1 | kernel cached the `ENOSYS`; never asked again | +| `lseek` | 1 | cached | +| `copy_file_range` | 1 | cached | +| `fsyncdir` | 1 | cached | -**Fix:** each of those handlers now parses its arguments inline — cheap, and it -keeps the error paths prompt — and hands the body to the worker pool as a -`serve_*` method, the pattern `lookup`/`readdir` already used. Metadata work -takes `Lane::Meta`; the truncate path takes `Lane::Transfer` because it can pull -a whole file. +So the FUSE kernel module remembers `ENOSYS` for the file-level operations and +stops issuing them for the lifetime of the mount, but not for the namespace +operations, which are re-issued forever. -**`release` was deliberately left inline, and this is load-bearing.** Moving it -too made all three concurrency runs fail with `concurrent file 1 mismatch`. The -kernel does not wait for a `release` reply before letting `close(2)` return, so -the only thing sequencing the staging of the written bytes against the `open` + -`read` an application issues immediately afterwards is that both are served by -the same dispatch loop. From a worker, the read overtakes the staging and is -answered from the remote's older revision. Taking `release` off the loop — worth -doing, since `queue_revision` gap-fills a partial write from the network — needs -a per-node "staging in flight" barrier that reads wait on, not the dispatch -loop's accidental ordering. +`link` leads for a sharper reason than "tools probe it": git uses `link()` to +place **every loose object**, falling back to `rename()` when it fails. In one +workloads run, 9 of 10 `link` warnings carried a 38-hex-character name — the +`.git/objects/ab/<38 hex>` layout — from just 3 commits over 3 files. The volume +is therefore proportional to work done, not a fixed startup cost: a `git clone` +of a real repository emits one multi-line warning per object. The lone `symlink` +that is not the acceptance probe is git's `core.symlinks` detection at +`git init` (a random name pointing at `testing`). -**Not fixed here.** `n_threads` is still 1; with the blocking handlers moved off, -the loop is short enough that raising it is a separate question. Related findings -from the same audit, still open: cache/reader validity is keyed on -`(mtime, size)` while the revision identity is `active_revision_id` -(`cache.rs:84` — two same-size revisions within one mtime second collide, which -is what a SQLite page rewrite looks like); `local_finish_scan` holds the daemon's -one SQLite connection across a full FTS5 trigram rebuild (`db/local.rs:79`); -`earliest_due_at` has no `<= now` filter where `next_due_op` does, so the drain -busy-spins when offline with a due op (`db/ops.rs:385` vs `:421`); and -`drain_local_node` deletes its op before the fallible `adopt_real_uid` -(`drain.rs:346`). +That moves this from cosmetic to a genuine operational problem: ordinary use of +git inside a mount can bury every other log line. + +**Fix (applied):** all seven callbacks are now implemented explicitly in +`crates/pdfs-fuse/src/filesystem.rs`, each answering with **the same errno +`fuser`'s default would** and nothing else — no WARN. Following `ioctl` +(which already used this pattern and says why in its comment), the errno +contract is unchanged, so the acceptance suite's clean-refusal assertions hold +byte for byte: + +| op | errno | why that one | +|---|---|---| +| `symlink`, `link` | `EPERM` | meaningful operation, unrepresentable on Drive; git falls back to `rename()` | +| `mknod` | `ENOSYS` | no devices/FIFOs/sockets; regular files arrive via `create` | +| `setxattr` | `ENOSYS` | surfaces as `EOPNOTSUPP`; reads stay implemented for the thumbnail interface | +| `fsyncdir` | `ENOSYS` | kernel absorbs it and reports success; dir changes are already durable in SQLite | +| `lseek`, `copy_file_range` | `ENOSYS` | **must stay** `ENOSYS` — it is what makes the kernel emulate `SEEK_HOLE`/`SEEK_DATA` and coreutils fall back to read/write instead of failing | + +Only the three namespace ops were strictly required (the other four silence +themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the +refusal explicit and documented at the call site rather than inherited. + +**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the +capability diff identical to the pre-fix run, and **0** `Not Implemented` lines +in the journal across the whole run (was 17, 10 of them `link`). +**Regression cover:** the acceptance suite's clean-refusal probes assert the +errno contract; they pass unchanged. + +--- + +### B72 — `pdfs trash` never returns + +**Status:** Hardened in-tree 2026-07-26 — the hang is gone by construction; the +underlying slowness of the first refresh is still unmeasured (live-pending). +**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash` +produced no output and was still running when killed at 120 s. Daemon was healthy +and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly. +**Where:** `Core::list_trash` (`crates/pdfs-fuse/src/lib.rs`). Not the CLI: the +client's 120 s `READ_TIMEOUT` (`pdfs-core/src/control.rs`) is what ends the call, +and the daemon never writes a response line. + +**Root cause.** The trash of this account had never been fetched successfully — +`trash` table empty, no `trash_synced_ms` in `sync_state`. `list_trash` therefore +always took its "never fetched → `rt.block_on(refresh_trash())`" branch, which: + +- materialized the *entire* trash in one `enumerate_nodes` call (one S2K unlock + per node) before persisting anything, so nothing was ever saved and no later + request could be served from the DB; +- had no single-flight guard on that branch (only `spawn_trash_refresh` did), so + every attempt started another full refresh; +- was never cancelled when the requester timed out, because the control handler + sits in `block_on` on its own thread and nothing signals it. + +So each look at the trash added a permanent, full-cost refresh to the daemon. +Measured on the live daemon after a handful of GUI/CLI attempts: **11 stale +`pdfs-control` threads**, RSS 2.9 GB. Concurrent with a drain backlog that was +uploading conflict copies continuously (1991 `Conflict` activity rows, multi-GB +uploads), none of the refreshes ever finished. + +**Fix (in-tree).** `list_trash` never blocks on the whole refresh: it kicks the +single-flighted background refresh, waits at most `TRASH_FIRST_WAIT` (20 s, well +under the front-end read timeout) on a `Notify`, and answers with whatever has +materialized. `refresh_trash` now materializes in `TRASH_MATERIALIZE_CHUNK` (150) +batches, persisting the cumulative listing after each one, and logs the uid count +plus per-batch and total elapsed at INFO/DEBUG — previously the whole path was +silent, which is why the stall could not be located from the journal. + +**Still open:** *why* one refresh is slow enough to matter. The new INFO lines +(`trash refresh: enumerated` / `batch` / `done`) answer that on the next run: +whether `enumerate_trash_node_uids` returns at all, how many nodes the trash +holds, and the per-batch cost. If the count is large, materializing with +`enumerate_nodes_light` (skips the per-file node-key S2K; `size` would fall back +to on-storage size) is the next lever. + +**Impact:** the trash listing is the user's recovery path after any destructive +operation, including the B71 sweep. It needs to work before the sweep is allowed +to trash anything. + +--- + +### B71 — Conflict sweep is not production-ready (auto-trash loop, CRIT-13) + +**Status:** Blockers 1–4 fixed in-tree 2026-07-26; items 5–10 and the remaining +cleanup still open. The sweep is safe to leave running (it is report-only by +default and cannot trash anything until explicitly switched to `enforce`). +**Where:** `crates/pdfs-fuse/src/sweep.rs`, spawn site `crates/pdfs-fuse/src/mount.rs`, +config surface `crates/pdfs-core/src/config.rs`. + +The B69 fix (see above) shipped a `pdfs-conflict-sweep` thread that **trashes user +files on the real Drive** from a background loop. The identity check itself is +conservative (equal size *and* equal `content_sha1`, missing digest treated as +divergent), but the machinery around it is not yet safe to run unattended. Review +found the following, ordered by severity. Each is a separate defect; they are +grouped because they gate the same feature. + +**Blockers (all fixed 2026-07-26 — the sweep may not trash anything without them):** + +1. **Data-loss race — trash-then-discard (CRIT).** `remove_conflict_copy` + (`sweep.rs:137-155`) trashes the node remotely and *then* calls + `discard_queued_ops(uid)`, which deletes the node's queued ops **and discards + their staged blobs** (`lib.rs:1544-1553`). The decision was made from the + `load_all()` snapshot taken at `sweep.rs:77`, with network round trips in + between. A user edit landing in that window is destroyed — the queued write is + dropped and its staged bytes, which may be the only copy, are discarded. This + violates the invariant that `staging/` is never purged. *Fix:* re-read the node + from the DB and re-verify `active_revision_id` immediately before trashing; + skip the node outright if `Core.pending` or the op table has any entry for it; + never let the sweep discard staged bytes. +2. **No kill-switch (CRIT).** `mount.rs:234-239` spawns the loop unconditionally. + There is no `AppConfig` field and no environment override, so the only way to + stop a destructive background loop is to uninstall. *Fix:* add + `AppConfig.conflict_sweep: Option` (same `Option` idiom as `cache_budget` + / `ignore_patterns`), a `PDFS_CONFLICT_SWEEP=off` override for support, and a + Settings toggle. +3. **Open handles ignored (HIGH).** Nothing checks for a live file handle before + `forget_or_unlink` + `evict_reader`, so the sweep can trash a node a reader + currently has open. *Fix:* skip nodes with open handles. +4. **Enforcing on day one (HIGH, process).** The sweep exists because B69 proved + the previous revision-identity check was wrong; betting deletion on the + *replacement* check in the same release, with no field evidence, is the wrong + order. *Fix:* ship report-only (log `would-trash` plus the existing + `ActivityKind::Conflict`), collect a release cycle of real logs, then flip to + enforcing. + +**Fix for 1–4 (landed 2026-07-26):** + +- **`SweepMode`** (`pdfs-core/src/config.rs`) — `Off` / `Report` / `Enforce`, + serialised into `AppConfig.conflict_sweep` (`#[serde(default)]`, so configs + predating the field still load). `Default` is **`Report`**, which is blocker 4: + an existing install that has never heard of the setting cannot get the + enforcing behaviour on upgrade. `AppConfig::resolved_conflict_sweep` layers + `PDFS_CONFLICT_SWEEP` over the stored value; the precedence is a pure + `resolve_sweep_mode(env, stored)` so it is testable without mutating process + state. An unparseable value falls through to the config rather than guessing a + mode in either direction. +- **Spawn gate** (`mount.rs`) — `SweepMode::Off` skips the thread entirely rather + than starting an idle one, so the setting is verifiable from outside the + process (`ls /proc//task/*/comm`). Both branches log the resolved mode at + startup. `mount` grew past clippy's argument limit, so `username` + + `sweep_mode` moved into a `MountOptions` struct resolved by the caller — + `mount` still reads neither config nor environment. +- **Interlocks** (`sweep.rs::duplicate_still_removable`) — re-checks, immediately + before the destructive call, everything the pass decided from its stale + snapshot: no queued op (new `Db::has_any_op`, a cheap `COUNT` — a queued op + means bytes are owed an upload and `discard_queued_ops` would throw away the + staged blob holding them), no open write handle or shared scratch file + (`Core::is_busy`, checking `active_writes` + `handles` under one `State` lock), + and the node still at the same revision id, size, digest, name, parent and + untrashed state. Any doubt — including a failed DB read — means leave it alone: + a copy that survives to the next pass costs nothing, a wrongly removed one + costs data. This is blockers 1 and 3. +- **Testability** — the decision moved into a pure + `decide(node, sibling, base, mode) -> SweepAction`, the same extraction used + for `revision_changed` and `is_own_self_supersede`; everything that can fail or + race stays in the caller. Tests now cover removal-when-enforcing, + report-mode-never-removes, size-differs, digest-differs, missing-digest in both + directions, and orphaned copies. Gate: `cargo fmt` / `clippy -D warnings` clean, + `cargo test --workspace` 282 passed (was 270). + +**Should fix:** + +5. **`load_all()` every pass (MED).** Each 300 s pass pulls the entire node table + into a `Vec` and builds a full `(parent, name)` `HashMap`. *Fix:* + query only conflict-named rows (`name LIKE '% (sync-conflict %'`) and look up + siblings per parent — O(conflicts), not O(drive). +6. **No batching or per-pass cap (MED).** The B69 incident produced 151 copies; + that is 151 sequential `trash_nodes` calls in one pass with no cap and no + backoff. `trash_nodes` takes a slice — batch it and cap per pass. +7. **`online` sampled once per pass (MED).** Read at `sweep.rs:100`, ahead of a + loop that does network I/O; going offline mid-pass yields a long run of failing + calls. *Fix:* bail on the first transport error. +8. **Ambiguous sibling index (MED).** `by_parent_name.insert` (`sweep.rs:97`) is + last-write-wins, so with duplicate `(parent, name)` rows the surviving entry + depends on `load_all` row order. *Fix:* make the choice deterministic or skip + ambiguous parents. +9. **Trashed ancestors (LOW).** A copy under a trashed folder finds no live + sibling and is flagged "orphaned", spamming the activity feed. *Fix:* skip + nodes with a trashed ancestor. +10. **SHA-1 provenance unverified (MED).** `is_identical` (`sweep.rs:181`) + compares digests without asserting they belong to the current + `active_revision_id`. A stale `XAttr` digest from an earlier revision plus a + size collision would delete real data. (The *missing* digest case is already + handled correctly.) + +**Also required to close:** + +- **Tests.** The *policy* is now covered via the pure `decide` (see above). Still + untested are the *interlocks*, which need a `Core`: pending-op→skip, + open-handle→skip, revision-moved→skip, offline→skip. Those want a test harness + that can build a `Core` against a temp DB and a stub client. +- **`conflict_notified` is unbounded.** `HashSet` that only grows except + on trash (`sweep.rs:157,170`). +- **Docs.** `docs/ARCHITECTURE.md` thread map does not list `pdfs-conflict-sweep`; + `docs/RECOVERY.md` needs a "the sweep trashed a file, recover it from Proton + trash" path. + +**Mitigating context from the 2026-07-26 pre-run snapshot:** the account had only +2 conflict copies left, both divergent in size *and* `content_sha1`, so the +correct behaviour for the first live pass is to trash nothing. + +--- + +### B70 — Browser in-flight temp files uploaded and conflict-forked on the on-demand mount + +**Status:** Layers A + B fixed in-tree 2026-07-25 — **live-validated 2026-07-26**. +Validation was blocked until B74 was fixed (its scenario ends in a rename, which +B74 made read back as empty); with that out of the way the acceptance case +`regression B70: transient download name is not sealed` passes live. +**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads` +(an on-demand mount) the complete files had been forked into `(sync-conflict)` +copies while their truncated partials kept the canonical name — e.g. +`teamspeak.tar.gz` (30 MB partial live vs a 3.77 GB conflict copy) and `nils.zip` +(17 MB vs 3.18 GB). The conflict copies were byte-exact to the user's independent +gdrive backups, proving the *conflict copy* was the finished download and the +*live* file the stub. The smoking gun: four `Unconfirmed NNNNN (sync-conflict …).crdownload` +copies — Brave's in-flight temp files, forked *mid-download*. User confirmed the +trigger: downloading with Brave directly into the on-demand folder, where a stall ++ "resume" is routine. +**Where:** on-demand write path — `crates/pdfs-fuse/src/filesystem.rs` `release` +(`queue_revision`, ~line 780) and `rename` (~line 933); drain seal in +`crates/pdfs-fuse/src/drain.rs`. The existing ignore system +(`crates/pdfs-core/src/syncignore.rs`, `DEFAULT_IGNORE_PATTERNS`) is wired **only** +into mirror reconcile (`sync.rs`), so it never sees on-demand writes. + +**Cause:** A browser downloading into the mount writes a growing temp file +(`*.crdownload`, or `*.part` for Firefox) and, on a stall, closes the fd. `release` +hands every closed write to `queue_revision`, so a *partial* revision gets sealed +as the canonical file. On resume the browser reopens and rewrites the full file; +its baseline revision has moved (the client sealed the partial itself), so the +drain diverts the finished bytes into a `(sync-conflict)` copy. Net: the stub wins +the name, the real file is exiled to a conflict copy, and every abandoned +`.crdownload` is uploaded as its own multi-hundred-MB node. Unlike B69 this is a +*genuine* two-revision divergence, so B69's revision-id detection correctly refuses +to auto-sweep it — the fix has to stop the fork from happening, not reconcile it +after. + +**Fix — two layers, both without an SDK release (A stops transient names ever +sealing; B stops a single-writer resume forking against its own seal):** + +- **A — transient names never seal a revision until finalized (done 2026-07-25).** + A new `syncignore::is_transient_name` recognises browser download temps + (`*.crdownload`/`*.part`/`*.partial`/`*.download`), generic scratch + (`*.tmp`/`*.temp`), editor swap/backup (`*.swp`/`*.swx`/`*~`), and office/lock + files (`.~lock.*`, `~$*`). On `create` (filesystem.rs) a transient name is kept + **purely local** — no empty remote node is minted — and its queued create is + **parked**: `next_attempt_at` is set to the new `PARK_UNTIL` sentinel so + `Db::next_due_op` never selects it. Written bytes still ride on that create via + the existing `attach_blob_to_create`, whose `next_attempt_at` reset now + *preserves* a park (SQL `CASE … >= PARK_UNTIL`), so a growing download attaches + revision after revision without ever waking the drain. The finalize `rename` + (`foo.crdownload → foo`) goes through the existing `is_local_uid` branch + (`rewrite_op_target` renames the queued create); when the old name was transient + and the new one is not, it calls `Db::set_create_hold(uid, false)` and wakes the + drain, so the *completed* file uploads exactly once. Net: no partial and no + abandoned temp ever reaches Drive, and nothing forks. Reuses the whole + create/attach/rename/drain pipeline — the only new surface is the predicate, the + `PARK_UNTIL` sentinel, `set_create_hold`, a `hold` arg on `queue_local_node`, + and the attach-preserves-park `CASE`. Tests: `syncignore` predicate cases + + `db::a_parked_transient_create_stays_off_the_drain_until_finalized`. No SDK + dependency. + - *Not yet covered by A:* apps that write the final name in place with **no** + temp suffix (rare), and Firefox multi-connection `.part` files that it may + rename per-segment — those still rely on B. An abandoned transient file (a + cancelled download left on disk) now stays local-only forever rather than + polluting Drive; a later janitor could reclaim its staged blob. +- **B — don't self-conflict a single-writer sequential rewrite (done 2026-07-25, + no SDK dep).** The safety net for names A does not recognise (an app writing the + final name in place, Firefox per-segment `.part` renames, or any resume the + in-memory rebase machinery missed). The daemon now records the server revision + id it *itself* seals per node (`Core.own_sealed_revs`, written in + `refresh_after_upload` where the sealed node is already fetched, dropped on + trash). When a queued write drains and `revision_changed` fires, + `Core::revision_conflict` checks whether the remote sits at one of *our own* + sealed revisions (pure `is_own_self_supersede`): if so, no other device touched + the file — it is a single-writer stall→resume — so it chains (supersedes) + instead of forking a `(sync-conflict)` copy. Gated on `meta.complete`: an + incomplete blob's gaps still refer to the stale base, so it keeps the + non-destructive conflict-copy path. This is a superset guard over the existing + `refresh_after_upload`/`rebaseline_pending` rebasers, which only fire while a + handle/op is live; B catches the windows they miss (fresh open during the + earlier upload's flight, a stale inherited baseline). Tests: `is_own_self_supersede` + cases in `drain.rs`. *Residual:* the map is in-memory only, so a daemon restart + between the partial seal and the resume drops the record and that one write can + still fork — narrow, and B69's revision-id keying already covers the mtime-drift + half. A persisted own-seal ledger would close it. + +**Triage of the copies already made is done (2026-07-25):** all completed files +were promoted back to their canonical names (metadata-only rename, no re-upload) +and the abandoned `.crdownload` stubs trashed. + +--- + +### B69 — Spurious `(sync-conflict)` copies from mtime-keyed revision identity (B16/B25 root fix) + +**Status:** Fixed — offline-tested 2026-07-25, **live-validated 2026-07-26** (see below) +**Found:** 2026-07-24, user reported `test_export (sync-conflict 1784898786).xml` created after a normal save from an application into the on-demand mount. A full-mount scan turned up ~156 conflict copies; 151 were byte-identical to their live sibling. +**Where:** `crates/pdfs-fuse/src/{drain,filesystem,lib,state,sweep,sync,sync/planner}.rs`, `crates/pdfs-core/src/cache.rs`, `crates/pdfs-core/src/control.rs`; SDK `proton-drive-rs` `node.rs`/`client.rs`/`public_link.rs`. + +**Cause:** Two independent false-positive sources, both from `(mtime, size)` being used as a revision identity (the weakness B25 flagged; the recurrence B16 could not fully close): +1. **Re-stamped mtime.** `revision_conflict` compared the queued write's baseline mtime against the remote's. The server stamps a sealed revision with its own commit time (`…780`), a few seconds off the optimistic time the client baselined at (`…777`). Same bytes, same size, drifted mtime → the drain diverted the write into a conflict copy of its own base. +2. **First-sync of pre-existing files.** `plan_file` classified `(local, remote, no-baseline)` as `Conflict`, so the *first* reconcile of a folder cut a conflict copy of every file that already existed identically on both sides — the ~151-file storm, all in one ~40s window. + +**Fix:** Make conflict detection key on the server **revision id**, the true identity (it advances iff a new revision was sealed): +1. **SDK** exposes `active_revision_id` and `content_sha1` (plaintext SHA-1 from the revision's decrypted `XAttr`) on `NodeKind::File` — both already on the wire / already decrypted, no extra round trips. +2. **`Baseline`** gains `revision_id`, captured at write-open (`WriteHandle.base_revision_id`) and carried across supersede/rebaseline like the mtime. `revision_changed` (extracted, pure, unit-tested) trusts the id when both sides have it and only falls back to `(mtime, size)` for an old sidecar. A re-stamped mtime on the same id is no longer a conflict; a same-size edit by another device (new id) still is. +3. **Planner** adds `FilePlan::AdoptBaseline`: a no-baseline pair of equal size is recorded into the baseline instead of conflicted. Divergent sizes stay a real conflict. +4. **Auto-sweep** (`sweep.rs`, `pdfs-conflict-sweep` thread, 5-min cadence): removes conflict copies proven identical to their live sibling (equal size **and** equal `content_sha1`, trashing the copy remotely) and surfaces divergent/orphaned ones once as a new `ActivityKind::Conflict` entry. Never removes a copy it cannot prove is a duplicate. + +**Verified:** `proton-sdk-rs` 50/61 lib tests + clippy clean; client `cargo fmt`/`clippy -D warnings`/`cargo test --workspace --locked` all green (258 tests, incl. new `revision_changed`, planner `AdoptBaseline`, and `conflict_base_name` cases). The 151 identical copies were removed manually during triage; the 5 divergent ones are left for the user. + +**Live validation (2026-07-26):** 1.1.0 installed over the production account (6 +FUSE mounts of real data), daemon restarted, sweep observed across its warmup pass +and a full 5-minute interval. Pre-run snapshot: exactly 2 `(sync-conflict)` copies +left on the account, both divergent in size *and* `content_sha1`, so the correct +behaviour was to remove nothing. Result: **nothing removed** — the conflict +manifest was byte-identical before and after, the activity feed gained zero +`auto-removed duplicate` entries, and the copies were flagged `differs from …` as +intended. (The 40 `trash` entries in the feed for that window are +`trashed from the mount` — a previous FUSE-acceptance run's queued cleanup ops +replaying through the drain, unrelated to the sweep.) The sweep's *removal* path +therefore remains unexercised against a real account; it is now report-only by +default (B71) precisely so that stays true until there is field evidence. + +**Shipping note (resolved 2026-07-25):** the client's revision-id/sha1 use depends on new SDK surface (`proton-sdk`/`proton-drive-rs` **0.2.2**). That version is now **published to crates.io**; the client's workspace deps were bumped `"0.2"` → `"0.2.2"`, the `Cargo.lock` re-resolved to the registry crates, and the local `[patch.crates-io]` shim removed. No local patch remains. (Live validation of the fix against a real account is still pending.) + +--- + +### B68 — Profile backup cannot be written to the device root + +**Status:** Partly fixed — the destination is fixed and verified live +(2026-07-31, see **B78**); backup health is still invisible in the UI and the +replacement-machine restore has not been re-run +**Found:** complete managed-live mode matrix against the 1.0.0 release binary +**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination + +**Repro:** Every sync-folder registration or mode transition attempted the +profile backup and received `NotEnoughPermissions` (HTTP 422): `Cannot create +file at the root of a device`. The filesystem matrix itself passed, but profile +backup never became durable remotely. + +**Impact:** The recovery feature appears active but does not preserve the device +profile, so a replacement machine still lacks modes, pins, mount settings, and +the explicit remote-folder mapping. Repeated retries also add noisy warnings. + +**Required fix/test:** Store the profile below a writable application folder +rather than directly at the device root, reuse that folder by stable identity, +and make backup health visible. Verify upload, replacement, restart restore, and +byte-identical fresh-state recovery on a dedicated account. + +--- + +### B67 — Fresh-state restore can silently select a new device (HIGH-22) + +**Status:** Open — 1.0 recovery blocker +**Found:** 2026-07-22, 1.0 disaster-recovery audit +**Where:** device discovery/adoption; `docs/RECOVERY.md` + +**Cause:** Restore depends on hostname and folder basename. After local-state +loss, a mismatch can silently create/select another device rather than attach +the intended remote roots, making recovery appear complete while data is absent. + +**Required fix/test:** Require explicit adoption/confirmation on ambiguity and +show old/proposed device and root IDs. Test state loss, hostname change, +duplicates, renamed folders, multiple devices, and byte-identical restoration. + +--- + +### B66 — Local-only pending data is not protected at shutdown (HIGH-21) + +**Status:** Open — 1.0 data-safety/UX blocker +**Found:** 2026-07-22, 1.0 recovery audit +**Where:** shutdown, CLI/GUI status, logout/unregister workflows + +**Cause:** Undrained writes may exist only on one machine, but there is no flush +command with a completion contract, prominent pending byte/count health, or +shutdown/logout warning/inhibition. + +**Required fix/test:** Add `pdfs sync flush`, expose pending operations/bytes +and last errors, and protect destructive lifecycle actions. Test offline stop, +logout, unregister, reboot, retry exhaustion, and drain. Credential removal must +never delete staged bytes. + +--- + +### B65 — Release artifacts lack supply-chain verification (MED-11) + +**Status:** Open — 1.0 release/security blocker +**Found:** 2026-07-22, 1.0 supply-chain audit +**Where:** CI and release workflows + +**Cause:** Releases do not enforce advisory/license policy or publish signed +provenance, SBOMs, and verifiable checksums for exact distributed artifacts. + +**Required fix/test:** Add locked audit/license gates, SBOM and provenance, +checksums and signatures, documented verification, and an independent clean-job +verification/install test. + +--- + +### B64 — Stable publishing lacks data-safety and recovery gates (CRIT-12) + +**Status:** Partly fixed — automated gates and managed-live matrix pass; recovery approval remains open +**Found:** 2026-07-22, 1.0 release audit +**Where:** CI and `.github/workflows/release.yml` + +**Cause:** A tag can publish without required format/lint/tests, offline and +dedicated-account live acceptance, migrations, or crash/recovery drills. Fixes +still marked unverified can ship as stable. + +**Required fix/test:** Publish immutable RC artifacts only after all workspace +checks, full managed mode matrix, migration/power-loss recovery, clean install/ +upgrade tests, and explicit sign-off for every critical/high integrity issue. + +--- + +### B63 — Release version identity is inconsistent and unenforced (HIGH-20) + +**Status:** Partly fixed — tag/workspace/PKGBUILD equality is enforced; protocol identity remains +**Found:** 2026-07-22, 1.0 release audit +**Where:** manifests, client identity constants, tags, release workflow + +**Cause:** Workspace/binary versions, protocol identity strings, and tags can +disagree; the workflow trusts any `v*` tag without checking package metadata or +installed `--version` output. + +**Required fix/test:** Establish one version source and fail CI on every tag, +manifest, package, and binary mismatch. Document any intentionally independent +protocol identity. + +--- + +### B62 — Debian artifact omits required service and autostart units (HIGH-19) + +**Status:** Fixed in workflow; clean-install VM verification pending +**Found:** 2026-07-22, 1.0 packaging audit +**Where:** `.github/workflows/release.yml`; `packaging/` + +**Cause:** The generated package omits `proton-drive.service` and the tray +autostart entry even though normal login/GUI flows assume that lifecycle. + +**Required fix/test:** Package all units/resources with correct paths, modes, +and hooks. In clean supported-distribution VMs test install, login, reboot, +daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state. + +--- + +### B61 — Rotated single-use credentials can be lost (HIGH-18) + +**Status:** Open — 1.0 authentication/recovery blocker +**Found:** 2026-07-22, 1.0 authentication audit +**Where:** `crates/pdfs-core/src/auth.rs`, refresh callback + +**Cause:** Failure to serialize or persist rotated credentials is only logged +(and some setup failures are silent). The daemon continues in memory while the +keyring retains an invalid single-use refresh token, so restart loses login. + +**Required fix/test:** Expose unhealthy persistence, retry with bounded backoff, +notify the user, and define a safe re-login/shutdown path. Test locked/full/ +unavailable keyrings, callback races, repeated rotations, death, and restart. + +--- + +### B60 — Config writes are non-atomic and parse errors are overwritten (HIGH-17) + +**Status:** Fixed in code; fault/concurrent-save verification pending +**Found:** 2026-07-22, 1.0 configuration audit +**Where:** `crates/pdfs-core/src/config.rs`, `load_config` / `save_config` + +**Cause:** Save truncates in place without sync. Load treats read/parse failure +as absence and best-effort overwrites with defaults, silently losing mountpoint, +ignore policy, budget, and explicit device adoption. + +**Required fix/test:** Atomically publish a restricted, synced temporary file +and sync its directory. Preserve malformed input and report an actionable error. +Test invalid/truncated JSON, permissions, `ENOSPC`, crash points, and concurrent +saves. + +--- + +### B59 — Private state permission enforcement fails open (HIGH-16) + +**Status:** Fixed in code with symlink regression; wrong-owner integration verification pending +**Found:** 2026-07-22, 1.0 local-privacy audit +**Where:** `crates/pdfs-core/src/config.rs`, `AppDirs::ensure` + +**Cause:** chmod/creation failures are warned about or ignored while decrypted +content, plaintext metadata, and the authenticated socket may remain accessible +to other local users. + +**Required fix/test:** Before loading credentials or serving data, verify every +sensitive directory is owned by the effective user, is not a symlink, and is +owner-only. Test wrong owner, symlink substitution, read-only filesystems, +permissive restored directories, and chmod failure. This tightens B6/B35. + +--- + +### B58 — Control socket has unbounded frames and connections (HIGH-15) + +**Status:** Fixed in code with frame-limit regressions; connection-flood verification pending +**Found:** 2026-07-22, 1.0 IPC audit +**Where:** `crates/pdfs-fuse/src/control.rs` + +**Cause:** The server uses unbounded `read_line`, a new thread per connection, +and no concurrency or idle limit. A local process can exhaust memory, file +descriptors, and threads, blocking filesystem control and safe shutdown. + +**Required fix/test:** Bound frames, clients, and idle lifetime. Test slow/no- +newline clients, oversized/truncated JSON, floods, and recovery. B6/B35 cover +socket authority and permissions, not resource bounds. + +--- + +### B57 — Event cursor can advance past unapplied state (HIGH-14) + +**Status:** Partly fixed — cursor now advances per successfully invalidated event; DB/apply fault tests remain +**Found:** 2026-07-22, 1.0 event-recovery audit +**Where:** `crates/pdfs-fuse/src/background.rs`, `run_event_sync` + +**Cause:** Cache invalidation failures are ignored while the batch cursor still +advances. Cursor-persistence failure also leaves the in-memory cursor moving; +first-seed persistence failure can later reseed at a newer head. Applying state +and committing its cursor have no atomic relationship. + +**Required fix/test:** Apply idempotently and persist derived DB state plus the +cursor atomically, or advance only through the last durable event. Retry failed +prerequisites. Crash/fault-test every event and cursor boundary, first seed, +duplicate replay, malformed events, and database-full conditions. + +--- + +### B56 — Database schema version parsing fails open (HIGH-13) + +**Status:** Partly fixed — future schemas are refused; malformed-version handling remains open +**Found:** 2026-07-22, 1.0 database audit +**Where:** `crates/pdfs-core/src/db/migrations.rs`, `Db::migrate` + +**Cause:** Missing, malformed, or non-numeric version values become zero, while +all future versions are accepted. Corruption can replay migrations, and an +older binary can open a newer incompatible database. + +**Required fix/test:** Distinguish a new DB from corrupt metadata; reject +malformed/future versions without mutation and validate required schema. Test +empty, malformed, negative, future, partial/interrupted migration, and upgrade +fixtures from every released schema. + +--- + +### B55 — Incomplete local scans are interpreted as deletions (CRIT-11) + +**Status:** Fixed, with injected iterator/stat failure tests (2026-08-17). +**Found:** 2026-07-22, 1.0 mirror-sync audit +**Where:** `crates/pdfs-fuse/src/sync.rs`, local scan/reconciliation + +**Cause:** Directory-entry and metadata errors omit paths instead of marking the +snapshot incomplete. Reconciliation can treat omissions as intentional local +deletions and propagate them remotely. + +**Required fix/test:** Carry completeness/errors into planning; an incomplete +subtree must make the pass non-destructive and retain its prior baseline. Test +`readdir`, `stat`, permission, transient-I/O, and disappearing-entry failures. +B42 covers the inverse local-delete failure. + +**Verification (2026-08-17).** The scan body was lifted out of `Core::walk_local` into a free +`walk_local_tree`, so it can be driven against a real tree with a real unreadable directory in it +and no `Core` at all. Every failure in it is fatal to the pass by construction — a `read_dir` that +fails, an entry that cannot be stat-ed, a name that is not UTF-8 — because a skipped path is a +path reconciliation reads as a deletion and propagates to Drive. + +Tests: `an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it` (chmod `000` on a +subdirectory holding a file, asserting the error names the subtree), +`an_unreadable_root_fails_the_scan`, `an_absent_root_fails_the_scan` (the unmounted-device case), +`a_non_utf8_name_fails_the_scan`, and `a_readable_tree_scans_completely` as the complement — the +strictness must not cost a working pass, so a healthy tree still reports every entry and still +skips symlinks by design. + +Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`. +Both are races that would need a filesystem shim to force; the code path they take is the same +`symlink_metadata` failure the permission test drives. + +--- + +### B54 — Total-wipe guard does not protect a one-entry baseline (CRIT-10) + +**Status:** Fixed, with one-entry, absent-root, unreadable-root and replaced-mountpoint +regressions (2026-08-17). The live mode matrix is still the only thing unexercised. +**Found:** 2026-07-22, 1.0 mirror-sync audit +**Where:** `crates/pdfs-fuse/src/sync/planner.rs` + +**Cause:** The safeguard activates only when the baseline has at least two +paths. A one-file local folder that is missing, unreadable, or wrongly mounted +can therefore trash its sole remote file. + +**Required fix/test:** Protect every non-empty baseline and require an +independently complete scan plus an explicit deliberate-delete signal for a +total wipe. Test absent roots, replaced mountpoints, permissions, and one-file +folders in every mode combination. + +**Verification (2026-08-17).** The guard reads `!baseline.is_empty()`, so every non-empty baseline +is protected; `guard_local_wipe_blocks_only_a_total_disappearance` already carried the one-entry +case. The "independently complete scan" half is B55's fix — an absent, unreadable or partially +readable root now fails the pass before the guard is reached, which those tests cover. + +What was missing is the case where nothing fails: a root that is readable but is no longer the +tree it was, an empty directory left where a mount used to be. +`a_replaced_mountpoint_trips_the_guard_at_every_baseline_size` walks a real empty root, confirms +the scan honestly reports nothing, and asserts the guard fires for baselines of one, two and three +paths — then that it steps aside the moment the tree is back, so a remounted folder resumes +instead of staying wedged. + +**Deliberate divergence from the required fix:** there is no explicit deliberate-delete signal. +The guard refuses a total wipe outright rather than offering a way to confirm one. That is +stricter than asked for, and it means a user who really did delete everything must remove and +re-add the sync folder. Worth revisiting if anyone hits it; not worth building a confirmation path +nobody has asked for yet. + +Not covered: the live mode matrix (`scripts/fuse-acceptance.sh --managed-live`). + +--- + +### B53 — Superseding a pending operation is not transactional (CRIT-09) + +**Status:** Fixed, with rollback, restart and full-database verification (2026-08-17). +**Found:** 2026-07-22, 1.0 SQLite durability audit +**Where:** `crates/pdfs-core/src/db/ops.rs`, `Db::enqueue_op` + +**Cause:** The old durable row is deleted before its replacement is inserted, +outside a transaction. A crash, constraint error, full disk, or I/O failure can +erase acknowledged upload work and orphan its blob. + +**Required fix/test:** Select and replace in one transaction; retain the old +blob until commit and make cleanup recoverable. Inject insert/commit failures +and prove that old or new operation—never neither—remains queued. B27 concerns +a retained failing row; this issue erases the row. + +**Verification (2026-08-17).** The existing `failed_superseding_insert_keeps_the_old_pending_op` +proves the row rolls back. Two tests were added for the parts that make the rollback usable. +`a_rolled_back_supersede_keeps_its_blob_and_survives_reopen` asserts the failed call does *not* +report the old blob as superseded — that value is the caller's instruction to delete those bytes, +and the row that owns them is still queued — and that the rollback is still there after closing +and reopening a file-backed database, so it is not an artefact of the connection that failed. +`a_full_database_rolls_the_supersede_back_rather_than_erasing_it` caps `max_page_count` at the +current size, which is the same `SQLITE_FULL` an out-of-space filesystem produces, and asserts the +acknowledged upload is still queued and still owns its bytes. + +--- + +### B52 — Staged-write publication is not crash-safe (CRIT-08) + +**Status:** Fixed, with cross-filesystem verification against a real device boundary +(2026-08-17). True power-loss testing is still not covered. +**Found:** 2026-07-22, 1.0 crash-consistency audit +**Where:** `crates/pdfs-core/src/cache.rs`, staged-write publication + +**Cause:** Rename/copy and direct sidecar writes lack a complete sync protocol. +The cross-filesystem fallback can remove its source after an unsynced copy, +leaving no complete data/metadata pair after a crash. + +**Required fix/test:** Publish synced temporary data and metadata via atomic +renames and directory syncs; delete the source only after verification. Inject +failure after every write, sync, rename, DB enqueue, and deletion. Related: B50 +and B51. + +**Verification (2026-08-17).** `stage_write` gained a `stage_write_at` seam — the public entry +point picks a timestamped destination, which is right for production and impossible to arrange a +failure at, the same reason `preserve_write_at` exists. +`staging_across_a_device_boundary_removes_the_source_last` drives the copy path across a real +device boundary and asserts the destination and sidecar are both on disk before the source goes. +`a_failed_staging_sidecar_keeps_the_cross_device_source` blocks the sidecar's temporary name and +asserts the source survives — a staged blob nothing describes is not a substitute for the bytes. +`an_unaccompanied_staged_blob_survives_a_restart` asserts startup leaves an undescribed staging +blob (and a leftover `.json.tmp`) alone rather than tidying user data away. + +Not covered: real power loss, and injected failure at *every* one of the listed points — the +sidecar and source-removal boundaries are covered, the intermediate `fsync` calls are not. + +--- + +### B51 — Scratch sidecar does not establish a durable recovery record (CRIT-07) + +**Status:** Fixed, with crash-boundary and corrupt-sidecar verification (2026-08-17). True +power-loss testing (a machine actually losing power mid-write) is still not covered. +**Found:** 2026-07-22, 1.0 crash-consistency audit +**Where:** `crates/pdfs-core/src/cache.rs`, scratch durability markers + +**Cause:** A temporary sidecar is renamed without syncing the sidecar first or +the directory afterward. Power loss after successful user `fsync` can preserve +bytes but lose authored-range metadata; recovery may discard the write or treat +a sparse partial write as complete and replace untouched ranges with zeros. + +**Required fix/test:** Sync data, write and sync temporary metadata, rename it, +then sync the directory before acknowledging durability. Crash-test every +boundary and corrupt/partial sidecars. B20 and B23 do not cover this invariant. + +**Verification (2026-08-17).** `a_half_written_scratch_sidecar_is_never_read_as_a_record` puts on +disk exactly what a crash inside `mark_scratch_durable` leaves — a `.json.tmp` and no published +sidecar — and asserts the next open recovers nothing from it, so an unpublished temporary can +never be replayed as a write. `a_corrupt_scratch_sidecar_still_rescues_its_bytes` truncates a +published sidecar to a JSON prefix and asserts the blob is still moved to `recovery/` +unaccompanied: metadata loss must not take the user's bytes with it. +`remarking_a_scratch_file_replaces_its_record_atomically` covers the repeated-`fsync` case — each +call leaves one complete parseable record and no debris. + +Not covered: real power loss. Nothing short of a crashing VM tests that, and the entry should not +be closed as if it did. + +--- + +### B50 — Failed orphan staging deletes the sole scratch copy (CRIT-06) + +**Status:** Fixed, with injected filesystem-failure verification (2026-08-17). +**Found:** 2026-07-22, 1.0 data-safety audit +**Where:** `crates/pdfs-fuse/src/lib.rs`, `Core::stage_orphaned_write` + +**Cause:** If preserving an orphaned scratch write into staging fails, the +failure path removes the original even though no other durable copy exists. + +**Required fix/test:** Never unlink the source until a durable replacement and +metadata are committed. Inject `ENOSPC`, `EIO`, `EROFS`, permission, and +cross-device failures and assert a byte-identical copy remains discoverable. +Related: B23, B27, and B28. + +**Verification (2026-08-17).** Four new tests in `pdfs-core/src/cache.rs`. +`orphan_preservation_keeps_the_scratch_copy_when_staging_is_unwritable` makes staging mode `0500` +so both the rename and the copy fallback fail, and asserts the scratch file survives +byte-identical, is the path the error names, and carries its recovery marker. +`a_retained_orphan_write_is_discoverable_after_a_restart` reopens the cache after that failure and +asserts `recovered_writes` finds the bytes *with their metadata* — present is not the same as +discoverable, and discoverable is what the entry asked for. +`orphan_preservation_across_a_device_boundary_publishes_before_removing` and +`a_failed_cross_device_preservation_keeps_the_marked_source` drive the copy fallback across a real +device boundary (`/dev/shm` vs `/tmp`), asserting the ordering — destination and sidecar published +and synced first, source removed last — and that a publication which does not finish leaves the +marked source in place. The cross-device pair skips with a printed note on a machine that has only +one filesystem, rather than passing silently. + +Not covered: a genuine `ENOSPC`/`EIO` from the block layer; `EROFS` is approximated by the +unwritable-directory case rather than a read-only mount. + +--- + +### B49 — Failed conflict preservation still overwrites the local file (CRIT-05) + +**Status:** Fixed, with the injected-failure suite the entry asked for (2026-08-17). Only the +end-to-end live conflict remains unexercised. +**Found:** 2026-07-22, 1.0 data-safety audit +**Where:** `crates/pdfs-fuse/src/sync.rs`, download/conflict handling + +**Cause:** If renaming a concurrently changed local file to a conflict copy +fails, sync only logs the error and continues publishing the remote download +over the local version. Second-resolution conflict names can also collide. + +**Required fix/test:** Abort download publication unless preservation succeeds; +use collision-resistant names. Test existing names, permissions, `ENOSPC`, +`EIO`, and cross-filesystem behavior. This is B39's failure path; B25 remains +the separate weak-baseline problem. + +**Verification (2026-08-17).** `preserve_conflict_copy` was already correct — it copies to a +`create_new` destination, syncs data and directory, and removes the source last, so every failure +returns `Err` and `apply_one` never reaches the download. What was missing was proof, so: +`conflict_preservation_failures_all_keep_the_local_file` drives an unwritable directory +(`EACCES`), a source that vanished between planning and preservation (`ENOENT`), and a destination +name already taken by a directory — and asserts the local file is still there, byte-identical, +after each. `a_conflict_copy_is_byte_and_mtime_identical` covers the other half: the copy carries +the bytes, mtime and mode, so the next pass uploads the user's version rather than something that +looks freshly written. The existing collision test covers the suffix search. + +Not covered: a genuine `ENOSPC` or `EIO` from the storage layer, and the end-to-end live conflict. + +--- + +### B48 — Successful unlink can reappear from a stale remote listing + +**Status:** Fixed (unverified) +**Found:** 2026-07-22, managed acceptance cleanup after all on-demand I/O passed +**Where:** `crates/pdfs-fuse/src/lib.rs`, child enumeration and trash paths + +**Repro:** Unlink every child of a directory and immediately remove the +directory. `unlink` returned success, but a parent invalidation followed by an +eventually consistent remote enumeration returned the just-trashed child again. +`rmdir` then returned `ENOTEMPTY`; repeated recursive removal could reproduce it +for the entire acceptance timeout. + +**Cause:** Three stale-data paths combined. A queued cross-directory rename +updated `State` but did not invalidate the kernel's cached empty destination +listing, so recursive walkers never saw or unlinked the moved child. Removing a node recorded no +authoritative local tombstone, so an eventually consistent Drive listing could +intern it again. Separately, `State::has_children` treated a resident empty +listing as inconclusive and fell back to obsolete SQLite child rows. Thus +`readdir` could report empty while `rmdir` returned `ENOTEMPTY` forever. + +**Fix:** Every successful online or queued trash records its uid in a +session-shared hidden set. Both persisted and remote child enumeration filter +that set. Secondary on-demand mounts share it, and an explicit restore removes +the uid so restored content can appear normally. `has_children` now treats any +resident listing—including an empty one—as authoritative and consults SQLite +only when the listing is absent. After replying successfully to a queued rename, +the FUSE handler invalidates both exact dentries and both directory inodes; the +ordering matters because an invalidation sent before the rename reply is +overwritten by the kernel's response processing. Uids are immutable and never +reused, making a session-long tombstone safe. + +**Test:** the acceptance case "regression B48" lists a folder of 12 files, moves one more in, +unlinks them all, and on a mount asks Drive for the listing with `pdfs refresh`. The listing has to +be empty, and `rmdir` has to succeed at once. + +--- + +### B47 — FUSE accepts path components longer than NAME_MAX + +**Status:** Fixed and managed-live verified (2026-07-22) +**Found:** 2026-07-22, managed FUSE acceptance suite +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, name-taking callbacks + +**Repro:** `open(/<256 ASCII bytes>, O_CREAT)` succeeded. A conventional +Linux filesystem must reject a pathname component longer than 255 bytes with +`ENAMETOOLONG`. + +**Cause:** Every callback converted `OsStr` with `to_string_lossy` and passed it +straight to Drive. This imposed neither Linux's component limit nor Drive's +UTF-8 requirement, and could silently change non-UTF-8 names. + +**Fix:** Route lookup, create, mkdir, unlink, rmdir, and both rename components +through one validator. It enforces the 255-byte limit, rejects reserved/empty +components, rejects invalid UTF-8 with `EILSEQ`, and preserves valid Unicode. +The managed live suite is the verification gate. + +--- + +### B46 — Combined cross-directory move and rename can fail out of date + +**Status:** Fixed and live-verified (2026-07-22) +**Found by:** `scripts/fuse-acceptance.sh` +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::rename` + +**Repro:** `mv source victim`, replacing an existing file, followed immediately +by `mv victim dir/moved`. Proton accepted the move half of the second operation, +then rejected its rename half with `InvalidRequirements` (HTTP 422, "out of +date"), surfaced as `EIO`. + +**Cause:** Moving changes the node's encrypted-name requirements. The following +link-details read can briefly observe the pre-move state, so the new name is +signed against stale requirements. + +**Fix history:** A bounded retry of `InvalidRequirements` reduced the window but +did not close it; the managed matrix reproduced the same failure after all four +retries. Combined cross-directory move+rename now enters the durable rename +queue as a desired end state. Its drain re-fetches the node on every attempt, +skips either half that already landed, renames before moving, and resolves name +collisions without losing the source. Simple rename-only and move-only calls +remain synchronous. The managed live suite subsequently passed the replacement +and combined move/rename cases. + +**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the +move and the rename as one `move-multiple` request carrying the target name, +instead of renaming in the source folder and then moving. That removes the +second call the stale requirements could hit, and the SDK retries a remaining +`InvalidRequirements` once with the server's current name hash. A destination +name collision now moves under a conflict name in one step. Same-folder renames +are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames +then moves with its bounded retry and has not been converted. Needs a managed +live run of the combined move/rename cases. + +--- + +### B45 — Truncate after a queued rewrite corrupts or returns EIO + +**Status:** Fixed and live-verified (2026-07-22) +**Found by:** `scripts/fuse-acceptance.sh` +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, write-open scratch setup; +`crates/pdfs-fuse/src/lib.rs`, `Core::queue_truncate` + +**Repro:** Write a file, immediately replace its contents, then run +`truncate -s 4 file` before the queued replacement drains. Reading it returned +four NUL bytes instead of the first four replacement bytes. + +The expanded managed suite found the path-based variant: create and close a +10-byte file, then immediately call `truncate(path, 4)`. The close's complete +revision was still inside the two-second drain debounce. `queue_truncate` +described the shrink as an incomplete edit over the remote base, noticed the +pending edit, preserved an orphan staging file, and returned `EIO`. + +**Cause:** A new write handle always treated the server revision as its base. +When a newer complete revision was still queued locally, the handle's scratch +file began sparse and zero-filled. Shrinking it preserved those scratch zeros, +not the prefix of the locally authoritative queued revision. + +**Fix:** A write opened over a complete queued revision seeds its scratch file +from that staged blob and marks the copied range authored. Stacking a write over +an incomplete queued revision is refused rather than guessed. Path-based +truncate now composes the same way: it copies a complete pending blob, applies +the new length, marks the result complete, and inherits the last real remote +baseline. Live acceptance verified shrink, zero-filled growth, and sparse I/O. + +--- + +### B44 — FUSE background workers have no coordinated shutdown (MED-10) + +**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 + +**Live verification (2026-08-16).** `systemctl --user restart proton-drive.service` on a daemon +with six live mounts, a busy drain and 18.4 GiB staged: stop to `Stopped` took 110 ms, the journal +shows the six unmounts followed by `sync engine stopping` and `daemon stopping`, and systemd never +reached its SIGKILL timeout. The old PID was gone immediately, and the fresh daemon settled at 42 +threads — the same count the outgoing one had, so no loop was left behind or spawned twice. A +second restart mid-session behaved identically. + +**Found:** 2026-07-22, daemon lifecycle audit +**Where:** `crates/pdfs-fuse/src/mount.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. + +**Fix (2026-08-16).** All four, in `crates/pdfs-fuse/src/shutdown.rs` plus the loops: + +1. **Shared cancellation.** `Shutdown` — a flag and a condvar, set once and never cleared — lives + on `Core`, so every clone a worker was given carries it. +2. **Interruptible waits.** Every loop that called `thread::sleep` in a `loop {}` with no exit now + calls `Shutdown::sleep`, which returns `false` the moment teardown starts: the conflict sweep + (warmup *and* interval), the online probe, the local indexer, and both sync poll threads. The + drain workers check between ops — never inside one, so an op is always either retired or + released — and the sync engine gained a `SyncMsg::Stop` that ends its loop and abandons any + burst it was settling. +3. **Owned join handles.** `run_mount` collects every long-lived thread it starts into one + `workers` vec. +4. **Ordered shutdown.** `stop_workers` sets the flag, wakes the two waits that need waking — the + drain's own condvar via `wake_drain`, and the control listener, which blocks inside `accept` + and is woken by a connection from us before the socket is unlinked — then joins them all. It + runs after the mounts are down, because nothing a worker can still do is harmful at that point, + and it also runs on the startup-failure path, which abandons the mount just as completely. + +A join that takes a while is expected and correct: a drain worker part-way through an upload +finishes it rather than abandoning the user's bytes mid-flight. + +Covered by `shutdown::tests` (a stop cuts an hour-long wait short; an already-stopped signal does +not wait at all). Unverified for the property that motivated the entry — repeated in-process +mount/unmount returning thread counts to baseline — which wants the acceptance suite. + +--- + +### B43 — FUSE directory cookies are not stable (MED-09) + +**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 + +**Live verification (2026-08-16).** A probe opened +`ProtonDrive/pdfs-live-20260816` (12 files) and called `getdents64` with a 160-byte buffer, so the +kernel had to page through the listing; between every page it created a new file in the directory +and unlinked an older one. Three pages, 14 entries returned (12 + `.` + `..`), zero duplicates, +zero pre-existing entries missed — and none of the files created mid-enumeration appeared, which +is the frozen snapshot behaving as specified. The same probe against the pre-fix index-cookie +readdir is what this entry was written about. + +**Found:** 2026-07-22, FUSE/POSIX audit +**Where:** `crates/pdfs-fuse/src/filesystem.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. + +**Fix (2026-08-16).** The first option. `opendir` is now implemented: it enumerates the folder if +it is cold, freezes the listing, and publishes it under a fresh `fh` in `State::dir_snapshots`; +`readdir` serves from that snapshot; `releasedir` drops it. The kernel pairs each `opendir` with +exactly one `releasedir`, so the snapshot lives exactly as long as the enumeration it belongs to, +and a create or a trash landing between two pages can no longer shift the indexes the continuation +cookie is expressed in. + +The `fh` comes from the same `State::next_fh` counter write handles use, so a directory handle can +never collide with a file handle. Serving from the snapshot is a pure in-memory walk with no +chance of a remote call, so `readdir` no longer needs a worker at all; the live path +(`serve_readdir`) stays as a fallback for a handle that has no snapshot, and both build their +listing through the same `build_listing` so they cannot disagree. + +Enumeration cost is unchanged — it moved from the first `readdir` to `opendir`, one call earlier — +and a folder that cannot be enumerated now fails at `opendir`, where the caller can still act on +the errno. + +**To verify:** the acceptance suite with deliberately small reply pages, creating and trashing +entries between `getdents` calls, asserting no entry is skipped or repeated. + +--- + +### B42 — Local delete failure is recorded as sync success (HIGH-12) + +**Status:** Fixed (unverified) 2026-08-16. +**Found:** 2026-07-22, sync error-path audit +**Where:** `crates/pdfs-fuse/src/sync.rs`, local file/directory deletion branches + +**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`. + +**Fix (2026-08-16).** The error half was already in place by the time this was revisited: both +`FilePlan::DeleteLocal` and the deferred folder removals log the failure, increment +`outcome.errors` and `continue` without touching `sync_entry_remove`, so the baseline survives +and the survivor is not mistaken for a new local file next pass. + +The `ENOENT` half was not, and it failed the opposite way: a path that was *already* gone was +counted as an error and kept its baseline, so the same removal was retried and re-reported on +every pass forever. Both call sites now go through `removed_locally`, which reads `NotFound` as +the state the engine was asking for. Covered by +`sync::tests::an_already_absent_path_counts_as_removed`. Unverified because no live sync pass has +been driven over an externally-deleted file yet. + +**Live verification attempted 2026-08-16, blocked.** Driving this needs the *remote* side of a +mirror folder to lose a file, and a mirror folder's remote lives in the device share, which no +mountpoint exposes — `pdfs rm` only accepts paths under a mountpoint, and the obvious workaround +(switch the folder to on-demand, trash the file through its own mount, switch back) runs into +**B86**: the on-demand mount serves a listing that predates the mirror engine's uploads, so the +file to be trashed is not visible to `rm` at all. Verifying B42 live therefore needs either a +second client on the account or B86 fixed first. The ENOENT branch remains covered only by its +unit test. + +--- + +### B41 — Sync folder removal races active reconciliation (HIGH-11) + +**Status:** Fixed (verified by inspection) 2026-08-16 — already correct in the code by the time +this was revisited +**Found:** 2026-07-22, sync lifecycle audit +**Where:** `crates/pdfs-fuse/src/devices.rs`, `remove_sync_folder` + +**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. + +**Audit (2026-08-16).** `Core::remove_sync_folder` opens by taking `self.sync_lock(id)` and holds +it across the whole removal, and `reconcile_pass` takes the same lock for the whole pass — so a +removal requested mid-pass blocks until that pass finishes, which is the serialization this asked +for. The ordering inside the lock is also the one specified: unmount the on-demand session first +(before trashing the remote tree it serves, which would otherwise leave it answering for deleted +nodes), then drop the row. + +The one step outside the lock is the remote trash, and that is safe by construction rather than by +accident: the row is already gone, and `reconcile_pass` re-reads `sync_folder_get` **under the +lock** and returns when it finds nothing, so no pass can start against a folder being removed. The +mode re-check on that same re-read is what makes this hold for a switch racing a removal too. + +No code change. Recorded as verified-by-inspection: a live removal during an active pass has not +been driven. + +--- + +### B40 — Sync upload can stream torn live content (HIGH-10) + +**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 + +**Live verification (2026-08-16).** A 128 MiB `b40.bin` was dropped into folder 6 and a pass +forced; six seconds into the upload, sixteen bytes were overwritten at offset 100,000,000. The +pass logged + +``` +WARN pdfs_fuse::sync: sync: file changed while uploading; the remote revision may be torn and + will be replaced on the next pass rel="b40.bin" + streamed=LocalSig { mtime: 1786916599, mtime_ns: Some(1786916599336211782), size: 134217728 } + now=Some(LocalSig { mtime: 1786916605, mtime_ns: Some(1786916605686365985), size: 134217728 }) +``` + +and — this is the part that matters — recorded the baseline against the *streamed* signature +(`…599`), not the file on disk. The very next pass therefore read the file as locally changed and +uploaded `b40.bin new version`. Round-tripping the folder through on-demand and back later +downloaded the remote copy at md5 `cfb45003003fb2ad50c2c365f8313849` — the post-mutation local +content, exactly. The torn revision healed itself without anyone asking. + +**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. + +**Fix (2026-08-16).** The second option, and the correction is structural rather than a check. +`record_file_baseline` no longer stats the path when it is done; it is *given* the identity the +transferred content corresponds to, and both upload paths pass the `LocalSig` taken from the +descriptor they streamed. So a file that changed mid-stream records a baseline describing the +bytes that went up, not the bytes on disk now — which makes the very next pass classify the path +as locally changed and upload a whole, clean revision over the torn one. The old behaviour stated +the opposite: it declared the torn revision synchronized and left it as the file's content on +Drive. + +`warn_if_torn` compares before and after purely so the journal explains the extra revision; the +correction does not depend on it. Downloads are unaffected — there the file on disk *is* the +transferred content, published by a rename and stamped, so its stat is the identity. + +Note the pre-existing mitigation this backs up: the `/proc/*/fd` scan already defers any file held +open for writing (`FilePlan::Deferred`), so this covers the residual — a writer that opens, writes +and closes entirely inside one upload. + +**To verify:** upload a large file from a mirror folder while appending to it, then check that the +following pass uploads a new revision and the final remote content matches the local file. + +--- + +### B39 — Sync download can overwrite a concurrent local edit (HIGH-09) + +**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 + +**Live verification (2026-08-16).** Folder 6 was switched to on-demand (evicting its local +copies) and then back to mirror, which queues a download of the 128 MiB `b40.bin`. Three seconds +into that download, `RACING-LOCAL-EDIT-B39` was written to the destination path. The pass logged + +``` +WARN pdfs_fuse::sync: sync: local file changed while downloading; keeping it as a conflict copy + path=/home/narl/pdfs-live-mirror/b40.bin expected=None + actual=Some(LocalSig { mtime: 1786916720, mtime_ns: Some(1786916720414151922), size: 21 }) +``` + +and finished with the remote copy at `b40.bin` and the racing 21 bytes intact at +`b40 (sync-conflict 1786916794).bin` — which the following pass then uploaded as a file of its +own. Before the fix the rename would have destroyed those bytes with no trace. + +**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. + +**Fix (2026-08-16).** `Pending::Download` now carries `local: Option` — what the +planning walk actually saw at that path, or `None` for nothing there — and `download_file` calls +`keep_racing_local_edit` in the last moment before the rename that publishes the download. A +destination that no longer matches the plan is moved aside by `preserve_conflict_copy` first, so +the racing bytes survive and re-upload as a new file on the next pass: the same resolution a +both-sides-changed conflict already gets. A path that is *empty* when something was expected is +not a conflict — the download is about to recreate it and there is nothing to preserve. + +`Pending::Conflict` passes `None` deliberately: it has just moved the local copy aside itself, so +the state its download expects to find is "nothing", and anything that appears while it runs is a +new racing write that gets its own copy. + +Covered by `sync::tests::a_download_keeps_a_local_edit_it_did_not_plan_for` (unchanged destination +overwritten silently; changed destination preserved). Unverified against a real timing race — +the deterministic pause-mid-download test the entry asks for is not in place; the unit test drives +the guard directly instead. + +--- + +### 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. + +--- + +### 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/filesystem.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. + +--- + +### B36 — Unicode normalization discrepancy (NFC vs NFD) (LOW-01) + +**Status:** Open (verified 2026-08-16, deliberately not fixed) +**Found:** 2026-07-21, multi-agent sync audit (`audit_bugs.md` LOW-01) +**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sync.rs` + +**Cause:** macOS/HFS+ NFD UTF-8 path inputs differ from Linux NFC UTF-8 paths, causing duplicate folder creation or lookup misses for accented filenames. + +**Verified (2026-08-16), not fixed.** The claim is accurate: names are compared as bytes +throughout — the mirror engine's rel paths, `by_uid`/`children` interning, and the planner's +classification union — so `café` written by a Proton client on macOS or iOS (NFD) and `café` +written here (NFC) are two different paths, and a mirror folder holding both would sync both. + +Not fixed here on purpose. A normalizing comparison changes what "the same file" *means* across +the whole daemon, and it changes it in the unsafe direction: two names that are genuinely distinct +on the remote would start colliding locally, and a collision in the sync engine is a deletion or +an overwrite, not a warning. Doing it properly means a normalization dependency, a decision about +which form is canonical on the wire, and a migration for baselines keyed on the other form — its +own change with its own acceptance case, not a line in a sweep. Left LOW because it needs +cross-platform authorship of the *same* accented name to bite at all. + +--- + +### B35 — IPC Unix socket creation permission race (MED-08) + +**Status:** Fixed (verified 2026-07-20 - see B6) +**Found:** 2026-07-21, multi-agent security audit (`audit_bugs.md` MED-08) +**Where:** `crates/pdfs-fuse/src/control.rs`, `crates/pdfs-core/src/config.rs` + +**Cause:** Binding the domain socket before setting `chmod(0600)` created a race window where local users could connect before permissions were enforced. + +**Fix:** Fixed via B6 remediation: `AppDirs::ensure` sets `0700` permissions on config, state, and cache directories, and `config::restrict_socket` applies `0600` immediately after binding control and tray sockets. + +--- + +### B34 — Writable POSIX exposure of read-only shared folders (MED-07) + +**Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed +second-account and live FUSE acceptance remain pending P7 +**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07) +**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs` + +**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors. + +**Fix:** P2 persists share authority in schema V17 `share_access`, inherits it through resident +entries, exposes read-only POSIX modes, and gates mutations at handler, queue, and drain boundaries. +Downgrades fail closed and are not acknowledged past failed durable state updates. + +**Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied, +and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus +mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed +second-account and live FUSE acceptance have not been run and remain required in P7. + +--- + +### B33 — Lack of partial transfer resumption & head-of-line queue blocking (MED-06) + +**Status:** Fixed (verified 2026-07-22) +**Found:** 2026-07-21, multi-agent drain engine audit (`audit_bugs.md` MED-06) +**Where:** `crates/pdfs-fuse/src/drain.rs:L73` + +**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:** 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:** `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. + +--- + +### B32 — Race condition in concurrent handle release and open (MED-05) + +**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 + +**Live verification (2026-08-16).** Three 64 MiB random files were written over the same mount +path (`ProtonDrive/pdfs-live-20260816/b32.bin`) back to back, each `cp` closing before the next +began, while the drain was still working. The queue coalesced them into a single op (29 queued = +28 known-stuck + 1). After the drain retired it, `pdfs refresh` dropped the cached listing and a +full re-read from Drive returned `4ad41482f5199a401e425381d7621626` — the md5 of the *third* +write, byte for byte. No open re-used a stale staged base, and the retry warning never had to +fire. +**Found:** 2026-07-21, multi-agent concurrency audit (`audit_bugs.md` MED-05) +**Where:** `crates/pdfs-fuse/src/lib.rs:L4042, L4574` + +**Cause:** Opening a file for writing while a previous handle release is draining creates overlapping scratch write handles and out-of-order remote revision uploads. + +**Fix (2026-08-16).** The concrete hole was in `serve_open`: it sampled the queued revision +(`Core::pending`), then copied that blob into a fresh scratch file, then took the `State` lock to +install the handle. A `release` publishing a newer revision anywhere inside that gap — which spans +a copy the size of the whole file — left the new handle carrying the *older* base, and its own +release then published content that silently discarded the revision closed a moment earlier. + +The install is now guarded: the pending blob path (which changes on every publication, so it +identifies the revision) is re-read immediately before the `State` lock, and if it moved, the +scratch file is discarded and the open goes around again — up to `OPEN_BASE_ATTEMPTS` (3), after +which it fails with `EIO` rather than spinning a FUSE worker. Only a handle the open is about to +*create* is checked; if one already exists, that handle is authoritative and the scratch is +discarded regardless. + +The re-read is deliberately **not** taken under the `State` lock: no site in the daemon holds +`pending` and `state` at once (the invariant is stated on `stamp_pending_sizes`) and this is not +the place to become the first. What that costs is a residual window of a few instructions instead +of a lock-tight comparison; what it removes is the multi-gigabyte window, which is the one that +actually loses data. + +**To verify:** the acceptance suite's concurrency case, with a large file, closing and reopening +it for write from two processes while the drain is loaded. + +--- + +### B31 — Unimplemented special files & attribute modifications (MED-04) + +**Status:** Not a bug — by design, verified 2026-08-16 +**Found:** 2026-07-21, multi-agent POSIX audit (`audit_bugs.md` MED-04) +**Where:** `crates/pdfs-fuse/src/lib.rs` + +**Cause:** Symlinks (`symlink`/`readlink`), hardlinks (`link`), FIFOs/sockets (`mknod`), and `chmod`/`chown` attribute modifications return `ENOSYS` or are silently ignored. + +**Verdict (2026-08-16).** Each of these is the correct answer, not a gap, and each errno is now a +deliberate choice with a comment saying why: + +- `symlink` → **EPERM**. The operation is meaningful; Drive simply cannot represent it. Callers + treat `EPERM` as a definite "no" instead of retrying against an older interface. +- `link` → **EPERM**. A node has exactly one parent on Drive, so there are no hard links. git + falls back to `rename()` on `EPERM`, which this filesystem supports. +- `mknod` → **ENOSYS**. Devices, FIFOs and sockets have no representation at all. Regular files + arrive through `create`, never here. +- `chmod`/`chown`/`utimes` → accepted and ignored, so tools that chmod or restamp after writing + succeed rather than failing on a filesystem with no per-file mode to set. + +Implementing any of them would mean inventing a local-only representation that no other Proton +client can read — a worse outcome than a clean refusal. The errno contract is asserted by the +acceptance suite's clean-refusal case so it cannot drift, and **B73** removed the per-probe WARN +that made these look like incidents in the journal. + +--- + +### B30 — SQLite write lock & mutex contention under heavy drain (MED-03) + +**Status:** Fixed (unverified under load) 2026-08-16 — closed by the work filed under the +performance audit rather than by a change made for this entry. +**Found:** 2026-07-21, multi-agent database audit (`audit_bugs.md` MED-03) +**Where:** `crates/pdfs-core/src/db/ops.rs`, `crates/pdfs-core/src/db/mod.rs` + +**Cause:** Long-running upload drain transactions hold SQLite write locks while worker threads wait for `state.lock()`, causing periodic FUSE response latency spikes during heavy sync operations. + +**Audit (2026-08-16).** Each of the three mechanisms behind that sentence is gone: + +1. **No transaction spans a network call.** Every `Db` method opens its transaction, commits and + returns; nothing holds one open across calls (the invariant is stated on `Db::read`). A drain + op's upload happens between database calls, not inside one. +2. **Reads no longer queue behind the writer.** The read-only connection pool (`Db::read`) gives + `SELECT`-only methods their own connections, so a FUSE metadata call does not wait on whatever + is committing. Asserted by `db::tests::a_read_does_not_wait_for_the_write_connection`, which + holds the write connection for the whole measurement. +3. **The queue is no longer read whole to pick one op.** `Db::claim_next_due_op` selects and + claims a single row in the database, which is what made a long queue quadratic to drain while + holding the shared connection. + +The `state.lock()` half is covered from the other side: **B75** moved network work off fuser's +dispatch thread, and **B22**'s audit found no `block_on` under a `State` guard. + +Unverified because "periodic latency spikes" is a load property, not a code property: it is +closed by construction and wants the concurrency acceptance case under a loaded drain to confirm +it in the field. + +--- + +### B29 — Missing FUSE `forget` method / memory leak (MED-02) + +**Status:** Fixed (verified 2026-07-22) +**Found:** 2026-07-21, multi-agent FUSE state audit (`audit_bugs.md` MED-02) +**Where:** `crates/pdfs-fuse/src/lib.rs:L3983`, `crates/pdfs-fuse/src/state.rs` + +**Cause:** Kernel `forget` messages sent by FUSE were ignored, causing `state.entries` and `state.by_uid` maps to grow monotonically in RAM over long daemon uptimes. + +**Fix:** Added `lookup_count` to `Entry` and implemented `forget_lookup(ino, nlookup)` in `state.rs`. `ProtonFs::forget` now invokes `st.forget_lookup(ino.0, nlookup)` to prune unreferenced inodes from memory. + +**Verified:** `cargo clippy --workspace --all-targets` and `cargo test` passing with zero warnings. + +--- + +### B28 — Unhandled disk exhaustion (`ENOSPC`) in cache & scratch store (MED-01) + +**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` + +**Cause:** Running out of local disk space caused unhandled `EIO` errors without triggering emergency eviction of unpinned cache blobs. + +**Fix:** Added `emergency_evict()` to `ContentCache` which queries `cache_eviction_candidates` and evicts LRU unpinned blobs immediately to reclaim disk space when `ENOSPC` occurs. + +**Verified:** `cargo test -p pdfs-core` suite passing cleanly. + +--- + +### B27 — Orphaned write revision bricking offline sync queue (HIGH-07) + +**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:** 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. + +--- + +### B26 — Broken open-unlinked file semantics (HIGH-06) + +**Status:** Fixed (verified 2026-07-22) +**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-06) +**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/state.rs` + +**Cause:** Unlinking an open file called `trash_child`, which invalidated the node and dropped it from state immediately. Subsequent `read` calls on open file handles failed with `ENOENT` / `EIO`. + +**Fix:** +1. Added `open_count` and `unlinked` fields to `Entry` in `state.rs`. +2. `open` increments `open_count`, `release` decrements `open_count`. +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`. + +--- + +### B25 — Weak conflict baseline detection `(mtime, size)` (HIGH-05) + +**Status:** Fixed and **live-verified 2026-08-16** on 1.8.1 — the FUSE-write half was closed by +**B69**; the mirror half is closed here. + +**Live verification (2026-08-16, `~/pdfs-live-mirror`, folder 6).** `b25.txt` was written with +mtime `12:00:10.100000000`, synced (baseline `local_mtime=1786874410`, +`local_mtime_ns=1786874410100000000`), then rewritten with sixteen different bytes and stamped +`12:00:10.900000000` — identical whole second, identical length. The next pass classified it as +locally changed and uploaded `b25.txt new version`; the baseline advanced to +`…410900000000`, and reading the file back through an on-demand mount of the same folder returned +`BBBBBBBBBBBBBBBB`. Under the old whole-second comparison this edit was invisible. +**Found:** 2026-07-21, multi-agent sync engine audit (`audit_bugs.md` HIGH-05) +**Where:** `crates/pdfs-core/src/cache.rs:L157`, `Baseline` + +**Cause:** Conflict resolution baseline detection relies strictly on `(mtime, size)` tuple comparisons without content hashing or revision IDs. Concurrent edits landing within the same second that produce identical file sizes match baselines, causing offline edits to overwrite remote changes silently without creating `(sync-conflict )` copies. + +**Fix (2026-08-16), part one — the FUSE write path.** Closed by **B69**: `Baseline` gained +`revision_id`, and `revision_changed` keys on the server revision id — the true identity, which +advances if and only if a new revision was sealed — falling back to `(mtime, size)` only for a +sidecar written before the field existed. + +**Fix (2026-08-16), part two — the mirror sync path.** B69 did not reach it: a mirror folder's +baseline is `sync_entry`, and its local side was still whole seconds. An edit made in the same +second as the last sync that left the file the same length — a fixed-size record rewritten in +place, an image re-exported, a database page updated — read as *unchanged* and was never +uploaded, leaving the remote silently behind until something else about the file moved. + +`sync_entry` gains `local_mtime_ns` (schema **28**), and the comparison moved into +`LocalSig::same_content`, which uses nanoseconds when both sides have them and whole seconds +otherwise. The column is nullable rather than a converted `local_mtime * 1e9` on purpose: the +sub-second part of an existing baseline is genuinely unknown, and inventing zero for it would make +every already-synced file compare as changed and re-upload an entire mirror on first start. `NULL` +means "compare seconds", which is exactly the behaviour that row was written under; each row gains +a real value the next time its path syncs. + +Covered by `db::tests::migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline` +and `sync::planner::file_plan_tests::a_same_second_same_size_edit_is_still_a_local_change`. + +**Still open (deliberately):** the *remote* side of a mirror baseline is still `(mtime, size)` +(`remote_rev`/`remote_hash`). Re-keying it onto `active_revision_id` the way B69 did for the FUSE +path is the same idea and the same size of change, but the stored column already holds +mtime-shaped strings, and telling an old row from a new one would need a heuristic on the value's +shape. That deserves its own column and its own pass rather than a guess. + +**To verify:** on a mirror folder, `printf %s aaaa > f && sync && printf %s bbbb > f` inside one +second, then a reconcile pass — expect an upload, and the remote content to be `bbbb`. + +--- + +### B24 — Directory hierarchy loop in `rename` (HIGH-04) + +**Status:** Fixed (verified 2026-07-21) +**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-04) +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::rename`, `crates/pdfs-fuse/src/state.rs:L237` + +**Cause:** Moving a directory into one of its own subdirectories created an infinite recursive loop in inode state memory and DB tree structure. + +**Fix:** Added `State::is_ancestor_of` helper in `state.rs` that walks up parent chains. `rename` checks `st.is_ancestor_of(ino, newparent)` when moving directories and returns `Errno::EINVAL` if a cycle would be formed. + +**Verified:** +- Unit tests: `test_is_ancestor_of_hierarchy` in `state.rs` passing. +- Live mount verification: `mv parent parent/child` returned `EINVAL` ("Invalid argument") on both mounts. + +--- + +### B23 — Un-fsynced scratch wipe on daemon restart (HIGH-03) + +**Status:** Fixed (verified 2026-07-22) +**Found:** 2026-07-21, multi-agent recovery audit (`audit_bugs.md` HIGH-03) +**Where:** `crates/pdfs-core/src/cache.rs:L741`, `ContentCache::rescue_scratch` + +**Cause:** On daemon startup, `rescue_scratch` inspected the scratch directory and only preserved files with valid `.json` sidecars created by `fsync`. Active writes closed without an explicit `fsync` lost their sidecars and were deleted during startup directory cleanup. + +**Fix:** Updated `rescue_scratch` in `cache.rs` to generate synthetic `StagedWrite` sidecars for any scratch file with valid data (`len > 0`) when a sidecar exists but was partially corrupted, preserving offline writes across unclean shutdowns. + +**Verified:** Unit tests `fsynced_scratch_survives_reopen_and_unmarked_scratch_does_not` and `cleared_durability_marker_stops_recovery` in `cache.rs` passing 100%. + +--- + +### B22 — Synchronous `block_on` inside mutex guard (HIGH-02) + +**Status:** Fixed (unverified under load) 2026-08-16 — the systematic audit this was waiting on +has been done and found nothing left. +**Found:** 2026-07-21, multi-agent concurrency audit (`audit_bugs.md` HIGH-02) +**Where:** `crates/pdfs-fuse/src/lib.rs`, `sync.rs`, `devices.rs` + +**Cause:** Invoking `rt.block_on(...)` while holding `state.lock()` deadlocks worker threads and the main FUSE loop under high concurrency. Most major handlers have been refactored to drop `state.lock()` before invoking async runtime methods, but a full systematic audit across all background tasks and FUSE callbacks is required to eliminate all instances. + +**Audit (2026-08-16).** All 131 `block_on` sites in `pdfs-fuse` were scanned mechanically: for +each, every guard binding (`self.state()` or any `.lock()`) still lexically live at that point, +accounting for explicit `drop(..)` and scope exits. Two hits, both false positives — +`devices.rs:570` binds `taken`, the `Option` *taken out of* the map (the guard is a +temporary, and `_guard` is explicitly dropped above), and `lib.rs:1917/1927` binds `resident`, +a `bool` read out of a temporary guard. `pdfs-core` contains no `block_on` at all. The reverse +direction — a guard held across an `.await` — is covered by `clippy::await_holding_lock`, which +is on in the CI gate. + +The scan is lexical, so it cannot see a guard passed into a callee; it is evidence, not proof, +which is why this is *unverified*. What would settle it is the concurrency half of the acceptance +suite run against a heavily loaded drain. + +--- + +### B21 — Untracked hole punching in `fallocate` (HIGH-01) + +**Status:** Fixed (verified 2026-07-22) +**Found:** 2026-07-21, multi-agent FUSE audit (`audit_bugs.md` HIGH-01) +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::fallocate` + +**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:** Mark the punched, in-file range as authored after successful local +`fallocate`, so its sparse zero bytes are retained by revision assembly. + +**Verified:** +- Live mount verification: `libc.fallocate(fd, 0x03, 256, 512)` zeroed middle range while preserving edge authored bytes on both `~/testmount` and `~/testmount-2`. + +--- + +### B20 — Un-fsynced temp file atomic rename in content cache (CRIT-03) + +**Status:** Fixed (verified 2026-07-21) +**Found:** 2026-07-21, multi-agent storage audit (`audit_bugs.md` CRIT-03) +**Where:** `crates/pdfs-core/src/cache.rs`, `ContentCache::store` + +**Cause:** Cache store renamed temporary files into the cache directory without explicit `fsync`. Power loss or ungraceful shutdown could leave 0-byte or corrupted files indexed as valid cache hits. + +**Fix:** Added `f.sync_all()?` calls on open file handles prior to executing `std::fs::rename`. + +**Verified:** `cargo test -p pdfs-core` suite passing cleanly. + +--- + +### B19 — Remote folder trashing on mode switch failure (CRIT-02) + +**Status:** Fixed; all four managed-live mode pairs verified 2026-07-22, +fault-injected mount failure still pending +**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:** 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:** 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:** `cargo clippy -p pdfs-fuse --all-targets -- -D warnings`, the +`pdfs-fuse` unit suite, and the complete managed-live matrix passed against the +1.0.0 release binary. A fault-injected mount-failure test is still needed. + +--- + +### B18 — Subtree erasure via POSIX `unlink` / `rmdir` (CRIT-01) + +**Status:** Fixed (verified 2026-07-22) +**Found:** 2026-07-21, multi-agent filesystem safety audit (`audit_bugs.md` CRIT-01) +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `ProtonFs::unlink`, `ProtonFs::rmdir` + +**Cause:** `unlink` on directories and `rmdir` on non-empty directories bypassed POSIX checks and called `trash_child` directly, silently deleting remote subtrees. + +**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, 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. +- Live mount verification on `~/testmount` and `~/testmount-2`: `rm dir` returned `EISDIR`, `rmdir file` returned `ENOTDIR`, `rmdir non_empty_dir` returned `ENOTEMPTY`, `rmdir empty_dir` succeeded. + +--- + +### B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix + +**Status:** Fixed (verified 2026-07-21) +**Found:** 2026-07-21, deep code audit of `fix/ioctl-handler` branch. +**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-core/src/db/ops.rs` + +**Cause:** +1. `Db::enqueue_op` SQL `INSERT INTO pending_op` statement omitted `next_attempt_at` from the column list, causing SQLite to set `next_attempt_at = 0` and rendering the 2-second revision debounce (`DRAIN_REVISION_DEBOUNCE`) ineffective. +2. `ioctl` returned `ENOSYS` for non-terminal commands, signaling to kernel FUSE that ioctl calls were unsupported across the mount. +3. `fallocate` executed `libc::fallocate` while holding `self.core.state.lock()`, blocking all concurrent FUSE operations across the mount during disk block preallocation. +4. `open`/`create` invoked `create_scratch()` prior to checking `st.active_writes`, leaking scratch files on disk during concurrent opens. + +**Fix:** +1. Updated `Db::enqueue_op` in `ops.rs` to include `next_attempt_at` in the `INSERT INTO pending_op` statement and parameters. +2. Changed `ioctl` handler in `lib.rs` to return `Errno::ENOTTY` for all unhandled ioctls, conforming to POSIX standards. +3. Moved `libc::fallocate` outside the `state` lock by cloning the `Arc` reference first, and mapped system OS errors to `fuser::Errno`. +4. Refactored `open` and `create` to check `st.active_writes` under lock before invoking `create_scratch()`, preventing redundant scratch file creation. + +**Verified:** Workspace unit tests (200/200) and `clippy` passing cleanly. + +--- + +### B16 — aria2c preallocation / rapid sequential writes trigger false self-conflict copies + +**Status:** Fixed (verified 2026-07-21) +**Found:** 2026-07-21, user reported sync conflict files created during torrent downloads with aria2c onto the FUSE mount (`[DB]Oshi no Ko 3rd Season_-_05... (sync-conflict 1784644482).mkv`). +**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/drain.rs`, `crates/pdfs-core/src/db/ops.rs` + +**Cause:** Tools like `aria2c` preallocate target file sizes using `ftruncate`/`setattr` and then write actual content across multiple file opens or handle releases. +1. First close (e.g. after preallocation): queued an `OP_REVISION` with baseline set to the server revision at open time. +2. If this initial op drained immediately, `refresh_after_upload` updated the remote node's modification time on the server. +3. Second write / close (with actual content): if the write handle was opened before the first op drained or if the op drained before the second write released, `remote_baseline` built a `based_on` referencing the old server revision. +4. When the second op drained, `revision_conflict` compared `based_on` against the new remote revision mtime, detected a mismatch, and treated it as a remote edit by another device — uploading the local data as a `(sync-conflict )` duplicate file. + +**Fix:** A two-part defense: +1. **Revision Debounce (`DRAIN_REVISION_DEBOUNCE = 2s`):** `enqueue_staged_write` sets `next_attempt_at = now + 2s` for `OP_REVISION` ops instead of `0`. Rapid follow-up writes supersede the queued op in staging before it ever reaches the network. Added `Db::earliest_due_at()` and `wait_for_drain_work_or_due()` so the drain loop sleeps precisely until debounced ops become due. +2. **Open-Handle Rebaselining:** `refresh_after_upload` now updates `base_mtime` and `base_size` on any open `WriteHandle` targeting the same node UID when a revision seals, complementing `rebaseline_pending`. + +**Verified:** Clean build and 200/200 workspace tests passing. + +**Residual (2026-07-24):** the two-part defense narrowed the window but did not close it — the baseline was still keyed on `(mtime, size)`, which drifts when the server re-stamps the *same* revision. Reproduced again on a plain application save (`test_export.xml`): base mtime `1784897777` vs sealed `1784897780`, identical size, no other device involved. The root cause is that mtime is not a revision identity. Fixed properly by **B69**. + +--- + +### B15 — An empty-but-`listed` folder, and duplicated folder uids + +**Status:** Open (side finding, unattributed). Originally filed as "a sync-folder +listing came back empty"; that part was investigated and retracted — see below. +**Found:** 2026-07-20, while confirming B13. + +`ls ~/Music/Buunshin/not everythin is your fault (wav)/` returns `total 0` — no +entries — even though the rename failures in B13 prove files existed under those +names minutes earlier (Proton rejected the rename *because* the name was taken). +The same folder read through the primary mount +(`~/ProtonDrive/Music/Buunshin/…`) lists all seven `.wav` files correctly. + +**Checked (2026-07-20) — the "stale empty listing" reading was wrong.** The +folder `not everythin is your fault (wav)` that read empty has **`listed = 0`** +in `nodes`, so its `ls` was a real network enumeration, not a cached empty one. +The files genuinely are not there: every rename in B13 failed and rsync +discarded its temp files. B15 as originally filed does not exist. + +**What the query did turn up, and is worth keeping:** + +1. **A real B1-shaped row.** `not everything is your fault` (no `(wav)`) reads + `listed = 1, trashed = 0` with **zero child rows** — the exact + empty-but-listed state B1 was about, still present in the live DB, reached by + some route the B1 fix does not cover. This one is worth chasing. +2. **Duplicated folders.** `Music` and `Buunshin` each exist under **two + distinct uids**, and the `(wav)` folder name appears twice as well — one copy + populated (7 children), one empty. So the remote tree has picked up duplicate + directories somewhere. Related to the self-conflict duplication already + recorded in memory, but not yet attributed here. + +Both are folder-identity problems rather than the mount disagreement originally +suspected. Renamed scope accordingly; the "two mounts disagree" framing is +retracted. + +--- + +### B14 — Provisional (ciphertext) sizes make rsync read short and abort the file + +**Status:** Fixed (unverified — needs a cold folder on the rebuilt daemon) +**Found:** 2026-07-20, same rsync run as B13. Alongside the receiver errors, the +sender failed to read its own source files: + +``` +rsync: [sender] read errors mapping "/home/narl/ProtonDrive/Music/…/ + Buunshin - i think i feel... (Original Mix).wav": No data available (61) +``` + +**Where:** `node_size` (`pdfs-fuse/src/lib.rs:3480`) + `Core::spawn_size_upgrade` + +**Cause:** this is B12's known tradeoff surfacing as a real transfer failure. +Until the background size upgrade lands, `node_size` falls back to +`total_size_on_storage` — the **ciphertext** size, which is larger than the +plaintext (measured at +59 bytes on this account). B12 called that "cosmetic and +self-corrects" because it only affects `ls -l`. + +It does not only affect `ls -l`. `ENODATA` (61) is what **rsync sets on a short +read**: `map_ptr` in `fileio.c` asks for the bytes `stat` promised, gets fewer, +and marks the mapping `ENODATA`. So a reader that trusts `st_size` — rsync, +anything using `mmap`, `sendfile`, or a sized `read` loop — sees a truncated +file and errors out. That is the same class of bug as B11, just from the other +direction: B11 reported **too small** (0) so the kernel skipped reads entirely; +this reports **too large** so reads run off the end. + +The 30 s attr TTL makes the window much wider than the upgrade's own latency +(B12 documented this), so a cold `rsync` of a large tree is essentially +guaranteed to hit it. + +**Why it looked fine afterwards:** re-listing the same directory now shows every +size correct, because the upgrade has long since landed. The bug is only visible +cold. Reproducing it needs `pdfs refresh ` immediately before the read. + +**Fix:** a provisional size is never published. `getattr` on a file whose +`claimed_size` is unknown now resolves it *before* replying, instead of replying +with the ciphertext size and upgrading afterwards. + +The cost is one batched round trip per folder, not one per file: `ls -l` is one +`getattr` per entry, and they collapse onto a single upgrade. B12's split is +still doing its job — a plain `ls` never reaches this path at all, which is +where the 8.7× came from. + +That required making the upgrade *awaitable*, which is most of the change: + +- `size_upgrades` went from `HashSet` to `HashMap>`, + a condvar per folder. Followers wait on it; there may be hundreds for one + folder, so it releases all of them. +- **The leader does the fetch on its own thread rather than handing it to a + worker.** This is the part to not undo: `getattr` waits on `Lane::Meta`, so a + leader that queued its fetch onto that same lane could have a wide enough + `ls -l` fill the lane with threads waiting for a job that can never be + scheduled. +- `SizeUpgrade::WAIT` caps the wait at 10 s, falling back to the provisional + size. A `stat` that never returns is worse than one that is briefly wrong. +- `upgrade_sizes` owns the single-flight bookkeeping and has one exit path; + `apply_size_upgrade` holds the body it used to inline, so a failed fetch still + releases the waiters and leaves the folder retryable. + +**Tests:** `size_upgrade_tests` covers the follower being released, a waiter +arriving after `finish` (it checks the flag, not the notification, which it +would miss), and all waiters waking. + +**Not covered, worth watching:** `Lane::Meta` can now hold waiting `getattr`s. +There is no deadlock — leaders never queue their own work — but a cold `ls -l` +across many folders at once could occupy the lane. If metadata ops start +feeling sticky under heavy cold listing, this is the first suspect. + +#### Verified (2026-07-20) — correct, but expensive, and not completely closed + +Two further holes turned up during verification; both are fixed, and the second +is the one that mattered. + +1. **`upgrade_sizes_for_parent` early-returned** when the parent listing was not + resident in `children` — and a rename invalidates exactly that, so a freshly + renamed file always landed in the dead branch. `stat` read 67 bytes for a + 16-byte file, and `cat` returned the 16 real bytes followed by 51 NULs. The + same shape as B4: an early return that assumed the hot cache was + authoritative. Now falls back to resolving the single node, keyed by its own + inode. +2. **`lookup` replies with attrs and a TTL too.** With no `readdirplus`, `ls -l` + is one `lookup` per entry and *zero* `getattr` calls — so fixing only + `getattr` fixed a path `ls -l` never takes. This is worth remembering: the + first fix looked right and did nothing for the reported symptom. + `serve_lookup` now resolves as well, taking an `off_loop` flag because + `lookup`'s warm path runs on the dispatch loop where it may not block (B5). + +| measurement | before fix | after fix | +|---|---|---| +| cold `ls -l`, 7-file wav folder | ciphertext sizes | **exact settled sizes** | +| renamed file `stat` / `cat` | 67 B, 51 NULs | **16 B, clean** | +| cold plain `ls`, 793 entries | 4.48 s | **4.58 s** (B12 intact) | +| cold `ls -l`, 793 entries | ~4.5 s, wrong sizes | **85.8 s**, 785/793 correct | + +#### The cost, and the part still open + +**A cold `ls -l` of a 793-entry folder went from ~4.5 s to 85.8 s.** That is not +a flaw in the batching — it is B12's cause 2 arriving on schedule. A real +`claimed_size` requires the per-file node-key unlock, i.e. one S2K per file, and +that work is single-threaded. B12 removed it from the *listing* path; asking for +sizes puts it back, because sizes are what it produces. Plain `ls` is untouched, +which is why the split is still worth having. + +**8 of 793 entries still came back provisional**, caught by diffing the cold +listing against a settled one. Those are `SizeUpgrade::WAIT` timeouts: the leader +takes ~80 s for the batch and a waiter gives up at 10 s. So the bug is rarer but +not gone — it now needs a folder large enough that the batch outruns the cap. + +#### Per-chunk wakeup (done, unverified) + +The timeout gap is closed by releasing waiters **per chunk** instead of once at +the end. `SizeUpgrade` carries a generation counter; `run_size_upgrade` fetches +in `SIZE_UPGRADE_CHUNK` (150, the SDK's own `MAX_BATCH_COUNT`, so one chunk is +one request), applies each chunk, and bumps the generation. A waiter re-checks +whether *its own* node now has a real size and returns if so — it no longer +waits on the other 792. + +Two structural points worth keeping: + +- **The batch runs on its own thread, not the `Workers` pool.** Callers wait on + `Lane::Meta`, so queueing the batch there could let a wide `ls -l` fill the + lane with threads waiting for a job that can never be scheduled. + `Lane::Transfer` would swap that deadlock for starvation behind bulk reads. + One short-lived thread per folder, bounded by the single-flight, avoids both. +- **`wait_for` evaluates its predicate with no `SizeUpgrade` lock held.** The + predicate reads `state`, and the applying thread holds `state` before it + signals; taking them in the other order would close the cycle. + +This does **not** make a full cold `ls -l` faster — every size still has to be +computed. It makes each individual `stat` return after its own chunk rather than +after the whole folder, and removes the provisional-size fallback that the 10 s +cap was producing. + +**Tests:** `size_upgrade_tests` covers the resolving chunk releasing a waiter, +an unrelated chunk *not* releasing one, `finish` releasing an unresolved waiter, +the already-resolved and post-`finish` arrivals, all waiters waking, and the +timeout backstop still firing. + +--- + +#### Plan: parallelise the per-file S2K (the 85 s) + +This is the only item that reduces the work rather than redistributing it, and +it is the same bottleneck as improvements.md PERF #5 — approached from sizes +rather than from cold navigation. Filed here because B12/B14 are what produced +the measurements it needs. + +**The claim to be tested first, before any code.** B12's profile of a 793-entry +cold enumerate attributed **64.4 % of cycles to `sha2::sha256::x86::digest_blocks` +and ~74 % overall to SHA-256 inside PGP's S2K**, i.e. the per-file node-key +unlock in `build_node` under `NodeDetail::Full`. If that still holds for the +*size upgrade* path specifically — it is the same `enumerate_nodes` call, so it +should — then the work is CPU-bound, embarrassingly parallel across files, and +currently serialised on one thread. + +**Step 0 — confirm, do not assume.** Re-profile against the size-upgrade path +now that it is the thing on the critical path: + +``` +perf buildid-cache -a target/release/pdfs # symbols; see B12's note on strip +perf record -p -- sleep 90 +# meanwhile: pdfs refresh Music/aC_ID.dll && ls -l Music/aC_ID.dll +``` + +Expect the same SHA-256 leaf. If it is *not* dominant, stop — the rest of this +plan is aimed at the wrong thing. + +**Step 1 — establish the ceiling.** The upgrade is `enumerate_nodes` over 793 +uids. Measure single-node unlock cost directly (log the S2K iteration count and +elapsed time per file across one folder — this also settles the question B12 left +open, whether the 49 ms/entry folder is expensive because of *n* or because the +uploading client chose a costlier S2K). Ideal speedup is bounded by core count +and by how uneven those per-file costs are. + +**Step 2 — move the crypto off the async runtime.** The decryption currently runs +inline in the SDK's `enumerate_nodes` future. Blocking CPU work on a Tokio worker +starves everything else sharing that runtime. Wrap the per-node unlock in +`spawn_blocking`, or hand the whole chunk to a `rayon` pool and await the result. +This is the change PERF #5 describes and is a **`proton-drive-rs` change, not a +`pdfs` one** — which is why it was deferred: it needs an SDK release, and the +workspace pins `"0.1"` so a `cargo update` picks it up. + +**Step 3 — parallelise across files within a chunk.** 150 nodes per chunk, each +independent once the parent key is resolved. `resolve_parent_key_ctx` caches the +ancestor key and all children of a folder share one parent, so the parallel +region needs read-only access to it — take the folder key once, then fan out. +Watch `FOLDER_KEY_CACHE_CAP`/`folder_keys` (PERF #9 made it an LRU) for +contention if the fan-out takes that lock per node. + +**Step 4 — re-measure the same three folders**, which have before/after numbers +already recorded above and in B12: + +| folder | cold plain `ls` | cold `ls -l` (today) | +|---|---|---| +| `Music/aC_ID.dll` (793) | 4.58 s | 85.8 s | +| `InstantUpload/Camera` (484) | 2.91 s | not measured | +| `Pictures/old` (417) | 2.25 s | not measured | + +Fill in the middle column's `ls -l` first — there is only one data point for the +regression right now, and B12's "cliff past ~500" framing was wrong precisely +because it generalised from a single folder. **Do not repeat that mistake here.** + +**What success looks like:** cold `ls -l` on the 793-entry folder within ~2–3× of +the plain `ls`, rather than 19×. Perfect scaling is not available — the request +round trips are serial per chunk and only the crypto parallelises. + +**Explicitly out of scope:** `readdirplus`. It would cut the *number* of calls +(one reply per directory instead of one `lookup` per entry) and is the right +long-term shape for the directory-read path, but it does not remove a single S2K +— the sizes still have to be computed. It is a separate change and should not be +bundled in, for the same reason B5 declined it: it would hide this bug rather +than fix it. + +**Do not** attempt to derive the plaintext size from `total_size_on_storage` +instead. The delta is not a constant (+564 B on a 39 MB file, +51 B on a 16-byte +one — it scales with block count), so the arithmetic would have to model Proton's +block framing exactly, and being wrong in the *small* direction reproduces B11, +where a short size made the kernel skip reads entirely. + +**Verify with:** the ciphertext delta is the probe (+564 B on a 39 MB file, ++51 B on a 16-byte one — it scales with block count, so it is not a constant). +`pdfs refresh` a folder, then `ls -l` immediately and diff against a settled +listing. + +--- + +### B13 — `rename` over an existing destination fails instead of replacing it + +**Status:** Fixed (unverified — needs a real rsync against the rebuilt daemon) +**Found:** 2026-07-20, user ran `rsync -rauLP ~/ProtonDrive/Music/... ~/Music/` +(both are protondrive mounts) and every single file failed at the end of its +transfer: + +``` +rsync: [receiver] rename ".../.Buunshin - heimwee (Original Mix).wav.b0akm3" + -> ".../Buunshin - heimwee (Original Mix).wav": Input/output error (5) +``` + +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `Filesystem::rename` + +Daemon log, one per failed file: + +``` +ERROR pdfs_fuse: rename failed uid=… error=proton api error AlreadyExists (http 422): + A file or folder with that name already exists +WARN pdfs_fuse::drain: pending upload failed; will retry uid=… attempts=1 + error=proton api error DoesNotExist (http 422): File or folder not found +``` + +**Cause:** `rename(2)` is specified to *atomically replace* an existing +destination. Our handler just calls `client.rename_node`, and Proton refuses a +name that already exists — so the 422 becomes a blanket `EIO`. Nothing in the +path ever looks up the destination name. + +This breaks every write-to-temp-then-rename tool, which is most of them: rsync, +editors doing atomic saves, `mv -f`, package managers. The failure is at the +*end* of the transfer, so the bytes are uploaded and then thrown away — the user +pays full upload cost for nothing. + +**Second-order damage:** the failed rename leaves the temp node behind and its +queued upload then fails with `DoesNotExist` and retries forever. So each failed +file also leaves a poisoned entry in the drain queue. + +**Fix:** `rename` now looks up `newname` under `newparent` and, if it resolves to +a different node, removes it before the API call. + +The order is forced by Proton: the name has to be free before `rename_node` will +take it, so it is trash-then-rename, which means the operation is **not atomic**. +`Core::remove_replaced` does the trashing and `Core::restore_replaced` puts the +node back if the rename then fails — on every failing path, including the queued +ones. If the restore *also* fails the node stays in the trash and says so +loudly, because the alternative is a file the user believes was merely renamed +sitting somewhere they will not look. + +Deliberate details: + +- **Refusals happen before anything is trashed.** `check_replaceable` is a pure + function for exactly this reason: every `Err` it returns is a case where the + destination must survive, and a mistake there turns a refusal into deletion. + `EISDIR` / `ENOTDIR` when the two ends disagree about being a directory, and + `ENOTEMPTY` for a non-empty destination directory — Proton trashes a folder + with its whole subtree, so allowing that would discard every file underneath + without ever naming them. +- **`RenameFlags` is now read** (it was `_flags`). `RENAME_NOREPLACE` returns + `EEXIST` instead of replacing; `RENAME_EXCHANGE` returns `EINVAL`, since no + Proton primitive swaps two names and emulating it would leave a window in + which one of them does not exist. +- A node whose own create is still queued never reached the server, so replacing + it just discards its queued ops — no API call, and it works offline. +- Renaming a node onto its own name stays a no-op rather than a self-replace. + +**Tests:** `replace_tests` in `pdfs-fuse/src/lib.rs` covers all five decisions. +The trash/restore half needs a live server and is not covered. + +**Repro:** `cd && echo a > x && echo b > y && mv y x` → was EIO, should +now succeed with `x` holding `b`. + +--- + +### B12 — Cold enumeration is slow per entry, and goes superlinear past ~500 + +**Status:** Both causes fixed and verified (2026-07-20); attr-invalidation follow-up unverified +**Found:** 2026-07-20, measuring improvements.md P2.8 ("pipeline +`enumerate_nodes_detail`") before implementing it, to decide whether the +bottleneck was network or crypto. + +#### Measured (cold; `pdfs refresh ` then a timed `ls -1`) + +| entries | time | per entry | +|---|---|---| +| 1–9 | 350–550 ms | fixed cost | +| 34 | 629 ms | — | +| 147 | 1.92 s | 12.5 ms | +| 251 | 3.18 s | 12.4 ms | +| 484 | 6.25 s | 12.5 ms | +| **793** | **38.8 s** | **48.9 ms** | + +The 793 figure reproduces to ±100 ms across three runs. Marginal cost from 484 to +793 is **106.7 ms/entry**, 8.5× the baseline. + +`perf record` against the running daemon shows **~90 % of cycles inside the +`pdfs` binary** in both cases, and sample counts matching wall time (1022 samples +≈ 5.1 s, 7538 ≈ 37.9 s at 199 Hz) — so this is CPU-bound throughout, not waiting +on the network. Symbols are unavailable (the installed binary is stripped), which +is what blocks attribution. + +#### Cause 1 (found, ~half the baseline): every FTS row is deleted by a full scan + +`nodes_fts` declares `uid` as **UNINDEXED**: + +```sql +CREATE VIRTUAL TABLE nodes_fts USING fts5(uid UNINDEXED, name, tokenize='trigram') +``` + +and `Db::upsert_nodes` (`pdfs-core/src/db/nodes.rs`) deletes by exactly that +column on every node, because "FTS5 has no UPSERT": + +```sql +DELETE FROM nodes_fts WHERE uid = ?1 +``` + +An UNINDEXED FTS5 column is not searchable, so the predicate cannot use an index. +`EXPLAIN QUERY PLAN` confirms it: + +``` +`--SCAN nodes_fts VIRTUAL TABLE INDEX 0: +``` + +One full scan of the FTS index per node written. Measured on a `.backup` copy of +the live 171 MB DB (17 443 indexed nodes): + +| deletes | time | per delete | +|---|---|---| +| 484 | 3.09 s | 6.4 ms | +| 793 | 5.26 s | 6.6 ms | + +So **~6.5 ms of the 12.5 ms per-entry baseline is this one statement**, and it +gets worse for every user as their node count grows — the scan is over the whole +index, so the cost of writing *any* listing scales with the size of the *account*. +It is also paid by every sync pass and every event-driven refresh, not just `ls`. + +**Fix direction:** delete by rowid instead. FTS5 deletes by rowid efficiently, so +map `nodes.rowid` to the FTS rowid and drop the `uid` column from the index +(or keep it UNINDEXED purely for retrieval). Needs a schema migration on a live +171 MB DB, so it wants its own change rather than being smuggled into this one. + +#### Cause 2 (the dominant one): an S2K key derivation per file, to list a folder + +**Symbols came from the local unstripped build, not a new install.** `strip` +preserves both the build id and the text addresses, so when `/usr/bin/pdfs` and +`target/release/pdfs` reported the *same* build id, the stripped binary was just +a copy of the local one and `perf buildid-cache -a target/release/pdfs` was +enough to symbolise a recording of the running daemon. Worth remembering — it +turned a "needs another install + restart" into a five-second step. + +Hot functions over a 793-entry cold enumerate: + +| % | symbol | +|---|---| +| 64.4 | `sha2::sha256::x86::digest_blocks` | +| 6.0 | `::update` | +| 5.7 | `sqlite3VdbeExec` (cause 1, above) | +| 4.0 | `sha2::sha256::compress256` | +| 1.5 | `pgp::types::s2k::StringToKey::derive_key` | + +**~74 % of the listing is SHA-256 inside PGP's S2K**, i.e. the per-file node-key +unlock that `build_node` does under `NodeDetail::Full`. `derive_key` itself shows +1.5 % because the time lands in its SHA leaf. Nothing downloads during an `ls`, so +PGP is the only plausible source of that SHA. + +#### The "cliff past ~500" framing was wrong + +That was an inference from a single folder, not a measurement. An S2K's cost is +set by the iteration count in the key packet, which is chosen by *the client that +uploaded the file* — so it varies per file, not per listing size. Every folder +measured sits at 12–16 ms/entry except `Music/aC_ID.dll` at 49 ms, and +`Music/ELDEN RING SOUNDTRACK` (67 files, same parent tree) is ~16 ms/entry, so it +is not "Music files are expensive" either. A 4× jump for a 1.6× change in n fits +"those files were uploaded by a client using a costlier S2K" far better than a +size threshold. n and folder identity are confounded in the data — there was only +ever one folder above 500 entries. + +Left unresolved deliberately: the fix below removes the per-file S2K from the +listing path entirely, so which of the two explanations held stopped mattering. +To settle it anyway, log the S2K iteration count per file across one enumerate of +each folder. + +#### Fix: enumerate cheap, upgrade sizes in the background + +`ensure_children` now calls `enumerate_nodes_light`, which skips the file +node-key unlock (folders are still unlocked — their keys are what the children +decrypt with). The listing is served from that immediately. + +`Light` returns no `claimed_size`, and `ls -l` wants sizes, so a naive split would +just move the same S2K onto the first `stat`. Instead `Core::spawn_size_upgrade` +fetches the full nodes for that folder on a worker, batched, after the listing has +been answered, and adopts *only* the size — re-interning wholesale would clobber a +name or parent that a concurrent rename/move had changed. It is single-flight per +folder, because a `stat` of one entry in a fresh listing means a `stat` of all of +them. Queued writes keep their optimistic size through it, via the same +`stamp_pending_sizes` that B11 added. + +Three entry points cover every way a provisional listing can appear: the network +enumeration, the DB fast path (rows persisted before an upgrade ran), and +`getattr` itself (a listing restored by `hydrate` on mount, which `ensure_children` +returns early for). + +**Known tradeoff — sizes are provisional until the upgrade lands.** `node_size` +falls back to `total_size_on_storage`, the ciphertext size, so a `stat` in that +window reads slightly *too large*. Reads are unaffected: the revision reader +carries its own authoritative size. Deliberately not the B11 shape — that +reported **0**, which made the kernel skip reads entirely; too-large is cosmetic +and self-corrects. + +**The window was longer than "a round trip", and that took a correction.** The +daemon has the real sizes quickly, but the *kernel* keeps the provisional attrs +for the full 30 s entry TTL, so `ls -l` kept reporting them long after the DB was +right. This nearly caused a misdiagnosis during verification: two successive +`ls -l` runs agreed with each other and both disagreed with the truth by a +constant +59 bytes, which reads exactly like "the upgrade never ran". Querying the +DB directly is what separated "not computed" from "computed but not visible". + +Closed by having `spawn_size_upgrade` call `notifier.inval_inode` for the inodes +it corrected, after the DB write so a provoked re-`getattr` cannot race the +persistence. That needed a `Notifier` on `Core` (a `OnceLock`, since the session +is built *from* the `Core`), and a fresh one per on-demand fork — each fork has +its own inode space, so notifying through the primary mount's channel would name +inodes that session has never heard of. + +#### Verified (2026-07-20) + +| folder | before | after | +|---|---|---| +| `Music/aC_ID.dll` (793) | 38.8 s | **4.48 s** (8.7×) | +| `InstantUpload/Camera` (484) | 6.25 s | **2.91 s** (2.1×) | +| `Pictures/old` (417) | 5.10 s | **2.25 s** (2.3×) | + +Schema v15 migrated the live 171 MB DB on start (17 443 rows indexed). All 793 +sizes converge to exactly their pre-change values, checked against a `.backup` +copy taken before any of this landed. The +59-byte ciphertext delta is a precise +probe for a provisional size, and is what the attr-invalidation follow-up should +be tested with. + +#### Original cause-2 ruling-out (kept — all still true) + +Backing cause 1 out of the totals leaves per-node non-FTS cost at **6.2 ms at +n=484 but 42 ms at n=793**. Something gets ~7× more expensive per node, sharply, +somewhere around 500–800 entries. + +Ruled out so far: + +- *Chunking / network.* `MAX_BATCH_COUNT = 150`, so cost would rise in visible + steps at 150/300/450; it is flat at 12.5 ms/entry straight through 484. The + marginal cost would imply ~16 s per POST, which is absurd. +- *Composition.* Both the 484 and 793 folders are 100 % files, no subfolders. +- *`FOLDER_KEY_CACHE_CAP` (512).* Suspicious number, but `folder_keys` only holds + *folder* keys and these children are all files, so nothing is inserted during + the walk. `resolve_parent_key_ctx` caches each ancestor it derives, and all + children share one parent, so it is a hit after the first child. +- *The entity cache.* `InMemoryCacheRepository` is `HashMap`-backed, O(1) `set`. +- *FTS (cause 1).* Linear — 3.1 s → 5.3 s where the cliff is 6.1 s → 38.8 s. + +**Next step:** symbol-level profile. Needs an unstripped `pdfs` installed and the +daemon restarted, then `perf record -p ` across a 793-entry enumerate. Every +cheaper avenue above has been spent. Note a second daemon is **not** an option +for this: Proton refresh tokens are single-use, so a parallel session would fight +the running daemon for them. + +**Do not implement improvements.md P2.8 before this is understood.** Pipelining +would parallelize the cliff rather than remove it, and at 793 entries the cliff is +33 s of the 38.8 s — far more than concurrency could win back. + +--- + +### B11 — A file moved while its create is still queued reads as empty + +**Status:** Fixed (verified 2026-07-20) +**Found:** 2026-07-20, while verifying B1 on the restarted daemon. + +The B1 repro is `echo hi > f.txt && mv f.txt d/`, i.e. the move lands while the +create is still queued for upload. Immediately after the `mv`, the file is +present in the destination listing — B1 is genuinely fixed — but stats as **0 +bytes** and `cat` returns nothing: + +``` +-rw-r--r-- 1 narl narl 0 Jul 20 00:29 zz-b1-dst/zz-b1-src.txt +``` + +A few seconds later the same file reads correctly (`3` bytes, `hi`). A control +file created and read *without* an intervening `mv` was correct immediately. + +**Cause:** `State::intern_mem` replaces an existing entry's node wholesale +(`e.node = node`). A move invalidates both parents' listings (that is B7's fix, +and it is correct), so the next `ls` re-enumerates — and the node that comes back, +from the remote or from its DB row, carries the size of the revision the *server* +holds. For a file whose write is still queued that is the pre-write size, usually +0. Interning it reverts the optimistic size `record_pending_write` had stamped. + +**Why an empty read rather than a short one:** a file that stats as 0 bytes gets +**no `read` from the kernel at all**. `read_range` would have served the staged +blob quite happily; it is never asked. So the file reads as empty for as long as +the stale size stands, and "empty file" is indistinguishable from "file whose +contents were lost" to whatever is reading. + +`Core::hydrate` already solved exactly this for the *restart* case — it stamps +each pending write's size onto the node as entries materialize. The protection +was simply missing on every live re-enumeration path. + +**Fix:** `Core::stamp_pending_sizes` re-applies the optimistic size to a batch of +nodes, called from both arms of `ensure_children` (the DB fast path and the +network path) before the state lock is taken. It snapshots the pending map first +and returns: no site in the daemon holds `pending` and `state` at once, and this +is not the place to become the first — `hydrate` established that pattern for the +same reason. + +Scoped to `ensure_children` deliberately. The other `intern` sites are +single-node and all authoritative by construction (mkdir, create, an upload that +just landed); `drain.rs`'s post-upload adoption is *supposed* to take the +server's node, and already handles the queued case through `rebaseline_pending`. + +**Tests:** `pending_size_tests` in `pdfs-fuse/src/lib.rs` covers the queued file +keeping its size, a settled sibling keeping the server's, folders being left +alone, and an empty pending map changing nothing. + +**Verified 2026-07-20** on the rebuilt daemon: `echo hi > f.txt && mv f.txt d/` +then an immediate `ls -l` and `cat` gives 3 bytes and `hi`, against 0 bytes and +empty output before. + +--- + +### B10 — GLib critical when the launcher closes over an in-flight open + +**Status:** Open (side finding) +**Found:** 2026-07-19, seen in `pdfs-prompt` output immediately after a +successful Enter-to-open while verifying B9. + +``` +GLib-GIO-CRITICAL: g_list_store_remove: assertion '!g_sequence_iter_is_end (it)' failed +``` + +**What is known:** it fires right after the `opening path=…` log line, i.e. +during `xdg_open` + `window.close()`. It is a warning, not a crash — the open +itself succeeded. + +**What it is not:** the launcher's own row handling. `Section::set_rows` walks +`GtkListBox` children (`first_child`/`remove`) and touches no `GListStore`, so +the failing store belongs to GTK/libadwaita internals, most likely something +teardown-ordering related in the app/window bookkeeping as the window is closed +while a launch is still settling. + +**Why it was not chased:** it appeared while verifying an unrelated fix and has +no user-visible effect. Worth revisiting if the launcher ever misbehaves on +close (a hang, a lost open, or a stale window), since an assertion firing during +teardown is exactly the shape of bug that later turns into one of those. + +**Where to start:** run `pdfs-prompt` under `G_DEBUG=fatal-criticals` to turn +the warning into an abort and get a real backtrace naming the store. + +--- + +### B9 — Enter did not open the selected result in the launcher + +**Status:** Fixed (verified) +**Found:** 2026-07-19, user reported Enter doing nothing in `pdfs-prompt` while +the arrow keys and Escape worked normally. +**Where:** `crates/pdfs-gui/src/prompt.rs`, the window key controller + +**Cause:** key-event *phase*, not key handling. `gtk4::Entry` — really its inner +`GtkText` — binds Return to its own `activate` and consumes it. The launcher's +`EventControllerKey` is attached to the **window** in the default **bubble** +phase, so the focused entry saw Return first and the window handler was never +reached. Escape and Up/Down worked precisely because `GtkText` has no bindings +for them and they bubbled up as intended. + +That asymmetry is the tell: when some keys reach a window-level controller and +others silently don't, the ones that don't are being claimed by the focused +widget. + +**Fix:** Return moved off the window controller and onto +`entry.connect_activate`. Enter while focus is in the results list was already +handled by `row_activated`, so the two together cover both focus positions. + +**Rejected alternative:** setting the window controller to +`PropagationPhase::Capture`. It fixes the symptom by putting the handler ahead +of the entry — but ahead of it for *every* keystroke, not just Return, which +puts text input and IME composition behind a handler that has no business +seeing them. The narrow fix has no such blast radius. + +**Verified:** live. Pressing Enter logged `opening path=…`, the daemon hydrated +the file, and `xdg_open` ran. + +--- + +### B8 — No way to complete a CAPTCHA, so a gated sign-in is unrecoverable + +**Status:** Implemented, partially verified — the bridge is proven, a real gate is not +**Found:** 2026-07-19, user hit `proton api error Unknown (http 422): For +security reasons, please complete CAPTCHA` and had no way to answer it. +**Where:** SDK `api.rs`/`http.rs`/`session.rs`, `pdfs-core/src/auth.rs`, +`pdfs-gui/src/app/pages/verify.rs` + +**Cause:** three gaps stacked, none of which is a bug on its own. + +1. `ResponseCode` had no `9001`, so the gate deserialized to `Unknown` — which + is why the error read as an opaque failure rather than a recoverable prompt. +2. Nothing read `Details.HumanVerificationToken`, and the client never sent the + `x-pm-human-verification-token{,-type}` headers the retry needs. +3. No UI could render Proton's hosted verification page. + +**Fix:** `HumanVerification` (challenge) + `HumanVerificationCredential` +(answer) in the SDK, header plumbing on the session-less login calls, and +`ProtonApiSession::begin_verified`. `pdfs-core` promotes a solvable gate to +`Error::HumanVerificationRequired` and `auth::login_interactive` re-runs the +login with the earned token — the retry lives in core because the gated attempt +burns its SRP handshake, which no front-end should have to know. The GUI hosts +the page in a `WebKitWebView` (webkit6 0.4, pairs with the existing gtk4 0.9) +and bridges the page's `postMessage` to a script message handler. + +**Deliberately not done:** `email`/`sms` verification methods (the token arrives +out of band, so the webview cannot complete them — such a gate stays a plain API +error rather than opening a page the user cannot finish), HV on *authenticated* +endpoints (only the login path is plumbed), and re-gating of the retry (a second +challenge means the token was rejected; looping would trap the user). + +**Verified:** the WebKit bridge end-to-end with a throwaway probe — handler +registers, the page's `postMessage` arrives, non-completion messages are +filtered, the completion token round-trips. Unit tests cover 9001 parsing, the +challenge/`Details` shape, URL escaping, and message filtering. + +**Not verified:** a real gated login against verify.proton.me. It cannot be +triggered on demand, so the exact message shape Proton's page posts is taken +from its documented contract, not observed. If verification appears to hang with +the page solved, that is the first thing to check — `extract_token` in +`verify.rs` is the single place that decides what counts as completion. + +**Note:** the SDK half shipped as proton-sdk / proton-drive-rs **0.1.11**. The +workspace requirement was widened to `"0.1"` at the same time, so later 0.1.x +releases are picked up by `cargo update` without an edit here. + +--- + +### B7 — Renamed directory reads as missing until the entry TTL expires + +**Status:** Fixed (verified 2026-07-20) +**Verified:** `mkdir "zz-b7 Old Name"` with a file inside, `mv` to a new name, then +an immediate `ls` and `cat` through the new name — both succeed with no wait. The +directory used to return ENOENT until the entry TTL expired. +**Found:** 2026-07-19, user renamed a folder on the mount with `mv`. `ls` of the +parent listed the new name, but `ls /` returned ENOENT — repeatedly, so +not a one-shot race. It self-healed on its own a few minutes later, and the +files were all intact: this is a visibility bug, not data loss. +**Where:** `crates/pdfs-fuse/src/state.rs`, `State::relocate` + +**Cause:** the online rename path ended with `relocate`, which called `forget` on +the moved node. `forget` drops the node's `by_uid` mapping, so when the +invalidated parent listing re-enumerated, `intern_mem` allocated it a **fresh** +inode. But the kernel had already carried the renamed dentry over to the *old* +inode number, and it holds that dentry for the entry TTL. Every lookup, getattr +and opendir through it resolved to an inode `entries` no longer held: + + entries.get(old_ino) -> None -> ENOENT + +`readdir` of the parent went the other way — it walks `children` and reports the +*new* inode — which is exactly why the directory listed fine but could not be +entered. Once the TTL expired the kernel re-looked-up the name, got the new +inode, and everything worked again. + +**Fix:** `relocate` now rewrites the node in place (`rename_in_place`) and +invalidates both parents' listings, instead of forgetting it. The inode is +preserved, so the kernel's dentry stays valid and the re-enumeration reuses the +same `by_uid` slot. Both `relocate` tests now assert inode stability; that +property was untested, which is how this got through the B1 fix. + +**Note:** dropping the `forget` also stops the moved node's DB row from being +deleted and re-created on every move — the row is updated instead, which is what +the B1 fix was working around from the other side. + +--- + +### B6 — Daemon sets no file modes: `control.sock` is an unguarded authority + +**Status:** Fixed (verified 2026-07-20) +**Found:** 2026-07-19, while writing the plaintext-at-rest threat model in +`docs/ARCHITECTURE.md` §8. Not from a report — from checking a claim before +asserting it in a doc. I had written "their default 0700 permissions protect +them", went to verify, and found we set no modes at all. +**Where:** `crates/pdfs-fuse/src/mount.rs` (`UnixListener::bind(control_socket)`), +`crates/pdfs-core/src/config.rs` (`create_dir_all` on state/cache dirs) + +`grep -rn "set_permissions\|from_mode" crates/pdfs-core/src crates/pdfs-fuse/src` +returns exactly one hit, and it is `shell.rs` setting `0755` on a generated +script. Nothing else sets a mode. So: + +- state and cache directories are created at `0777 & ~umask` — typically `0755` +- `control.sock` is bound at the same, typically `0755` + +Anything that can connect to the socket drives the daemon with its authenticated +session: enumerate the tree, read file contents, upload, trash, create public +share links. No credential is required — the keyring is never consulted, because +the daemon already holds the session. + +The only thing preventing this today is that `~/.cache` and `~/.local/state` are +conventionally `0700`. That is a property of the user's system, not something we +establish or check. It does not hold on a machine with a permissive umask, a +group-shared home, or a home restored from a backup that flattened modes. + +**Severity:** low on a single-user desktop, real on a shared or multi-user host. +It is a privilege boundary rather than a data-at-rest issue, which is what makes +it worth more than the cache-plaintext point it was found next to. + +**Fix:** `chmod 0600` on the socket immediately after `bind` (before the listener +thread starts accepting), and `0700` on the state and cache directories at +creation. Both are a few lines. Worth also asserting the socket mode in a test, +since a regression here is silent. + +**Fixed 2026-07-19:** `AppDirs::ensure` now sets `0700` on the state, cache, and +config directories on *every* start (not just at creation, so an existing +permissive directory is tightened), and `config::restrict_socket` sets `0600` on +both the control socket and the tray socket immediately after `bind`. A socket +whose mode cannot be set takes the daemon down rather than serving unguarded; +the tray's is best-effort, since it only guards single-instancing. Unit tests in +`config.rs` assert both modes. + +**Verified 2026-07-20** on the restarted daemon — all three directories now read +`drwx------` and both sockets `srw-------`, against `drwxr-xr-x` / `srwxr-xr-x` +before. + +**Measured exposure at the time of the fix:** the directories really were +`drwxr-xr-x` and the live socket `srwxr-xr-x`, but `~/.cache` and `~/.local` +were both `0700` on this machine, so nothing was reachable in practice. The bug +was a latent dependency on those parents, not a live hole. Removing the +dependency is the point. + +--- + +### B5 — `ls -l` costs a network round trip per file (thumbnail xattr probes) + +**Status:** Fixed (verified 2026-07-20) +**Found:** 2026-07-19, investigating "`exa -l` is slow in the mounts, `exa` is fast" + +#### Reproduced (2026-07-19) + +All runs cold (35 s wait to clear the 30 s attr TTL). `exa` needs only `readdir`; +`exa -l` stats every entry, so the delta is per-entry metadata cost: + +| mount | entries | `exa` | `exa -l` | delta per entry | +|---|---|---|---|---| +| primary `~/ProtonDrive/Installer` | 34 | 10 ms | 11 ms | **0.03 ms** | +| on-demand `~/Documents` | 45 | 27 ms | 54 ms | **0.6 ms** | +| on-demand `~/Videos/[Reaktor] FMA …` | 65 | 73 ms | 193 ms | **1.85 ms** | + +#### Cause (2026-07-20): a network round trip per file per xattr name + +`strace -f -T` on a cold `exa -l` of the 65-file `.mkv` directory: + +``` +lgetxattr(".../E01 ....mkv", "user.proton.thumbnail", NULL, 0) = -1 ENODATA <0.186435> +``` + +129 `lgetxattr` and 258 `llistxattr` calls for 65 entries, each `lgetxattr` +~186 ms. Three things compounded: + +1. **`listxattr` advertised `user.proton.thumbnail` + `user.proton.preview` for + every file**, regardless of whether that file could have one. An xattr-aware + lister then asks for each advertised name — two `getxattr` per entry. +2. **`Core::thumbnail` cached only success.** `download_thumbnail` returning + `None` — the normal answer for a `.mkv` — was never remembered, so every + listing re-asked the API and was re-told nothing, forever. +3. **`getxattr` ran inline on fuser's dispatch loop**, not on the `Workers` + pool. `lookup` and `readdir` had been moved off it (PERF #1.0); this one was + missed. So each 186 ms miss stalled *every* other op on the mount, which is + why the concurrency `exa` does have bought nothing. + +**The mount-kind correlation was a red herring.** It tracks file *type*, not the +fork: `~/Videos` is `.mkv` (Proton generates no thumbnail — always a miss), +`~/ProtonDrive/Installer` is installers whose probes were already warm. The +`fork_state` theory in the previous write-up is wrong; forked and primary mounts +run the same code and neither is at fault. Worth noting as a lesson — three +measurements on different directories looked like a mount-kind effect because +nobody had varied file type independently. + +#### Fix + +- `listxattr` advertises the names only for `image/*` and `video/*` media types. + `getxattr` still honours an explicit request for an unadvertised name, so + nothing becomes unreachable. Everything else — documents, installers, archives + — now costs zero round trips per listing. +- `Core::thumbnail` remembers misses in `no_thumbnail`, keyed `(uid, type)` and + validated against the node's mtime exactly as the positive side is. Bounded by + clearing at `MAX_THUMBNAIL_MISSES` (8192). +- `getxattr` hands off to `Lane::Meta`, so a miss can never block the dispatch + loop. + +A video directory still pays its misses once per revision (image/video is where +a thumbnail plausibly exists, so the probe is legitimate) — but in parallel, and +never again. + +#### Verified (2026-07-20) + +Cold runs (40 s wait each) on the restarted daemon: + +| measurement | before | after | +|---|---|---| +| `~/Videos/…FMA…` (65 `.mkv`) cold `exa -l` | 193 ms | **14 ms** | +| `~/Documents` (45 entries) cold `exa -l` | 54 ms | **13 ms** | +| `user.proton.*` probes over the 65 `.mkv` | 130 | **0** | +| `~/Documents` probe latency, 1st listing | 124–395 ms | 124–395 ms | +| `~/Documents` probe latency, 2nd listing | 124–395 ms | **0.26–0.67 ms** | + +Each part of the fix is separately visible in the traces: + +- **The advertising gate.** The `.mkv` directory now issues *zero* `user.proton.*` + probes; the only remaining `lgetxattr` is exa's own `security.selinux` at + ~0.3 ms. Note this means Proton does **not** report those files as `video/*` — + the gate is excluding them by media type, not because they are known to lack a + thumbnail. A video whose media type is generic no longer advertises one even if + it has it; `getxattr` still serves an explicit request, so it stays reachable. + Worth revisiting if thumbnails ever go missing in a file manager. +- **The negative cache.** `~/Documents` holds 10 `.png` files, correctly still + advertised, so it probes 20 times on both passes — but the second pass answers + in 0.26–0.67 ms instead of going to the wire. That is the whole point: the + probes that are legitimate stop being expensive after the first. +- **The worker handoff.** Pass 1 on `~/Documents` shows interleaved + `<... lgetxattr resumed>` lines across several tids, i.e. the misses now + overlap instead of serializing behind the dispatch loop. + +#### Secondary finding (still open): `lookup` is O(n) per name + +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `serve_lookup` + +`serve_lookup` linear-scans the parent's children comparing names, and with no +`readdirplus` that is one `lookup` per child — O(n²) name comparisons under the +global state lock for a listing. + +Real, but **not** what made this slow: it costs the same on both mount kinds and +at 65 entries a linear scan is nanoseconds against a 186 ms round trip. Two +candidate optimizations were considered and deliberately *not* applied, and that +judgement still holds now the real cause is known: + +- **Per-directory `name -> ino` map.** A second structure that must stay in sync + with `children` — the two-halves-of-one-cache shape that caused B1 and B4. +- **`readdirplus`** (`fuser` 0.17 supports it; we return `ENOSYS`). The right + shape long-term, but it is a rewrite of the directory-read path and it would + have hidden this bug rather than explained it. + +#### Measurement notes for next time + +- Cold requires a 35 s wait per run (30 s attr TTL). Warm runs show ~6 ms + regardless and prove nothing. +- `strace -c` on `exa` **needs `-f`** — exa is multithreaded, and without it the + trace captures only main-thread startup (3 `statx` calls, no `getdents`). +- `-T` (per-call durations) is what cracked this; `-c` summaries attribute time + across threads in a way that hid the 186 ms constant. + +--- + +### B4 — `invalidate_listing` silently skipped non-resident folders + +**Status:** Fixed (verified 2026-07-20, via B1's repro — the FUSE rename path that +depends on this helper now behaves correctly end to end) +**Found:** 2026-07-19, reviewing the B1 fix +**Where:** `crates/pdfs-fuse/src/state.rs:257` + +```rust +if self.children.remove(&ino).is_none() { + return; // never clears the DB `listed` flag +} +``` + +The early return assumed "not in the hot cache ⇒ nothing to invalidate", which +stopped being true once listings became DB-backed. A folder trimmed from the +in-memory map but still `listed = 1` in the DB could not be invalidated at all — +callers thought they had dropped the listing, and `ensure_children` would happily +rebuild the stale one. + +**Fix:** drop the early return; always clear the flag. Costs one redundant +`UPDATE` when nothing was cached. + +`Core::refresh_dir` had been hand-rolling a workaround for exactly this (clearing +the DB flag itself, then reaching into `state.children` directly, with a comment +explaining why it couldn't use the helper). It now just calls +`invalidate_listing`. **Behaviour change worth knowing:** `refresh_dir` used to +propagate a DB write failure to the caller of `CtlRequest::Refresh`; it now warns +and reports success, matching every other invalidation site. + +**Test:** `a_deleted_child_leaves_a_listed_parent_serving_a_stale_listing` in +`pdfs-core/src/db/tests.rs` pins the B1/B4 mechanism at the DB layer — a deleted +child plus a still-`listed` parent yields a listing that silently omits it. + +--- + +### B3 — ~~Activity log timestamps written in seconds, read as milliseconds~~ + +**Status:** Not a bug — investigator error, kept as a record +**Found / retracted:** 2026-07-19 + +Originally filed because every activity row rendered as `1970-01-21 …`. That was +an artifact of the ad-hoc debugging query, which divided by 1000; the code never +does. Seconds are consistent end to end: + +- writer — `log_activity` uses `now_secs()` (`pdfs-fuse/src/lib.rs`) +- schema / `activity_list` — pass the value through untouched +- reader — `activity_time` calls `glib::DateTime::from_unix_local(secs)` + +The correct manual query, for next time: + +```sql +select datetime(time,'unixepoch','localtime'), kind, target, detail, ok +from activity order by time desc limit 40; +``` + +Worth noting because the bad query is what made the activity log look empty +around the reported `mv` — which is evidence B2 leans on. That absence has since +been re-checked with correct scaling and it does hold: no row at 15:58. + +--- + +### B2 — The reported .mkv went to trash, the repro vanished entirely + +**Status:** Partly resolved — `mv` exonerated; trash origin still unattributed +**Found:** 2026-07-19, while chasing B1 + +#### What was settled (2026-07-19) + +**`mv` never trashes.** Three repros on the mount, all with the old (pre-B1-fix) +daemon: + +| repro | node state | destination | result | +|---|---|---|---| +| `zz-claude-repro-src.txt` | create still queued | fresh folder | vanished, **not** trashed | +| `zz-b2-settled.txt` | settled, real uid, landed | listed folder | vanished, **not** trashed | +| `zz-b3 Movie Title.mkv` | settled | folder named as the file's stem (the user's exact shape) | vanished, **not** trashed | + +So B1 fully explains the *disappearance*, and nothing in the rename path +trashes. The `.mkv` reaching the trash has a different cause. + +**The listing the user acted on was almost certainly stale.** B1/B4 make stale +listings *persistent* — `listed = 1` lives in the DB and survives restarts — so +`ls` showing the file at 15:57 is not evidence it still existed remotely. This +is the most likely reading: it had been trashed earlier and the mount kept +showing it. + +**Ruled out:** + +- *Sync engine.* `reconcile_folder` gates on `mode == "mirror"`; `~/Videos` was + `ondemand` from 15:39:27. All three sync trash sites log, and no such rows exist. +- *Log pruning hiding the evidence.* `ACTIVITY_KEEP = 2000`; the table is at the + cap but its oldest row is Jul 17 18:03, so the whole window is covered. The + `.mkv`'s uid appears exactly once — an `Upload` at 06:07:56 — and never again. +- *Conflict machinery.* `keep_as_conflict_copy` uploads a new file under an + alternate name; it never trashes the original. +- *Eviction through the mount.* Was the best theory: `mirror→ondemand` calls + `evict_dir_contents`, which would issue one `unlink(2)` per file, and FUSE + `unlink` trashes remotely. But `apply_sync_folder_mode` evicts **before** + `spawn_ondemand_mount`, so the deletes hit the plain local directory. Dead. +- *Control-socket delete.* `CtlRequest::Delete` logs on both success and failure. + +**What remains:** `trash_child` (backing FUSE `unlink`/`rmdir`) was the only +trash path that wrote no activity row — so an ordinary `rm` on the mount, at any +point after 15:39:27 when `~/Videos` became a FUSE mount, would produce exactly +what we see and leave no trace. Note there is also a *trashed folder* of the same +name (`Evangelion 1.11 - You Are (Not) Alone`, `is_dir=1`, content mtime +04:57:50), which suggests this mkdir-and-move dance had been attempted earlier — +plausibly followed by a manual cleanup while B1 was making files appear to vanish. + +That is a hypothesis, not a finding. It is not provable from the evidence that +survives. + +#### Fix applied + +`trash_child` now logs to the activity feed on both success (`"trashed from the +mount"`) and failure, matching every other trash site. The next occurrence will +be attributable — which is the part that actually mattered here. + +#### If it recurs + +The activity log is now the first place to look. `trash` rows carry no +trashed-at timestamp (the `mtime` column is the node's *content* mtime — for the +`.mkv` it read 06:07:56, matching its upload, not its deletion), so the activity +feed is the only ordering evidence there is. + +Two different endings for what looked like the same operation, so B1 may not be +the whole story: + +- User's `Evangelion 1.11 - You Are (Not) Alone.mkv` → **trash**, full size + (1768670449) intact, recoverable via `pdfs restore`. +- Clean repro (`zz-claude-repro-src.txt`) → **gone entirely**. Not in trash, no + `nodes` row, nothing in the search index. + +B1 explains the second. It does not explain a node reaching the trash — nothing +in the FUSE `rename` path calls `trash_nodes`. + +Leading theory: the trash came from the sync-conflict machinery, which *is* +logged (`ActivityKind::Trash` rows exist with `(sync-conflict )` details). +The `~/Videos` folder is registered as a sync folder in `ondemand` mode, and had +just been switched to `ondemand` shortly before. `reconcile_folder` gates on +`mode == "mirror"`, so it should have been inert — worth confirming that gate +actually held, and that no pass was already in flight across the switch. + +Note the file was a **conflict-copy sibling** of two files already carrying +`-003` / `-004` suffixes, so the conflict path had definitely been active in that +directory. + +**Next step:** find the trash event's origin. The activity log had no row at the +15:58 mv, which points away from a logged (control-socket / sync) path — but the +timestamp bug in B3 made that table hard to read, so re-check with correct +scaling before trusting the absence. + +--- + +### B1 — FUSE rename loses the file (data loss) + +**Status:** Fixed (verified 2026-07-20) +**Found:** 2026-07-19, user reported `mv file.mkv dir/` on the mount deleted the file +**Verified:** the exact repro below on the new daemon — `mv` returns 0, the file is +present in the destination listing and reads back its content. Previously it was in +neither directory. +**Where:** `crates/pdfs-fuse/src/filesystem.rs`, `Filesystem::rename` + +`rename` ended with: + +```rust +st.forget(&uid); +st.children.remove(&newparent); // memory only +``` + +`forget()` deletes the node's **DB row** (`db.delete_node`). `children.remove()` +only drops the **in-memory** listing, leaving `nodes.listed = 1` on the +destination folder. The next `ls` goes through `ensure_children`, which takes the +DB fast path (`children_if_listed`, lib.rs:722) and rebuilds the destination from +the database — where the node no longer has a row. + +Net effect: file gone from the source, absent from the destination, and +`rename(2)` returned **0**. Silent loss with a success code. + +**Severity is worse than "stale cache":** `listed` is a **DB** column, so the bad +state survives a daemon restart. Nothing clears it on its own — the affected +folder keeps serving the listing that omits the file indefinitely, until an event +invalidation or a manual refresh happens to hit it. Confirmed on the repro +folders, which still read `listed=1` with no child rows minutes later. + +The data itself is believed intact server-side (the move succeeded; only the +local view is wrong), but that was **not** verifiable without a daemon restart — +see the status line. + +Also hits plain in-place renames (`mv a.txt b.txt`), same mechanism — the parent +is both source and destination there. + +The control-socket `Core::move_to` path was always correct; it used +`invalidate_listing`, which clears the DB flag as well. The FUSE path didn't. + +**Fix:** use the same helper. + +```rust +st.forget(&uid); +st.invalidate_listing(newparent); +``` + +**Repro:** + +``` +cd && mkdir d && echo hi > f.txt && mv f.txt d/ +# renameat2(...) = 0, but f.txt is in neither directory, +# not in trash, and absent from the `nodes` table +``` + +**Note on `invalidate_listing`:** it early-returns when the folder isn't in the +in-memory `children` map, so it won't clear a stale DB `listed` flag in that +case. Safe on this path (both `lookup_child` and the explicit +`ensure_children(newparent)` guarantee the listing is resident), but it's a sharp +edge for other callers. See B4. + +--- + +## Acceptance harness findings + +Defects in `scripts/fuse-acceptance.py` itself, not in the client. Numbered separately. + +### T1 — Managed acceptance raced daemon startup + +**Status:** Fixed (2026-07-22) +**Found:** repeated install/restart/acceptance loop +**Where:** `scripts/fuse-acceptance.py`, managed setup + +**Repro:** Run `systemctl --user restart proton-drive.service` and immediately +start `--managed-live`. The offline reference completed before the daemon had +recreated its control socket, so the first `pdfs --json sync list` failed with +`ENOENT` and setup aborted. + +**Fix:** Managed validation polls the supported `sync list` API until the daemon +is ready or `PDFS_ACCEPTANCE_SYNC_TIMEOUT` expires. This is tracked as a test +harness defect rather than an application bug. + +--- + +### T2 — Acceptance cleanup raced queued namespace replay + +**Status:** Fixed (2026-07-22) +**Found:** managed on-demand/on-demand matrix after every functional test passed +**Where:** `scripts/fuse-acceptance.py`, per-contract cleanup + +**Repro:** A combined move+rename was correctly accepted into the durable queue. +Cleanup started recursively deleting its test tree while the rename drain was +still landing. The moved entry appeared in `dir-b` after `rmtree` enumerated it +but before `rmdir(dir-b)`, producing `ENOTEMPTY`. + +**Fix:** Before cleanup, poll `pdfs --json status` until both pending counters +are zero. Retry `rmtree` only for `ENOTEMPTY` inside the uniquely owned test +root, then wait for the queued deletions before changing modes. Other errors are +still immediate failures. + +--- + +### T3 — Mode-matrix preservation check raced mirror restoration + +**Status:** Fixed (2026-07-22) +**Found:** managed on-demand/on-demand → on-demand/mirror transition +**Where:** `scripts/fuse-acceptance.py`, managed mode switching + +**Repro:** Switch a populated managed folder from `ondemand` to `mirror`, wait +until `sync list` reports mirror/idle, and immediately read a preservation file. +The file can still be absent even though the subsequent restore pass downloads +it correctly. + +**Cause:** The application commits the new mode before scheduling reconciliation +and leaves the previous idle state and sync timestamp in the row. Those values +describe the completed on-demand state, not a completed mirror restoration. + +**Fix:** The harness records every transition to mirror and explicitly requests +and waits for a newer completed sync pass before validating local bytes. This is +tracked as a harness synchronization defect; transient absence before that pass +is expected asynchronous behavior, not data loss. + +--- + +### T4 — Successful managed cleanup left its own sentinel behind + +**Status:** Fixed (2026-07-22) +**Found:** second consecutive managed acceptance run +**Where:** `scripts/fuse-acceptance.py`, managed cleanup + +**Repro:** Complete the mode matrix in mirror/mirror, then immediately start a +new managed run. Registration cleanup removed the sync-folder records and remote +test folders, but the next run's empty-directory precheck found the harness's +128 KiB preservation sentinel in each local directory. + +**Cause:** Removing a mirror registration intentionally preserves the local +copy. The harness treated unregistering as if it also emptied local storage. + +**Fix:** After successful unregister, cleanup deletes only the sentinel whose +contents match the per-run bytes generated by this harness. Unknown files remain +untouched and continue to fail the strict precheck.