mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
9342 lines
538 KiB
Markdown
9342 lines
538 KiB
Markdown
# Bug ledger
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
- [Numbers](#numbers)
|
||
- [Statuses](#statuses)
|
||
- [Adding an entry](#adding-an-entry)
|
||
- [Index](#index)
|
||
- [Entries](#entries)
|
||
- [Acceptance harness findings](#acceptance-harness-findings)
|
||
|
||
## Numbers
|
||
|
||
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<n>`.
|
||
|
||
## 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<n>` (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
|
||
### B196 — 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 |
|
||
|---|---|---|
|
||
| [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI |
|
||
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
|
||
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | Not fixed |
|
||
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
|
||
| [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 |
|
||
| [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 |
|
||
|---|---|---|
|
||
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way |
|
||
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked |
|
||
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors |
|
||
| [B262](#b262--watching-a-large-synced-folder-holds-up-the-sync-engine-and-a-failed-watch-is-never-retried) | Watching a large synced folder holds up the sync engine, and a failed watch is never retried | The first pass still waits for the watch; a failed watch is not shown in the app |
|
||
| [B259](#b259--the-sync-engine-can-die-or-hang-and-nothing-shows-it-until-a-restart) | The sync engine can die or hang, and nothing shows it until a restart | No pass deadline; an ended engine is reported, not restarted; the trigger in #53 is not known |
|
||
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
|
||
| [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 |
|
||
|---|---|
|
||
| [B265](#b265--address-keys-whose-token-is-for-an-inactive-account-key-never-open) | Address keys whose token is for an inactive account key never open |
|
||
| [B274](#b274--opening-a-file-for-writing-fails-with-eio-while-its-queued-revision-lands) | Opening a file for writing fails with EIO while its queued revision lands |
|
||
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing |
|
||
| [B266](#b266--pin-unpin-search-and-node-loads-report-a-database-error-as-offline) | Pin, unpin, search and node loads report a database error as "offline" |
|
||
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running |
|
||
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
|
||
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
|
||
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
|
||
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
|
||
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
||
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
||
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
||
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
||
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
||
| [B251](#b251--two-computers-can-make-the-same-conflict-copy-name) | Two computers can make the same conflict copy name |
|
||
| [B250](#b250--a-revision-made-during-a-mirror-download-never-arrives) | A revision made during a mirror download never arrives |
|
||
| [B249](#b249--a-mirror-edit-replaces-a-revision-another-computer-made-since) | A mirror edit replaces a revision another computer made since |
|
||
| [B248](#b248--a-joining-mirror-takes-a-same-size-file-for-the-same-file) | A joining mirror takes a same-size file for the same file |
|
||
| [B247](#b247--tray-status-lines-are-not-translated) | Tray status lines are not translated |
|
||
| [B246](#b246--signing-in-with-a-locked-keyring-freezes-the-app-window) | Signing in with a locked keyring freezes the app window |
|
||
| [B245](#b245--any-child-of-the-daemon-can-answer-the-watchdog-for-it) | Any child of the daemon can answer the watchdog for it |
|
||
| [B244](#b244--photos-without-a-sidecar-or-a-dated-name-are-filed-under-the-upload-day) | Photos without a sidecar or a dated name are filed under the upload day |
|
||
| [B243](#b243--a-hung-mount-freezes-the-app-when-a-file-is-opened) | A hung mount freezes the app when a file is opened |
|
||
| [B242](#b242--opening-a-large-photo-freezes-the-app) | Opening a large photo freezes the app |
|
||
| [B241](#b241--every-closed-photo-viewer-stays-in-memory-with-its-photo) | Every closed photo viewer stays in memory with its photo |
|
||
| [B240](#b240--pdfs-mount-can-be-core-dumped-with-the-keys-in-memory) | pdfs mount can be core-dumped with the keys in memory |
|
||
| [B239](#b239----wait-reports-success-for-imports-and-re-dates-that-failed) | --wait reports success for imports and re-dates that failed |
|
||
| [B238](#b238--draining-a-large-offline-batch-is-quadratic-in-its-size) | Draining a large offline batch is quadratic in its size |
|
||
| [B237](#b237--every-reconnect-resends-refused-uploads-all-in-the-same-second) | Every reconnect resends refused uploads, all in the same second |
|
||
| [B236](#b236--imported-re-dated-and-app-uploaded-photos-have-no-thumbnail) | Imported, re-dated and app-uploaded photos have no thumbnail |
|
||
| [B235](#b235--scanning-a-large-takeout-folder-takes-half-a-minute) | Scanning a large Takeout folder takes half a minute |
|
||
| [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included |
|
||
| [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton |
|
||
| [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup |
|
||
| [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes |
|
||
| [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name |
|
||
| [B229](#b229--a-queued-write-can-be-lost-to-a-power-cut-after-close-returned) | A queued write can be lost to a power cut after close returned |
|
||
| [B228](#b228--a-gap-filled-write-whose-upload-answer-was-lost-becomes-a-conflict-copy-of-itself) | A gap-filled write whose upload answer was lost becomes a conflict copy of itself |
|
||
| [B227](#b227--reading-a-file-open-for-writing-downloads-on-the-dispatch-loop) | Reading a file open for writing downloads on the dispatch loop |
|
||
| [B226](#b226--thumbnails-of-portrait-phone-photos-lie-on-their-side) | Thumbnails of portrait phone photos lie on their side |
|
||
| [B225](#b225--a-takeout-photo-without-a-sidecar-takes-a-siblings-name-and-date) | A Takeout photo without a sidecar takes a sibling's name and date |
|
||
| [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file |
|
||
| [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change |
|
||
| [B222](#b222--a-full-disk-at-close-is-reported-to-nobody-and-fsync-on-a-read-only-file-fails) | A full disk at close is reported to nobody, and `fsync` on a read-only file fails |
|
||
| [B221](#b221--a-cold-listing-holds-the-global-lock-across-a-database-transaction) | A cold listing holds the global lock across a database transaction |
|
||
| [B220](#b220--thumbnails-on-a-stalled-link-hold-up-every-other-request) | Thumbnails on a stalled link hold up every other request |
|
||
| [B219](#b219--listing-a-shared-folder-freezes-the-whole-mount-on-a-slow-link) | Listing a shared folder freezes the whole mount on a slow link |
|
||
| [B218](#b218--pdfs-diagnose-freezes-the-mount-while-it-checks-the-database) | `pdfs diagnose` freezes the mount while it checks the database |
|
||
| [B217](#b217--the-search-indexes-grow-to-many-times-their-size-and-slow-search-down) | The search indexes grow to many times their size and slow search down |
|
||
| [B216](#b216--a-damaged-database-is-rebuilt-without-the-writes-still-queued-in-it) | A damaged database is rebuilt without the writes still queued in it |
|
||
| [B215](#b215--a-remote-name-holding--or--becomes-a-path-outside-the-sync-folder) | A remote name holding `/` or `..` becomes a path outside the sync folder |
|
||
| [B214](#b214--deleting-a-mirror-folder-here-trashes-another-devices-new-files-in-it) | Deleting a mirror folder here trashes another device's new files in it |
|
||
| [B213](#b213--a-file-edited-during-a-mirror-pass-is-deleted-because-drive-deleted-it-earlier) | A file edited during a mirror pass is deleted because Drive deleted it earlier |
|
||
| [B212](#b212--a-node-on-drive-that-cannot-be-decrypted-is-deleted-from-the-mirror) | A node on Drive that cannot be decrypted is deleted from the mirror |
|
||
| [B211](#b211--a-folder-deleted-on-drive-takes-unsynced-local-files-with-it) | A folder deleted on Drive takes unsynced local files with it |
|
||
| [B210](#b210--after-a-password-reset-the-mount-retries-missing-key-forever) | After a password reset, the mount retries "missing key" forever |
|
||
| [B209](#b209--a-blocked-sign-in-says-the-app-version-is-no-longer-supported) | A blocked sign-in says the app version is no longer supported |
|
||
| [B208](#b208--a-file-landing-while-its-folder-is-pinned-is-kept-but-not-available-offline) | A file landing while its folder is pinned is kept but not available offline |
|
||
| [B207](#b207--a-move-between-locations-goes-ahead-under-a-file-opened-just-after-its-create-landed) | A move between locations goes ahead under a file opened just after its create landed |
|
||
| [B206](#b206--an-upgrade-leaves-the-old-daemon-running-and-nothing-says-so) | An upgrade leaves the old daemon running, and nothing says so |
|
||
| [B205](#b205--a-photo-upload-the-sdk-refuses-says-youre-offline) | A photo upload the SDK refuses says "You're offline" |
|
||
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
|
||
| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token |
|
||
| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again |
|
||
| [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back |
|
||
| [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy |
|
||
| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive |
|
||
| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored |
|
||
| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing |
|
||
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
|
||
| [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) |
|
||
| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) |
|
||
| [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 |
|
||
|---|---|
|
||
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
||
| [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.
|
||
|
||
### B275 — On a slow link, a file's last write can land only in a conflict copy
|
||
|
||
**Status:** Open
|
||
**Found:** 2026-10-31, CI on #60: `one_client_on_a_slow_link`, profile `one-client-wifi` seed 2,
|
||
failed with `Drive does not hold what the clients wrote, 120s after the links came up`. The
|
||
same seed passed 4 times locally, and #60 changes no daemon logic, so the failure depends on
|
||
timing.
|
||
**Where:** not located; the drain's conflict check in `crates/pdfs-fuse/src/drain.rs` is the
|
||
first suspect
|
||
|
||
**Repro:** Only the CI run above. The last steps created `x/z/y/a.txt` (37 bytes), wrote 42
|
||
bytes into it at offset 20, and brought every link up. Drive then held the file at its first
|
||
37 bytes, and the full content was in `x/z/y/a (sync-conflict …).txt`. No other client wrote
|
||
to it.
|
||
|
||
**Cause.** Not known. Nothing was lost, since the conflict copy holds the bytes, but the file
|
||
conflicted with its own create. B183 and B184 found the same symptom after a lost create
|
||
reply.
|
||
|
||
**Fix.** Not done. Needs a reproduction: run the seed in a loop under load and keep the state
|
||
directory of a failing run.
|
||
|
||
---
|
||
|
||
### B274 — Opening a file for writing fails with EIO while its queued revision lands
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, CI: the simulation
|
||
`a_file_whose_upload_was_not_read_back_takes_two_writes_at_once` failed once with
|
||
`Input/output error` on a write, after `copy queued revision into write scratch failed … No
|
||
such file or directory`. It did not fail in 54 local runs.
|
||
**Where:** the write open in `crates/pdfs-fuse/src/filesystem.rs`
|
||
|
||
**Repro:** Write a file, and open it for writing again at the moment its queued revision
|
||
finishes uploading. The open fails with EIO, and the program sees its save fail.
|
||
|
||
**Cause.** The open samples the queued revision, then copies its blob into the new handle's
|
||
scratch file. The drain lets go of the pending entry and then deletes the blob when the upload
|
||
lands. A blob deleted between the sample and the copy failed the open, although the re-check
|
||
just after the copy already retries a revision that changed during it.
|
||
|
||
**Fix.** A failed copy checks whether the pending entry still names that blob. When it does
|
||
not, the revision landed or was replaced, and the open goes around again with the base the
|
||
file has now, as the re-check does. Since the drain drops the entry before the blob, a blob
|
||
that is gone and still named is a real failure and still returns EIO.
|
||
|
||
**Test:** none deterministic; the simulation above covers the window.
|
||
|
||
---
|
||
|
||
### B273 — A restore proposes another computer's paths when the two share a hostname
|
||
|
||
**Status:** Open
|
||
**Found:** 2026-10-31, code audit after issue #46.
|
||
**Where:** `saved_folder` in `crates/pdfs-core/src/profile.rs`
|
||
|
||
**Repro:** Two computers with the same hostname, for example both left at a distribution's
|
||
default `archlinux`, sync the same device. Restore a folder on one of them. The proposed local
|
||
path and mode come from whichever of the two profiles is found first, not from this computer's.
|
||
|
||
**Cause.** `saved_folder` prefers the profile whose `hostname` matches. The machine id that
|
||
B252 added tells computers apart, but this lookup does not use it.
|
||
|
||
**Fix.** Not done. Match on the machine id first, then fall back to the hostname for profiles
|
||
written before it existed.
|
||
|
||
---
|
||
|
||
### B272 — After a folder is removed and added again, its file events go to the old id
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, code audit after issue #53.
|
||
**Where:** `rewatch` in `crates/pdfs-fuse/src/sync/engine.rs`
|
||
|
||
**Repro:** Remove a synced folder and add the same path again before the engine handles the
|
||
first `Rewatch`, so both land in one burst. Edits in the folder are not synced until the
|
||
two-minute poll, and the same goes for every later edit until the daemon restarts.
|
||
|
||
**Cause.** `rewatch` matches the watches it has against the ones it wants by path alone. The
|
||
path is still wanted, so the old entry stays with the removed folder's id. Its events become
|
||
`Reconcile(old id)`, `sync_folder_get` finds no row, and the request is dropped.
|
||
|
||
**Fix.** `keep_wanted` keeps a watch whose path is still wanted and gives it the id the
|
||
folder list has now.
|
||
|
||
**Test:** `a_re_added_folder_keeps_its_watch_under_the_new_id` in `pdfs-fuse`.
|
||
|
||
---
|
||
|
||
### B271 — One failed listing makes a large first sync start over from nothing
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
|
||
second computer sat at `scanning…` with nothing downloaded, and the journal showed
|
||
`RequestTimeout (http 408)`.
|
||
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
|
||
that drops a request now and then. The pass walks the whole remote tree before it downloads
|
||
anything. When one folder's listing fails after the SDK's retries, the pass fails, and the
|
||
next one walks the whole tree again. On a slow enough link it never gets past the walk.
|
||
|
||
**Cause.** Each `enumerate_folder_children_node_uids`, `enumerate_nodes_light` and
|
||
`enumerate_nodes` call in the walk ends the whole walk through `?`. Nothing is applied until
|
||
the walk is done. Files that did download keep their baseline rows, so only the walk is lost,
|
||
but on a first sync that is all of it. Progress is also counted only after a folder's full
|
||
listing, so a large folder shows `scanning…` with 0 checked for a long time.
|
||
|
||
**Fix.** A listing that fails below the sync root records that folder as a gap in
|
||
`RemoteGaps`, next to the folders whose listing came back short. The planner deletes nothing
|
||
under a gap, the rest of the pass still applies, and the pass report names the folder as
|
||
tried again next pass. A failed listing of the root itself still fails the pass, since there is
|
||
nothing left to apply. The progress count inside one large folder is unchanged.
|
||
|
||
**Test:** `a_folder_that_failed_to_list_hides_everything_under_it` in `sync::planner`.
|
||
|
||
---
|
||
|
||
### B270 — One file left in a mirror folder clears the wipe guard for all the others
|
||
|
||
**Status:** Open
|
||
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
||
several times.
|
||
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
|
||
|
||
**Repro:** Sync a mirror folder with many files. Make all of them but one unavailable here: a
|
||
half-mounted disk, a restored backup that brought back one file, or a script that emptied the
|
||
folder and wrote one file back. The next pass trashes every other file on Drive. On a device
|
||
that two computers sync, the other computer then deletes its copies too.
|
||
|
||
**Cause.** The guard refuses a pass only when every baseline path is missing. One surviving
|
||
path is enough to clear it, so it guards against an empty folder and nothing short of that.
|
||
The remote guard has the same shape.
|
||
|
||
**Fix.** Not done. Also refuse when most of a large baseline is missing at once, and leave the
|
||
choice to the user: delete on the other side, or sync from scratch.
|
||
|
||
---
|
||
|
||
### B269 — A restore that skipped every folder reports success
|
||
|
||
**Status:** Partly fixed
|
||
**Found:** 2026-10-31, code audit after issue #46: the journal showed `restore complete
|
||
restored=0 skipped=1` several times, and the user saw nothing.
|
||
**Where:** the `RestoreSyncFolders` and `RestoreDeviceFolders` handler in
|
||
`crates/pdfs-fuse/src/control.rs`; `restore_sync_folders` in `crates/pdfs-fuse/src/profile.rs`
|
||
|
||
**Repro:** Restore a folder from another computer to a path that is already a sync root here,
|
||
for example a folder left over from an earlier attempt. `pdfs devices join` prints `restoring
|
||
folders`, and the app shows the same. Nothing downloads, and no error appears anywhere.
|
||
|
||
**Cause.** The daemon acks before it works, because a restore outlasts the socket timeout. The
|
||
result, `restored 0 folder(s); skipped: …`, is then logged as a successful activity, so the
|
||
reason is only in the activity text and the journal. When the database insert fails partway,
|
||
the `?` also skips the `Rewatch` for the folders already restored.
|
||
|
||
**Fix.** `restore_sync_folders` returns what it restored and what it skipped, with the reason
|
||
for each skip. The daemon logs a restore that skipped any folder as a failed activity, with the
|
||
reasons as its detail, and warns in the journal. A database error partway now sends the
|
||
`Rewatch` for the folders already restored before it returns. `pdfs devices join` points to
|
||
`pdfs activity` for the result.
|
||
|
||
Left: the app shows the result only in the activity feed, and nothing offers to replace or reset
|
||
the folder in the way.
|
||
|
||
---
|
||
|
||
### B268 — `pdfs diagnose` says "No problems found" while sync is stuck
|
||
|
||
**Status:** Partly fixed. The sync engine's own state is not checked yet.
|
||
**Found:** 2026-10-31, issue #53: a folder that never synced, and `diagnose` found nothing.
|
||
**Where:** `diagnose` in `crates/pdfs-cli/src/main.rs`
|
||
|
||
**Repro:** Leave a synced folder in `error`, or have queued writes that keep failing, or pause
|
||
sync. `pdfs diagnose` reports every check `[ok]` and exits 0.
|
||
|
||
**Cause.** The `Status` reply is matched with `..`, which drops `failing_ops`, `failing_error`,
|
||
`parked_uploads`, `staged_oldest_secs` and `paused`. `diagnose` never asks for the synced
|
||
folders or for `Diagnostics`, so a folder in error and a stalled worker are not seen. A
|
||
control socket file with no daemon behind it is also reported `[ok]`; only the next line says
|
||
the daemon does not answer.
|
||
|
||
**Fix.** `diagnose` warns about writes that keep failing (with the last error), a write that
|
||
has waited a day or more to upload, a pause, and each synced folder in `error` or `conflict`
|
||
(`sync_health` and `folder_health` in `crates/pdfs-cli/src/main.rs`). A control socket file
|
||
that nothing listens on is a warning, not `[ok]`. Parked uploads are left out: a file still
|
||
wearing a download's temporary name is normal. Not done: the sync engine's state, which
|
||
`Diagnostics` reports once B259's change is in.
|
||
|
||
**Test:** `a_stuck_queue_or_a_pause_is_reported` and `a_synced_folder_in_error_is_reported` in
|
||
`pdfs-cli`. On 2026-10-31 the new `diagnose` against a healthy 3.6.0 daemon still reported
|
||
"No problems found".
|
||
|
||
---
|
||
|
||
### B267 — A key failure at a start from a warm cache shows as offline for good
|
||
|
||
**Status:** Open
|
||
**Found:** 2026-10-31, code audit after issue #40. B202 fixed the same path for a refused
|
||
session only.
|
||
**Where:** `fetch_or_recall_root` in `crates/pdfs-fuse/src/mount.rs`; `run_online_probe` in
|
||
`crates/pdfs-fuse/src/link.rs`
|
||
|
||
**Repro:** Sign in once so the cache holds the My Files root. Then start the daemon in a state
|
||
where the root cannot be decrypted, for example the address-key failure of B265. The mount
|
||
comes up "offline", the app says you are offline, and it stays so while the network is fine.
|
||
|
||
**Cause.** Any error from `get_my_files_folder` other than a revoked session becomes
|
||
`Start::Offline` when a cached root exists. The online probe then retries the same call and
|
||
logs its failures at debug level, so the journal at the default level says nothing.
|
||
|
||
**Fix.** Not done. Use `Start::Offline` only for an error `is_network_error` accepts, and
|
||
report the others as their own state through `Status`.
|
||
|
||
---
|
||
|
||
### B266 — Pin, unpin, search and node loads report a database error as "offline"
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, code audit after issue #38. B205 fixed the photo upload path only.
|
||
**Where:** `CoreError::from_api` in `crates/pdfs-core/src/error.rs`; its callers in
|
||
`crates/pdfs-fuse/src/lib.rs` (`pin`, `unpin`, the searches, `load node`)
|
||
|
||
**Repro:** Pin a folder while the cache database is locked or full. The app says "You're
|
||
offline" and drops the reason.
|
||
|
||
**Cause.** `from_api` classifies any error that is not a `ProtonError` as `Offline`, because
|
||
it was written for SDK calls, where that means a transport error. These callers pass it
|
||
`pdfs_core::Error` values from the cache and the database. A `ProtonError` wrapped in
|
||
`pdfs_core::Error::Proton` also misses the downcast, since the source chain is not walked.
|
||
|
||
**Fix.** `from_api` walks the source chain. The first `ProtonError` or `pdfs_core::Error` in it
|
||
decides the kind, the same way `From<Error>` does, and only an error with neither, a transport
|
||
error, reads as offline. That covers every caller at once, the database ones included.
|
||
|
||
**Test:** `a_database_error_handed_to_from_api_is_not_offline` in `pdfs-core`.
|
||
|
||
---
|
||
|
||
### B265 — Address keys whose token is for an inactive account key never open
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, code audit after issue #40: a Duo account whose mount failed on every
|
||
start with `fetch My Files root: cryptography error: failed to decrypt: missing key`, after three
|
||
`failed to decrypt address key token` warnings. The web app worked. B210's fix does not apply,
|
||
because the account has an active volume.
|
||
**Where:** `user_keys` and `decrypt_address` in `proton-sdk` (`crates/proton-sdk/src/account.rs`
|
||
in `proton-sdk-rs`)
|
||
|
||
**Repro:** Not reproduced. An account whose address-key tokens are encrypted to an account key
|
||
that is no longer active cannot open those address keys, and so cannot open the My Files share.
|
||
|
||
**Cause.** `user_keys` skips every inactive account key, even one the password opens, and the
|
||
address-key tokens are decrypted only with what it returns. `keycheck.rs` in this repository
|
||
already reports "token is for inactive account key", so the case is known. Separately,
|
||
`addresses()` fails as a whole through `?` when one address has no usable primary key, which
|
||
takes the working addresses down with it.
|
||
|
||
**Fix.** In `proton-sdk` 0.10.1 (narrrl/proton-sdk-rs#3). Tokens are tried with every account
|
||
key that opens, active ones first, and signing uses active keys only. An address that cannot be
|
||
opened is left out with a warning, and the others work; `addresses()` fails only when none
|
||
opens. When the left-out address is the default, `default_address` fails with the reason
|
||
rather than signing with another address. `key_report` walks the same chain, so `pdfs diagnose`
|
||
reports such a key as opening. Not verified against an account with an inactive key, since
|
||
none is at hand; issue #40's reporter can confirm.
|
||
|
||
**Test:** `an_address_key_whose_token_is_for_an_inactive_account_key_opens`,
|
||
`an_address_whose_keys_do_not_open_leaves_the_others_working` and
|
||
`an_account_whose_addresses_all_fail_to_open_is_an_error` in `proton-sdk`.
|
||
|
||
---
|
||
|
||
### B264 — A mount that fails after starting its workers leaves them running
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, code audit after issue #40.
|
||
**Where:** `run_mount` in `crates/pdfs-fuse/src/mount.rs`
|
||
|
||
**Repro:** Make the mount fail after the workers start: a busy mountpoint, or a control socket
|
||
whose permissions cannot be set. The daemon retries every 5 s, and the drain, sync, sweep,
|
||
probe and index threads of the failed attempt keep running beside the next one.
|
||
|
||
**Cause.** The early returns after the workers are spawned (the `?` on the socket bind, the
|
||
permission check, a `spawn_session` error, and a `?` on a later thread spawn) skip
|
||
`stop_workers`. The threads hold a `Core`, and the `Core` holds the database. If that keeps
|
||
the `flock` held, every later attempt fails with "another daemon is using cache.db" instead
|
||
of the real error. That part is inferred from the code and not observed.
|
||
|
||
**Fix.** Every early return in `mount` after the first worker starts stops the workers
|
||
(`abandon_workers`, `keep_or_abandon` in `crates/pdfs-fuse/src/mount.rs`). Unlike a normal stop
|
||
it does not send `STOPPING=1`, because the daemon is about to retry and systemd would
|
||
otherwise wait for it to exit and then kill it. The control-thread failure path, which already
|
||
stopped its workers, no longer sends it either.
|
||
|
||
**Test:** none of its own; the simulations (`sim::daemon`, 55 passed) cover the normal start
|
||
and stop.
|
||
|
||
---
|
||
|
||
### B263 — A mount that can never succeed retries every 5 s and tells only the journal
|
||
|
||
**Status:** Partly fixed. Errors that no retry fixes are not told apart from network errors yet.
|
||
**Found:** 2026-10-31, issue #40: the app showed "Connecting… waiting for the Proton Drive
|
||
service" for days, `pdfs diagnose` only reported the control socket missing, and the reporter
|
||
uninstalled.
|
||
**Where:** the mount loop in `crates/pdfs-cli/src/main.rs`; `run_mount` in
|
||
`crates/pdfs-fuse/src/mount.rs`
|
||
|
||
**Repro:** Start the daemon with a session whose keys cannot open My Files (B265). The journal
|
||
shows `mount failed; retrying in 5s` forever. The app and the tray wait for the service, and
|
||
nothing says why.
|
||
|
||
**Cause.** Every mount error gets the same 5 s retry, whether it is a network error or one that
|
||
no retry fixes, such as a key or a 4xx answer. Each attempt builds a new client and sends about
|
||
four requests, so a broken account sends tens of thousands a day, which may also feed
|
||
Proton's sign-in blocks (#39). The control socket is bound only after the mount succeeds, and
|
||
the error is written nowhere a front end can read it.
|
||
|
||
**Fix.**
|
||
- The retry doubles from 5 s to a 2-minute ceiling (`mount_retry_delay` in
|
||
`crates/pdfs-cli/src/main.rs`), about 720 attempts a day instead of 17,000. The ceiling stays
|
||
at minutes because a cold start without a network fails the same way and has to mount soon
|
||
after the network returns.
|
||
- Each failure is written to `mount-failure.json` in the state directory
|
||
(`crates/pdfs-core/src/mountfail.rs`), with the error, since when it fails and the attempt
|
||
count. A mount that comes up removes it, and so does a new daemon.
|
||
- The app shows "Can't open your Drive" with the error and a Retry button instead of
|
||
"Connecting…". The tray says "Signed in as … — can't open your Drive". `pdfs diagnose` reports
|
||
`[FAIL] mount: failing for …`. `systemctl --user status` shows the error as the unit's status.
|
||
- Not done: key, `KeysLocked` and 4xx errors still retry like network errors, only less often.
|
||
|
||
**Test:** `a_failing_mount_backs_off_to_a_ceiling` in `pdfs-cli`;
|
||
`a_recorded_failure_reads_back_until_cleared` in `pdfs-core`. On 2026-10-31 a second
|
||
`pdfs daemon` beside the running one failed on the database lock three times in 18 s (at 0, 5
|
||
and 15 s) and recorded `attempts: 3` with the lock error.
|
||
|
||
---
|
||
|
||
### B262 — Watching a large synced folder holds up the sync engine, and a failed watch is never retried
|
||
|
||
**Status:** Partly fixed. A watch is still set up before the first pass, and a failed watch
|
||
is not shown in the folder's state.
|
||
**Found:** 2026-10-31, code audit after issue #53.
|
||
**Where:** `rewatch` and `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`
|
||
|
||
**Repro:** Add a synced folder with many subfolders, or one that holds a symlink to a large
|
||
tree, on a system near `fs.inotify.max_user_watches`. The new folder's first pass waits until
|
||
the recursive watch is set up. When the watch fails, the journal has one `sync: watch failed`
|
||
line, the folder is never watched, and later changes are found only by the two-minute poll.
|
||
|
||
**Cause.**
|
||
- `rewatch` runs on the engine thread before the reconcile, and notify's recursive watch walks
|
||
the whole tree, at startup for every folder.
|
||
- notify follows symlinks by default, and the local walk does not.
|
||
- When `watch` fails partway, the watches added before the failure stay registered, but the
|
||
folder is not recorded as watched. Their events match no folder and become `ReconcileAll`,
|
||
and they use up watches the next folder needs.
|
||
- Errors the watcher reports later are dropped in its callback.
|
||
|
||
**Fix.**
|
||
- A failed watch is unwatched, which removes the watches it had added under the folder, and
|
||
is tried again every 10 minutes. The warning names `fs.inotify.max_user_watches`.
|
||
- The watcher no longer follows symlinks.
|
||
- Errors the watcher reports are logged.
|
||
- Not done: the watch still runs before the folder's first pass. Watching first is what keeps
|
||
a change made during the pass from being missed, so the order stays; the cost is one walk
|
||
of a new folder's tree. A failed watch is not shown in the folder's state yet.
|
||
|
||
**Test:** none for the watch failure itself; it needs the inotify limit reached.
|
||
|
||
---
|
||
|
||
### B261 — A folder that has never synced reads "up to date"
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, issue #53: "In Computers I add the folder. It appears as synced."
|
||
**Where:** `sync_folder` in `crates/pdfs-core/src/db/migrations.rs`; `sync_state_label` in
|
||
`crates/pdfs-gui/src/app/pages/devices.rs`; the location rows in
|
||
`crates/pdfs-gui/src/app/pages/locations.rs`
|
||
|
||
**Repro:** Add a synced folder. Until its first pass starts, the app shows it as up to date.
|
||
The same happens after a switch from on-demand to mirror, after **Sync From Scratch…**, and for
|
||
every folder while sync is paused for the whole account.
|
||
|
||
**Cause.** The `state` column defaults to `idle`, and every state the label does not know reads
|
||
"up to date". `last_sync` is 0 for a folder that has never synced, but the rows do not look at
|
||
it, and a test pins the label for that case. The rows only show a folder's own pause, not the
|
||
account-wide one.
|
||
|
||
**Fix.** A mirror folder whose `last_sync` is 0 and whose state is `idle` reads "waiting for
|
||
first sync" on its row. The This computer row on the Computers page counts such folders, as in
|
||
"2 folders backed up · 1 waiting for its first sync". An on-demand folder has no first sync,
|
||
so it is left out. The account-wide pause stays where it already is, the pause card at the top
|
||
of the page; repeating it on every row would only add noise.
|
||
|
||
**Test:** `a_folder_reads_up_to_date_only_after_its_first_sync` and
|
||
`a_folder_before_its_first_sync_is_not_counted_as_backed_up` in `pdfs-gui`; the pinned test now
|
||
expects the new label.
|
||
|
||
---
|
||
|
||
### B260 — The poll pushes a new folder's first sync behind every other folder
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, code audit after issue #53.
|
||
**Where:** `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`
|
||
|
||
**Repro:** Sync a few large mirror folders whose passes take longer than two minutes, then add
|
||
another folder. Its first pass waits until every other folder has been walked again, each
|
||
time.
|
||
|
||
**Cause.** The poll sends `ReconcileAll` every two minutes whether or not a pass is still
|
||
running. When a burst holds both a `ReconcileAll` and a folder's `Reconcile(id)`, the loop runs
|
||
only `reconcile_all`, which goes in id order. A new folder has the highest id, so it always
|
||
goes last.
|
||
|
||
**Fix.** `run_burst` runs the folders a burst names by id first, lowest id first, and then
|
||
every other folder when the burst also holds a `ReconcileAll`. A named folder is read again
|
||
before its pass. A pause that lands between passes now stops the rest of the burst.
|
||
|
||
**Test:** `named_folders_run_before_the_rest_of_a_burst` in `pdfs-fuse`.
|
||
|
||
---
|
||
|
||
### B259 — The sync engine can die or hang, and nothing shows it until a restart
|
||
|
||
**Status:** Partly fixed. A hung pass is reported but not ended, and a panic outside a pass
|
||
still stops the engine; it is reported, not restarted.
|
||
**Found:** 2026-10-31, issue #53: a newly added folder did not sync until the reporter restarted
|
||
the app. All FUSE workers were idle, nothing was queued, and the warning-level journal was
|
||
empty (B258).
|
||
**Where:** `spawn` and `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`;
|
||
`reconcile_pass` in `crates/pdfs-fuse/src/sync.rs`; `crates/pdfs-fuse/src/diagnostics.rs`
|
||
|
||
**Repro:** Not reproduced. Make one pass panic, or block in a call that does not return. Every
|
||
later add, `pdfs sync now`, file event and poll is dropped without a word. The folder that was
|
||
syncing stays `syncing` with its last progress. A restart runs a pass over every folder, which
|
||
is why it helps.
|
||
|
||
**Cause.**
|
||
- The engine is one thread, and nothing catches a panic in a pass. When it unwinds, the
|
||
receiver is gone and every `let _ = sync_tx.send(..)` fails silently. The poll thread stops
|
||
at its first failed send.
|
||
- The thread's `JoinHandle` is checked only at shutdown.
|
||
- A pass has no overall deadline. Each request is bounded, but a pass is not; B165 dealt with
|
||
the stop only.
|
||
- Nothing logs a pass starting, or ending without changes. `Diagnostics` covers the FUSE
|
||
workers and the request queue, not this thread, so "workers idle, nothing queued" says
|
||
nothing about sync.
|
||
- A database error when the engine reads a folder drops that folder's request without a log.
|
||
|
||
**Fix.**
|
||
- Each pass runs under `catch_unwind` (`EngineHealth::contain` in
|
||
`crates/pdfs-fuse/src/sync/engine.rs`). A panic ends that pass only: its progress is cleared,
|
||
the folder is set to `error`, the activity feed says so, and the next poll tries again.
|
||
- `Diagnostics` carries the engine's state: running or not, the folder of the running pass, how
|
||
long it has run and how long since it last reported progress, and how many passes panicked.
|
||
`pdfs diagnostics` prints it as `sync engine: …`.
|
||
- The supervisor warns when the engine thread has ended, and when a pass has reported no
|
||
progress for 15 minutes.
|
||
- A first pass logs its start and end at info, as does any pass that changed something or
|
||
took 30 s or more. A request for a folder the database cannot read is logged.
|
||
- Not done: a deadline for a pass, and restarting an engine thread that has ended. The
|
||
channel's receiver ends with the thread, so a restart needs a new channel in every `Core`
|
||
clone.
|
||
|
||
**Test:** `a_panicking_pass_is_contained_and_counted`, `the_running_pass_is_reported` and
|
||
`the_engine_reads_as_stopped_once_its_thread_ends` in `pdfs-fuse`;
|
||
`a_stopped_sync_engine_is_a_stall` and `only_a_quiet_sync_pass_is_a_stall` in the supervisor.
|
||
|
||
---
|
||
|
||
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
|
||
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
|
||
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
|
||
|
||
**Repro:** Make the daemon log a warning, then run the command above. It prints no entries.
|
||
Without `-p` the warning is there.
|
||
|
||
**Cause.** The daemon logs with plain `tracing_subscriber::fmt()` to standard output. journald
|
||
stores every line of a service's output at the unit's default priority, info, unless the line
|
||
starts with a `<N>` prefix. So every `WARN` and `ERROR` line is stored as info, and the
|
||
troubleshooting guide's filter hides all of them.
|
||
|
||
**Fix.** When standard output is the journal stream (`JOURNAL_STREAM` names the same
|
||
device and inode as fd 1), each line starts with the `<N>` prefix for its level, and ANSI colours
|
||
are off. A terminal that merely inherited `JOURNAL_STREAM` keeps the plain format
|
||
(`crates/pdfs-cli/src/journal.rs`).
|
||
|
||
**Test:** `each_line_opens_with_the_priority_of_its_level` and
|
||
`journal_stream_must_name_the_same_file` in `pdfs-cli`. On 2026-10-31, `pdfs diagnose` run with
|
||
`RUST_LOG=debug` under `systemd-run --user -p StandardOutput=journal` stored its debug lines at
|
||
`PRIORITY=7` and its report at 6.
|
||
|
||
---
|
||
|
||
### B256 — `pdfs copy` right after a save copies the old version
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
||
failing on most runs. The feature was not released yet.
|
||
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
||
`crates/pdfs-fuse/src/lib.rs`; `has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`
|
||
|
||
**Repro:** Save a file in the mount and copy it, or its folder, with `pdfs copy` straight away.
|
||
The copy goes ahead and Drive copies the version before the save.
|
||
|
||
**Cause.** `close(2)` returns before the kernel sends `release`. `release` takes the write handle
|
||
out of the open writes and then queues the revision, which takes a few milliseconds. A copy
|
||
checked in between found the edit in neither place. A folder copy looked only at the queue, so
|
||
it also missed a file still open with unsaved writes.
|
||
|
||
**Fix.** `release` keeps the inode in `State::releasing` until the revision is queued. A copy
|
||
refuses a file or folder with unsaved writes, or a release still queueing, at it or below it.
|
||
A move checks the same set.
|
||
|
||
**Test:** The simulation above passed 6 of 6 runs after the fix.
|
||
|
||
---
|
||
|
||
### B255 — A synced folder whose files are all gone here is stuck for good
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
||
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
||
`crates/pdfs-fuse/src/devices.rs`
|
||
|
||
**Repro:** Sync a folder in mirror mode, then delete all of its files on this computer while
|
||
the folder is paused. Resume it. Every pass stops with `every one of the N synced paths is
|
||
missing locally`, the folder stays in error, and a switch to online only waits for good.
|
||
|
||
**Cause.** The wipe guard is right to stop a pass that would trash the whole folder on Drive.
|
||
But nothing got the folder out of that state. A switch to on-demand waits for a pass that ends
|
||
`idle`, and its push pass hit the same guard. The baseline that made the empty folder read as
|
||
"delete everything" could only be cleared by removing the folder from sync. How the reporter's
|
||
folder lost its files is not known yet.
|
||
|
||
**Fix.** The push pass for a switch to on-demand forgets the baseline rows of a local copy that
|
||
is gone in full, instead of trashing them on Drive, so the switch goes ahead and the mount shows
|
||
what Drive has. A switch asked for before any pass saw the folder empty found it still `idle`,
|
||
and the check for what only this computer has refused it at once: every path read as "deleted
|
||
here but not on Proton Drive yet". The account run found that. Such a switch now waits for the
|
||
same push pass (`mirror_tree_gone` in `crates/pdfs-fuse/src/sync.rs`, checked in
|
||
`apply_sync_folder_mode`). **Sync From Scratch…** in the app and `pdfs sync reset ID` clear a mirror
|
||
folder's baseline, so the next pass compares both sides as on the first sync: one-sided files
|
||
are copied across, files that differ keep both versions, and nothing is deleted. Restore now
|
||
logs why it skipped a folder.
|
||
|
||
**Test:** The simulations `a_folder_whose_local_copy_is_gone_still_goes_on_demand` (which
|
||
failed in exactly the reported state before the fix),
|
||
`a_folder_emptied_before_any_pass_still_goes_on_demand` (which failed with the account run's
|
||
error) and `a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset`; the acceptance case
|
||
"an emptied mirror folder switches to on-demand".
|
||
|
||
---
|
||
|
||
### B254 — An edit in the same second as a new upload never arrives
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, the B250 simulation failing about one run in five on CI.
|
||
**Where:** `unchanged_remote_size` in `crates/pdfs-fuse/src/sync/planner.rs`; the
|
||
`FilePlan::Unchanged` arm of `do_reconcile` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** A mirror pass uploads a new file, and Drive does not list it yet when the pass
|
||
reads it back. Another computer saves a new revision with the same modification time, to the
|
||
second. The mirror keeps the old content, and no pass ever downloads the new one.
|
||
|
||
**Cause.** The read-back found no node, so the baseline row got the local modification time
|
||
and size but no revision id. The next walk reused the recorded size whenever the modification
|
||
time matched and the revision id had not moved. A row without an id never counts as moved, so
|
||
the walk never read the new size, and the file looked unchanged.
|
||
|
||
**Fix.** A row without a revision id reuses its size only while Drive gives no id either.
|
||
Otherwise the walk reads the size for real. If the file is still unchanged, the pass writes
|
||
Drive's revision id into the row, so the cost is one extra read per row. Rows from before
|
||
schema 44 have no id either, so the first pass after upgrading reads every file's size once.
|
||
|
||
**Test:** `a_row_without_a_revision_makes_the_walk_read_the_size_again` in `pdfs-fuse`. The
|
||
simulation `a_revision_made_during_a_mirror_download_still_arrives` failed 3 of about 15 runs
|
||
before the fix and passed 25 of 25 after it.
|
||
|
||
---
|
||
|
||
### B253 — The app shows the sign-in page when the keyring is locked
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, user report: "the login each time you reboot or logout and back in".
|
||
**Where:** `build_window` in `crates/pdfs-gui/src/app/main.rs`; `wire_login` in
|
||
`crates/pdfs-gui/src/app/pages/login.rs`
|
||
|
||
**Repro:** Sign in, then start `pdfs-app` while the Secret Service is locked, not yet running,
|
||
or unreachable. The app shows the sign-in form as if nobody were signed in. The saved session
|
||
is still in the keyring, and the daemon waits for it.
|
||
|
||
**Cause.** The app read the session with `auth::load().ok()`, which turns every keyring error
|
||
into "signed out". Only `NotLoggedIn` means that. The tray already told the two apart.
|
||
|
||
**Fix.** `read_session` keeps `NotLoggedIn` apart from keyring errors. On an error the login
|
||
page shows a banner saying the keyring could not be read, with a Retry button. The app reads
|
||
the keyring again after 2, 5, 10 and 30 seconds and on Retry, and opens on Files once it
|
||
answers, without asking for a sign-in. A sign-in whose session cannot be read back gets the
|
||
same banner.
|
||
|
||
**Test:** None automated; the GTK window has no test harness. Checked by hand with
|
||
`DBUS_SESSION_BUS_ADDRESS` pointing nowhere: the banner shows over the form.
|
||
|
||
---
|
||
|
||
### B252 — Computers sharing a device overwrite each other's profile
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, design review for syncing several computers into one device.
|
||
**Where:** `save_profile` and `load_profiles` in `crates/pdfs-fuse/src/profile.rs`
|
||
|
||
**Repro:** Adopt one device on two computers and sync a different folder on each. The
|
||
device's `profile.json` lists only the folders of whichever computer saved last.
|
||
|
||
**Cause.** The profile was one file per device, written as a new revision by every computer
|
||
that synced into it, each with only its own folders.
|
||
|
||
**Fix.** Each machine writes `profile-<machine id>.json`. The id is random, made on first use
|
||
and kept in `machine-id` next to `config.json`, so nothing that identifies the host goes to
|
||
Drive. Restore reads every profile in the folder, including the shared `profile.json` of older
|
||
clients. A folder's proposal comes from this computer's own profile, otherwise from the newest
|
||
one that has the folder. The legacy file is left alone.
|
||
|
||
**Test:** `the_saved_folder_comes_from_this_computer_then_the_newest_profile` and
|
||
`a_profile_name_is_the_legacy_one_or_one_per_machine` in `pdfs-core`;
|
||
`each_machine_saves_its_own_profile` in `pdfs-fuse`.
|
||
|
||
---
|
||
|
||
### B251 — Two computers can make the same conflict copy name
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, design review for syncing several computers into one device.
|
||
**Where:** `conflict_path_with_suffix` in `crates/pdfs-fuse/src/sync/planner.rs`, `conflict_name`
|
||
in `crates/pdfs-fuse/src/lib.rs`
|
||
|
||
**Repro:** Two computers sync one folder and each keeps a conflict copy of the same file in the
|
||
same second. Both name it `notes (sync-conflict 1700000000).txt`, so the second upload meets
|
||
the first on Drive, and neither copy says which computer it came from.
|
||
|
||
**Cause.** The name held only the time. The `-<n>` suffix is made unique on the local disk, not
|
||
on Drive.
|
||
|
||
**Fix.** Conflict copies carry the hostname, cut down to one token of letters, digits, `-`, `_`
|
||
and `.`: `notes (sync-conflict laptop 1700000000).txt`. `conflict_base_name` reads both forms,
|
||
so the sweep and **Resolve** still find copies made before.
|
||
|
||
**Test:** `a_hostname_becomes_one_safe_token`, `conflict_name_keeps_the_extension`,
|
||
`conflict_path_keeps_extension_and_directory` and `parses_conflict_names_and_leaves_others_alone`.
|
||
|
||
---
|
||
|
||
### B250 — A revision made during a mirror download never arrives
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, design review for syncing several computers into one device.
|
||
**Where:** `download_file` and `record_file_baseline` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** Another computer saves a file in a mirrored folder while this computer downloads the
|
||
previous version. This computer keeps the previous version, and later passes do not fetch the
|
||
newer one.
|
||
|
||
**Cause.** After a download the baseline read the node again and recorded what Drive held then.
|
||
A revision committed while the download ran was recorded as synced, although the disk held the
|
||
older one.
|
||
|
||
**Fix.** The baseline records the revision the pass planned to download. If the download already
|
||
fetched the newer one, the next pass fetches it once more, which costs a transfer and loses
|
||
nothing. After an upload the read-back is still used, but only when Drive's digest is the one
|
||
just sent; otherwise the baseline is left as it was and the next pass keeps a conflict copy.
|
||
|
||
**Test:** `a_revision_made_during_a_mirror_download_still_arrives` (simulation, FUSE). It fails
|
||
with the old read-back.
|
||
|
||
---
|
||
|
||
### B249 — A mirror edit replaces a revision another computer made since
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, design review for syncing several computers into one device.
|
||
**Where:** `upload_revision` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** Edit a file in a mirrored folder while another computer saves its own edit of the
|
||
same file, after this computer's pass looked at Drive and before it uploads. This computer's
|
||
revision becomes the current one. The other edit survives only in the version history, and no
|
||
conflict copy is made.
|
||
|
||
**Cause.** The upload made a new revision of whatever Drive held at that moment, from a plan
|
||
that could be minutes old. Nothing compared Drive with the baseline before uploading. Change
|
||
detection used only the link's modification time and the size.
|
||
|
||
**Fix.** The baseline records the revision id it was synced at (schema 44). The upload reads
|
||
the node first and leaves a file whose revision moved for the next pass, which keeps the local
|
||
edit as a conflict copy. A new revision id counts as a remote change even at the same
|
||
modification time and size. A revision committed between the check and the upload's own draft
|
||
is not caught, because the SDK reads the current revision itself; the read-back after the upload
|
||
catches one committed after it.
|
||
|
||
**Test:** `a_mirror_edit_does_not_replace_a_revision_another_computer_made_since` (simulation,
|
||
FUSE), `a_new_remote_revision_is_a_change_even_at_the_same_mtime_and_size` and
|
||
`migration_v44_adds_the_synced_revision_without_disturbing_a_v43_baseline`.
|
||
|
||
---
|
||
|
||
### B248 — A joining mirror takes a same-size file for the same file
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, design review for syncing several computers into one device.
|
||
**Where:** `plan_file` in `crates/pdfs-fuse/src/sync/planner.rs`
|
||
|
||
**Repro:** Sync a local folder into a folder on Drive that already holds a file of the same name
|
||
and size but different content, for example on a second computer joining a folder. The first
|
||
pass records both as in step, and the two computers go on holding different files.
|
||
|
||
**Cause.** The first sync of a path present on both sides adopted the baseline whenever the sizes
|
||
matched, to avoid a conflict copy of every pre-existing file.
|
||
|
||
**Fix.** The adoption needs Drive's content digest, and the local file is hashed first. A match
|
||
is adopted; a different file, or one without a digest on Drive, is kept as a conflict copy.
|
||
|
||
**Test:** `a_joining_mirror_keeps_a_same_size_different_file_as_a_conflict` (simulation, FUSE)
|
||
and `first_sync_without_a_remote_digest_is_a_conflict`.
|
||
|
||
---
|
||
|
||
### B247 — Tray status lines are not translated
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, tray strings).
|
||
**Where:** `sync_line` in `crates/pdfs-gui/src/main.rs`
|
||
|
||
**Repro:** Run the tray in German or French while one file uploads. The tray's status line reads
|
||
`↑ report.pdf (45%)`, with the percent sign as English writes it.
|
||
|
||
**Cause.** Three status lines of the tray were built with `format!`, outside gettext, so no
|
||
language could adapt them.
|
||
|
||
**Fix.** They go through `gettext_f` and are translated in every catalog. German and French put
|
||
a space before the percent sign, as they already do for the storage quota.
|
||
|
||
**Test:** `msgfmt --check --statistics` on every catalog reports only translated messages.
|
||
|
||
---
|
||
|
||
### B246 — Signing in with a locked keyring freezes the app window
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.4).
|
||
**Where:** `sign_in` in `crates/pdfs-gui/src/app/pages/login.rs`
|
||
|
||
**Repro:** Sign in from the app while the system keyring is locked. The keyring asks for its
|
||
password, and the app window stops redrawing until that prompt is answered.
|
||
|
||
**Cause.** After a successful sign-in the app read the stored session back from the keyring on
|
||
the GTK main thread. A locked keyring answers that read only once it is unlocked.
|
||
|
||
**Fix.** The read runs in `gio::spawn_blocking`. The read at startup was left as it is: it runs
|
||
before any window exists, and the launch actions (`--page`, `--confirm-stop`) need the window
|
||
built in the same turn.
|
||
|
||
**Test:** none automated; the GUI tests run without a display.
|
||
|
||
---
|
||
|
||
### B245 — Any child of the daemon can answer the watchdog for it
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, systemd unit).
|
||
**Where:** `packaging/proton-drive.service`
|
||
|
||
**Repro:** A child of the daemon, such as `ffmpeg` making a video thumbnail, inherits
|
||
`NOTIFY_SOCKET` and can send `WATCHDOG=1` while the daemon itself is wedged.
|
||
|
||
**Cause.** The unit set `NotifyAccess=all`, which accepts notifications from every process of the
|
||
unit. The watchdog exists to restart a daemon that stopped answering, and any child could keep it
|
||
from doing that.
|
||
|
||
**Fix.** `NotifyAccess=main`: only the daemon's own process is heard. The rest of the hardening
|
||
the audit listed was left out on purpose. In a user unit every seccomp-based option implies
|
||
`NoNewPrivileges`, which stops the setuid `fusermount3` from mounting.
|
||
|
||
**Test:** none automated; `systemd-analyze verify --user` accepts the unit.
|
||
|
||
---
|
||
|
||
### B244 — Photos without a sidecar or a dated name are filed under the upload day
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.3).
|
||
**Where:** `crates/pdfs-core/src/exiftime.rs`; the `UploadPhoto` request in
|
||
`crates/pdfs-fuse/src/control.rs`; `Core::import_takeout` in `crates/pdfs-fuse/src/takeout.rs`;
|
||
the upload in `crates/pdfs-gui/src/app/pages/photos.rs`
|
||
|
||
**Repro:**
|
||
- Import a Takeout export in which a photo has no sidecar and no date in its name, such as
|
||
`DSC01234.JPG`. It lands in the timeline under the day of the import.
|
||
- Copy a photo from a camera card with a tool that does not keep modification times, and upload
|
||
it from the app's gallery. It lands under the day of the copy.
|
||
|
||
**Cause.** Nothing read a photo's own EXIF for its capture time. A Takeout photo fell back to its
|
||
sidecar, then to a date in its name, then to nothing, which Drive files as "now". The app sent
|
||
the file's modification time.
|
||
|
||
**Fix.** `exiftime::capture_time` reads `DateTimeOriginal`, then `DateTimeDigitized`, each moved
|
||
by its `OffsetTime…` tag when present and read as UTC otherwise, like a date in a file name. A
|
||
Takeout photo with no other date uses it when the photo was copied out of the archive for its
|
||
thumbnail (B236). The app no longer sends a time; the daemon dates the upload by EXIF, then by
|
||
the modification time.
|
||
|
||
**Test:** `exiftime::tests` (four cases, among them
|
||
`a_photo_is_dated_by_its_original_time_and_offset`).
|
||
|
||
---
|
||
|
||
### B243 — A hung mount freezes the app when a file is opened
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.3, and the unreaped child processes
|
||
in section 3).
|
||
**Where:** `present_within` in `crates/pdfs-gui/src/activation.rs`; `download_and_open` and
|
||
`open_entry_with` in `crates/pdfs-gui/src/app/pages/browser.rs`; `open_path` in
|
||
`crates/pdfs-gui/src/app/main.rs`; `reap` in `crates/pdfs-core/src/opener.rs`
|
||
|
||
**Repro:** Let the mount hang (a stalled link with a request stuck in the kernel), then
|
||
double-click a file in the app's browser, or open the Proton Drive location. Separately, open a
|
||
few files from the app and look for `<defunct>` children of `pdfs-app` in `ps`.
|
||
|
||
**Cause.** Opening a file asked the mount whether it had the file with a `stat` on the GTK main
|
||
thread, and opening a location asked whether it was a folder the same way. A hung mount never
|
||
answers, so the whole app hung with it. The search launcher already asked on a worker with a
|
||
2 s limit. Separately, nothing waited for the applications the app, the tray and the player
|
||
button started, so each one stayed a zombie until the app quit.
|
||
|
||
**Fix.** The browser's Open and Open With ask through `present_within`, which the launcher now
|
||
shares: a thread of its own, given up on after 2 s, with the daemon as the fallback. Opening a
|
||
location runs on a thread. Every child the GUI starts is waited for on a thread of its own.
|
||
|
||
**Test:** `opener::tests::a_reaped_child_does_not_stay_a_zombie`; the mount checks have no
|
||
automated test, as the GUI tests run without a display.
|
||
|
||
---
|
||
|
||
### B242 — Opening a large photo freezes the app
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.1).
|
||
**Where:** `load_photo` and `show_info` in `crates/pdfs-gui/src/app/pages/photo_viewer.rs`
|
||
|
||
**Repro:** Open a 50-megapixel photo in the viewer and press → repeatedly while each one loads.
|
||
|
||
**Cause.** When the full-size file arrived, the viewer decoded it with
|
||
`Texture::from_filename`, read its EXIF and stat'ed it on the GTK main thread. For a large photo
|
||
that took a good part of a second each time, and the whole app froze for it: no redraw, no
|
||
input, no other page.
|
||
|
||
**Fix.** The decode, the EXIF read and the stat run on a worker (`gio::spawn_blocking`). The
|
||
result is used only if the viewer still shows that photo; a reply for one the user has left is
|
||
dropped.
|
||
|
||
**Test:** none automated; the GUI tests run without a display.
|
||
|
||
---
|
||
|
||
### B241 — Every closed photo viewer stays in memory with its photo
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.5.2).
|
||
**Where:** `open_photo_viewer` in `crates/pdfs-gui/src/app/pages/photo_viewer.rs`;
|
||
`repaint_share_people` and `repaint_share_link` in
|
||
`crates/pdfs-gui/src/app/widgets/share_dialog.rs`
|
||
|
||
**Repro:** Open and close the photo viewer twenty times on large photos, and watch the app's
|
||
memory. Or open and close the Share dialog of a shared folder many times.
|
||
|
||
**Cause.** Every handler of the viewer held a strong `Rc<Viewer>`, and the viewer holds the
|
||
widgets those handlers belong to. Several handlers also held the window, which owns them. Each
|
||
pair was a reference cycle, so a closed viewer was never freed: not its window, and not the
|
||
full-size photo its picture showed. The Share dialog had the same cycle between its state and
|
||
the handlers of the rows inside the groups that state holds.
|
||
|
||
**Fix.** The handlers hold weak handles to the viewer and its window. The one strong handle sits
|
||
in the window's close handler, which drops it on close and clears the picture. The Share
|
||
dialog's row handlers hold the state weakly too.
|
||
|
||
**Test:** none automated; the GUI tests run without a display. Checked by reading every handler
|
||
`open_photo_viewer` and the share rows connect.
|
||
|
||
---
|
||
|
||
### B240 — pdfs mount can be core-dumped with the keys in memory
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, core dumps and the
|
||
file-manager script).
|
||
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `script_body` in
|
||
`crates/pdfs-core/src/shell.rs`
|
||
|
||
**Repro:**
|
||
- Run `pdfs mount` and send it `SIGSEGV`, with core dumps enabled. The dump holds the key
|
||
passphrases.
|
||
- In the mount, name a file `--json` and choose **Scripts → Keep offline** on it in Nautilus.
|
||
|
||
**Cause.** Only `pdfs daemon` marked itself non-dumpable. `pdfs mount`, `login`, `unlock` and
|
||
`logout` hold the password, the key passphrases or the session tokens too, and could be
|
||
core-dumped or ptraced by another process of the same user. Separately, the file-manager script
|
||
ran `pdfs pin "$path"`, so a file named like an option was read as one.
|
||
|
||
**Fix.** Those five commands mark themselves non-dumpable at start. The script passes each path
|
||
after `--`. A name with a newline still arrives split from Nautilus, whose list is
|
||
newline-separated; `pdfs` then fails on both halves and changes nothing.
|
||
|
||
The app's own login was left as it is: a non-dumpable GUI process hides its `/proc` entries from
|
||
the desktop portal, which identifies the caller through them.
|
||
|
||
**Test:** `tests::a_path_after_a_double_dash_is_a_path_even_when_it_looks_like_a_flag` in
|
||
`crates/pdfs-cli/src/main.rs`; `shell::tests::the_script_ends_options_before_each_path`.
|
||
|
||
---
|
||
|
||
### B239 — --wait reports success for imports and re-dates that failed
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, CLI).
|
||
**Where:** `finished_run`, `cmd_import_google_photos` and `cmd_redate_photos` in
|
||
`crates/pdfs-cli/src/main.rs`; the run threads in `crates/pdfs-fuse/src/takeout.rs` and
|
||
`redate.rs`; `ImportSummary::error` and `RedateSummary::error` in `crates/pdfs-core/src/control.rs`;
|
||
`refresh_takeout` in `crates/pdfs-gui/src/app/pages/takeout.rs`
|
||
|
||
**Repro:** `pdfs import-google-photos --wait notes.txt; echo $?`, or an import where some photos
|
||
fail to upload, or one cancelled from the app.
|
||
|
||
**Cause.** `--wait` exited 0 however the run ended. A run that failed as a whole (an archive that
|
||
is not a zip) left no summary at all, so the CLI printed "No import has run yet" and the app
|
||
announced "Import finished". A run with failed photos, or a cancelled one, also exited 0. A
|
||
script could not tell any of these from a clean run.
|
||
|
||
**Fix.** A run that fails as a whole now leaves a summary with the reason. `--wait` exits 1 when
|
||
the run failed, was cancelled, left photos failed, or ended without a result, and says which.
|
||
The app shows the reason instead of "Import finished".
|
||
|
||
**Test:** `tests::a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error` in
|
||
`crates/pdfs-cli/src/main.rs`.
|
||
|
||
---
|
||
|
||
### B238 — Draining a large offline batch is quadratic in its size
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.2.5).
|
||
**Where:** `MIGRATION_V43` in `crates/pdfs-core/src/db/migrations.rs`; the queries in
|
||
`has_pending_trash_named`, `has_withdrawn_create`, `has_pending_move_from` and
|
||
`wake_ops_waiting_for_tx` in `crates/pdfs-core/src/db/ops.rs`
|
||
|
||
**Repro:** Offline, copy a folder of 20,000 small files into the mount, then go online and watch
|
||
the queue drain.
|
||
|
||
**Cause.** Before each create, rename and trash, the drain asks whether a queued trash or rename
|
||
holds the name it wants, and a name that is let go wakes the ops waiting for it. Nothing indexed
|
||
the queue by name, so each of those questions read the whole queue. Draining a large offline
|
||
batch therefore took time quadratic in its size, under the database's write lock for the wake.
|
||
|
||
**Fix.** Schema 43 adds an index on `pending_op(kind, name)`. The kind leads, so the lookup of
|
||
every queued rename uses it too. The `json_extract` on a rename's original name still reads the
|
||
renames, which are few.
|
||
|
||
**Test:** `db::tests::queue_name_checks_use_an_index`, which checks the query plans.
|
||
|
||
---
|
||
|
||
### B237 — Every reconnect resends refused uploads, all in the same second
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.3.3).
|
||
**Where:** `drain_backoff` in `crates/pdfs-fuse/src/drain.rs`; the reconnect in
|
||
`crates/pdfs-fuse/src/link.rs`; `Db::retry_unrefused_ops_now` in
|
||
`crates/pdfs-core/src/db/ops.rs`
|
||
|
||
**Repro:** Fill the Drive quota and queue a few hundred files, then let the link drop and come
|
||
back a few times.
|
||
|
||
**Cause.** Three things together:
|
||
- Every reconnect made every failed op due at once, the ones Drive refused for quota or access
|
||
included. Each reconnect then sent the whole refused queue again.
|
||
- All ops that failed together were retried together: the backoff had no spread.
|
||
- The doubling stopped at 2^6, so the wait never grew past 128 s, and the 300 s maximum was never
|
||
reached.
|
||
|
||
**Fix.** A reconnect makes due only the ops without a recorded refusal. "Retry all" still moves
|
||
every op. The backoff doubles up to the 300 s maximum, plus up to a fifth more, which differs
|
||
from op to op.
|
||
|
||
**Test:** `db::tests::a_reconnect_leaves_refused_ops_to_their_backoff`;
|
||
`drain::tests::a_failing_op_backs_off_to_the_maximum_with_a_spread`.
|
||
|
||
---
|
||
|
||
### B236 — Imported, re-dated and app-uploaded photos have no thumbnail
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.2).
|
||
**Where:** `Core::import_takeout` and `stage_takeout_entry` in `crates/pdfs-fuse/src/takeout.rs`;
|
||
`Core::redate_one` in `redate.rs`; the `UploadPhoto` request in `control.rs`;
|
||
`can_make_thumbnail` in `photos.rs`
|
||
|
||
**Repro:** Import a Google Takeout export, re-date a photo, or upload a photo from the app's
|
||
gallery. Look at the new photos in Proton's web or phone app.
|
||
|
||
**Cause.** All three paths uploaded with an empty thumbnail list. The drain and the mirror sync
|
||
make thumbnails for what they upload, but these three did not, so the photos showed as blank
|
||
tiles in Proton's apps. The 3.0 changelog says imports carry thumbnails.
|
||
|
||
**Fix.** The re-date and the app's upload make thumbnails from the file they upload. A Takeout
|
||
photo that can have a thumbnail (an image small enough to decode, or a video while ffmpeg is
|
||
installed) is copied out of the archive into the private staging folder first. The thumbnail is
|
||
made from the copy, the upload reads the copy, and the copy is removed. When the copy fails, the
|
||
photo is uploaded from the archive without a thumbnail, as before.
|
||
|
||
**Test:** `photos::thumb_tests::only_a_photo_that_can_have_a_thumbnail_is_worth_copying_out`; the
|
||
thumbnail itself is covered by the existing `thumb_tests`.
|
||
|
||
---
|
||
|
||
### B235 — Scanning a large Takeout folder takes half a minute
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.1).
|
||
**Where:** `SidecarIndex` and `find_sidecar` in `crates/pdfs-core/src/takeout.rs`
|
||
|
||
**Repro:** Scan a Google Takeout export with 10,000 photos in one folder, as "Photos from 2019"
|
||
folders often are.
|
||
|
||
**Cause.** Each photo was ranked against every sidecar of its folder, so the scan grew with the
|
||
square of the folder size: 2,000 photos took 0.8 s, 10,000 took 27–32 s before the import
|
||
showed anything.
|
||
|
||
**Fix.** The sidecars of a folder are kept in a sorted map by stem. Every matching rule needs the
|
||
stem to start with a prefix that the photo name determines, so each photo is ranked only against
|
||
the stems found by a few range lookups. On the same 10,000 photos the scan takes 0.1 s, with the
|
||
same matches: ties still go to the first sidecar by name.
|
||
|
||
**Test:** `takeout::tests::the_sidecar_index_finds_what_ranking_every_sidecar_finds`, which
|
||
compares the index with ranking every sidecar, across every rule.
|
||
|
||
---
|
||
|
||
### B234 — Any folder can become a sync root, the home folder included
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.12).
|
||
**Where:** `crates/pdfs-core/src/syncroot.rs`; `Core::add_sync_folder` and `sync_root_reserved` in
|
||
`crates/pdfs-fuse/src/devices.rs`; `Core::restore_sync_folders` and the restore proposals in
|
||
`crates/pdfs-fuse/src/profile.rs`
|
||
|
||
**Repro:** `pdfs sync add ~`, or `pdfs sync add /`. Or restore a profile whose recorded path is
|
||
`/home/u/../..`, or whose folder on Drive has a name with a `/`.
|
||
|
||
**Cause.** A new sync root was only checked for being a directory and not already synced. A
|
||
restore checked only that the path was absolute. Nothing refused `/`, the home folder, a parent
|
||
of either, the Proton Drive mount, the app's own state, cache and config folders, or a root
|
||
inside or around another root. Adding the home folder uploads the decrypted content cache,
|
||
`cache.db` and `~/.ssh`, and walks into the mount, which downloads the Drive only to upload it
|
||
again. A restore proposal joined a folder name from Drive onto the home folder as it was.
|
||
|
||
**Fix.** One check, `syncroot::problem`, runs wherever a root is added or restored. It refuses
|
||
the cases above. Paths are compared after `syncroot::resolve`, which follows symlinks as far as
|
||
the path exists and refuses `..`. Restore proposals leave out folders whose name could not be a
|
||
local file name.
|
||
|
||
The audit also suggested not crossing into other file systems during the local walk. That was
|
||
left out on purpose: a btrfs subvolume inside a sync root has a device id of its own, and would
|
||
silently stop syncing.
|
||
|
||
**Test:** `syncroot::tests` (six cases, among them
|
||
`the_file_system_root_home_and_its_parents_are_refused` and
|
||
`a_root_inside_or_around_another_root_is_refused`).
|
||
|
||
---
|
||
|
||
### B233 — Signing out leaves the session valid on Proton
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, logout).
|
||
**Where:** `logout` and `end_server_session` in `crates/pdfs-core/src/auth.rs`; `cmd_logout` in
|
||
`crates/pdfs-cli/src/main.rs`
|
||
|
||
**Repro:** Copy the keyring entry, run `pdfs logout` (or **Sign Out** in the app), and resume a
|
||
session from the copy.
|
||
|
||
**Cause.** Signing out only deleted the keyring entry and the local caches. The session was never
|
||
ended on Proton's side, so its tokens stayed valid until they expired. The SDK has the call for
|
||
it (`ProtonApiSession::end`, `DELETE auth/v4`); nothing used it.
|
||
|
||
**Fix.** Logout resumes the stored session and ends it on Proton before it clears anything
|
||
locally. The call is bounded to 10 s and runs on a thread of its own. When Proton cannot be
|
||
reached, the local sign-out still happens, and `pdfs logout` says that the session stays valid
|
||
until it expires or is signed out in the account's security settings.
|
||
|
||
**Test:** none automated; ending a session needs a live account.
|
||
|
||
---
|
||
|
||
### B232 — Names with & or < show as empty rows or are drawn as markup
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, Pango markup injection).
|
||
**Where:** the toast helpers in `crates/pdfs-gui/src/app/main.rs`; rows in
|
||
`app/pages/shared.rs`, `devices.rs`, `status.rs`, `takeout.rs`, `photo_viewer.rs`,
|
||
`app/widgets/share_dialog.rs`, `details.rs`, `versions_dialog.rs`; every status page helper
|
||
|
||
**Repro:** Accept an invitation to a folder named `R&D`, or name a computer `Tom & Jerry`.
|
||
Its row in the app is empty. A name such as `<span size="80000">x</span>` is drawn as markup.
|
||
|
||
**Cause.** Libadwaita rows, toasts and status page descriptions read their text as Pango markup
|
||
unless told otherwise. Names that other people control (shared items, inviter addresses, device
|
||
names, link URLs), file paths, EXIF values and daemon error messages went in unescaped. Text that
|
||
did not parse showed as an empty row; text that did was drawn as markup.
|
||
|
||
**Fix.** Rows and toasts that show such text are built with `use_markup(false)`. Status page
|
||
descriptions, which have no such switch, are escaped with `markup_escape_text`.
|
||
|
||
**Test:** none automated; the change only sets a widget property. Checked by reading every row,
|
||
toast and status page that takes text from outside the program.
|
||
|
||
---
|
||
|
||
### B231 — A large upload under a low upload limit never finishes
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.9).
|
||
**Where:** `upload_deadline` in `crates/pdfs-fuse/src/link.rs`; `TransferRegistry::upload_cap` in
|
||
`crates/pdfs-fuse/src/transfers.rs`; the three uploads in `crates/pdfs-fuse/src/drain.rs`
|
||
|
||
**Repro:** Set the upload limit to 512 KiB/s or less, and queue several large files.
|
||
|
||
**Cause.** An upload's deadline allowed for at least 32 KiB/s. The upload cap is one budget
|
||
shared by every running upload: up to 16 drain workers and 8 mirror sync tasks. Under a low cap
|
||
each upload got less than 32 KiB/s, so a large file ran out of time on every attempt. The
|
||
timeout took the mount offline, and on reconnect the upload started again from its first byte.
|
||
|
||
**Fix.** The deadline counts an upload's share of the cap as the slowest rate when that share is
|
||
below 32 KiB/s. Without a cap, or with a generous one, the deadline is unchanged.
|
||
|
||
**Test:** `link::tests::an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs`.
|
||
|
||
---
|
||
|
||
### B230 — A create queued offline adopts another device's file of the same name
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.14).
|
||
**Where:** `adoptable` and `Core::first_sent` in `crates/pdfs-fuse/src/drain.rs`;
|
||
`note_create_target`, `create_first_sent` and `parse_sent_to` in
|
||
`crates/pdfs-core/src/db/ops.rs`
|
||
|
||
**Repro:** Offline, create an empty file `notes.txt` in the mount. Before this machine goes
|
||
online again, create an empty `notes.txt` in the same folder on another device. Go online.
|
||
|
||
**Cause.** A queued create that finds its name taken may adopt the node there, when that node is
|
||
the one an earlier attempt made and lost the answer to (B127, B184). One condition is that the
|
||
node was made around the time of the op. That time was when the op was queued, so an op queued
|
||
offline for days accepted any empty node of the same name made since, including another
|
||
device's file or folder. A folder adopted that way merged both devices' contents.
|
||
|
||
Separately, a `sent_to` record that did not parse was read as empty, and the next target
|
||
replaced it. That erased the earlier targets the B184 and B191 checks look for.
|
||
|
||
**Fix.** Each target in `sent_to` now records when it was first sent, and the adoption window
|
||
starts at the op's first send. Records written before this change fall back to the queue time.
|
||
A record that does not parse is kept as it is and a warning is logged. Without it a lost answer
|
||
forks a conflict copy, which loses nothing.
|
||
|
||
**Test:** `db::tests::a_create_remembers_when_it_was_first_sent`;
|
||
`drain::tests::only_a_fresh_empty_twin_of_the_same_kind_is_adopted`.
|
||
|
||
---
|
||
|
||
### B229 — A queued write can be lost to a power cut after close returned
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.13).
|
||
**Where:** `Durable` and `Db::durable` in `crates/pdfs-core/src/db/mod.rs`; `enqueue_op`,
|
||
`replace_ops_with_trash`, `attach_blob_to_create`, `finish_create` and `rewrite_op_target` in
|
||
`crates/pdfs-core/src/db/ops.rs`
|
||
|
||
**Repro:** Write a file into the mount, let `close()` return, and cut the power before the next
|
||
WAL checkpoint.
|
||
|
||
**Cause.** The write connection runs with `synchronous=NORMAL`. In WAL mode a commit then
|
||
survives a crash of the process but not a power loss, because the WAL is synced only at
|
||
checkpoints. The staged blob is synced to disk before its op is queued, but the `pending_op` row
|
||
that makes it reachable could roll back. The write `close()` reported as done was then gone, and
|
||
the blob was deleted as an orphan later.
|
||
|
||
**Fix.** The commits that accept a change into the queue raise the writer to
|
||
`synchronous=FULL` for themselves and set it back to NORMAL after. A FULL commit syncs the WAL,
|
||
which covers the NORMAL commits before it too. The other writes keep NORMAL's cost.
|
||
|
||
**Test:** `db::tests::queueing_a_write_commits_at_full_sync_and_restores_normal`.
|
||
|
||
---
|
||
|
||
### B228 — A gap-filled write whose upload answer was lost becomes a conflict copy of itself
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.8).
|
||
**Where:** `Core::drain_revision` in `crates/pdfs-fuse/src/drain.rs`; `Db::update_op_meta_for_blob`
|
||
in `crates/pdfs-core/src/db/ops.rs`
|
||
|
||
**Repro:** Offline, write a few bytes into the middle of a large file that is only on Drive, and
|
||
close it. Go online, and lose the answer to the upload that follows (a timeout, or a daemon
|
||
stopped before the op is retired).
|
||
|
||
**Cause.** The staged blob of such a write holds only the written ranges. The drain fills the
|
||
gaps from the base before the upload, in place, but the op's sidecar still said
|
||
`complete = false`. The retry then saw a new remote revision, and both checks that recognise
|
||
the daemon's own lost upload (`is_own_self_supersede` and the B155 SHA-1 match) require a
|
||
complete blob. The write was treated as a conflict and uploaded again as a
|
||
`(sync-conflict)` copy of itself.
|
||
|
||
**Fix.** After a successful gap-fill the drain records the sidecar as complete. The update is
|
||
made only while the op still holds that blob, so a newer write that closed in the meantime keeps
|
||
its own sidecar.
|
||
|
||
**Test:** `db::tests::marking_a_blob_whole_leaves_a_newer_blob_alone`.
|
||
|
||
---
|
||
|
||
### B227 — Reading a file open for writing downloads on the dispatch loop
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.2).
|
||
**Where:** `read` in `crates/pdfs-fuse/src/filesystem.rs`; `Core::serve_open_read_local` and
|
||
`assemble_open_read` in `crates/pdfs-fuse/src/reads.rs`
|
||
|
||
**Repro:** On a slow link, open a large file that is only on Drive with `O_RDWR`, write a few
|
||
bytes, and read elsewhere in it, as SQLite and many editors do, while another program uses the
|
||
mount.
|
||
|
||
**Cause.** A read on a file open for writing was always answered on the dispatch loop, so that it
|
||
kept its order with the writes on the same handle. When the bytes it asked for were neither
|
||
written nor cached, it downloaded them there, for up to the 120 s read deadline plus the wait
|
||
for a connection. Every other request of the mount waited behind it.
|
||
|
||
**Fix.** The dispatch loop answers such a read from the scratch file and the local caches only.
|
||
When a range of the base would have to be downloaded, the read goes to a transfer worker, with
|
||
the handle as it was when the read came in. The kernel issued the read before any later write,
|
||
so that is the content it asked for.
|
||
|
||
**Test:** `reads::open_read_tests::a_read_needing_the_base_says_so_instead_of_fetching`; the
|
||
simulation runs cover the order of writes and reads on one handle.
|
||
|
||
---
|
||
|
||
### B226 — Thumbnails of portrait phone photos lie on their side
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.4).
|
||
**Where:** `decode_upright` and `scale_thumbnail_with_exiftool` in
|
||
`crates/pdfs-fuse/src/photos.rs`
|
||
|
||
**Repro:** Upload a portrait photo taken with a phone that stores landscape pixels plus an EXIF
|
||
orientation tag, which most do. Its tile in the app, and in Proton's own apps, lies on its side.
|
||
|
||
**Cause.** Generated thumbnails were decoded with `ImageReader::decode`, which ignores the EXIF
|
||
orientation. Only the RAW preview path applied it. The drain uploads the generated thumbnail with
|
||
the photo, so every client showed it sideways.
|
||
|
||
**Fix.** `decode_upright` reads the orientation from the decoder and applies it before the image
|
||
is scaled.
|
||
|
||
**Test:** `photos::tests::a_thumbnail_is_turned_the_way_its_exif_says`.
|
||
|
||
---
|
||
|
||
### B225 — A Takeout photo without a sidecar takes a sibling's name and date
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.11). A scratch import through
|
||
`scan_archives` gave `IMG_12.jpg`, which had no sidecar, the name `IMG_1.jpg` and IMG_1's capture
|
||
time. It gave `IMG_2-edited.jpg` the title of its original.
|
||
**Where:** `sidecar_rank`, `find_sidecar` and `is_edited_copy_of` in
|
||
`crates/pdfs-core/src/takeout.rs`
|
||
|
||
**Repro:** Import a Takeout folder holding `IMG_1.jpg` with its sidecar and `IMG_12.jpg` without
|
||
one.
|
||
|
||
**Cause.** The last matching rule accepted any sidecar whose stem, cut at a `.`, was a prefix of
|
||
the photo's name. That rule exists for sidecar names Google cut at its 51-character cap, but it
|
||
also matched short names that had not been cut, so `IMG_1`'s sidecar claimed `IMG_12.jpg`. The
|
||
photo then went up under another photo's title and date, and two photos ended up with the same
|
||
name. Edited copies, which Google exports without a sidecar of their own, matched the same way
|
||
and took their original's title.
|
||
|
||
**Fix.** The prefix rule applies only to a sidecar name that reaches the cap. An edited copy,
|
||
`<original>-<word>.<ext>` with the word in any language, is matched to its original's sidecar
|
||
by its own rule. It takes the capture time from that sidecar and keeps its own name.
|
||
|
||
**Test:** `takeout::tests::a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings`,
|
||
`takeout::tests::an_edited_copy_takes_its_originals_date_and_keeps_its_name`,
|
||
`takeout::tests::longest_prefix_wins_when_the_photo_name_itself_was_truncated`.
|
||
|
||
---
|
||
|
||
### B224 — `fallocate` zero ranges are undone and collapse ranges corrupt the file
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, FUSE semantics).
|
||
**Where:** `fallocate`, `falloc_kind` and `FallocKind::effect` in
|
||
`crates/pdfs-fuse/src/filesystem.rs`
|
||
|
||
**Repro:** `fallocate --zero-range -o 0 -l 4096 FILE` on a file in the mount whose content is on
|
||
Drive, then close it: the range comes back with its old bytes. `fallocate --collapse-range` on the
|
||
same file corrupts it.
|
||
|
||
**Cause.** The handler passed any mode to the scratch file and recorded only a punched hole or
|
||
growth. A zeroed range stayed "not written", so the commit filled it in again from the remote
|
||
base. A collapse or an insert moved bytes in the scratch file that `len` and `written` did not
|
||
follow. Every failure came back as `EIO`, so glibc's `posix_fallocate` did not fall back to
|
||
writing zeros, and `ENOSPC` was lost. The syscall also ran under the global state lock.
|
||
|
||
**Fix.** Only plain allocation, `KEEP_SIZE`, `PUNCH_HOLE | KEEP_SIZE` and `ZERO_RANGE` with or
|
||
without `KEEP_SIZE` are accepted; anything else gets `EOPNOTSUPP`. A zeroed or punched range
|
||
counts as written, and growth updates the length. The syscall's own errno is passed through, and
|
||
the lock is not held across it.
|
||
|
||
**Test:** `filesystem::falloc_tests::only_the_modes_a_handle_can_record_are_accepted`,
|
||
`filesystem::falloc_tests::a_zeroed_or_punched_range_counts_as_written`.
|
||
|
||
---
|
||
|
||
### B223 — A damaged config file is replaced with defaults on the next settings change
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.10).
|
||
**Where:** `AppDirs::save_config`, `AppDirs::load_config` and `refuse_to_replace_unreadable` in
|
||
`crates/pdfs-core/src/config.rs`
|
||
|
||
**Repro:** Truncate `~/.config/proton-drive-linux/config.json`, then change any setting in the
|
||
app, or let the daemon record its device.
|
||
|
||
**Cause.** `load_config` gives defaults for a file it cannot parse, and logs that it leaves the
|
||
file alone. But every setter loads, changes one field and saves, in the daemon and in the app.
|
||
So the first change after the damage wrote the defaults over the file, losing the mountpoint,
|
||
the device the client had adopted, the ignore rules and the `open_with` choices. ARCHITECTURE
|
||
§10.5 says the file is never replaced with defaults. The first-run default was also written with a
|
||
plain `std::fs::write`, which is not atomic and takes the umask's mode.
|
||
|
||
**Fix.** `save_config` refuses while the file on disk exists and does not parse, and says which
|
||
file to fix. The first-run default goes through `save_config`, so it is written atomically with
|
||
mode 0600.
|
||
|
||
**Test:** `config::tests::an_unreadable_config_is_never_written_over`.
|
||
|
||
---
|
||
|
||
### B222 — A full disk at close is reported to nobody, and `fsync` on a read-only file fails
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.5 and section 3, FUSE semantics).
|
||
**Where:** `flush`, `serve_fsync` and `scratch_sync_errno` in `crates/pdfs-fuse/src/filesystem.rs`
|
||
|
||
**Repro:** Fill the disk that holds the cache, then save a file in the mount: the program reports
|
||
success. Separately, `python3 -c 'import os; os.fsync(os.open("FILE", os.O_RDONLY))'` on a file
|
||
in the mount fails with `EBADF`.
|
||
|
||
**Cause.**
|
||
- `flush` answered `ok` at once, and `release` synced the scratch file and queued it. `close(2)`
|
||
returns what `flush` answers; the kernel drops the answer to `release`. Delayed allocation
|
||
usually holds `ENOSPC` back until the sync, so a full disk was reported to nobody.
|
||
- The same sync in `release` wrote back the whole file on the dispatch loop, so copying a large
|
||
file stalled every other request on the mount.
|
||
- A descriptor opened for reading has no write handle, and `fsync` answered it with `EBADF`,
|
||
which POSIX does not allow.
|
||
|
||
**Fix.** `flush` syncs the scratch file of a handle with writes on it, on a transfer worker, and
|
||
returns `ENOSPC`, `EDQUOT` or `EIO` when that fails. `release` then finds the pages already on
|
||
disk. `fsync` on a descriptor without a write handle succeeds.
|
||
|
||
**Test:** `sim::daemon::tests::close_syncs_a_written_file_and_fsync_accepts_a_read_only_one`.
|
||
|
||
---
|
||
|
||
### B221 — A cold listing holds the global lock across a database transaction
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.4).
|
||
**Where:** `Core::list_children` in `crates/pdfs-fuse/src/lib.rs`; `State::intern_batch_with_inos`,
|
||
`inos_for` and `State::record_listed` in `crates/pdfs-fuse/src/state.rs`; `serve_mkdir` in
|
||
`crates/pdfs-fuse/src/filesystem.rs`
|
||
|
||
**Repro:** List a large folder for the first time while another program stats files elsewhere in
|
||
the mount, and while the drain is busy.
|
||
|
||
**Cause.** A new node's inode comes from its row's local id, which `Db::lids_for` allocates in a
|
||
write transaction. A cold listing ran that transaction for every new child while it held the
|
||
global state lock, and then marked the folder listed, also under the lock. Every inline handler
|
||
of every mount waits on that lock, and the transaction itself waits on the single write
|
||
connection that the drain and the sync engine also use. That went against the `StateGuard`
|
||
outbox, which exists so that the lock is released before the writes are done. Marking the folder
|
||
listed before its children's rows were written also meant a crash in between left a folder
|
||
recorded as complete with no children.
|
||
|
||
**Fix.** `inos_for` allocates the listing's ids before the lock is taken, and
|
||
`intern_batch_with_inos` uses them. Marking a folder listed goes through the outbox with
|
||
`record_listed`, after the children's rows.
|
||
|
||
**Test:** `state::tests::inodes_allocated_before_the_lock_are_the_ones_the_node_keeps`,
|
||
`state::tests::a_listing_is_recorded_complete_after_its_children`.
|
||
|
||
---
|
||
|
||
### B220 — Thumbnails on a stalled link hold up every other request
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.3 and 2.1.11).
|
||
**Where:** `Core::thumbnail`, `Core::run_size_upgrade` and `Core::ensure_shared_children` in
|
||
`crates/pdfs-fuse/src/lib.rs`
|
||
|
||
**Repro:** Open a folder of photos in a file manager while the link stalls.
|
||
|
||
**Cause.** A file manager asks for a thumbnail per file, through `getxattr` on the metadata
|
||
workers. The download had no deadline of its own, so on a stalled link each one held a worker for
|
||
the SDK's whole retry budget, about two minutes. Only three workers are kept for metadata, so
|
||
every cold `lookup`, `create` and `rename` on the mount queued behind them. Offline, every
|
||
uncached thumbnail still went to the network. The size upgrade and the Shared with me listing
|
||
had the same unbounded calls.
|
||
|
||
**Fix.** Offline, a thumbnail that is not cached is answered as absent. Online, its download
|
||
gives up after `INTERACTIVE_CALL_TIMEOUT` (20 s). Each chunk of a size upgrade and the two calls
|
||
behind the Shared with me listing give up after `BATCH_CALL_TIMEOUT` (60 s).
|
||
|
||
**Test:** `tests::a_handler_does_not_wait_out_the_sdk_retry_budget`.
|
||
|
||
---
|
||
|
||
### B219 — Listing a shared folder freezes the whole mount on a slow link
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.1.1).
|
||
**Where:** `Core::children_cached` in `crates/pdfs-fuse/src/lib.rs`; `lookup`, `opendir` and
|
||
`readdir` in `crates/pdfs-fuse/src/filesystem.rs`
|
||
|
||
**Repro:** Open a folder shared with you, wait more than a minute, then `ls` it on a slow link
|
||
while another program reads files elsewhere on the mount.
|
||
|
||
**Cause.** A handler answered inline whenever the folder's listing was resident, and handed off
|
||
to a worker only otherwise. But `list_children` fetches a shared folder again once its listing is
|
||
a minute old, and the Shared with me root likewise. That fetch ran on the dispatch loop, the
|
||
single thread that reads every request of the mount, so all of them waited for Drive.
|
||
|
||
**Fix.** `children_cached` asks the same question `list_children` does: a shared listing that
|
||
would be fetched again counts as not cached, so the request goes to a metadata worker.
|
||
|
||
**Test:** `tests::a_stale_shared_listing_is_fetched_off_the_dispatch_loop`.
|
||
|
||
---
|
||
|
||
### B218 — `pdfs diagnose` freezes the mount while it checks the database
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.2.2). `PRAGMA integrity_check` took
|
||
33.9 s on a copy of a live 567 MiB `cache.db`.
|
||
**Where:** `Db::integrity_check` and `Db::stats` in `crates/pdfs-core/src/db/maintenance.rs`
|
||
|
||
**Repro:** Run `pdfs diagnose` or `pdfs cache inspect` on a large database while copying files
|
||
into the mount.
|
||
|
||
**Cause.** Both read the whole file through the single write connection. Every write in the
|
||
daemon took that connection's mutex in the meantime: the write-through of the tree, the drain's
|
||
claims, every enqueue. So the mount looked hung for as long as the check ran.
|
||
|
||
**Fix.** Both run on a pooled read connection. WAL lets them read a consistent snapshot while the
|
||
writer goes on. `VACUUM` still needs the write connection and remains a deliberate command.
|
||
|
||
**Test:** `db::maintenance::tests::the_integrity_check_and_stats_do_not_wait_for_the_writer`.
|
||
|
||
---
|
||
|
||
### B217 — The search indexes grow to many times their size and slow search down
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.2.1 and 2.2.3). On a live 567 MiB
|
||
`cache.db`, `local_fts` held 317 MiB for 40,000 indexed paths. Rebuilt, it was 20 MiB, and a
|
||
search candidate query went from 27 ms to 7 ms. `nodes_fts` shrank from 21 to 16 MiB.
|
||
**Where:** `Db::local_finish_scan`, `Db::compact_search_indexes_if_due` in
|
||
`crates/pdfs-core/src/db/local.rs`; `index_local_files` in `crates/pdfs-fuse/src/background.rs`
|
||
|
||
**Repro:** Run the daemon for weeks with a home directory where files come and go, then compare
|
||
the size of `local_fts_data` with what a `rebuild` leaves.
|
||
|
||
**Cause.** FTS5 keeps a deleted entry's data until the segments holding it are merged, and with
|
||
the few large segments these indexes settle into, that is close to never. The client never ran
|
||
`optimize` or `rebuild`. The local scan made it worse: a scan that stopped at the entry cap, or
|
||
lost a batch, still pruned every path it had not reached, and the next scan put them all back.
|
||
|
||
**Fix.** The search indexes are compacted at most once a day, at the end of a local scan:
|
||
`local_fts` is rebuilt from `local_files`, and `nodes_fts` is merged into one segment. Each takes
|
||
about half a second at that size. A scan that did not see everything closes with
|
||
`local_finish_partial_scan`, which prunes nothing.
|
||
|
||
**Test:** `db::tests::compacting_the_search_indexes_keeps_them_whole_and_runs_once_a_day`,
|
||
`db::tests::a_partial_local_scan_prunes_nothing`.
|
||
|
||
---
|
||
|
||
### B216 — A damaged database is rebuilt without the writes still queued in it
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.7).
|
||
**Where:** `Db::open`, `salvage` and `quarantine_corrupt_db` in `crates/pdfs-core/src/db/mod.rs`
|
||
|
||
**Repro:** Damage `cache.db` so that SQLite reports `SQLITE_CORRUPT` or `SQLITE_NOTADB` at open,
|
||
with writes still queued.
|
||
|
||
**Cause.** Opening a damaged database moved it aside and started an empty one, logging that
|
||
"no user data is stored here". The same file holds the upload queue (`pending_op`), the mirror
|
||
baselines (`sync_entry`, `sync_folder`), the pins and the record of revisions this machine sealed.
|
||
Every queued create, rename and trash was dropped without a word. The staged blobs lost their ops,
|
||
and those of files made here could never be queued again.
|
||
|
||
**Fix.** After the rebuild, `salvage` attaches the damaged file read only and copies those tables
|
||
back, each in its own transaction and with only the columns both files have. The queue keeps the
|
||
node rows it names by local id, so files made here keep their identity; those rows come back
|
||
unlisted, so their folders are fetched again. A table SQLite cannot read to the end is left out
|
||
and logged as an error.
|
||
|
||
**Test:** `db::tests::a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file`,
|
||
`db::tests::a_corrupt_database_is_moved_aside_and_rebuilt`.
|
||
|
||
---
|
||
|
||
### B215 — A remote name holding `/` or `..` becomes a path outside the sync folder
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.2).
|
||
**Where:** `walk_remote` and `walk_remote_dirs` in `crates/pdfs-fuse/src/sync.rs`; `node_visible`
|
||
in `crates/pdfs-fuse/src/lib.rs`
|
||
|
||
**Repro:** Needs a node whose decrypted name holds `/`, `..` or NUL. Drive checks names only when
|
||
a client creates or renames a node, and only for length, so another client or a server that
|
||
should not be trusted can make one. Then let a mirror pass or a listing of its folder run.
|
||
|
||
**Cause.** The mirror engine joined each decrypted name onto its parent's relative path and
|
||
turned that into a local path, so `x/../../.config/autostart/a.desktop` named a file outside the
|
||
sync root. The pass then created, replaced or removed it there. In the mount, the kernel refuses a
|
||
whole `readdir` with EIO when one name in it is not a single path component.
|
||
|
||
**Fix.** `remote_name_is_safe` accepts a name only if it is one path component: not empty, not
|
||
`.` or `..`, no `/` or NUL, at most 255 bytes. Both remote walks of the mirror engine and the
|
||
listing filter of the mount leave out any other name, with a warning. A name left out cannot be a
|
||
local path at all, so leaving it out cannot read as a deletion of anything local.
|
||
|
||
**Test:** `tests::a_remote_name_that_is_not_one_path_component_is_left_out`.
|
||
|
||
---
|
||
|
||
### B214 — Deleting a mirror folder here trashes another device's new files in it
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.5).
|
||
**Where:** `Core::do_reconcile` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** Delete a synced folder locally while another device adds a file to it on Drive, before
|
||
the next pass.
|
||
|
||
**Cause.** The pass planned the new file as a download, which recreated the folder locally. At
|
||
the end of the same pass it trashed the remote folder, because the folder was in the baseline and
|
||
gone locally, and took the new file with it. The next pass then saw the downloaded copy unchanged
|
||
and its remote gone, so it deleted that too. The other device's work survived only in the Drive
|
||
trash, and nothing said so.
|
||
|
||
**Fix.** Before a pass trashes a remote folder, `remote_subtree_changed` looks for anything under
|
||
it on Drive that is not in the baseline or whose signature moved. If there is, the folder stays on
|
||
Drive with its row, the activity feed says why, and only its unchanged files are trashed, one by
|
||
one, as local deletions.
|
||
|
||
**Test:** `planner::file_plan_tests::a_remote_folder_with_new_or_changed_files_is_not_trashed`.
|
||
|
||
---
|
||
|
||
### B213 — A file edited during a mirror pass is deleted because Drive deleted it earlier
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.6).
|
||
**Where:** `Core::do_reconcile`, `FilePlan::DeleteLocal`, in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** Delete a synced file on another device. On this one, edit the file while a pass over a
|
||
large folder walks Drive.
|
||
|
||
**Cause.** A pass takes its local signatures before the remote walk, which takes minutes on a
|
||
large tree, and deleted a file that Drive had deleted without looking at it again. Downloads got
|
||
that second look in B39 (`keep_racing_local_edit`); deletions did not, so the edit was lost with
|
||
no conflict copy.
|
||
|
||
**Fix.** `changed_since_scan` compares the file's signature with the one the scan planned on, right
|
||
before the deletion. A changed file stays, and the next pass uploads it as a new file.
|
||
|
||
**Test:** `tests::a_file_changed_since_the_scan_is_not_deleted`.
|
||
|
||
---
|
||
|
||
### B212 — A node on Drive that cannot be decrypted is deleted from the mirror
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.4). The SDK leaves out nodes it cannot
|
||
decrypt, which B112 showed on real accounts and B97 during cancellation.
|
||
**Where:** `Core::walk_remote` and `Core::do_reconcile` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** Make one synced file or folder fail to decrypt, for example with a key the session
|
||
cannot unlock, and let a pass run.
|
||
|
||
**Cause.** The remote walk kept what the SDK returned and nothing else. A node the SDK left out
|
||
was then missing from the remote map, and the planner reads "in the baseline and here, but not on
|
||
Drive" as a deletion on Drive. A file was deleted locally; a folder was removed with
|
||
`remove_dir_all`. The local side had a guard against a scan that came back empty
|
||
(`guard_local_wipe`), the remote side had none.
|
||
|
||
**Fix.**
|
||
- A listing that returns fewer nodes than it named marks its folder as short. A file whose size
|
||
could not be read is marked unread.
|
||
- `RemoteGaps::hides` leaves out of the pass every path such a gap could explain, as `Unscanned`
|
||
does for the local scan. The folder is reported with the paths that cannot be synced.
|
||
- `guard_remote_wipe` refuses a pass in which none of the synced paths is left on Drive.
|
||
|
||
**Test:** `planner::file_plan_tests::a_short_remote_listing_hides_what_it_did_not_return`,
|
||
`planner::file_plan_tests::a_file_whose_size_could_not_be_read_is_hidden`,
|
||
`planner::file_plan_tests::the_remote_wipe_guard_blocks_only_a_total_disappearance`.
|
||
|
||
---
|
||
|
||
### B211 — A folder deleted on Drive takes unsynced local files with it
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.1).
|
||
**Where:** `Core::do_reconcile` and `remove_local_dirs` in `crates/pdfs-fuse/src/sync.rs`
|
||
|
||
**Repro:** In a mirror folder, add a file to a synced subfolder. Before the next pass, delete the
|
||
subfolder on another device.
|
||
|
||
**Cause.** The pass put the folder on its list of local deletions. The upload of the new file
|
||
failed for want of a remote parent and was counted as an error. At the end of the pass,
|
||
`remove_dir_all` removed the folder with everything in it: new files, edits, files open for
|
||
writing, and ignored content such as `.git`, none of which had ever been on Drive.
|
||
|
||
**Fix.** `remove_local_dirs` asks `mirror_subtree_unsynced` about each folder before it removes
|
||
it, against the baseline as the pass has left it. A folder that holds anything Drive does not
|
||
have stays, and loses its baseline row, so the next pass creates it on Drive again and uploads
|
||
what it holds. What was synced in it is still deleted.
|
||
|
||
**Test:** `tests::a_folder_deleted_on_drive_keeps_what_drive_never_had`,
|
||
`tests::a_folder_deleted_on_drive_with_only_synced_content_is_removed`.
|
||
|
||
---
|
||
|
||
### B210 — After a password reset, the mount retries "missing key" forever
|
||
|
||
**Status:** Fixed, not verified live
|
||
**See also:** B265. The account in issue #40 has an active volume, so this fix does not run for
|
||
it; its "missing key" comes from address keys that do not open.
|
||
**Found:** 2026-10-15, issue #40: on a fresh 3.4.0 install the sign-in worked, but the daemon
|
||
logged `mount failed: fetch My Files root: cryptography error: failed to decrypt: missing key`
|
||
every 5 seconds. The app stayed on "Connecting…" because the daemon never got far enough to
|
||
create `control.sock`.
|
||
**Where:** `ProtonDriveClient::ensure_my_files` in proton-drive-rs; `fetch_or_recall_root` in
|
||
`crates/pdfs-fuse/src/mount.rs`
|
||
|
||
**Repro:** Reset the account password through Proton's recovery flow, which replaces the account
|
||
keys, then sign in on a client without a cache.
|
||
|
||
**Cause.** A password reset gives the account new keys and locks the Drive volume whose share was
|
||
encrypted to the old ones. `v2/shares/my-files` still returns that share, so none of the unlocked
|
||
address keys can open its passphrase. The SDK only created a new volume when the share did not
|
||
exist, so every mount attempt failed the same way. The web client checks for an active regular
|
||
volume and creates a new one when there is none.
|
||
|
||
**Fix.** proton-drive-rs 0.9.1 handles a share key that fails to decrypt the way the web client
|
||
does. When `GET volumes` shows no active regular volume, it creates a new My Files volume and uses
|
||
that. The locked volume is left alone and can still be restored from the web with the previous
|
||
password.
|
||
|
||
**Test:** `client::tests::a_locked_main_volume_is_not_an_active_regular_one`,
|
||
`client::tests::an_active_regular_volume_is_found_with_or_without_a_type` (proton-drive-rs).
|
||
|
||
---
|
||
|
||
### B209 — A blocked sign-in says the app version is no longer supported
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-13, issue #39: the first sign-in on a fresh 3.4.0 install showed "This version
|
||
of the app is no longer supported. Please update to continue using the app." Issue #28 showed the
|
||
same text on 3.0.0 to 3.1.2, and its debug log gave `path="/auth/v4" http_status=422 code=2028`.
|
||
Sign-in worked there after a day without attempts, with no change to the client.
|
||
**Where:** `login_error_message` in `crates/pdfs-gui/src/app/pages/login.rs`; `cmd_login` and
|
||
`cmd_diagnose` in `crates/pdfs-cli/src/main.rs`
|
||
|
||
**Repro:** Needs Proton to block the account or network, which cannot be triggered on purpose.
|
||
|
||
**Cause.** Proton answers a sign-in it blocks for abuse or rate limits with code 2028. Its `Error`
|
||
text claims the version is no longer supported. The SDK had no name for 2028, so it read as
|
||
`Unknown`, and the app passed Proton's text through. Users were told to update a current release.
|
||
`pdfs diagnose` also failed the `database` check on a fresh install, before the daemon had ever
|
||
started, which looked like part of the fault.
|
||
|
||
**Fix.** proton-sdk 0.8.3 names 2028 `ResponseCode::Blocked`. `error::sign_in_blocked` detects it,
|
||
and the app and `pdfs login` say that Proton is refusing sign-in for now and that waiting helps.
|
||
`pdfs diagnose` warns instead of failing when `cache.db` does not exist yet.
|
||
|
||
**Test:** `error::tests::only_code_2028_is_a_blocked_sign_in`,
|
||
`pages::login::tests::a_blocked_sign_in_does_not_ask_for_an_update`.
|
||
|
||
---
|
||
|
||
### B208 — A file landing while its folder is pinned is kept but not available offline
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-11, CI simulation run of `3e4886f`.
|
||
`sim::daemon::tests::a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned` waited
|
||
30 s for `Docs/sub/b.txt` to read as cached and gave up. It failed about once in 25 runs under load
|
||
and never alone.
|
||
**Where:** `Core::pin_subtree` in `crates/pdfs-fuse/src/lib.rs`
|
||
|
||
**Repro:** Pin a folder while a create in it lands, after the drain has repointed the tree at
|
||
the real uid and before it has read the node back. The file's cached blob must be gone, as after
|
||
`pdfs cache clear`, so the pin downloads it.
|
||
|
||
**Cause.** The pin walks the folder first and downloads each file under the modification time
|
||
and size it read then. The landing's read-back then sets the time Drive gave the file, and keeps
|
||
the uploaded bytes under it. The pin's download ended after that and stored the blob again under
|
||
the old time. The cache records one time per blob, so the file read as stale: shown as kept
|
||
offline, but downloaded again on the next read, and unreadable offline.
|
||
|
||
**Fix.** After each download the pin compares the file's time and size with the tree's. If they
|
||
moved, it fetches the file again under the new ones, at most three times.
|
||
|
||
**Test:** `sim::daemon::tests::a_folder_pinned_while_a_create_in_it_lands_keeps_that_file` holds
|
||
the read-back and the pin's download with `FakeClient::hold_next_download`, new for this, and
|
||
lets them go in that order.
|
||
|
||
---
|
||
|
||
### B207 — A move between locations goes ahead under a file opened just after its create landed
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-11, account acceptance run of 3.3.0, case "move refuses a file still being
|
||
written" on the on-demand → mirror pairing. `pdfs move` moved the folder while `open.txt` in it
|
||
was open for writing, and the write landed after the move.
|
||
**Where:** `Core::has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`; `retire_create`
|
||
in `crates/pdfs-fuse/src/drain.rs`
|
||
|
||
**Repro:** Create a file in a mount, and open it for writing right after its create lands, before
|
||
the drain has repointed the tree at the real uid. Then `pdfs move` its folder to another
|
||
location. The account run did this by chance: it opened the file about a second after the
|
||
create landed.
|
||
|
||
**Cause.** `retire_create` takes the create out of the queue and re-keys its `nodes` row to the
|
||
real uid first, then repoints the in-memory tree and the open write handles. A file opened in
|
||
between gets a write handle that holds the placeholder uid. `has_pending_work_under` placed each
|
||
open handle by looking its uid up in the `nodes` table, which no longer has the placeholder, so it
|
||
found nothing open under the folder. The queue was already empty, and the move went ahead.
|
||
|
||
**Fix.** Open handles are placed in the in-memory tree by inode, which does not change when a
|
||
create lands.
|
||
|
||
**Test:** `sim::daemon::tests::a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing`
|
||
holds the drain at that point with `FakeClient::hold_next_landing_of_any`. The simulation can now
|
||
sync local folders: the fake Drive lists and registers devices, and the simulated daemons no longer
|
||
read the developer's own `config.json`.
|
||
|
||
---
|
||
|
||
### B206 — An upgrade leaves the old daemon running, and nothing says so
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-11, issue [#33](https://github.com/narrrl/proton-drive-linux/issues/33). On
|
||
3.2.0 the reporter still saw `Couldn't sync Documents: Read …/snapshots/37: Permission denied`,
|
||
the B196 failure 3.2.0 fixed. Since 3.2.0 the scan leaves an unreadable subfolder out and
|
||
`walk_local_dir` can only fail the pass on the sync root, so that message comes from a daemon
|
||
older than 3.2.0.
|
||
**Where:** `Response::Status` and `daemon_outdated` in `crates/pdfs-core/src/control.rs`;
|
||
`status_response` in `crates/pdfs-fuse/src/control.rs`; the tray in `crates/pdfs-gui/src/main.rs`;
|
||
the sync card in `crates/pdfs-gui/src/app/pages/status.rs`; `pdfs status` and `pdfs diagnose`
|
||
|
||
**Repro:** With the service running, install a newer package, then open the app.
|
||
|
||
**Cause.** A package upgrade replaces the binaries but does not restart the user service, so the
|
||
running daemon keeps the old release and its bugs until the next login or a manual restart. The
|
||
app, the tray and the CLI start from the new binaries and could not tell, so they described the
|
||
new release while the old one did the work.
|
||
|
||
**Fix.** The daemon reports its release in `Status`. A daemon from before the field omits it,
|
||
which reads as older. When the daemon is older than the front-end, the tray says "Update not
|
||
finished — restart Proton Drive" with a Restart Proton Drive item, the sync card says "Restart to
|
||
finish updating" with a Restart button, and `pdfs status` and `pdfs diagnose` print the
|
||
`systemctl --user restart proton-drive` command. A newer daemon is not flagged: then the
|
||
front-end is the stale one, and restarting the service would not help.
|
||
|
||
**Test:** `control::tests::only_an_older_daemon_is_outdated`;
|
||
`tests::a_daemon_older_than_the_tray_needs_a_restart` in `pdfs-tray`.
|
||
|
||
---
|
||
|
||
### B205 — A photo upload the SDK refuses says "You're offline"
|
||
|
||
**Status:** Fixed, not verified live
|
||
**See also:** B266, the same misclassification on the pin, unpin, search and node-load paths.
|
||
**Found:** 2026-10-11, issue #38: uploading an image from the Photos page showed "You're offline"
|
||
while other uploads went through. Each `upload_photo` failed about 5 µs after it started, with no
|
||
request sent, and the journal gave no reason.
|
||
**Where:** `CoreError::from_api` and `From<Error> for CoreError` in `crates/pdfs-core/src/error.rs`;
|
||
the `UploadPhoto` handler in `crates/pdfs-fuse/src/control.rs`; the Photos page in
|
||
`crates/pdfs-gui/src/app/pages/photos.rs`
|
||
|
||
**Repro:** On an account without a photo library, open Photos and upload an image.
|
||
|
||
**Cause.** Every `ProtonError` other than `Api` was classified as `ErrorKind::Offline`, on the
|
||
reasoning that it never reached Proton. That holds for `Transport` only. `InvalidOperation`,
|
||
`Crypto`, `KeysLocked` and `Serialization` happen on this machine or after Proton answered. A
|
||
failure that fast is most likely the SDK's "account has no photos volume", which it returns from
|
||
its cache without sending a request. The app turns `Offline` into "You're offline", and the
|
||
daemon only sent the error back, so the journal did not say what it was.
|
||
|
||
**Fix.** `proton_kind` classifies by variant: `Transport` is `Offline`, `InvalidOperation` is
|
||
`Invalid`, `Serialization` is `Remote`, and `Crypto` and `KeysLocked` are `Internal`. The app then
|
||
shows "Couldn't upload photo" with the daemon's reason. The daemon logs a failed photo upload. The
|
||
Photos page greys out Upload when the daemon reports no photo library.
|
||
|
||
**Test:** `error::tests::a_call_the_sdk_refuses_to_send_is_not_offline`.
|
||
|
||
---
|
||
|
||
### B204 — A write over a partial edit whose base was never read fails with `EIO`
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-09, by CI on 2a86830 (`one_client_on_a_good_link`, seed 1, which did not replay
|
||
locally). A partial write to `x/f.txt`, a file renamed twice before, was renamed over `z/d`. A
|
||
create over `z/d` a moment later failed with `EIO`, and the daemon logged "refusing write over
|
||
incomplete queued revision".
|
||
**Where:** `ProtonFs::serve_open` in `crates/pdfs-fuse/src/filesystem.rs`
|
||
|
||
**Repro:** Write at an offset into a file this client never read, then open it for writing again
|
||
before the drain uploads the first write.
|
||
|
||
**Cause.** The release of a partial write fills its gaps from the content cache only. When the
|
||
cache does not hold the base, the queued revision stays incomplete until the drain fills it from
|
||
Drive, and a write open over an incomplete revision was refused. B123, B157, B169 and B170 each
|
||
closed one way the cache could miss the base. Any other miss, such as a file never read here,
|
||
still failed the next write open for the length of the drain debounce.
|
||
|
||
**Fix.** A write open over an incomplete queued revision copies the blob and fills its gaps from
|
||
the remote base with `Core::fill_gaps`, as the drain does before an upload. The open fails only
|
||
when that fill fails, for example offline.
|
||
|
||
**Test:** `a_partial_write_over_bytes_never_read_takes_another_write_at_once` in `sim/daemon.rs`.
|
||
Without the fix the second write fails with `EIO`.
|
||
|
||
---
|
||
|
||
### B203 — `pdfs unlock` leaves a running daemon a spent refresh token
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-09, reading the code for issue #35 after the B201 fix
|
||
**Where:** `crates/pdfs-core/src/auth.rs`, `stored_session_is_newer`; `crates/pdfs-fuse/src/link.rs`,
|
||
`Core::mark_session_expired`; `crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-cli/src/main.rs`,
|
||
`cmd_daemon`
|
||
|
||
**Repro:** With the daemon running, run `pdfs unlock` while the stored access token has expired.
|
||
Unlock resumes the same session, refreshes it and stores the new tokens. The daemon still holds
|
||
the old refresh token. If it refreshes before unlock restarts it, Proton refuses the spent token,
|
||
and since B202 the mount reports the session expired, although the keyring holds a live one.
|
||
|
||
**Cause.** Two processes ran the same session, and only one of them could hold its live refresh
|
||
token. B201 stopped the daemon from writing its spent token over the live one, but nothing made the
|
||
daemon pick up the live one.
|
||
|
||
**Fix.** Before a refused session counts as expired, `auth::stored_session_is_newer` compares the
|
||
keyring with what this process last stored. When the keyring holds a newer rotation of the same
|
||
session, or another login, the mount ends with `MountOutcome::ResumeStored`, and the daemon mounts
|
||
again at once from the keyring. At startup the same check turns the refusal into a mount error,
|
||
which the daemon retries. A rotation that another process already wrote into the blob is now
|
||
taken as this session's, so the daemon keeps writing its later rotations.
|
||
|
||
**Test:** `auth::tests::a_session_rotated_by_another_process_is_newer_in_the_keyring` and
|
||
`auth::tests::a_rotation_already_in_the_blob_is_taken`.
|
||
|
||
---
|
||
|
||
### B202 — A refused session shows as offline forever, and nothing says to sign in again
|
||
|
||
**Status:** Fixed, not verified live
|
||
**See also:** B267. Other errors at a start from a warm cache still mount as offline.
|
||
**Found:** 2026-10-09, issue #35: the tray and `pdfs diagnose` said "offline, 3 queued", and
|
||
diagnose also said "keyring session: ok", while every call in the log failed with
|
||
`InvalidRefreshToken (http 400)`. `pdfs sync now` and restarting the service changed nothing.
|
||
**Where:** `crates/pdfs-fuse/src/link.rs`, `Core::lost_link` and `Core::probe_until_online`;
|
||
`crates/pdfs-fuse/src/mount.rs`, `fetch_or_recall_root`; `crates/pdfs-fuse/src/background.rs`;
|
||
`crates/pdfs-core/src/control.rs`, `Response::Status`; the tray, the app's status, `pdfs status`
|
||
and `pdfs diagnose`
|
||
|
||
**Repro:** Make the stored refresh token invalid (B201 is one way), then start the daemon. It
|
||
mounts from the cache and says offline; the probe retries every 30 s, then every 5 minutes, and
|
||
logs the refusal only at debug. A daemon that started online and loses its session later does not
|
||
even go offline: its failures show up as EIO and drain backoff.
|
||
|
||
**Cause.** Every failure to fetch the root at startup counted as offline, and nothing told a
|
||
refused session apart from a lost connection. No status field could carry it, so no front end
|
||
could say it.
|
||
|
||
**Fix.** `pdfs_core::error::session_revoked` names the errors only a new login fixes:
|
||
`InvalidRefreshToken`, `AccountDeleted`, `AccountDisabled`. When the root fetch, the probe, the
|
||
drain, an event poll, the event seed or the photos root sees one, the mount goes offline with
|
||
`session_expired` set, logs one warning naming `pdfs login`, and stops probing and retrying.
|
||
Cached files stay readable and changes stay queued for the daemon the next login starts.
|
||
`Response::Status` carries `session_expired`. The tray shows "Session expired — sign in again"
|
||
with **Sign In Again…**, which opens the app's sign-in page; the app's status and Sync card say the
|
||
same, with a **Sign In Again** button. `pdfs status` and `pdfs diagnose` report it, the latter as
|
||
a FAIL. A refused session is not recorded as a sync issue of the op that ran into it.
|
||
|
||
**Test:** `error::tests::only_a_refused_refresh_or_a_closed_account_revokes_the_session`, the
|
||
tray's `tests::a_refused_session_outranks_everything_else` and
|
||
`tests::a_daemon_that_does_not_say_has_a_live_session`.
|
||
|
||
---
|
||
|
||
### B201 — After signing in again, the old daemon puts its spent tokens back
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-09, reading the code for issue #35, where every call failed with
|
||
`InvalidRefreshToken` and signing in again did not obviously help.
|
||
**Where:** `crates/pdfs-core/src/auth.rs`, `persist`, `register_refresh_handler`, `unlock` and
|
||
`migrate_legacy`; `crates/pdfs-cli/src/main.rs`, `cmd_daemon`
|
||
|
||
**Repro:** With the daemon running, run `pdfs login`. It stores the new session and restarts the
|
||
unit. The old daemon's stop runs `auth::persist`, which wrote its own access and refresh tokens
|
||
into the blob, now the new session's. The new daemon starts with a refresh token of another
|
||
session, and the first refresh fails with `InvalidRefreshToken`. `pdfs unlock` had the same
|
||
shape: its session could rotate the tokens, and the restarted daemon's stop put the spent refresh
|
||
token back. `unlock` and `migrate_legacy` could do it themselves, writing a copy of the blob read
|
||
before verifying the mailbox password, which may have rotated the tokens.
|
||
|
||
**Cause.** Proton refresh tokens are single-use. `store_tokens` loaded whatever blob was stored
|
||
and replaced its tokens, without checking that the blob was still this session's.
|
||
|
||
**Fix.** Each session writes its rotations through a `TokenSink`. It writes only into a blob with
|
||
its session id that still holds the refresh token the session last left there; a blob that moved
|
||
on, to a new login or to another session's rotation, is left alone. The stop no longer writes the
|
||
session's tokens, only a rotation the keyring refused when it happened. `unlock` and
|
||
`migrate_legacy` read the blob again after verifying. A failed final write is an error in the log,
|
||
not a debug line claiming there was nothing to save.
|
||
|
||
**Test:** `auth::tests::tokens_of_another_session_are_not_written`,
|
||
`auth::tests::tokens_are_not_written_over_a_newer_rotation_of_the_same_session`,
|
||
`auth::tests::a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login`.
|
||
|
||
---
|
||
|
||
### B200 — Offline with changes queued, the daemon keeps several cores busy
|
||
|
||
**Status:** Fixed, not verified live
|
||
**Found:** 2026-10-09, issue #35: "offline, 3 queued", and systemd counted 17 minutes of CPU in
|
||
2 m 16 s of wall time, 10 hours over a longer run.
|
||
**Where:** `crates/pdfs-fuse/src/drain.rs`, `Core::run_pending_drain` and `idle_wait`;
|
||
`crates/pdfs-core/src/db/ops.rs`, `Db::earliest_due_at`
|
||
|
||
**Repro:** Queue a write, then cut the link (`nmcli networking off`). `pidstat -t -p $(pgrep -x
|
||
pdfs) 5` shows the drain threads using most of several cores for as long as the outage lasts.
|
||
|
||
**Cause.** Offline, a drain worker claims nothing, and then sleeps until the earliest queued op
|
||
falls due. Every queued op was due already, and with no attempts nothing moves its
|
||
`next_attempt_at`, so the sleep was zero. All 16 workers ran a query on every pass, the primary
|
||
one its idle chores as well. The same spin happened online when a due op sat behind a claimed op
|
||
on the same node, such as a rename queued behind a long upload: `earliest_due_at` counted it,
|
||
`claim_next_due_op` skipped it.
|
||
|
||
**Fix.** A worker that claims nothing, offline or paused, sleeps the full idle poll; a reconnect
|
||
or a resume wakes it. `earliest_due_at` now uses the claim's own exclusions, one SQL fragment for
|
||
both.
|
||
|
||
**Test:** `drain::tests::an_offline_worker_waits_the_idle_poll_for_a_past_due_op`,
|
||
`db::tests::earliest_due_at_skips_an_op_held_behind_a_claimed_one`.
|
||
|
||
---
|
||
|
||
### B199 — A stopped share stays on the Shared by me page for minutes
|
||
|
||
**Status:** Partly fixed. The share itself is still not deleted.
|
||
**Found:** 2026-10-09, reported from the app: after **Stop Sharing**, the row stayed, and an
|
||
older share showed with no people and no link.
|
||
**Where:** `crates/pdfs-fuse/src/sharing.rs`, `Core::stop_sharing_for_uid` and
|
||
`Core::list_shared_by_me`; `crates/pdfs-gui/src/app/pages/shared_by_me.rs`, `prompt_stop_sharing`
|
||
|
||
**Repro:** Share a folder with a person, then choose **Stop Sharing…** on the Shared by me page.
|
||
The row stays, now with "—" for people and link, until a reload some minutes later.
|
||
|
||
**Cause.** The Rust SDK has no call that deletes a share. Stop Sharing removes the public link, the
|
||
invitations and the members one by one, and leaves an empty share behind. Drive deletes an empty
|
||
share on its own, but only after a while, and until then `GET v2/volumes/{vid}/shares` still lists
|
||
it. The JS SDK ends `unshareNode` with `DELETE drive/shares/{shareId}?Force=1` for exactly this
|
||
reason.
|
||
|
||
**Fix.** The daemon leaves a share with no member, no invitation and no link out of the Shared by
|
||
me listing, unless one of those lookups failed. The app also drops a stopped item from the page as
|
||
soon as the request succeeds, before the confirming reload. Left: a `delete_share` call in
|
||
`proton-drive-rs`, made at the end of `stop_sharing_for_uid`, so the share is really gone.
|
||
|
||
**Test:** none automated; the listing needs a live account.
|
||
|
||
---
|
||
|
||
### B198 — Switching to online-only deletes local files that never reached Drive
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-08, reading the switch while fixing B196.
|
||
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
|
||
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
|
||
|
||
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
|
||
`.git/` is gone from the disk and was never on Drive.
|
||
|
||
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
|
||
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
|
||
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
|
||
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
|
||
all of those.
|
||
|
||
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
|
||
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
|
||
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
|
||
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
|
||
stays for good and would hold the switch forever.
|
||
|
||
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
|
||
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
|
||
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
|
||
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
|
||
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
|
||
itself has not been driven against a live account.
|
||
|
||
---
|
||
|
||
### B197 — Switching to online-only trashes synced files that became ignored
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-08, reading the push pass while fixing B196.
|
||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||
`local_deletions`
|
||
|
||
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
|
||
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
|
||
local copy.
|
||
|
||
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
|
||
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
|
||
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
|
||
what the user guide promises ("Ignoring never deletes anything").
|
||
|
||
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
|
||
without ignored paths, and without paths the scan could not read (B196).
|
||
|
||
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
|
||
`local_deletions_come_out_shallowest_first`.
|
||
|
||
---
|
||
|
||
### B196 — One unreadable folder stops the whole synced folder from syncing
|
||
|
||
**Status:** Fixed (unverified). The reporter has not run the fix yet.
|
||
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
|
||
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
|
||
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
|
||
denied`, and nothing else in `Documents` synced.
|
||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||
`Unscanned`
|
||
|
||
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
|
||
|
||
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
|
||
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
|
||
not transient: the pass failed on every poll, for good. The same held for a file deleted between
|
||
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
|
||
|
||
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
|
||
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
|
||
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
|
||
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
|
||
error, still fails the pass. The activity feed names the unreadable paths once each time the set
|
||
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
|
||
and still silences the report.
|
||
|
||
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
|
||
`a_listable_but_unsearchable_folder_is_left_out_whole`,
|
||
`a_path_the_scan_left_out_is_never_a_local_deletion`,
|
||
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
|
||
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
|
||
`an_absent_root_fails_the_scan` still hold.
|
||
|
||
---
|
||
|
||
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
||
|
||
**Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but
|
||
the CAPTCHA still could not be solved; the browser fallback below has not run on secureblue yet.
|
||
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
|
||
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
|
||
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
|
||
`Failed to fully launch dbus-proxy`.
|
||
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`,
|
||
`crates/pdfs-core/src/auth.rs`, `crates/pdfs-cli/src/main.rs`
|
||
|
||
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
|
||
|
||
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
|
||
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
|
||
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
|
||
WebKit binds the socket with `--ro-bind-try`, but bubblewrap skips only a missing path, not one it
|
||
may not read, and WebKit has no switch to leave the socket out.
|
||
The view was in the app's own process, so the whole app went down, and nothing in our code can
|
||
catch that.
|
||
|
||
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
|
||
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
|
||
status no longer ends the sign-in: the app offers the page in the user's own browser, as Proton
|
||
Mail Bridge does. The tab cannot post the token back, so the user says when they are done, and the
|
||
retry carries the challenge token, typed with every offered method. `pdfs login` takes the same
|
||
path. The sandbox is not switched off by the app: users choose a hardened system for that
|
||
isolation. TROUBLESHOOTING.md keeps the one-time workaround as a last resort.
|
||
|
||
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
|
||
`a_token_counts_even_if_the_window_died_after_it`, `the_token_is_found_among_other_output`,
|
||
`the_browser_gets_the_full_verification_page` and
|
||
`a_browser_verification_retries_with_the_challenge_token`. The browser retry has not run against a
|
||
live challenge: Proton only asks for one from a network it distrusts.
|
||
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
|
||
and handed back its token.
|
||
|
||
---
|
||
|
||
### B194 — On a fresh install the tray exits and the Takeout page cannot remember its archives
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-07, in the log of [#29](https://github.com/narrrl/proton-drive-linux/issues/29):
|
||
`failed to bind tray single-instance socket: No such file or directory` and
|
||
`cannot remember staged Takeout archives: No such file or directory`.
|
||
**Where:** `crates/pdfs-gui/src/main.rs`, `build_window` in `crates/pdfs-gui/src/app/main.rs`
|
||
|
||
**Repro:** Start `pdfs-app` with no `~/.local/state/proton-drive-linux`, as on a fresh install. The
|
||
tray is gone, and staging a Takeout archive logs the warning.
|
||
|
||
**Cause.** Both write to the state directory, and only the daemon and the CLI made it
|
||
(`AppDirs::ensure`). The app starts the tray before the first sign-in, so before the daemon has
|
||
ever run.
|
||
|
||
**Fix.** `pdfs-tray` and `pdfs-app` call `AppDirs::ensure` at start, which also makes the
|
||
directories owner-only (B6).
|
||
|
||
**Verified:** 2026-10-08, `pdfs-tray` with `XDG_STATE_HOME`, `XDG_CONFIG_HOME` and
|
||
`XDG_CACHE_HOME` pointing at an empty directory. It exited with the error before the fix, and
|
||
afterwards ran with `tray.sock` in a `0700` state directory.
|
||
|
||
---
|
||
|
||
### 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;
|
||
no replay reproduced it). A file renamed over `y/y/d`, then emptied, was gone from Drive and
|
||
from the local database, with nothing queued. The run kept no log. The path below explains it,
|
||
and the test reproduces that path every time.
|
||
|
||
**Where:** `Core::withdrawn_twin_in` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A delete turns a create that was sent into a trash of what it may have made, under a
|
||
name it was sent with (B151, B184). The drain looks there for a node made in the last two
|
||
minutes that holds bytes the create sent, or none. It did not leave out a node in the tree, so
|
||
an empty file of the user's renamed to that name matched, and was trashed. A create's own search
|
||
for its twin (`Core::adoptable_twin`) already left such a node out, and nodes this mount trashed
|
||
(B162).
|
||
|
||
**Fix.** The trash's search leaves out the same nodes. A file a lost answer made that a listing
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-05, while writing the test for B193. An empty file made on an idle mount was
|
||
still queued 30 s later.
|
||
|
||
**Where:** `Core::queue_revision` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** Queueing a file's create does not wake the drain: its bytes ride on the create, and
|
||
the release that attaches them wakes it (B160). A file closed without a write (`touch`, `: > f`)
|
||
attaches nothing, so its create waited for the workers' 30 s idle poll.
|
||
|
||
**Fix.** Closing a clean handle of a file whose create is queued wakes the drain.
|
||
|
||
**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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, in the confirmation run on 1436c5f (`one_client_on_a_flaky_link` seed 11,
|
||
one replay in six). A create of `a.txt` got no answer, though Drive had made the file. The file
|
||
was written again, renamed twice and deleted. Drive kept `a.txt`, and nothing was queued.
|
||
|
||
**Where:** `drop_doomed_ops` in `crates/pdfs-core/src/db/ops.rs`.
|
||
|
||
**Cause.** A delete turns a create that was sent into a trash of what it may have made (B151).
|
||
Whether it was sent was read from its attempts and its claim. A write after the failure attaches
|
||
its bytes to the create and clears the attempts, so that the retry goes out at once
|
||
(`Db::attach_blob_to_create`). The delete then took the create for one never sent, and dropped
|
||
it whole.
|
||
|
||
**Fix.** A create with a target recorded (`sent_to`, B184) counts as sent as well. Test
|
||
`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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, in the confirmation run on 2baad67 (`one_client_on_a_flaky_link` seed 9,
|
||
about one run in three). A client wrote a 32-byte file and closed it, and the daemon was
|
||
restarted at once. Drive held the file empty, and nothing was queued.
|
||
|
||
**Where:** the stop in `crates/pdfs-fuse/src/mount.rs`, and `Core::queue_unreleased_writes` in
|
||
`crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** `close(2)` returns before the kernel sends `release`, and the release is what queues
|
||
a handle's bytes. The stop aborts the FUSE connection so that the unmount cannot hang, and the
|
||
abort drops the requests the kernel still has queued. A release among them never reached the
|
||
daemon. The file's create went out with what it held before the write, and the bytes went with
|
||
the handle's scratch file. A handle a program still held open at the stop lost what it had
|
||
written in the same way.
|
||
|
||
**Fix.** Once every session has ended, the stop queues each write handle the kernel never
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, in the confirmation run on 2baad67 (`three_clients_one_writer_each`
|
||
seed 6, every replay). A client's link was down for 0.3 s, during which it made a folder and a
|
||
file. Their uploads failed once, and 4 s after the link came back they were still queued.
|
||
|
||
**Where:** the failure path of `Core::run_pending_drain` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** Every syscall and the event poll take the mount offline when Drive does not answer,
|
||
and the probe that brings it back makes every backed-off op due at once. The drain was the one
|
||
caller that did not. An outage that only the drain met left the mount online, so nothing probed
|
||
for the link's return, and the ops sat out a backoff that doubles with each failure, up to five
|
||
minutes.
|
||
|
||
**Fix.** A drain attempt that cannot reach Drive takes the mount offline like any other call.
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, in the confirmation run on 2baad67 (`three_clients_one_writer_each`
|
||
seed 12). With its link down, a client overwrote a file, deleted it while open, appended to it
|
||
and restarted. After the restart a revision of the deleted file retried on "No such file or
|
||
directory" until the run gave up.
|
||
|
||
**Where:** `Core::reconcile_staging` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** Since B186's fix, the queued trash of an open file keeps the staged bytes the handle
|
||
reads, and the trash's landing removes them. A restart in between forgets which handle the bytes
|
||
were kept for. The scan at mount found them owned by no op and queued them again as a revision of
|
||
the node. The trash, queued first, landed and removed the bytes, and the revision kept retrying on
|
||
a blob that was gone. B186's fix brought this in, so no release has it.
|
||
|
||
**Fix.** The scan at mount discards staged bytes whose node has a queued trash, as the trash would
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, writing the test for B186: after its file was deleted, the cache still
|
||
held a `.tmp` file with its bytes.
|
||
|
||
**Where:** `ContentCache::store_file` in `crates/pdfs-core/src/cache.rs`.
|
||
|
||
**Cause.** The cache adopts an uploaded file by linking its staged bytes to a temp name and
|
||
renaming that over the cached blob. A landed create and the revision after it can adopt the same
|
||
staged bytes. The temp name was then a second name of the cached blob, and a rename between two
|
||
names of one file does nothing. The temp file stayed, outside the cache's budget, until the next
|
||
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"
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, in the confirmation run on 3073052 (`three_clients_one_writer_each`
|
||
seed 4; it fails on da013c2 too). With its link down, a client opened a file it had uploaded,
|
||
deleted it, and appended to it. The append took 30 s and failed with `EIO`.
|
||
|
||
**Where:** `Core::queue_trash` in `crates/pdfs-fuse/src/lib.rs` and `Core::drain_trash` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A delete sent to Drive at once keeps what an open handle reads, its queued and cached
|
||
bytes, until the final close (B106). A delete queued, as local-first and offline do, dropped
|
||
them at once. A partial write fills its gaps from those bytes, so it had to download the file,
|
||
and offline it could not.
|
||
|
||
**Fix.** A queued delete of an open file keeps them as well. The final close lets go of them
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, in the confirmation run on 3073052 (`one_client_on_a_good_link` seed 6,
|
||
then `one_client_deleting_files_as_they_upload` seed 4). The queues did not drain in time: a
|
||
create had failed with "No such file or directory" and was backing off.
|
||
|
||
**Where:** `Db::retry_if_retargeted` in `crates/pdfs-core/src/db/ops.rs`, called from the drain
|
||
in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A write to a file whose create is queued attaches a new blob to the create and
|
||
discards the old one at once. An attempt that had claimed the create but not yet opened the old
|
||
blob failed on a file that was gone, and backed off as if Drive had refused it. The new blob was
|
||
ready, but the create waited out the backoff.
|
||
|
||
**Fix.** A failed create whose blob was replaced while it ran is due again at once, the way one
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, replaying `one_client_on_a_flaky_link` seed 11 with the B183 fix. `a.txt`'s
|
||
create reached Drive and lost its answer. The file was then rewritten, renamed to `b.txt` and
|
||
then `d`, and deleted. Drive kept `a.txt` with the bytes the create had sent.
|
||
|
||
**Where:** `Core::withdrawn_twin` and `Core::create_local_node` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A rename while a create waits rewrites the op's parent and name, but the node a lost
|
||
answer left stays where the create was sent. The trash a delete made of the create, and the
|
||
create's retry, looked for that node under the op's last name only. The trash found nothing
|
||
and left the file on Drive. The retry made a second file under the new name, so Drive had both.
|
||
|
||
**Fix.** Each attempt records the parent and name it is sent under on the op's row, in a new
|
||
`pending_op.sent_to` column (schema 42). The trash looks for its node under each of them. A
|
||
retry first adopts a node an earlier attempt made under another name, and the rename to the
|
||
current name follows it. Sim tests `a_file_renamed_after_its_create_lost_its_answer_lands_once`
|
||
and `a_file_renamed_and_deleted_after_its_create_lost_its_answer_leaves_nothing`, and
|
||
`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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, by the known-bug confirmation run (`PDFS_SIM_KNOWN=fail`) on da013c2:
|
||
`one_client_on_a_flaky_link` seed 11 failed every replay. `d` was made, written again while
|
||
its create was on the wire, and deleted after several restarts. Drive kept a `d` with the bytes
|
||
the create had sent, and the next file renamed to `d` landed as `d (sync-conflict …)`.
|
||
|
||
**Where:** `Core::withdrawn_twin` and `Core::adoptable_twin` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The trash a delete makes of a create that was sent looks for a node Drive made with
|
||
the bytes the create sent (B151). Those were kept in memory only (B156). After a restart the
|
||
trash knew only the blob the op held last, which a later write had replaced. It found no node of
|
||
its own and left the file on Drive. A create retried after a restart had the same gap and forked
|
||
a conflict copy.
|
||
|
||
**Fix.** Each attempt records the SHA-1 of the blob it sends on the op's row, in a new
|
||
`pending_op.sent_sha1` column (schema 42). The row keeps it through a newer blob, through the
|
||
turn into a trash and across restarts. The in-memory map is gone. Tests
|
||
`what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart` in
|
||
`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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-05, by the known-bug confirmation run on da013c2:
|
||
`one_client_on_a_flaky_link` seed 6 failed two of three replays. `y/x/c.bin`'s create landed and
|
||
the link went down before it was read back. The daemon restarted, and a write to the file landed
|
||
as `c (sync-conflict …).bin`, with Drive keeping the old bytes.
|
||
|
||
**Where:** `Core::finish_adoption` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A create that landed but was not read back is adopted before the next op for the
|
||
node is sent (B143). The node is remembered for that in memory, and a restart forgot it. The
|
||
tree then still had the file as it was made here, with no revision of Drive's, and a write was
|
||
based on that. The drain found Drive's revision in its way and took it for someone else's.
|
||
|
||
**Fix.** A queued write over a file the tree holds without a revision id is adopted first, as
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-05, checking that every name collision and conflict the drain resolves is
|
||
reported (sync issues, `docs/ARCHITECTURE.md` §5). Each one is in the activity log, and the app words
|
||
each in the user's language but one.
|
||
|
||
**Where:** `detail_label` in `crates/pdfs-gui/src/app/pages/activity_text.rs`; the daemon
|
||
writes the entry in `Core::keep_as_conflict_copy` and `Core::abandon_to_staging`
|
||
(`crates/pdfs-fuse/src/drain.rs`).
|
||
|
||
**Cause.** A queued edit whose file was changed, trashed or removed on Drive meanwhile is
|
||
uploaded as a conflict copy, or kept in staging when it cannot be. The entry's detail is the
|
||
reason followed by "; local changes uploaded as {copy}" or "; local changes kept at {path}".
|
||
The app knew neither shape, so it showed the detail as the daemon wrote it, in English: "The
|
||
remote revision changed under the queued write (based on revision …); local changes uploaded
|
||
as …".
|
||
|
||
**Fix.** The app reads both shapes and the three reasons the daemon gives, and says each as
|
||
one sentence: "It was changed on Proton Drive, so your changes were uploaded as {name}". A
|
||
reason it does not know still gets a sentence without the why. Translated in every catalog.
|
||
|
||
**Test:** `an_edit_kept_as_a_copy_says_why_in_words` in
|
||
`crates/pdfs-gui/src/app/pages/activity_text.rs`.
|
||
|
||
---
|
||
|
||
### 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`
|
||
found "background workers did not finish in time" in the log, left there by two earlier tests
|
||
whose stops had given up on a drain worker. On its own,
|
||
`a_folder_made_again_while_the_old_one_is_trashed_is_a_new_folder` took 11 s instead of 2 s in
|
||
about one run in three.
|
||
|
||
**Where:** `Core::wait_for_drain_work` and `Core::wait_for_drain_work_or_due` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The drain workers sleep on one flag and one condvar. A stop sets its flag, then
|
||
raises the wake-up and wakes them all, and the first worker to wake takes the wake-up. A worker
|
||
that was between its stop check and its sleep at that moment found no wake-up and slept its
|
||
timeout, up to 30 s. The stop gave up on it after 10 s and shut down around it. With 16 workers
|
||
since step 7 this was common.
|
||
|
||
**Fix.** A worker checks the stop under the wake-up's lock before it sleeps. The stop sets its
|
||
flag before it takes that lock, so a worker that finds it unset is asleep when the wake-up comes.
|
||
|
||
**Test:** `a_drain_worker_that_missed_the_stop_wake_up_does_not_sleep` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
names case read `<angle>|pipe` back as 0 bytes instead of 12, and B70's completed download as 0
|
||
bytes instead of 147456. Both files had a write that landed during their create, which the
|
||
daemon queued as a revision.
|
||
|
||
**Where:** `Core::read_range` in `crates/pdfs-fuse/src/reads.rs`.
|
||
|
||
**Cause.** A read takes the file's uid from the tree and reads on a transfer worker. A file made
|
||
on the mount has its stand-in uid there until its create lands. When it lands, the queued write
|
||
moves to the uid Drive gave the file. A read that still asked by the stand-in found no write
|
||
queued under it, took the file for one created and never written, and returned nothing. The
|
||
read waits on the transfer lane, which a run keeps busy, so the window is not small.
|
||
|
||
**Fix.** A stand-in with no queued write is looked up by its local id. Once its create has
|
||
landed, the read goes on under the uid Drive gave it.
|
||
|
||
**Test:** `a_file_read_as_its_create_lands_has_its_bytes` in `crates/pdfs-fuse/src/sim/daemon.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
`pdfs rm b116` with sync paused left the create of `b116/sub/deeper/b116 held.bin` queued as
|
||
trash #16098.
|
||
|
||
**Where:** `Core::run_pending_drain` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** Trashing a folder drops the creates queued below it, but keeps a create that may
|
||
already be on the wire as a trash, for a node Drive may have made (B151). A claimed create
|
||
counts as such. A drain worker that woke while sync was paused or offline claimed the next due
|
||
op and only then saw it could not send it, and handed it back. The rename that lets a parked
|
||
create go wakes the workers, so a trash in that moment kept a create nothing had sent.
|
||
|
||
**Fix.** A worker checks that it is online and not paused before it claims anything.
|
||
|
||
**Test:** `a_paused_drain_claims_nothing` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
`lstat` of `b100-copy/dir-0/sub-0/file-00.bin` failed with `EIO`. The daemon logged
|
||
`enumerate folder children failed folder_uid=local~163903 ... InvalidEncryptedIdFormat` 85 ms
|
||
before `dir-0`'s create landed.
|
||
|
||
**Where:** `Db::children_if_listed` and `Db::set_listed` in `crates/pdfs-core/src/db/nodes.rs`.
|
||
|
||
**Cause.** A folder made on the mount is marked as listed, so its listing comes from the
|
||
database and never from Drive, which has not heard of it. The flag was looked up by uid. In
|
||
B173's gap the row already has the uid Drive gave the folder and the tree still asks by the
|
||
stand-in, so the lookup found no row, and the listing went to Drive with the stand-in.
|
||
|
||
**Fix.** A stand-in finds its row by its local id, before and after its create lands.
|
||
|
||
**Test:** `a_stand_in_folder_stays_listed_once_its_create_has_landed` in
|
||
`crates/pdfs-core/src/db/tests.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
in a loop. A write at offset 6 to a file whose create was landing never reached Drive; Drive
|
||
ended with the file's first bytes and the later truncate only.
|
||
|
||
**Where:** `Core::queue_revision` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** A write handle closed on a file not yet on Drive first checks whether the create has
|
||
landed and, if so, follows it to the uid Drive gave it. It then checks that the create is still
|
||
queued; a file with neither was unlinked before it was created, and its write is dropped. A
|
||
create that landed between the two checks looked like that, and the write was dropped.
|
||
|
||
**Fix.** The landing removes the create op and gives the row its uid in one transaction. So a
|
||
missing create op is checked against the row, and a landed file's write is queued for its uid.
|
||
|
||
**Test:** `a_write_closed_as_its_create_lands_is_queued` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
in a loop. `rename b.txt -> a.txt` replaced an `a.txt` whose create had landed a few
|
||
milliseconds before, and the next `rename a.txt -> d` moved the old `a.txt` on Drive. Drive
|
||
ended with the old bytes in `d` and the new ones still in `a.txt`, and the queue did not drain.
|
||
|
||
**Where:** `Core::remove_replaced` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** The same gap as B173, through `rename`. The replace found the old file under its
|
||
stand-in, found no op left to drop, and took it for a file Drive had never seen. So nothing
|
||
trashed it on Drive, and the tree took it back under its name once it got its uid.
|
||
|
||
**Fix.** As in B173: after dropping a stand-in's ops, the replace checks whether the node's row
|
||
has a real uid. If it has, the file is trashed on Drive like any other replaced file.
|
||
|
||
**Test:** `a_file_replaced_as_its_create_lands_is_trashed_on_drive` in
|
||
`crates/pdfs-fuse/src/sim/daemon.rs`.
|
||
|
||
---
|
||
|
||
### 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):
|
||
`rename c.bin -> b.txt` answered `EACCES` right after a settle. Not reproduced in three replays.
|
||
|
||
**Where:** `Db::effective_node_access` in `crates/pdfs-core/src/db/share_access.rs`.
|
||
|
||
**Cause.** Every change on the mount first asks whether the node may be written, and a node
|
||
with no row is refused. When a create lands, its row takes the uid Drive gave it before the
|
||
tree does (B173's gap). A rename in between asked by the stand-in the tree still had, found no
|
||
row under it, and was refused.
|
||
|
||
**Fix.** A stand-in is looked up by its local id, so it finds its row before and after the
|
||
create lands.
|
||
|
||
**Test:** `a_stand_in_keeps_its_access_once_its_create_has_landed` in
|
||
`crates/pdfs-core/src/db/tests.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
in a loop: one of nine runs failed. A deleted `c.bin` was still on Drive with its old bytes,
|
||
and the next `c.bin` made on the mount landed as a conflict copy beside it.
|
||
|
||
**Where:** `trash_child` in `crates/pdfs-fuse/src/filesystem.rs`.
|
||
|
||
**Cause.** When a create lands, the drain retires its op and moves the node's row to the uid
|
||
Drive gave it, then gives the tree that uid. In between, it caches the uploaded bytes. A delete
|
||
in that gap still found the file under its stand-in, found no op left to drop, and took it for
|
||
a file Drive had never seen. So nothing trashed it on Drive.
|
||
|
||
**Fix.** After dropping a stand-in's ops, the delete checks whether the node's row has a real
|
||
uid. If it has, the create landed first, and the file is trashed on Drive like any other.
|
||
|
||
**Test:** `a_file_deleted_as_its_create_lands_is_trashed_on_drive` in
|
||
`crates/pdfs-fuse/src/sim/daemon.rs`, which holds the drain between the two steps.
|
||
|
||
---
|
||
|
||
### 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
|
||
in a loop: two of nine runs failed. In one, a write to `f.txt` answered `ENOENT`; in the other,
|
||
a rename of `f.txt`. The mount showed the file as `a.txt` again.
|
||
|
||
**Where:** `refresh_after_upload` and `place_landed` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** `a.txt` was renamed to `f.txt` while its create was on the wire, with a write behind
|
||
it. The create landed, and its op became a revision with a rename queued after it. The upload
|
||
retires its op before it reads the node back, so the rename went out while Drive answered the
|
||
read-back, and that answer still had the old name. The drain asked whether a rename was queued
|
||
only after the answer came, found none, and put `a.txt` back into the tree. The create's
|
||
read-back already asks first (B161); the upload's did not.
|
||
|
||
**Fix.** The upload's read-back notes whether a rename is queued, and where the tree has the
|
||
file, before it asks Drive. The tree keeps the name and folder the user gave the file if a
|
||
rename was queued then or is now, or if the file moved meanwhile.
|
||
|
||
**Test:** `a_file_renamed_while_its_upload_is_read_back_keeps_its_new_name` in
|
||
`crates/pdfs-fuse/src/sim/daemon.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
did not drain within 4 s of the settle: it still held a trash of `b.txt` with one attempt and
|
||
the error "No such file or directory (os error 2)", due seconds later.
|
||
|
||
**Where:** `drop_doomed_ops` and `Db::record_op_failure` in `crates/pdfs-core/src/db/ops.rs`, and
|
||
the drain loop in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A file deleted after its create was sent keeps its op as a trash, in case the create
|
||
made the node and only its answer was lost (B151). The op kept the create's attempts, error and
|
||
backoff. The drain also recorded the failure of a create attempt still on the wire on the row,
|
||
which by then was the trash. Here the attempt failed because a write had replaced the blob it
|
||
was reading, and the trash waited out the create's backoff.
|
||
|
||
**Fix.** The trash starts with no attempts and is due at once, as a create that becomes a
|
||
revision already does. The drain records a failed attempt only if the op is still the kind it
|
||
ran as (`Db::record_attempt_failure`).
|
||
|
||
**Test:** `a_create_deleted_after_it_was_sent_leaves_a_trash_that_is_due_at_once` in
|
||
`crates/pdfs-core/src/db/tests.rs`.
|
||
|
||
---
|
||
|
||
### 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
|
||
`one_client_on_a_good_link` (seed 4; seed 5 in the local suite). A write at an offset was
|
||
refused with `EIO`, and the daemon logged "refusing write over incomplete queued revision".
|
||
Just before, an open had retried with "queued revision changed while opening for write", and
|
||
the queued write it then made was incomplete.
|
||
|
||
**Where:** `serve_open` in `crates/pdfs-fuse/src/filesystem.rs`, and `Core::refresh_after_upload`
|
||
in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** When an upload lands, the drain caches its bytes under the node as Drive has it,
|
||
rebases the open write handles onto it, lets go of the blob, and then puts the node in the
|
||
tree. A write open in between found no blob to copy and took the base it had sampled at its
|
||
start: the mtime and size the tree had before the upload. The release of its partial write
|
||
filled the gaps from the cache under that key, found nothing, and queued an incomplete
|
||
revision, and a write open over one is refused (B123). Under load, the open retried because
|
||
of the landing and met that window.
|
||
|
||
**Fix.** From before the drain lets go of the blob until the tree has the node, the landing
|
||
node is kept aside. A write open with no queued revision takes its base from that node, or
|
||
else from the tree as it is when the handle goes in, not from its first sample.
|
||
|
||
**Test:** `a_write_opened_as_an_upload_lands_takes_the_landed_revision_as_its_base` in
|
||
`sim/daemon.rs` holds the drain in that window with the fake Drive's new `hold_next_landing`,
|
||
writes at an offset, and writes again after it. Without the fix the second write fails with
|
||
`EIO`.
|
||
|
||
---
|
||
|
||
### 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
|
||
stop that came while a mirror pass was uploading logged "background workers did not finish in
|
||
time" for `pdfs-sync`, then "sync: task panicked … was cancelled" and a run of "no remote
|
||
parent" warnings: the pass had gone on to deeper folders while the stop waited for it, and the
|
||
runtime's end cancelled the uploads under it.
|
||
|
||
**Where:** `flush_batch`, `walk_remote` and `reconcile_pass` in `crates/pdfs-fuse/src/sync.rs`.
|
||
|
||
**Cause.** The mirror pass never asked whether the daemon was stopping. It sends its work a
|
||
level of folders at a time and went on to the next level after the stop, until the stop gave up
|
||
on it at its 10 s limit (B165).
|
||
|
||
**Fix.** Once the daemon is stopping, a batch starts none of its ops still waiting for their
|
||
turn, lets the ones already sending finish, and ends the pass. So do the remote walk and the
|
||
start of a pass. The folder keeps the state it had before the pass; the next start's pass picks
|
||
up from the baseline.
|
||
|
||
**Test:** `a_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending` in `sim/daemon.rs` holds a
|
||
mirror upload, stops the daemon, then lets the upload through, and checks that the file in the
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-04, by the journal check of the account run on the working tree after B165:
|
||
"re-interned an authority missing from the local tree; retrying uid=local~151607". The wide
|
||
directory case removed its folder while three of its files were still on their way to Drive.
|
||
The folder's trash landed first, and Drive refused the three creates (422). The drain then
|
||
logged "parent folder is gone remotely; creating in the root instead" for each, and failed
|
||
only because the deleted files' bytes were gone. The trashes the deletes had queued then
|
||
found the folder missing from the tree and read it back from Drive.
|
||
|
||
**Where:** `create_local_node` and `resolve_unknown_authority` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A delete of a file whose create is on the wire turns the create's op into a trash,
|
||
which later looks in the create's folder for a node the create may have made (B151). The
|
||
create's own failure path did not look at that: a create refused because its folder is gone
|
||
is re-homed in the root (for a folder trashed on another device), deleted file or not. And the
|
||
trash's access check asks about that folder, whose row the folder's own trash had removed, so
|
||
it read the trashed folder back into the cache, deferred, and only then found nothing to trash.
|
||
|
||
**Fix.** A create a delete withdrew is not re-homed. A withdrawn create's trash whose folder
|
||
is trashed or gone is done: Drive took the node it was looking for along with the folder. It
|
||
used to record a sync issue when the folder was gone for good.
|
||
|
||
**Test:** `a_file_removed_with_its_folder_while_uploading_stays_removed` in `sim/daemon.rs`
|
||
holds the file's create on its way to Drive until the folder's trash has landed, then checks
|
||
that nothing was sent to the root and the folder was not read back (each fails without its
|
||
half of the fix). `FakeClient::hold_next_create` is new for it.
|
||
|
||
---
|
||
|
||
### 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
|
||
B165, which found no control socket after the last daemon restart. Every start in that run
|
||
took 2 to 3 s from "mounting" to "hydrated metadata cache from db" (37,000 nodes), the one
|
||
before the cleanup 8.8 s. That was not the cleanup's failure: the socket is bound before the
|
||
hydrate, and a request made during it waits. The cleanup ran some 1.5 s after the restart,
|
||
before the new daemon got as far as binding it. `pdfs status` reports a daemon that is not
|
||
listening yet as not running, and the run's queue wait took that for an empty queue.
|
||
|
||
**Where:** `hydrate` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** The hydrate rebuilt the listing of every listed folder with a pass over all
|
||
entries: 4,600 listed folders times 37,000 entries. That took 1.7 s on an idle machine
|
||
(measured on a tree of that shape with entries of the real size), before the mount came up.
|
||
|
||
**Fix.** The listings are gathered in one pass over the entries (`listed_children`), which
|
||
takes 3 ms on the same tree.
|
||
|
||
**Test:** `hydrated_listings_hold_each_folders_live_children` in `lib.rs`: each listed folder
|
||
gets its live children, the root is not listed inside itself, and an unlisted folder gets no
|
||
listing.
|
||
|
||
---
|
||
|
||
### 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
|
||
My files and on on-demand pairing 1): "concurrent unlinks left the wrong entries". Every
|
||
create and all 64 trashes landed as they should, so the drain was right and the listing was
|
||
wrong. The run logs no names. The simulation lost files that were never unlinked. The same
|
||
run read `-starts-with-dash`, 17 bytes, back as 0 bytes on on-demand pairing 1, just as its
|
||
create landed, so B163's fix does not cover it.
|
||
|
||
**Where:** `list_children` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** A folder whose listing was dropped is listed from Drive, then overlaid with what
|
||
the queue still holds for it (B132). The queue was read after Drive answered. A create that
|
||
landed in between was in neither: Drive had taken the listing before the node existed, and
|
||
the queue had let go of it by the time it was read. The file was missing until the folder
|
||
was listed again. On Wi-Fi, with 16 workers landing creates while the event feed drops the
|
||
folder's listing, this happened in almost every run of the wide-directory case. When
|
||
Drive's listing already had the node, but from before its create was committed, the node had
|
||
no revision, and the file was listed at 0 bytes until its size was read again (the B163 state,
|
||
reached through a listing instead of the read-back).
|
||
|
||
**Fix.** The queued children are read before Drive is asked as well as after. A node queued
|
||
before and gone from the queue after has landed meanwhile, and is listed as the DB has it
|
||
now, under its name. One unlinked or moved away meanwhile is no longer a child of the folder
|
||
in the DB, so it stays out.
|
||
|
||
**Test:** `half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half` in `sim/daemon.rs`
|
||
(failed 4 of 4 runs before the fix, passed 8 of 8 after), and
|
||
`a_listing_taken_before_a_create_commits_keeps_the_landed_file`, which holds Drive's answer to
|
||
the listing until the create has landed (the daemon listed the file at 0 bytes before the
|
||
fix).
|
||
|
||
---
|
||
|
||
### B165 — A stop waits on a stuck thread until systemd kills the daemon
|
||
|
||
**Status:** Fixed (unverified).
|
||
**See also:** B259. A pass still has no deadline while the daemon runs, and a stuck or dead
|
||
sync engine is not reported.
|
||
**Found:** 2026-10-04, by the account run on 2.10.0 (cleanup after on-demand pairing 2). The
|
||
daemon was stopped at 15:53:04 and logged "control socket closing", but never "sync engine
|
||
stopping" or "daemon stopping". systemd killed it 15 s later (`TimeoutStopSec`). The run's
|
||
cleanup then found no control socket, because the new daemon had not opened it yet, and left
|
||
its folders behind.
|
||
|
||
**Where:** `stop_workers` in `crates/pdfs-fuse/src/mount.rs`; `mount_once` in
|
||
`crates/pdfs-cli/src/main.rs`.
|
||
|
||
**Cause.** A stop joined every background thread and waited as long as each one took. Every
|
||
clean stop in the run logged the sync engine stopping just before the control socket
|
||
closed. This one did not, so the engine was busy. It was most likely in a mirror pass over the
|
||
run's mirror folder: that pass calls Drive with no deadline and does not check for a stop.
|
||
The thread is inferred from the log, not observed.
|
||
|
||
**Fix.** The stop waits up to 10 s for the background threads and the FUSE workers together,
|
||
then names any thread still running in a warning and exits. A queued op it cut off is sent
|
||
again on the next start, as after a kill. The runtime is shut down with a 2 s limit, so a
|
||
blocking task cannot hold the exit either. The pass itself still has no deadline (STATUS,
|
||
"Any time").
|
||
|
||
**Test:** `a_stop_names_the_workers_still_running_at_its_deadline` in `mount.rs`.
|
||
|
||
---
|
||
|
||
### 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).
|
||
Eight threads wrote 128 files into a new folder, and the listing that followed held 129
|
||
entries. Each name was created on Drive once, and nothing was logged.
|
||
|
||
**Where:** `overlay_queued` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** A listing from Drive shows what is still queued for the folder over what Drive has
|
||
(B132). A create Drive has made but whose answer is still on its way, or which the drain is
|
||
still reading back, is in both: Drive lists it under its new uid, the queue under its stand-in.
|
||
That listing only happens when the folder's listing was dropped, and the feed's event for a
|
||
new file does that when it comes before the drain has noted the create as its own. Both were
|
||
listed under the same name.
|
||
|
||
The run logged nothing for the folder, so the event is inferred, not observed. It is the one
|
||
path found that lists a name twice.
|
||
|
||
**Fix.** A node queued in the folder holds its name there: Drive's node of the same name is
|
||
left out of the listing until the queued op lands.
|
||
|
||
**Test:** simulation test `a_create_drive_lists_before_it_is_read_back_is_listed_once` (failed
|
||
before the fix: the folder listed `c.bin` twice).
|
||
|
||
---
|
||
|
||
### 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
|
||
pairing). The case wrote some twenty files into a new folder, listed it and read each back.
|
||
`-starts-with-dash` read 0 bytes for 17. Its create had landed with the bytes a moment before.
|
||
No revision was queued for it and nothing was logged.
|
||
|
||
**Where:** `Core::read_back` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** After a create, the drain reads the new node back and adopts that answer into the
|
||
tree. Drive can answer a read before it lists a node it has just made, so an empty answer is
|
||
asked again (B143). It can also list the node before the create's revision is committed, as a
|
||
file with no active revision: no claimed size and nothing on storage, so its size reads as 0.
|
||
The adoption took that answer, and the tree showed the new file as empty. The folder was made
|
||
here, so its listing came from the tree. The size fetch the listing starts met the same answer
|
||
and left the 0. The kernel capped the read at it.
|
||
|
||
The run logged nothing for the file, so the stale answer is inferred, not observed. It is the
|
||
one path found that leaves a landed file empty with no op queued.
|
||
|
||
**Fix.** `read_back` treats a file without an active revision like a node Drive does not list
|
||
yet: it asks again after 250 ms and 1 s. If both answers are still without a revision, it
|
||
reports the node as not found, and the drain adopts it later, as it does for a node Drive
|
||
never lists in that time.
|
||
|
||
**Test:** simulation test `a_file_read_back_before_drive_committed_it_keeps_its_bytes` (failed
|
||
before the fix: the listing showed the file with 0 bytes). The fake Drive can now answer a
|
||
file's next reads as Drive lists a file that is not committed yet
|
||
(`FakeClient::answer_reads_unrevised`).
|
||
|
||
---
|
||
|
||
### 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`,
|
||
seed 4). The client removed folder `z` and made `z` again, with `z/x` and `z/x/y` inside. All
|
||
three stayed in the mount but never reached Drive. The log showed the new `z` "adopting" the old
|
||
one.
|
||
|
||
**Where:** `Core::adoptable_twin` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A create that finds its name taken looks for a node an earlier, unanswered attempt
|
||
of its own made (B127), and adopts it. The old folder's trash was still on the wire, so Drive
|
||
refused the new folder's name, and the listing showed the old folder, not in the tree any more
|
||
and made a moment ago. The new folder took it for its own twin. What went into the new folder
|
||
was created in the old one, and the trash then took it all along.
|
||
|
||
**Fix.** The twin search skips nodes this mount trashed (its `hidden` set), as listings already
|
||
do. The create then waits for the trash to free the name (`name_is_held`), or takes the name
|
||
at once if the trash has landed (B144).
|
||
|
||
**Test:** simulation test `a_folder_made_again_while_the_old_one_is_trashed_is_a_new_folder`
|
||
(failed before the fix: Drive ended up empty). The fake Drive can now hold a trash
|
||
(`FakeClient::hold_next_trash`).
|
||
|
||
---
|
||
|
||
### 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`,
|
||
seed 3). A file written and then moved at once ended up on Drive as `y/z/d`, but the mount
|
||
showed it as `y/z/c.bin`, its name before the move, and `d` was missing.
|
||
|
||
**Where:** `Core::adopt_real_uid` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A move made while a file's create is on the wire waits in the queue until the create
|
||
lands. Once the create is retired, the drain reads the new node back from Drive and adopts it.
|
||
The tree keeps the name the user gave only while the move is still queued. The drain asked
|
||
about that after the read-back. On a slow link the move could land in between: the answer
|
||
still had the old name, and the move was no longer queued, so the old name won.
|
||
|
||
**Fix.** The drain asks whether a move is queued before it reads the node back. It still asks
|
||
again under the tree's lock (B152).
|
||
|
||
**Test:** simulation test `a_file_moved_while_its_create_was_on_the_wire_keeps_its_new_name`
|
||
(failed before the fix: the new folder listed nothing). The fake Drive can now hold a read's
|
||
answer, taken when the read arrives, until the test releases it
|
||
(`FakeClient::hold_answer_to_next_read`).
|
||
|
||
---
|
||
|
||
### 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`,
|
||
seed 4). Folders made last before the client waited for its queue reached Drive 30 s late. The
|
||
log showed each `mkdir` queued, then claimed exactly 30 s later.
|
||
|
||
**Where:** `Core::queue_local_node` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** The drain workers sleep until an op is due, or for up to 30 s when none is. Every
|
||
other way of queueing an op (a write, a rename, a trash) wakes them. Queueing a folder's create
|
||
did not, so with the workers asleep it waited for their next poll. A file's create did not need
|
||
the wake: its bytes ride on the create, and the release that attaches them wakes the drain.
|
||
|
||
**Fix.** Queueing a folder's create wakes the drain.
|
||
|
||
**Test:** simulation test `a_folder_made_while_the_drain_is_idle_reaches_drive_at_once` (failed
|
||
before the fix: the folder was still queued after 10 s).
|
||
|
||
---
|
||
|
||
### 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`,
|
||
seed 4, one of ten runs on c2f9344). Half a second after the client restarted, a write into its
|
||
folder answered `EAGAIN`, and nothing was logged. The folder was still a queued create.
|
||
|
||
**Where:** `Core::ensure_children` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** A lookup in the root also places the "Shared with me" entry. It reads that entry's
|
||
plan from the database against a snapshot of the root listing, then publishes it only if the
|
||
listing has not changed meanwhile, and answers `EAGAIN` if it has. Right after a restart the
|
||
root is listed again while the first lookups come in, so a lookup could lose that race. Listing
|
||
a shared folder answers `EAGAIN` the same way when a newer refresh overtook it. No caller
|
||
retries, so the error reached the application.
|
||
|
||
**Fix.** `ensure_children` takes a listing again, up to four times, when an attempt answers
|
||
`EAGAIN`, and logs each retry at debug level.
|
||
|
||
**Test:** unit test `a_listing_changed_while_published_is_taken_again`. A stress test with
|
||
lookups and creates in the root at once did not hit the window (about 1,900 plans, none stale),
|
||
so the race itself has no regression test.
|
||
|
||
---
|
||
|
||
### 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,
|
||
four of ten runs on 4676a4e). A file was renamed to `b.txt`, whose name a create on the wire
|
||
still held, and then to `e.md`. Drive renamed it to `e.md` and then back to `b.txt`. The
|
||
liveness check found `b.txt` on Drive and `e.md` in the mount.
|
||
|
||
**Where:** `Db::record_op_failure` and `Db::release_op_claim` in
|
||
`crates/pdfs-core/src/db/ops.rs`.
|
||
|
||
**Cause.** B141's fix drops a rename that fails while a newer one is queued behind it. The worker
|
||
records the failure first and releases its claim after that. The second rename came in between:
|
||
the first was still claimed, so the second queued behind it, but the failure had already been
|
||
recorded with nothing behind it, so the first was kept with a backoff. Once released, it was not
|
||
due and the second was, so the second landed first. More workers made the gap easier to hit.
|
||
|
||
**Fix.** Releasing the claim of a rename drops it as well when a newer rename of the same node is
|
||
queued behind it. Both checks match the node by local id as well as by uid.
|
||
|
||
**Test:** unit test `a_rename_on_the_wire_lands_before_the_one_that_replaces_it`, extended.
|
||
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`
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation's Wi-Fi profile (`one_client_on_a_slow_link`, seed 4,
|
||
every replay on 16fca10, none on dbc4d9a: sixteen drain workers changed the timing). After an
|
||
upload of `c.bin` landed, the read-back of the node went unanswered. A write at an offset
|
||
followed, and the next write to the file failed with `EIO`. The daemon logged "refusing write
|
||
over incomplete queued revision".
|
||
|
||
**Where:** `Core::refresh_after_upload` and `Core::revision_conflict` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The uploaded bytes are cached under the revision the read-back returns (B123). With no
|
||
read-back, the drain let go of the staged blob and cached nothing. A partial write opened next had
|
||
nothing to fill its gaps from, so it queued an incomplete revision, and a write open over one is
|
||
refused. Had it been filled, the write would have landed as a conflict copy of itself: the
|
||
revision id of the upload was never recorded as ours (`own_sealed_rev`).
|
||
|
||
**Fix.** When the read-back fails, the uploaded bytes are cached under the size and mtime the
|
||
tree still has for the file, which is the base a write opened next takes, unless a newer write
|
||
has taken over. The SHA-1 of those bytes is kept in memory, and a queued write that finds the
|
||
remote holding them chains onto it instead of forking. After a restart the write still forks a
|
||
conflict copy, which loses nothing.
|
||
|
||
**Test:** sim test `a_file_whose_upload_was_not_read_back_takes_two_writes_at_once`, with the
|
||
fake Drive's new `lose_read_back_of_next_revision`. Without the fix the second write fails with
|
||
`EIO`; with only the cache fix, it 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,
|
||
two of six replays on e5f3f61). The create of `a.txt` timed out twice, but Drive had made the file
|
||
with the bytes of the first write. A later write replaced the blob the queued create carried. The
|
||
third attempt found the name taken and created `a (sync-conflict …).txt`; `a.txt` kept the old
|
||
bytes.
|
||
|
||
**Where:** `Core::adoptable_twin` and `Core::withdrawn_twin` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A twin is adopted when it holds the bytes the create uploads (B127), and only the blob
|
||
the op held at the retry was hashed. The twin held the bytes an earlier attempt sent.
|
||
|
||
**Fix.** Each attempt records the SHA-1 of the blob it uploads, by op id, in memory. A twin holding
|
||
any of them is ours. One holding older bytes than the op's is adopted, and the op's blob goes up
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation's Wi-Fi profile (`one_client_on_a_slow_link`, seed 2,
|
||
second replay on 7269cb5). `x/z/y/a.txt` was written after its create landed. The revision upload
|
||
reached Drive, but the answer was lost and the call timed out. On the retry, Drive held
|
||
`a (sync-conflict …).txt` beside the file, with the same bytes.
|
||
|
||
**Where:** `Core::revision_conflict` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A queued write remembers the revision it was made against. The retry found the file one
|
||
revision further, sealed by the lost upload, which this daemon never heard of, so it was not
|
||
recorded as our own (`own_sealed_rev`). The write was taken for a change from elsewhere and kept as
|
||
a conflict copy.
|
||
|
||
**Fix.** A complete write whose bytes the remote already holds, by size and SHA-1, is not a
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by CI on 7269cb5 (`one_client_on_a_good_link`, seed 9, every run). `c.bin`
|
||
was renamed to `d` while its create was on the wire, and a new `c.bin` was made. The new file landed
|
||
as `c (sync-conflict …).bin`, the mount showed that name, and `unlink c.bin` answered `ENOENT`.
|
||
|
||
**Where:** the failure path of `Core::run_pending_drain` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A regression from the B153 fix. The new file's create found `c.bin` held by the first
|
||
create, which was still on the wire, and failed as a wait. The B153 fix then woke the ops waiting
|
||
for the name. The database cannot see a create on the wire, only queued trashes and renames, so
|
||
it found the name free and the op was due again at once. Four waits went in 70 ms, and the create
|
||
took a conflict name.
|
||
|
||
**Fix.** No wake while another create on the wire holds the name. It frees the name when it
|
||
lands, or hands it to the rename queued behind it, which wakes the waiters when it lands. Sim
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation's LAN profile (`one_client_on_a_good_link`, seed 4,
|
||
about one run in five, also before the B151 change). `d` was renamed over `c.bin` while the
|
||
create of the old `c.bin` was on the wire. The queues did not drain within 4 s: the rename still
|
||
owed `c.bin is still held by a node whose trash or rename is queued`.
|
||
|
||
**Where:** the failure path of `Core::run_pending_drain` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The rename found the name held by the queued trash of the old `c.bin` and failed. The
|
||
trash landed a millisecond later and woke the ops waiting for the name
|
||
(`Db::wake_ops_waiting_for`), before the rename had recorded its failure, so there was nothing to
|
||
wake. The failure then set the doubled backoff of a network error, 4 s on the first attempt and
|
||
more after it.
|
||
|
||
**Fix.** A held name fails as `NameHeld`, which backs off by the minimum (2 s) without doubling,
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation's LAN profile (`one_client_on_a_good_link`, seed 4,
|
||
about one run in ten). `a.txt` was renamed to `b.txt` 3 ms before its create finished landing.
|
||
The next rename, `b.txt` to `e.md`, answered `ENOENT`, and the node row kept `a.txt` although
|
||
Drive had `b.txt`.
|
||
|
||
**Where:** `Core::adopt_real_uid` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** Adoption puts the node Drive made into the tree, but keeps the tree's name and folder
|
||
while a rename is queued. It asked whether one was queued before it wrote the remote uid into the
|
||
queue and took the tree lock. A rename between the two was overwritten with the name the create
|
||
was sent with, in the tree and in the node row, which was written after the lock.
|
||
|
||
**Fix.** Adoption asks again under the tree lock. `Core::queue_rename` queues its op before it
|
||
renames the entry under that lock, so a renamed entry always has its op. The node row is written
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation's Wi-Fi profile in CI (`one_client_on_a_slow_link`,
|
||
seed 2). `x/a.txt` was made, written and deleted within a third of a second. The queues never
|
||
drained, and Drive kept `x/a.txt` with the bytes of its create.
|
||
|
||
**Where:** `drop_doomed_ops` in `crates/pdfs-core/src/db/ops.rs`, and `Core::drain_trash` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A create that times out may still have made the file; only its answer was lost (as in
|
||
B127). The create stays queued with a backoff. A delete before the retry dropped the queued create
|
||
as if nothing had been sent, so the node Drive had made kept the deleted file's name and bytes.
|
||
Only a create on the wire at the time of the delete was turned into a trash (B129), and when that
|
||
create failed, the trash took it as never made.
|
||
|
||
**Fix.** A create that was sent, on the wire or before, becomes a trash that keeps the create's
|
||
folder and blob. The drain looks in that folder for a node under the create's name that the
|
||
create would have adopted (B127): made around when it was queued, and empty or holding the
|
||
blob's bytes. It trashes that node, and then discards the blob. A new create of the same name in
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the journal check of the account run on a6f36d5: 33 warnings, one
|
||
every two minutes for the whole run, all "queued write conflicts; keeping a conflict copy" for a
|
||
`write.bin` from an earlier run's B79 case, each followed by `DoesNotExist` from Drive.
|
||
|
||
**Where:** `Core::keep_as_conflict_copy` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A queued write to a file that was trashed or deleted on Drive is kept as a conflict
|
||
copy beside the file. When the file's folder was deleted too, the upload into it failed, the op
|
||
backed off and tried again, with no end. The copy only fell back to the root when the tree had
|
||
forgotten the file, not when Drive had lost its folder.
|
||
|
||
**Fix.** The copy checks that the folder is still on Drive and not trashed, and lands in the
|
||
location's root otherwise. Sim test `a_queued_write_whose_folder_was_trashed_lands_in_the_root`.
|
||
|
||
**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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the account run on a6f36d5: the B121 case failed on every on-demand
|
||
mount. After renaming and moving a file through the mount, it ran `pdfs rename` and `pdfs move`,
|
||
and `pdfs ls` then showed the file in the right folder under the name the mount had given it.
|
||
|
||
**Where:** `Core::rename` and `Core::move_to` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** Since changes made through the mount are queued (local-first), the mount's rename was
|
||
still in the queue when the CLI's requests came. Those went straight to Drive, landed first, and
|
||
the queued rename landed after them and put its own name back. A file whose create was still
|
||
queued could not be renamed by `pdfs` at all, since Drive does not know it yet.
|
||
|
||
**Fix.** `pdfs rename` and `pdfs move` within a mount are queued whenever the mount would queue
|
||
the same change, or the node has anything queued. The tree and the kernel are updated at once,
|
||
as for a rename through the mount, and a taken name is refused up front. A node whose create is
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, while planning the move of queued ops to local ids: every table keyed by
|
||
uid was checked for what happens when a placeholder lands, and `pins` was the one no landing
|
||
touched.
|
||
|
||
**Where:** `adopt_placeholder_row_tx` in `crates/pdfs-core/src/db/nodes.rs`.
|
||
|
||
**Cause.** A folder made through the mount has a placeholder uid (`local~…`) until its mkdir lands.
|
||
Pinning it then stored the placeholder in `pins.uid`. The landing moved the node row, the children
|
||
and the queued ops to the real uid, but not the pin, which then named no node: the folder was no
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by `sim::run::tests::three_clients_one_writer_each`, seed 2, in about one
|
||
run of eight. Client 1 made `a.txt`, renamed it to `b.txt`, and made `a.txt` again. Drive ended
|
||
up without `b.txt`, and `e.md`, a later rename of the new `a.txt`, held `b.txt`'s bytes.
|
||
|
||
**Where:** `Core::adoptable_twin` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The first file's create went out as `a.txt` and the rename was queued behind it. While
|
||
that create was on the wire, the second file's create found `a.txt` taken by a fresh, empty file,
|
||
which is how the node of an unanswered create of its own looks, and adopted it. Both local files
|
||
landed on the one node, and the first file's rename took it to `b.txt`.
|
||
|
||
**Fix.** A create no longer adopts a node another create is making under the same name and folder:
|
||
it waits for that one to land, as for any name a queued change holds. Nor does it adopt a node
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the CI simulation run (`sim::run`, profile one-client-echoes, seed 4).
|
||
Client 0 renamed `a.txt` to `b.txt`, and a millisecond later renaming `b.txt` to `e.md` failed
|
||
with `ENOENT`. Not reproduced locally.
|
||
|
||
**Where:** `Core::refresh_after_upload` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** After an upload lands, the drain reads the node back and puts Drive's metadata in the
|
||
tree. It keeps the tree's name only while a rename is queued, and it asked the queue before taking
|
||
the tree's lock. A rename queued between the two had already renamed the entry, and Drive's
|
||
metadata, which still had the old name, put it back.
|
||
|
||
**Fix.** The drain notes the entry's name and folder before it asks the queue. If either changed
|
||
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"
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the account acceptance run (B70 check). The browser-style rename
|
||
`b70-download.zip.crdownload` → `b70-download.zip` failed with `EBUSY`. The journal shows
|
||
`swept parked creates released=0 dropped=1` 3 ms after the create was queued, then
|
||
`queued create vanished under a rename`.
|
||
|
||
**Where:** `Core::sweep_parked_creates` and `park_verdict` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A transient file's create is parked until the rename that gives it its final name.
|
||
`queue_local_node` queues the create before the caller writes the node's row. The park sweep
|
||
reads a parked create with no row as a file that was deleted and drops it. A sweep that ran
|
||
between the two steps dropped a live file's create, and the rename found nothing to rename.
|
||
|
||
**Fix.** A parked create with no row is dropped only once it is a minute old and not open.
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation runs (`sim::run`, profile one-client-echoes, seed 4).
|
||
Client 0 renamed `d` to `c.bin` while the trash of the old `c.bin` was queued. The rename landed
|
||
as `c (sync-conflict …).bin`, and the client's tree and Drive disagreed.
|
||
|
||
**Where:** `Core::drain_rename_in_place`, `Core::drain_rename` and `Core::create_local_node` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** When Drive answers that the name is taken, the drain asks whether a queued trash or
|
||
rename still holds it, and if so waits for that to land. The trash landed between Drive's answer
|
||
and the check. Nothing held the name any more, so the drain took the answer as a real clash and
|
||
fell back to a conflict name.
|
||
|
||
**Fix.** When nothing holds the name, the drain sends the request once more under the wanted name
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation runs (`sim::run`, profile three-clients, seed 1).
|
||
Client 0 made `y/z/b.txt` while the link was down. When the link came back, the create landed and
|
||
the link went down again right after it. A write to the file was then kept as
|
||
`b (sync-conflict …).txt`: "expected 56 bytes at mtime 1791068288, found 56 at 1791068289". Only
|
||
client 0 writes that file.
|
||
|
||
**Where:** `Core::drain_local_node`, `Core::drain_op` and `Core::finish_adoption` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The gap B131 closed, for a link that drops instead of a lookup that is late.
|
||
`Core::read_back` asks again only while Drive answers that it does not list the node yet. When
|
||
the link is down it gives up, and `adopt_real_uid` fails after the create's op has retired. The
|
||
tree keeps the placeholder's node under the real uid, and nothing reads it back later. A write
|
||
opened after that takes the local clock's mtime as its base. Drive stamped the create with the
|
||
second it arrived, so the drain saw a revision it did not know and kept the client's own write as
|
||
a conflict copy.
|
||
|
||
**Fix.** A create whose read-back fails is remembered, under the uid Drive gave it. Before the
|
||
drain sends the next op for that uid, it reads the node back and rebases handles and queued writes
|
||
onto it, as `adopt_real_uid` would have done. If the read-back fails again, the op is retried
|
||
later, as for any other failed request.
|
||
|
||
**Test:** the daemon test `a_write_over_a_create_whose_read_back_failed_is_not_a_conflict`. The
|
||
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"
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation runs (`sim::run`, profile three-clients, seed 3).
|
||
Client 1 renamed `c.bin`, a file whose create was still queued, to `f.txt`, and got `EBUSY`.
|
||
|
||
**Where:** `serve_rename` in `crates/pdfs-fuse/src/filesystem.rs`.
|
||
|
||
**Cause.** A file whose create is queued is renamed by rewriting the name in the create op. When
|
||
the create landed between the lookup and the rewrite, the op was gone, and the rename answered
|
||
`EBUSY` as if the create had vanished.
|
||
|
||
**Fix.** When the op is gone, the rename looks up the node's row. If the create has landed, the
|
||
row already holds the uid Drive gave the file, and the rename is queued against that uid, as for
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation runs (`sim::run`, profile one-client-echoes, seed 2).
|
||
`e.md` was moved into `x`, and right after that into the client folder. Drive kept it in `x`,
|
||
and the liveness check found it in a different place on Drive than in the mount. In about one of
|
||
three replays.
|
||
|
||
**Where:** `Db::enqueue_op` and `Db::claim_next_due_op` in `crates/pdfs-core/src/db/ops.rs`.
|
||
|
||
**Cause.** A rename replaces the rename queued before it, by deleting that row. When a drain
|
||
worker had already taken the older row, deleting it also lifted the rule that one node has one op
|
||
in flight. A second worker sent the newer rename at once, while the first was still retrying its
|
||
own after Drive called it out of date (B111). The older one landed last.
|
||
|
||
**Fix.** A rename a worker holds stays queued, and the newer one waits behind it. If the older one
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-04, by the simulation runs (`sim::run`, profile one-client-flaky, seed 4),
|
||
replaying B132. The client folder `c0` was made with files in it, and its mkdir landed after the
|
||
mount had stopped. Right after the restart, a write to `c0/a.txt` failed with `ENOENT`, and the
|
||
listing of `c0` was empty. In about half of the replays.
|
||
|
||
**Where:** `adopt_placeholder_row_tx` in `crates/pdfs-core/src/db/nodes.rs` and `Core::hydrate` in
|
||
`crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** When a folder lands, the rows below it are handed to the real uid by their
|
||
`parent_uid` column only. The node each row holds (`node_json`) still named the placeholder. A
|
||
running daemon rewrites the rows from its tree on the next change, so this did not show. A restart
|
||
builds the tree from `node_json`, found no row for the placeholder and left the children out of the
|
||
folder.
|
||
|
||
**Fix.** The landing rewrites the parent in `node_json` as well, in the same statement.
|
||
|
||
**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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 1), at the
|
||
end check for lost data. A file made through the mount was written and renamed while its create
|
||
was queued. Its handle was closed just as the create landed. The daemon logged "cannot queue
|
||
write; bytes kept in staging" with "access changed before the write could be queued", and Drive
|
||
kept the bytes from before the last write.
|
||
|
||
**Where:** `Core::queue_revision` in `crates/pdfs-fuse/src/lib.rs` and `release` in
|
||
`crates/pdfs-fuse/src/filesystem.rs`.
|
||
|
||
**Cause.** A write handle keeps the uid the node had when it was opened. When the create lands,
|
||
`finish_create` retires the op and the placeholder row becomes the real node's, before the tree
|
||
moves the open handle over. A handle closed in that gap still named the placeholder, which no
|
||
longer had a row, so the access check refused it and the bytes went to staging instead of the
|
||
queue.
|
||
|
||
**Fix.** On close, a handle that names a placeholder looks up the node by its inode's local id,
|
||
which survives the landing, and queues the write against the real uid when the node has one.
|
||
|
||
**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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 9).
|
||
`c.bin` was created and renamed to `d` while its create was on the wire, and a new `c.bin` was
|
||
made at once. The new file landed on Drive under a conflict name.
|
||
|
||
**Where:** `drain_local_node`, `drain_rename` and `drain_rename_in_place` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** When Drive refuses a name, the drain waits only if a queued trash holds it
|
||
(`has_pending_trash_named`); otherwise it lands the op under a conflict name. A create on the wire
|
||
holds the name it was sent with until the rename queued behind it lands, and a queued rename away
|
||
from a name holds it until it lands. Several workers drain at once, so the new create could reach
|
||
Drive first.
|
||
|
||
**Fix.** The drain also waits while another create on the wire was sent with that name in that
|
||
folder, or a queued rename moves a node away from it. Those waits end after a few attempts, as two
|
||
files swapping names each hold the name the other wants. When a rename or trash frees a name, the
|
||
ops that failed on it are made due at once, and so is a create renamed away from a name it failed
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 9), at the
|
||
end check for lost data. A file renamed and then deleted stayed on Drive, and the queue was
|
||
empty.
|
||
|
||
**Where:** the `pending_op` table (`crates/pdfs-core/src/db/migrations.rs`) and `Db::delete_op`
|
||
in `crates/pdfs-core/src/db/ops.rs`.
|
||
|
||
**Cause.** `pending_op.id` was a plain `INTEGER PRIMARY KEY`, so SQLite hands out the largest id
|
||
plus one, and an id comes back once the newest row is deleted. Deleting a node drops its queued
|
||
ops and queues a trash in one transaction, so the trash could take the id of the rename the drain
|
||
was sending. When that rename landed, the drain deleted its row by id, which was now the trash.
|
||
|
||
**Fix.** Schema 37 rebuilds `pending_op` with `AUTOINCREMENT`, so an id is never given out
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 3), at
|
||
the end check for lost data. A file made offline was renamed after the drain had taken its create.
|
||
The create landed under the old name and the new name was gone. Every replay of the seed.
|
||
|
||
**Where:** `Db::finish_create` in `crates/pdfs-core/src/db/ops.rs`, `drain_local_node` and
|
||
`adopt_real_uid` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A rename of a node whose create is still queued rewrites the create's parent and name
|
||
(`Db::rewrite_op_target`) instead of queuing a rename. The drain copies the op when it takes it,
|
||
so a rewrite while the create was on the wire changed only the row. When the create landed,
|
||
`finish_create` deleted the row without looking at it again, and `adopt_real_uid` put the node
|
||
back where Drive had it. The move was lost on Drive and in the tree.
|
||
|
||
The same seed showed a second fault. When a folder made offline landed, the nodes in it still
|
||
named the folder's placeholder uid as their parent in memory. The next write of one of them put
|
||
the placeholder back on its row.
|
||
|
||
**Fix.** The drain passes where it sent the create and where it landed (a conflict name, or the
|
||
root when the folder was gone) to `finish_create`. When the row names another parent or name than
|
||
it was sent with, the same transaction queues a rename of the real uid to it, with the landed
|
||
parent and name as the original ones. `adopt_real_uid` keeps the tree's name and parent while
|
||
that rename is queued. When a node lands, its children's parent is changed to its real uid.
|
||
|
||
**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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 1), once
|
||
B131 was fixed. `rename a.txt -> y/f.txt` succeeded, and the `rename y/f.txt -> y/a.txt` two
|
||
milliseconds later failed with `ENOENT`. Once in about four replays of the seed.
|
||
|
||
**Where:** the online path of `serve_rename` in `crates/pdfs-fuse/src/filesystem.rs`.
|
||
|
||
**Cause.** After the move lands on Drive, `serve_rename` settles the tree with `State::relocate`.
|
||
It took the inode lock directly (`state.lock()`) instead of through `Core::state`, whose guard
|
||
writes what a mutation owes the database once the lock is released (DB1). So the writes stayed in
|
||
the state's outbox: both folders no longer listed, and the node's new parent. Whichever thread
|
||
next released a guard applied them, after it had let go of the lock. The lookup of `y/f.txt`
|
||
found `y` unlisted in memory and asked the database, which still marked `y` as listed and placed
|
||
the file in the root, so it rebuilt `y` without the file. It took another thread taking the lock
|
||
in between, so it happened only now and then.
|
||
|
||
**Fix.** `serve_rename` relocates through `Core::state`, so the database has the move before the
|
||
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
|
||
|
||
**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.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 3).
|
||
`c.bin` was written, renamed to `f.txt` and deleted, all online. The drain logged "queued write
|
||
conflicts; keeping a conflict copy" for `f.txt`, with "the file was trashed remotely". The copy
|
||
was named after the file, not `recovered-…`, so the tree still placed the file when the drain
|
||
looked. It did not come up again in five replays with debug logs.
|
||
|
||
**Where:** `trash_child` in `crates/pdfs-fuse/src/filesystem.rs`, and `Core::drain_revision` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** An online `unlink` trashes the node on Drive first, and only then forgets it in the
|
||
tree and drops its queued write (`discard_queued_ops`, or `withdraw_queued_ops` for an open
|
||
file). A drain worker that has claimed the write and reads the node in between finds it trashed.
|
||
B105's check asks whether the op is still queued, and it still is, so the drain keeps the write
|
||
as a conflict copy of a file the user deleted. When the copy runs before the unlink discards the
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-deletes, seed 3).
|
||
`y/e.md` was written, and the drain picked the write up. The step held the drain's read of the
|
||
node, deleted the file, and released the read. The next step wrote `a.txt`. The drain then logged
|
||
"queued write conflicts; keeping a conflict copy" for `y/e.md` as `recovered-L4`, with "the file
|
||
was trashed remotely". It is B105 again, though B105's fix was in place. Here the copy failed
|
||
because the blob was already gone.
|
||
|
||
**Where:** `Core::drain_revision` in `crates/pdfs-fuse/src/drain.rs`, `Db::op_exists` in
|
||
`crates/pdfs-core/src/db/ops.rs`, and the `pending_op` table in
|
||
`crates/pdfs-core/src/db/migrations.rs`.
|
||
|
||
**Cause.** B105's fix asks whether the op the worker holds is still queued, by id. `pending_op.id`
|
||
is an `INTEGER PRIMARY KEY` without `AUTOINCREMENT`, so SQLite gives a new row the largest id
|
||
in the table plus one, or 1 when the table is empty. The delete dropped the newest row, so the
|
||
next op queued took the same id. `op_exists` found that other op and said the write was still
|
||
wanted. A SQLite workload queues a write to the database right after deleting its journal, so
|
||
this is the usual case there, not a rare one.
|
||
|
||
**Fix.** With B137's: schema 37 gives `pending_op` ids with `AUTOINCREMENT`, so an op queued
|
||
after a delete never takes the dropped op's id.
|
||
|
||
**Test:** the simulation runs make the file again, or not, while the drain's read is held, and
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 4).
|
||
The first listing of the Drive root failed with the link down, and the client's folder `c0` was
|
||
made offline. The link came back, and the client restarted with 8 ops still queued, `c0`'s
|
||
mkdir among them. Right after the restart, a write to `c0/a.txt` failed with `ENOENT`. The kernel's
|
||
lookup of `c0` in the root had answered `ENOENT`. A moment later the mkdir landed.
|
||
|
||
**Where:** `Core::ensure_children` in `crates/pdfs-fuse/src/lib.rs`, with
|
||
`Core::relist_offline_listings` and `State::invalidate_listing`.
|
||
|
||
**Cause.** A listing served offline is invalidated when the link comes back
|
||
(`relist_offline_listings`), which also clears its `listed` row. The running daemon does not
|
||
notice: the kernel still holds the dentries. After a restart, the first lookup in the folder
|
||
lists it on Drive. Drive does not hold a node whose create is still queued, or does not list a
|
||
fresh one yet (the lag of B118). `ensure_children` takes Drive's listing as the whole folder and
|
||
records it as complete, without the queued node's placeholder. Every path through that node fails
|
||
with `ENOENT` until something lists the folder again. The same may happen without a restart
|
||
whenever a folder holding a queued create is listed on Drive.
|
||
|
||
**Fix.** A listing read from Drive is laid over with what the queue holds for the folder
|
||
(`Db::queued_children`): a node a queued create, mkdir or rename put there is listed as the DB has
|
||
it, and one a queued rename moved away is left out. Replays of the seed after this fix turned up a
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 1).
|
||
`a.txt` was created while the link was down, and the link came back right after. The create
|
||
landed, and the drain logged "pending upload failed; will retry" for its placeholder with
|
||
"fetch node: no such file or folder". Half a second later a write to `a.txt` queued a revision.
|
||
The drain kept it as `a (sync-conflict …).txt`: "expected 19 bytes at mtime 1791005792, found
|
||
19 at 1791005793". No other device had touched the file.
|
||
|
||
**Where:** `Core::drain_local_node` and `Core::adopt_real_uid` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** `drain_local_node` retires the create's op (`retire_create`) before
|
||
`adopt_real_uid` reads the new node back. Drive can answer a create before a lookup finds the
|
||
node (B118), and then the read-back fails. By then the op is gone, so the "will retry" never
|
||
happens. The tree's entry has the real uid but still holds the placeholder's node: the local
|
||
clock's mtime and no revision id. `rebase_open_writes` and `rebaseline_pending` never run. A
|
||
write opened afterwards takes that node as its base. Drive stamped the file when the create
|
||
landed, and if that was in a later second than the placeholder's mtime, the drain sees the
|
||
remote revision changed and keeps the client's own write as a conflict copy.
|
||
|
||
Once the read-back succeeded, the same seed found a second gap. A write handle open on the file
|
||
is repointed at the real uid when the op retires, and its release fills its gaps from the content
|
||
cache. `adopt_real_uid` cached the create's bytes only after the read-back, which waits for Drive
|
||
to list the node. A release in between found nothing to fill from and queued an incomplete
|
||
revision, and the next write open was refused with `EIO` (as in B123).
|
||
|
||
**Fix.** `adopt_real_uid` reads the node back with `Core::read_back`, which asks again for a moment
|
||
while Drive does not list it yet, as the online create does (B118). Before the op retires,
|
||
`Core::cache_created` caches the uploaded bytes under the real uid, keyed by the size and mtime
|
||
the tree has for the file, which is what an open handle has as its base. `adopt_real_uid` caches
|
||
them again, keyed as Drive has the node, before it rebases anything onto it.
|
||
|
||
**Test:** seed 1 of three-clients passes with `PDFS_SIM_KNOWN=fail`. Before the fix it failed on
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 1).
|
||
After the last step, a read of `c0/z/f.txt` never returned. The watchdog's stacks showed the
|
||
read's worker in `read_range_remote`, waiting in `block_on` for its fetch, and a runtime worker
|
||
running `run_event_sync` blocked in the kernel in `inval_inode`, waiting for a page lock. The
|
||
read had started at 05:13:38. Its 120 s timeout, a 20 s `bounded` call and every other timer
|
||
of the runtime all fired at 05:17:24, the moment the watchdog aborted the mount.
|
||
|
||
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`, which runs on the runtime and
|
||
flushes its invalidations through `Core::for_each_mount` and `NotifyBatch::flush` in
|
||
`crates/pdfs-fuse/src/lib.rs`, and `Core::read_range_remote` in `crates/pdfs-fuse/src/reads.rs`.
|
||
|
||
**Cause.** The kernel holds the page lock of the pages a FUSE read fills until the daemon
|
||
answers. `inval_inode` on the same file waits for that lock, so it waits as long as the read.
|
||
The event sync sends it from a runtime worker, which blocks that worker in the kernel. If that
|
||
worker was the one parked on the runtime's driver, nothing drives the timers or the I/O any
|
||
more: tokio hands the driver on only when a parked worker is woken for new work, and the others
|
||
sleep on a condvar. The read's fetch waits on a timer (here the fake's latency, on Drive an
|
||
HTTP response), so it never finishes and never lets go of the page lock. The daemon stays stuck
|
||
until some other work wakes a worker, and on an idle machine that may never happen.
|
||
|
||
**Fix.** Every notice to the kernel goes through `off_the_runtime`, which on a runtime worker
|
||
sends it from inside `block_in_place`: the worker's tasks, and the driver with them, go to
|
||
another thread while it waits. Anywhere else the notice is sent as before.
|
||
|
||
**Test:** `a_kernel_notice_sent_from_the_runtime_leaves_its_timers_running` in
|
||
`crates/pdfs-fuse/src/lib.rs`: the one worker of a runtime sends a notice that returns only once
|
||
a timer of that runtime has fired. The simulation runs still recognise it in the watchdog's
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 4).
|
||
`a.txt` was created, renamed to `b.txt` and then to `e.md` while its create was still queued.
|
||
A moment later `rename f.txt -> e.md` failed with `EIO`, and the daemon logged "rename failed"
|
||
with Drive's `AlreadyExists`.
|
||
|
||
**Where:** `Core::remove_replaced` and `Core::discard_queued_ops` in `crates/pdfs-fuse/src/lib.rs`,
|
||
`trash_child` in `crates/pdfs-fuse/src/filesystem.rs`, and `drain_local_node` and
|
||
`upload_created_file` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A node whose create is still queued has no node on Drive, so deleting it or renaming
|
||
another file over it only drops the queued op (`discard_queued_ops`). That cannot stop a drain
|
||
worker that has already claimed the op. `cancel_upload` only reaches revisions, and the upload
|
||
of a create has no cancel flag. The create lands anyway. `finish_create` finds no op row to
|
||
retire, and the tree has already forgotten the local uid, so nothing trashes the new node.
|
||
Drive keeps a file that the mount no longer shows. Its name stays taken: an online rename onto
|
||
it, as here, fails with `EIO`, and a later create with that name lands as a conflict copy.
|
||
|
||
**Fix.** A delete or replacing rename no longer drops a create the drain has taken. It turns
|
||
the row into a trash of the placeholder, still claimed by the upload, which holds the name as any
|
||
queued trash does. When the create lands, `finish_create` points that trash at the landed node in
|
||
the same transaction, and the drain sends it at once; if that fails it stays queued with the usual
|
||
backoff. A create whose row is gone altogether gets such a trash too. A trash of a placeholder
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 3).
|
||
While the link was down, `f.txt` was deleted and a new `f.txt` was made. Its first version had
|
||
not landed yet, so Drive held it empty. When the link came back, the drain logged "the name is
|
||
held by our own unanswered create; adopting it" with the old file's uid, then "pending trash
|
||
landed" for that uid. The queue drained empty, and Drive held no `f.txt`, only the trashed old
|
||
one.
|
||
|
||
**Where:** `drain_local_node` and `adoptable_twin` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** Several workers drain at once, so the new file's create can run before the trash of
|
||
the old one has landed, and Drive refuses the name. On that refusal the drain first looks for a
|
||
node its own unanswered create made: one with that name, made within `ADOPT_WINDOW_MS`, and
|
||
empty for a file. The old file matched, because it was made a moment earlier and its bytes were
|
||
still queued. The drain adopted it, so the new bytes went to it as a revision, and the queued
|
||
trash then trashed it. Only after the adoption check does the drain ask whether a queued trash
|
||
holds the name (`has_pending_trash_named`), and the adoption does not exclude a node that has
|
||
one.
|
||
|
||
**Fix.** With B162's: the adoption skips a node this mount trashed (its `hidden` set), which a
|
||
queued trash puts it in, and the create waits for the trash to free the name (B144).
|
||
|
||
**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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 1).
|
||
The upload of a queued create timed out after Drive had made the file. On the next attempt the
|
||
drain logged "name is taken remotely; creating under a conflict name". Drive ended with the file
|
||
and with a conflict copy of the same bytes next to it.
|
||
|
||
**Where:** `drain_local_node` and `adoptable` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** When a create finds its name taken, the drain adopts the node only if it looks like
|
||
one its own unanswered create made (`adoptable_twin`). It must be made within `ADOPT_WINDOW_MS`
|
||
and, for a file, be empty. A queued create uploads its bytes in the same call, though
|
||
(`upload_created_file`). So the node a lost answer leaves behind holds those bytes, is not
|
||
adopted, and the file is uploaded a second time under a conflict name.
|
||
|
||
**Fix.** A file is also adopted when Drive's SHA-1 of its content is that of the op's blob. The
|
||
blob then counts as uploaded. Sim test `a_create_whose_answer_was_lost_lands_once`. A write that
|
||
replaced the blob after the lost upload still forks a copy: the node holds the older bytes.
|
||
|
||
**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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile three-clients, seed 3).
|
||
While the link was down, `b.txt` was renamed to `c.bin`, and a new `b.txt` was made. When the link
|
||
came back, the drain logged "name is taken remotely; creating under a conflict name" for
|
||
`b.txt`. The new file ended up only as `b (sync-conflict …).txt`.
|
||
|
||
**Where:** `drain_local_node` in `crates/pdfs-fuse/src/drain.rs`, and the claim order in
|
||
`Db::claim_next_due_op` in `crates/pdfs-core/src/db/ops.rs`.
|
||
|
||
**Cause.** The drain serialises the ops of one node, but not ops on different nodes that share a
|
||
name. Several workers drain at once, so the create of the new `b.txt` can reach Drive before the
|
||
rename that frees the name has landed. Drive then refuses it as taken. `drain_local_node` waits
|
||
when the name is held by a node whose trash is queued (`has_pending_trash_named`), but not when a
|
||
queued rename is about to move it away. It treats the name as someone else's file and lands the
|
||
new one under a conflict name.
|
||
|
||
**Fix.** With B138's: the create also waits while a queued rename moves a node away from its
|
||
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
|
||
|
||
**Status:** Fixed.
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seeds 3
|
||
and 4). Right after a restart, `rmdir y` (seed 3), and a write to a file in the client's folder
|
||
(seed 4), failed with `EIO`. The daemon logged "enumerate folder children failed" with Drive's
|
||
404 for a `local~` folder uid. Each folder had been made while its create was being queued:
|
||
offline, or, as for the client's own folder, through B118's fallback when the read-back missed.
|
||
|
||
**Where:** `Core::ensure_children` in `crates/pdfs-fuse/src/lib.rs`, and everything that calls
|
||
`State::invalidate_listing`.
|
||
|
||
**Cause.** A folder whose mkdir is still queued exists only here, so `serve_mkdir` records it as
|
||
listed and empty, and `ensure_children` never asks Drive about it. `State::invalidate_listing`
|
||
clears that flag for any folder, though, and so do its callers: `relocate` after a rename, and
|
||
`relist_offline_listings` when the link comes back. The next access asks Drive to list a `local~`
|
||
uid. Drive answers 404, and every lookup in the folder fails with `EIO` until its mkdir lands.
|
||
Right after it lands, Drive may not list the new folder yet (the lag of B118). Then the listing
|
||
fails with a 404 for the real uid too.
|
||
|
||
**Fix.** A folder still known by its stand-in is listed from the database and never asked of
|
||
Drive. Until its mkdir lands the folder is only here; once it has, the database holds what Drive
|
||
does. The second case, a listing by the real uid that Drive does not show yet, needs a create
|
||
that landed without being read back: the read-back waits up to 1.25 s for Drive to list the
|
||
folder, longer than the lag of any simulation profile. The runs keep counting both until they
|
||
show neither again.
|
||
|
||
**Test:** `a_folder_whose_mkdir_is_queued_lists_after_a_refresh` in
|
||
`crates/pdfs-fuse/src/sim/daemon.rs`: with sync paused, a folder is made with a file in it, a
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seed 1). A
|
||
rename `x/f.txt -> z/d` over an existing `z/d` failed with `EIO` (B122). Then `create z/d`
|
||
failed with `ENOENT`, although `z/d` was back on Drive.
|
||
|
||
**Where:** `Core::remove_replaced` and `Core::restore_replaced` in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** Before the rename, `remove_replaced` trashes the destination on Drive, drops it from
|
||
every mount's state and adds it to `hidden`, which filters listings. When the rename then fails,
|
||
`restore_replaced` restores the node on Drive but does not take it out of `hidden` or put it back
|
||
in the state, as the ordinary restore does. Until the daemon restarts, the file is on Drive but
|
||
missing from the mount, and a create by its name fails.
|
||
|
||
**Fix.** `restore_replaced` takes the node out of `hidden`, and drops the listing of the folder
|
||
it is restored to, and the kernel's entry for its name, in every mount that shows the folder. The
|
||
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`
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seeds 5 and
|
||
9). A write at an offset (seed 5) or a create over an existing file (seed 9) failed with `EIO`.
|
||
The daemon logged "refusing write over incomplete queued revision". The one-client-echoes profile,
|
||
whose events come late, twice and out of order, found it in four more seeds.
|
||
|
||
**Where:** the write open in `crates/pdfs-fuse/src/filesystem.rs`, the `complete` flag that
|
||
`queue_revision` in `crates/pdfs-fuse/src/lib.rs` stamps on a queued revision, and everything that
|
||
leaves the content cache without the bytes of the file's base.
|
||
|
||
**Cause.** A write open over a queued revision that is not complete is refused by design, because
|
||
a second partial edit on top of one with gaps cannot be represented. Ordinary cases left a
|
||
revision incomplete, though:
|
||
|
||
- A file closed empty never counted as complete. `queue_revision` compared the authored ranges
|
||
with `[(0, len)]`, which for a length of 0 is `[(0, 0)]`, while the authored ranges are empty.
|
||
The truncate path already treated a size of 0 as complete.
|
||
- The gaps of a partial edit are filled from the content cache, and the cache could be empty or
|
||
out of reach then:
|
||
- The echo of our own change evicted it, taken for someone else's. `rename_remote`,
|
||
`move_remote` and the drain's `move_renaming` did not record an echo, and neither did a
|
||
create made online, so the create's echo took the one the upload recorded. The feed can also
|
||
deliver an event twice, or replay one after a batch out of order, and the second copy found
|
||
the echo already taken.
|
||
- Drive stamps a link's `ModifyTime` on a rename or a move too. The listing after it moved the
|
||
file's mtime, and the cache, keyed by mtime and size, no longer matched.
|
||
- When an upload landed, the drain let go of the staged blob, read the node back, rebased open
|
||
write handles onto it, and only then cached the blob. A handle released in between found
|
||
neither the blob nor the cached bytes.
|
||
|
||
Either way, the next write open on the file within the drain debounce (`DRAIN_REVISION_DEBOUNCE`,
|
||
2 s) failed, until the revision had landed.
|
||
|
||
**Fix.** `StagedWrite::covers` counts an empty file as complete, at every site that sets the flag.
|
||
`Core::changed_remote` records the echo of every rename and move, and `fetch_minted_node` the echo
|
||
of every create made online. The event sync skips a node update whose id it applied lately
|
||
(`RecentEvents`). `State::intern` keeps a file's mtime, and the size a listing left out, while its
|
||
active revision id stays the same. `refresh_after_upload` caches the uploaded bytes before it
|
||
rebases open handles, and lets go of the staged blob only after.
|
||
|
||
**Test:** the unit tests `an_empty_write_covers_the_file_and_a_gap_does_not`,
|
||
`a_node_update_seen_again_is_skipped_but_other_events_are_not`,
|
||
`a_renamed_file_keeps_its_mtime_until_its_content_changes` and
|
||
`a_rename_or_move_drops_the_sdk_copy_and_expects_its_echo`, and the FUSE tests
|
||
`a_file_closed_empty_takes_a_second_write_at_once` and
|
||
`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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-lan, seeds 1, 7
|
||
and 10). A `rename(2)` like `x/f.txt -> z/d` failed with `EIO` while `x/d` existed. The daemon
|
||
logged "rename failed" with Drive's `AlreadyExists`.
|
||
|
||
**Where:** `serve_rename` in `crates/pdfs-fuse/src/filesystem.rs`.
|
||
|
||
**Cause.** Online, a rename that changes both the folder and the name is done in two calls. It
|
||
renames in the source folder first, then moves (`rename_remote`, then `move_remote`). A sibling in
|
||
the source folder with the new name makes Drive refuse the first call, although the destination
|
||
is free. B46's follow-up already notes that this path was not converted to the single
|
||
`move-multiple` request the drain uses. If the rename was replacing a file, B124 follows.
|
||
|
||
**Fix.** `Core::move_rename_remote` moves and renames in one `move-multiple` request, so the name
|
||
only has to be free in the destination. `serve_rename` uses it for every rename across folders,
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-03, in the journal of the account run on flaky Wi-Fi. Each change answered
|
||
correctly, but slowly. One rename and move through the mount collected 13 `operation failed …
|
||
method=PUT status=422` warnings over 8.2 seconds, and another collected 7 over 3.9 seconds. Each
|
||
one was a request that Drive refused as out of date and the daemon tried again half a second
|
||
later.
|
||
|
||
**Where:** every remote rename and move: `serve_rename` in `crates/pdfs-fuse/src/filesystem.rs`,
|
||
the control `rename` and `move_to` in `crates/pdfs-fuse/src/lib.rs`, `move_between` in
|
||
`crates/pdfs-fuse/src/relocate.rs`, and the drain's renames and `move_renaming` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** A rename or move sends the node's current name hash as `OriginalHash`. The SDK takes
|
||
it from its entity cache before the hash in the link it has just fetched. Its own rename and move
|
||
do not update that cache entry. Only the event feed's echo of the change drops it, and the daemon
|
||
polls the feed every 10 seconds (`POLL_INTERVAL`). Until then, each rename or move of the same
|
||
node sent the old hash. A rename across folders, which is a rename and then a move, was refused
|
||
until the next poll, and the retries from B111 hid that as latency. A plain move was not retried
|
||
at all. When it came within the poll interval, `mv a b && mv b dir/` through the mount failed with
|
||
`EIO`, and `pdfs rename` followed by `pdfs move` failed with Drive's "out of date" error.
|
||
|
||
**Fix.** `Core::forget_sdk_node` passes the SDK the event its own change will echo, which drops
|
||
the cached node. Every rename, and every move but the drain's, goes through `Core::rename_remote`
|
||
or `Core::move_remote`. Both drop the entry once the change has landed, and before each retry of a
|
||
request Drive called out of date. The drain's `move_renaming` drops it once its move lands.
|
||
|
||
**Test:** the unit tests `a_rename_or_move_drops_the_sdk_copy_and_expects_its_echo` and
|
||
`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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-02, in the journal of the account run on flaky Wi-Fi. A folder made while the
|
||
link was down was queued as a mkdir. When it landed, the journal logged `pending create landed …
|
||
kind=mkdir`, and right after it `re-interned an authority missing from the local tree; retrying`
|
||
and `pending operation deferred … reason="the node was missing locally and has been re-read"` for
|
||
a file queued inside it. The file landed on the next recheck. No bytes were lost.
|
||
|
||
**Where:** `Db::finish_create` and `Db::remap_local_uid` in `crates/pdfs-core/src/db/ops.rs`, and
|
||
`adopt_real_uid` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** When a queued create landed, `finish_create` repointed the queued children at the real
|
||
uid and deleted the placeholder's row in one transaction. The real node's row was only written
|
||
later, by `adopt_real_uid`, after it had read the node back from Drive. A drain worker that picked
|
||
up a child in between found no row for its parent. It read the folder again from Drive and put
|
||
the child off by one recheck (`DRAIN_ACCESS_RECHECK`, 5 seconds).
|
||
|
||
**Fix.** `adopt_placeholder_row_tx` in `crates/pdfs-core/src/db/nodes.rs` hands the placeholder's
|
||
row to the real uid in the same transaction, along with the rows below it, and drops the old row
|
||
from the search index. `adopt_real_uid` writes the server's copy over it later. A row that the
|
||
real uid already has, from the event stream, is newer and is kept.
|
||
|
||
**Test:** the db tests `a_landed_folder_keeps_a_row_until_the_server_copy_replaces_it` and
|
||
`a_landed_placeholder_never_overwrites_the_server_copy`. The suite cannot queue a mkdir on demand,
|
||
because a mkdir online is made at once even while sync is paused. Instead, `--journal-check`
|
||
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
|
||
|
||
**Status:** Fixed (unverified since the second fix below; "regression B119" passed on one account
|
||
run of 2026-10-03 and failed on the next).
|
||
**Found:** 2026-10-02, by the account run on flaky Wi-Fi, which crashed at its very end. The
|
||
run's last step reads back a file it wrote to compare digests. The Wi-Fi had dropped, the read
|
||
waited for `open_revision` for 120 seconds and then failed with `EIO`, and the suite stopped with
|
||
an `OSError` traceback.
|
||
|
||
**Where:** the landed-upload path of the drain in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** Once an upload landed, the drain kept the staged bytes as cached content only for a
|
||
pinned file. For any other file it discarded them, and then dropped the reader too. The next read
|
||
of a file that had just been written therefore downloaded it again, and without a link it could
|
||
not.
|
||
|
||
**Fix.** After the upload, `refresh_after_upload` reads the node back and now returns it. The
|
||
staged blob becomes the file's cached content, linked rather than copied, and stamped with the
|
||
mtime and size of the revision just read back, so the cache treats it as current. A file larger
|
||
than a sixteenth of the cache budget (`LANDED_KEEP_SHARE`, 320 MiB of the default 5 GiB) is still
|
||
discarded unless it is pinned, so one large upload cannot push the rest of the cache out. Nothing
|
||
is kept when the read-back fails or reports a different size. The suite records a failed end-of-run
|
||
read-back as a failed check instead of crashing.
|
||
|
||
**Found again** on 2026-10-03, by "regression B119" on an account run over a slow link (uploads of
|
||
a few bytes took 1.4 seconds). The case failed with "block.bin not cached after the upload" at the
|
||
same second the journal logged that upload as landed.
|
||
|
||
**Cause, second part.** The drain retired the op first, and only then read the node back and
|
||
linked the blob into the cache. For that one round trip the status reported an empty queue for a
|
||
file that was not yet cached. Anything that waits for an empty queue before reading could still
|
||
need the network.
|
||
|
||
**Fix, second part.** `Core::landing_uploads` counts an upload from just before its op is retired
|
||
until its bytes are cached, and the status adds it to `pending_uploads`. The status reads the
|
||
queue before the count, so it always sees the upload in one of them. The op still goes first: an
|
||
op restored after a crash would re-send a revision this daemon has not yet recorded as its own,
|
||
and `revision_conflict` would keep it as a conflict copy.
|
||
|
||
**Test:** the drain tests `a_landed_upload_stays_cached_unless_it_would_crowd_the_cache_out` and
|
||
`a_landing_upload_counts_until_its_guard_goes`, `a_landed_upload_stays_pending_until_its_bytes_are_cached`
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-10-02, by the account run on flaky Wi-Fi. In the first on-demand folder, case 14
|
||
failed in its sqlite step with "unable to open database file", and case 15 failed in `mkdir`
|
||
with `FileNotFoundError`. In both, the file or folder had been made on Drive.
|
||
|
||
**Where:** `serve_create` and `serve_mkdir` in `crates/pdfs-fuse/src/filesystem.rs`.
|
||
|
||
**Cause.** After a create or mkdir succeeds, the mount reads the new node back to answer the
|
||
kernel. Drive can answer the create before its listing returns the new node. The read-back then
|
||
came back empty, which `fetch_node` reports as `ENOENT`, and the mount passed that on with nothing
|
||
in the log. The program saw its create fail for something that existed.
|
||
|
||
**Fix.** `Core::fetch_minted_node` asks again after 250 ms and after one more second
|
||
(`MINTED_READ_BACK_DELAYS`). If the node still cannot be read, the mount queues the create as it
|
||
does offline (`queue_local_node`), and the drain adopts the existing node by its name. The program
|
||
gets its file or folder either way. `pdfs mkdir` uses the same read-back, and when it still misses
|
||
it says that the folder was made but is not listed yet, instead of reporting that it does not
|
||
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
|
||
|
||
**Status:** Fixed (verified 2026-10-03: "regression B117" passed in My files on the account run,
|
||
and its journal had no deferral).
|
||
**Found:** 2026-10-02, while verifying B113 live. Three files were moved with `pdfs move` while
|
||
their uploads were queued. Until each upload landed, `pdfs sync queue` listed it by its node id
|
||
instead of its new path. After the resume, the first attempt of each logged `pending operation
|
||
deferred … reason="the node was missing locally and has been re-read"`, and the upload landed on
|
||
the next recheck, about 8 seconds later. No bytes were lost.
|
||
|
||
**Where:** `settle_source` in `crates/pdfs-fuse/src/relocate.rs` and `Core::rename` in
|
||
`crates/pdfs-fuse/src/lib.rs`, which forget the node. `pending_op_infos` in
|
||
`crates/pdfs-fuse/src/queue.rs`, and `resolve_unknown_authority` in `crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** `State::forget` deletes the node's row as well as its entry in memory, so the mounts
|
||
re-listed it under its new path. Until a listing brought the row back, nothing local knew the
|
||
node. `pending_op_infos` then had no path for the queued op and fell back to its name, or to the
|
||
raw uid. The drain's access check found no node either. It re-read the node from Drive, stored it
|
||
and put the op off to the next recheck instead of going on with it. `pdfs rename` forgot the node
|
||
the same way.
|
||
|
||
**Fix.** `pdfs move` and `pdfs rename` move the node instead of forgetting it
|
||
(`Core::relocate_everywhere`). Each mount that has the destination folder moves the node in place
|
||
with `State::relocate`, which keeps its inode and writes its row through. A mount without the
|
||
destination drops the node from memory only and keeps the row. When no mount had the node, its
|
||
row is moved directly. Both names are invalidated in the kernel, as B114 needs.
|
||
|
||
**Test:** the fuse test `a_control_move_keeps_the_node_and_its_row_and_drops_the_old_name`, and
|
||
the acceptance case "regression B117". It pauses sync, rewrites an uploaded file and moves it with
|
||
`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
|
||
|
||
**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
|
||
version wrote `*.part` files, which park their create until a rename instead of minting the file
|
||
(B70). It moved one of them, still named `moved.part`, into the subfolder `b113/moved here` and
|
||
then hung, waiting for a queue that held a parked op. The run was interrupted, and its cleanup
|
||
trashed the test root with `pdfs rm` and deleted it permanently. The parked create stayed queued
|
||
and kept its staged bytes. The cleanup reported it as left over (`queued create #2 for
|
||
…/b113/moved here/moved.part`).
|
||
|
||
**Where:** `drop_doomed_ops` in `crates/pdfs-core/src/db/ops.rs`, which
|
||
`Core::discard_queued_ops` runs when a folder is trashed.
|
||
|
||
**Cause.** The ops removed with a folder are those for the folder and for the nodes queued below
|
||
it through `pending_op.parent_uid`, plus the revisions of every file the node tree places below
|
||
it (B102). A create for a file inside a *real* subfolder is neither: its parent is not a queued
|
||
node, and the tree only contributes revisions. Such a create comes from a transient name, or
|
||
from a file created offline in a folder that already exists on Drive. A parked one stays queued
|
||
for good, holding its bytes. One that is due would run into a trashed parent, and by the code the
|
||
drain then re-homes it to the root (`parent folder is gone remotely; creating in the root
|
||
instead`), so the file would reappear in My files after its folder was trashed. Only the parked
|
||
case has been seen.
|
||
|
||
**Fix.** `drop_doomed_ops` also takes the queued creates and mkdirs whose parent is anywhere in
|
||
the node tree below the folder, together with everything queued below those.
|
||
|
||
**Test:** the db test `trashing_a_folder_drops_the_queued_creates_in_its_subfolders`, and the
|
||
acceptance case "regression B116", which replays the due case. With sync paused, it writes a
|
||
`*.part` file two folders below a new folder and renames it to its final name, so its create is
|
||
queued and due. It then trashes the folder with `pdfs rm` and expects the create gone. It restores
|
||
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
|
||
|
||
**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
|
||
6-byte `note.txt` as 57 bytes, and listed `tickets.pdf` as 202316 bytes where the mount's `stat`
|
||
said 202257. The acceptance case "open B115" then showed it for every file it wrote, in My files
|
||
and in an on-demand folder, right after the upload and again after `pdfs refresh`: 6 bytes listed
|
||
as 57, 4097 as 4150, and 4 MiB + 7 bytes (4194311) as 4194421. The mount's `stat` had the real
|
||
sizes throughout.
|
||
|
||
**Where:** `node_size` in `crates/pdfs-fuse/src/lib.rs` and `crates/pdfs-core/src/db/nodes.rs`.
|
||
|
||
**Cause.** `node_size` reports the node's `claimed_size` and falls back to
|
||
`total_size_on_storage` when it has none. That is the size on Drive's storage: encrypted blocks
|
||
plus their signatures. A folder is first listed without claimed sizes, to be quick, and the real
|
||
sizes are fetched for the whole listing in the background. `stat` through the mount waits for that
|
||
fetch. `pdfs ls` and the app's file browser read the listing straight away, so they showed the
|
||
encrypted sizes until it landed.
|
||
|
||
**Fix.** `Core::list_dir`, which serves both, waits for the listing's size fetch first
|
||
(`settle_listing_sizes`), for at most `SizeUpgrade::WAIT` (10 seconds), as `stat` does. Offline it
|
||
does not wait, since the fetch cannot succeed.
|
||
|
||
**Test:** the fuse tests `a_listing_for_the_cli_settles_sizes_before_reading_them` and
|
||
`a_listing_waits_for_the_whole_size_batch_but_not_forever`, and the acceptance case
|
||
"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
|
||
|
||
**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
|
||
folder was gone from `ls ~/Downloads`, but `ls -d ~/Downloads/pdfs-b88-test` still found it for
|
||
up to 30 seconds. `~/ProtonDrive` behaves the same, so B88's routing did not cause it.
|
||
|
||
**Where:** `Core::rename` and `Core::delete` in `crates/pdfs-fuse/src/lib.rs`, and the `TTL` the
|
||
kernel gets for entries in the same file.
|
||
|
||
**Cause.** Both change Drive, forget the node and drop the parent's listing, but never tell the
|
||
kernel. The kernel cached the name's lookup for `TTL`, 30 seconds, and answers from that cache
|
||
until it expires. A rename or unlink through the mount has no such gap, because the kernel made
|
||
that change itself. `pdfs move` already sends `inval_entry` for its destination
|
||
(`settle_dest` in `crates/pdfs-fuse/src/relocate.rs`); its source was not checked.
|
||
|
||
The account run on flaky Wi-Fi on 2026-10-02 hit it in another place. "regression B111" renamed
|
||
a file with `pdfs rename` and straight back, and then failed with `FileNotFoundError`: the
|
||
kernel still pointed the first name at the inode the rename had forgotten.
|
||
|
||
**Fix.** `pdfs rm` forgets the node in every mount and sends each kernel session a delete notice
|
||
for the name (`Core::forget_everywhere`). `pdfs rename` and `pdfs move` invalidate the old and the
|
||
new name (`Core::relocate_everywhere`, see B117). The notices go out after the mount's lock is
|
||
released.
|
||
|
||
**Test:** the acceptance case "regression B114" removes one file with `pdfs rm` and renames another
|
||
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
|
||
|
||
**Status:** Fixed (verified 2026-10-02).
|
||
**Found:** 2026-10-02, on the production account. aria2 downloaded two seasons into the on-demand
|
||
`~/Downloads`. The episodes were moved into `Videos/anime/…` while their uploads were still
|
||
queued. 45 of 48 landed as `(sync-conflict …)` copies next to a 0-byte original, each with
|
||
`the remote revision changed under the queued write (expected 0 bytes at mtime 1790955809, found 0
|
||
at 1790956593)`. The three that drained before the move uploaded normally.
|
||
|
||
**Where:** `serve_create` in `crates/pdfs-fuse/src/filesystem.rs`, `adopt_real_uid` in
|
||
`crates/pdfs-fuse/src/drain.rs`.
|
||
|
||
**Cause.** The handle a create opens is based on the empty file the server mints, but it was
|
||
given no revision id. B69 keys the drain's conflict check on the revision id, and without one it
|
||
falls back to `(mtime, size)`. The node's mtime is the link's modify time, which the server
|
||
advances on a move or rename. So a new file moved before its first write drained looked like
|
||
another device had changed it. A file created offline had the same gap in another place: a handle
|
||
still open when its queued create landed kept the placeholder's local-clock mtime and no revision
|
||
id.
|
||
|
||
**Fix.** `WriteHandle::created` takes the minted node's revision id along with its mtime. When a
|
||
queued create lands, `adopt_real_uid` rebases the handles still open on it onto the new node,
|
||
as `refresh_after_upload` already does after a sealed upload. Both go through
|
||
`WriteHandle::rebase_onto`.
|
||
|
||
**Verified live (2026-10-02)** on the installed daemon, in a scratch folder in `~/Downloads`. A
|
||
`pdfs move` advanced an empty file's server mtime from 1790962386 to 1790962408, the change the old
|
||
check took for another device's edit. With sync paused, three 16 MiB files were written and moved
|
||
into a subfolder while their uploads were queued. After the resume all three logged `pending upload
|
||
landed`, with no conflict warning or copy, and they read back at their exact sizes. Each upload was
|
||
put off once after the move (`the node is missing locally and was re-read`) and landed on the
|
||
retry about 8 seconds later. Meanwhile `pdfs sync queue` showed raw ids instead of the new paths.
|
||
Both are filed as B117. Not verified live: a file created offline that is still open when its
|
||
create lands. Only the unit test covers that half. The acceptance case "regression B113" replays
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-30, reported in #25. An account with several accepted shares from one other
|
||
account listed only some of them, in both the control listing and the FUSE mount. The journal
|
||
showed `skipping undecryptable node ... failed to decrypt: missing key`. Each missing folder
|
||
opened on the Proton Drive website and resolved when the SDK was asked for it alone.
|
||
|
||
**Where:** `proton-drive-rs` 0.7.1, `enumerate_nodes_detail`, which serves both
|
||
`enumerate_nodes` and `enumerate_nodes_light`. The app hit it in `list_shared_with_me`
|
||
(`crates/pdfs-fuse/src/sharing.rs`) and in the shared-root listing in
|
||
`crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** The SDK cached parent keys per batch by the link's parent id. Every share root has
|
||
no parent, so all roots on one volume shared the `None` entry, and the roots after the first were
|
||
decrypted with the first root's share key (proton-sdk-rs#2).
|
||
|
||
**Fix.** Require `proton-drive-rs` 0.7.2, which names each key `Parent(folder)` or `Root(link)`.
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-29, by the acceptance case "unusual but legal names", on My files and on the
|
||
on-demand folder. The case renames each file to `renamed-N` and back at once. One rename back per
|
||
run failed with `EIO`, and the journal showed `rename failed ... proton api error
|
||
InvalidRequirements (http 422): This file or folder was out of date, rename failed.`
|
||
|
||
**Where:** every remote rename: `serve_rename` in `crates/pdfs-fuse/src/filesystem.rs`,
|
||
`drain_rename_in_place` in `crates/pdfs-fuse/src/drain.rs`, and the control `rename` in
|
||
`crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** The SDK's `rename_node` sends the node's current name hash as `OriginalHash`. Right
|
||
after a rename of the same node, it still sent the old one. Drive then refuses the request as out
|
||
of date. The daemon reported this at once as `EIO`, although the same request succeeds a moment
|
||
later. The old hash comes from the SDK's entity cache, not from the server (see B121).
|
||
|
||
**Fix.** All remote renames go through `Core::rename_remote`. It retries a rename that fails with
|
||
`InvalidRequirements` up to 20 times, half a second apart, the same window the move after a
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-29, by the acceptance case "application workloads", which timed out on My
|
||
files and on the on-demand folder on a fast network. The journal showed `a fuse worker has held
|
||
the same job for a long time worker=pdfs-fuse-8 job=read` growing past 700 s with no network
|
||
error. `/proc/<pid>/task/*/wchan` put that worker, and one more, in `__folio_lock`, while the
|
||
daemon had no file on any mount open.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::apply_size_upgrade`).
|
||
|
||
**Cause.** B100 made `read` settle a provisional size before it fetches, through
|
||
`resolve_provisional_size` and `apply_size_upgrade`. That function tells the kernel about the new
|
||
size with `inval_inode(ino, 0, 0)`, which also drops the file's page cache. The kernel holds the
|
||
page lock of the read being served until the daemon replies. The invalidation waits for the same
|
||
lock, on the thread that has to send the reply, so neither ever finishes. The process that
|
||
issued the read hangs, and so does anything else that touches those pages.
|
||
|
||
**Fix.** The size upgrade invalidates attributes only (`offset -1`). A size upgrade does not
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-29, by the acceptance case "application workloads" on My files. It failed
|
||
several runs in a row with `database disk image is malformed` at the `SELECT COUNT(*)` right
|
||
after a 500-row insert, and the daemon logged nothing. A diagnosis added to the case showed
|
||
`workload.db` at 0 bytes while SQLite still held it open with 16 KiB written.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/state.rs` (`State::keep_open_write_size`, called from
|
||
`intern_mem_with_access`), the local-uid remap in `crates/pdfs-fuse/src/drain.rs`, and the size
|
||
upgrade in `crates/pdfs-fuse/src/lib.rs`.
|
||
|
||
**Cause.** An online create seals an empty file on Drive. Until the last close, the written
|
||
bytes live only in the open handle, and the entry's size is the handle's. Any re-listing of the
|
||
parent re-interned the node from Drive, which replaced that size with 0. Such re-listings come
|
||
from event echoes and sibling creates, and SQLite's journal is one. The kernel then got size 0
|
||
from `getattr` and ended SQLite's reads at EOF without asking the daemon, so the database read
|
||
back as empty pages. Queued writes were already protected (B11); open handles were not.
|
||
|
||
**Fix.** A node that has an open write handle keeps the handle's size and its local mtime when
|
||
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`
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-29, by the account run of `scripts/fuse-acceptance.sh --quick`. It stopped with
|
||
`sync folder … entered error` before the managed-move phase. At the same time the journal
|
||
showed `sync: step failed; continuing error=open …/workloads/tree.tar: No such file or directory`
|
||
for eleven files. The contract suite had removed its `workloads` tree while the mirror pass
|
||
was still uploading it.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/sync.rs` (`upload_new`, `upload_revision`, `open_to_upload`).
|
||
|
||
**Cause.** A pass plans its uploads from a scan and runs them later. When a planned file was
|
||
deleted in between, opening it failed with `NotFound`. That counted as a failed step, so the pass
|
||
ended in `error` and logged "item(s) failed; will retry" for files nobody wanted any more.
|
||
|
||
**Fix.** An upload whose local file is gone when it starts is skipped (`Applied::Vanished`) and
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-29, by the acceptance case "application workloads" on My files, which hit its
|
||
180 s timeout. The journal showed `a fuse worker has held the same job for a long time
|
||
worker=pdfs-fuse-10 job=read` from `age_secs=124` up to `age_secs=2059`. Event polls in the
|
||
same window failed with `HTTP transport error` after 91-103 s.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/reads.rs` (`Core::read_range_remote`, `READ_FETCH_TIMEOUT`).
|
||
|
||
**Cause.** The SDK bounds each request (30 s for the API, 300 s for a storage block) and retries
|
||
failed ones, but nothing bounds a whole read. A read can need a block table, several blocks and a
|
||
repair, each with its own retries. On a connection that stalled without dropping, one `read`
|
||
held its FUSE worker for about 34 minutes, and the program that issued it could not be
|
||
interrupted for that time.
|
||
|
||
**Fix.** The network part of a read now has a 120 s deadline. When it passes, the read fails
|
||
with `EIO`, the file's revision reader is dropped so the next read starts fresh, and blocks that
|
||
already arrived stay cached for the retry. A single read is at most a few 4 MiB blocks, so this
|
||
only fails a read on a link slower than about 70 KB/s. A block repair that has to download a
|
||
large file whole can also hit the deadline on a slow link; pinning the file avoids that.
|
||
|
||
The first version of this fix built the timer on the FUSE worker thread, outside the runtime.
|
||
Every read that went to the network panicked with `there is no reactor running`, and the kernel
|
||
got `EIO`. The next account run failed 11 cases on My files this way. The timer is now built
|
||
inside `block_on`, and a unit test runs that pattern from a thread without a runtime.
|
||
|
||
---
|
||
|
||
### 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,
|
||
after installing the B104 fix: `OSError: [Errno 5]` from `os.replace` in the editor-save loop,
|
||
at the third save. The journal had `rename failed … AlreadyExists (http 422): A file or folder
|
||
with name already exists` from `rename_node`.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::remove_replaced`,
|
||
`Core::withdraw_queued_ops`), `crates/pdfs-fuse/src/filesystem.rs` (`trash_child`,
|
||
`finish_release`), `crates/pdfs-fuse/src/drain.rs` (`Core::drain_revision`).
|
||
|
||
**Cause.** A regression from B104. The save loop writes a temporary file, renames it over the
|
||
document, and reads the document back. `close()` returns before the daemon sees the release, so
|
||
at the next save the replaced file still counts as open. B104 then left its queued upload in
|
||
the queue until the final close, and the drain could send it after the file had been trashed.
|
||
The next rename onto the name was refused. `unlink` of an open file had kept its queued upload
|
||
the same way.
|
||
|
||
**Fix.** A file trashed or replaced while open now loses its queued ops at once, and an upload
|
||
already under way is cancelled. Only the staged bytes stay, because the open handle reads them,
|
||
and they are deleted at the final close. A drain worker that claimed the op before it was
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-29, in the daemon journal of an acceptance run on the account. During the
|
||
SQLite workload two `queued write conflicts; keeping a conflict copy` warnings named files called
|
||
`recovered-XrPUSW5W` and `recovered-JbZM_pWK`, with the reason `the file was trashed remotely`.
|
||
Each was followed by `pending upload failed; will retry` and `No such file or directory`.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/drain.rs` (`Core::drain_revision`),
|
||
`crates/pdfs-core/src/db/ops.rs` (`Db::op_exists`).
|
||
|
||
**Cause.** SQLite writes, syncs and deletes its journal file within milliseconds. The sync
|
||
queues an upload of the journal. The delete trashes the node and drops that queued upload, but a
|
||
drain worker that already holds the op carries on. It then asks the server about the node,
|
||
finds it trashed, and treats the trash as another device's change: it tries to upload the bytes
|
||
as a conflict copy. The tree has already forgotten the node, so the copy is named `recovered-…`
|
||
and placed in the Drive root. Here the blob was already gone and the attempt failed, but with
|
||
the blob still on disk the copy would land.
|
||
|
||
**Fix.** Before keeping a conflict copy, the drain checks that its op is still queued. When the
|
||
op is gone, the write was withdrawn by the delete, or superseded, while the worker held it, and
|
||
it is dropped. The check goes by op id, which the next op queued can take over (B133).
|
||
|
||
---
|
||
|
||
### 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
|
||
folder: `OSError: [Errno 5] Input/output error` at
|
||
`os.pread(reader, 64, 0)` after `os.replace(replacement, target)` with `reader` open on `target`.
|
||
The mirror folder, a local disk, passed.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::remove_replaced`).
|
||
|
||
**Cause.** A rename over an existing file first trashes that file, because Drive has no atomic
|
||
replace. That step forgot the replaced inode outright and evicted its cache and reader, even
|
||
when a handle was still open on it. The next read through that handle found no inode and
|
||
failed. `rename(2)`, like `unlink(2)`, keeps a replaced file readable through the handles
|
||
already open on it. The unlink path (`trash_child`) already did this, but the rename path
|
||
did not.
|
||
|
||
**Fix.** `remove_replaced` now handles an open victim as `unlink` does. It keeps the inode as
|
||
an unlinked entry, and leaves its cache and reader to the final close. That applies both to a
|
||
node the server knows and to one whose create is still queued. Its queued ops at first also
|
||
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
|
||
|
||
**Status:** Fixed (unverified). The hang itself has not been reproduced on purpose.
|
||
**Found:** 2026-09-29, while timing the acceptance suite's wide-directory case. Its cleanup trashed
|
||
the test folder, then waited for it to show up in the trash so it could delete it permanently. It
|
||
never did. From 06:55 UTC on, every trash listing logged `trash refresh still running; answering
|
||
with what has materialized` and returned the same 362 entries. No `trash refresh: enumerated` line
|
||
followed, so the first server call of the refresh never returned.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::refresh_trash`, `within_trash_call`).
|
||
|
||
**Cause.** Trash refreshes are single-flight: a request joins the refresh already running instead
|
||
of starting another. The refresh awaited its server calls with no deadline, so one call that
|
||
never returned kept the flag set forever. Every later request joined it, waited out its 20 s
|
||
budget, and answered with the stale listing.
|
||
|
||
**Fix.** Each server call of a refresh (`enumerate_trash_node_uids`, and each `enumerate_nodes`
|
||
chunk) fails after `TRASH_CALL_TIMEOUT` (120 s). The refresh then ends with an error, clears the
|
||
flag, and the next request starts a fresh one.
|
||
|
||
---
|
||
|
||
### B102 — Removing a folder leaves the queued uploads of the files inside it
|
||
|
||
**Status:** Fixed (unverified). No daemon carrying the change has run the acceptance suite yet.
|
||
**Found:** 2026-09-29, after the account run that found B101. Its cleanup removed both test roots
|
||
and reported "nothing left behind", but `pdfs sync queue` still listed two `revision` ops for
|
||
`pdfs-acceptance-…/boundaries/reshaped`, each holding about 8 MiB of staged bytes. They retried
|
||
against the trash forever ("the share has not been writable for 300s").
|
||
|
||
**Where:** `crates/pdfs-core/src/db/ops.rs` (`drop_doomed_ops`, used by
|
||
`Db::replace_ops_with_trash` and `Db::delete_ops_for_uid`), `crates/pdfs-fuse/src/lib.rs`
|
||
(`Core::delete`, `Core::release_dropped_ops`).
|
||
|
||
**Cause.** Two gaps.
|
||
|
||
- **The queue could not see which files a folder holds.** Removing a node dropped the ops for it
|
||
and for anything *queued* beneath it, found through `pending_op.parent_uid`. Only `create` and
|
||
`mkdir` ops carry a parent, so a `revision` of a file that already existed remotely was never
|
||
found, however deep in the removed folder it sat.
|
||
- **`pdfs rm` dropped nothing at all.** The control socket's delete trashed the node remotely but
|
||
left every op queued for it, unlike an unlink through the mount.
|
||
|
||
**Fix.** Removal also follows the node tree (`nodes.parent_uid`) and drops the `revision` ops of
|
||
every file below the node, with their blobs, pending entries and in-flight uploads. Queued renames
|
||
below it are kept: one that moved a node into the folder still has to land for the node to go to
|
||
the trash with it. `pdfs rm` now drops the queued ops once the remote trash succeeds. The
|
||
acceptance janitor lists the daemon queue after cleanup and reports any op still naming one of its
|
||
roots as a leftover.
|
||
Regression test: `trashing_a_folder_drops_the_queued_revisions_of_files_below_it`.
|
||
The acceptance case "regression B102" pauses sync, rewrites a file two folders down in each of
|
||
two folders, and removes one folder with `pdfs rm` and the other with `rm -r` through the mount.
|
||
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
|
||
|
||
**Status:** Fixed (unverified). No daemon carrying the change has run the acceptance suite yet.
|
||
**Found:** 2026-09-29, by the new acceptance case "block boundaries and overwrites that change
|
||
the block count". It failed with `Input/output error` in My files and in an on-demand folder, every
|
||
time. The journal had `the revision's block table disagrees with the node size ... fsize=8388611
|
||
reader_size=0`, then `pending upload failed; will retry ... gap-fill from base failed: Errno(5)`
|
||
on every retry.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/state.rs` (`WriteHandle::record_write`),
|
||
`crates/pdfs-fuse/src/drain.rs` (`Core::drain_local_node`, `Core::retire_create`),
|
||
`crates/pdfs-fuse/src/lib.rs` (`Core::enqueue_staged_write`),
|
||
`crates/pdfs-core/src/db/ops.rs` (`Db::finish_create`).
|
||
|
||
**Cause.** Two defects.
|
||
|
||
- **The hole of a write past the end was not authored.** A write handle tracks which ranges it
|
||
wrote; everything else below the base size is filled from the base when it is committed. A
|
||
grow through `setattr` marks its new tail as written, but a `pwrite` past the end did not mark
|
||
the hole it leaves. After `ftruncate(fd, 4 MiB)` and `pwrite(fd, data, 4 MiB + 10)`, bytes
|
||
4 MiB to 4 MiB + 10 were refilled from the old base instead of reading as zeros. That silently
|
||
restored truncated bytes. When the base was itself a queued revision the remote did not have
|
||
yet, the fill read a revision of the wrong size, and B84's check failed it: `EIO` on read, and
|
||
a drain that retried forever.
|
||
- **A write that closed while its file's create uploaded was lost.** The create of a new file
|
||
drains on its own, often while the file is still open. A write that closes meanwhile attaches
|
||
its blob to the create's queue row. When the create landed, the drain deleted that row by id,
|
||
so the server kept the older bytes (often an empty file) and nothing was queued to replace
|
||
them. A handle still open after the create landed carried the placeholder uid, found no create
|
||
to attach to, and failed its `close` with `EIO`.
|
||
|
||
**Fix.** A write past the end authors the hole (`WriteHandle::record_write`).
|
||
`Db::finish_create` retires a landed create in one transaction: if the row now holds a newer blob
|
||
than the one uploaded, it becomes a revision of the real uid instead of being deleted. The drain
|
||
does this under the `pending` lock, which `enqueue_staged_write` holds from attaching a blob until
|
||
it records the pending entry, and then repoints open write handles to the real uid. A write that
|
||
still finds the create gone waits for its inode's real uid (`Core::landed_uid`) and queues a
|
||
revision against it.
|
||
Regression tests: `a_write_past_the_end_authors_the_hole_it_leaves`,
|
||
`a_write_attached_while_its_create_uploads_becomes_a_revision`,
|
||
`a_create_whose_blob_landed_is_retired`, and the live acceptance cases "block boundaries and
|
||
overwrites that change the block count" (now also read back after the queue drains) and
|
||
`regression B101`.
|
||
|
||
---
|
||
|
||
### B100 — A freshly copied file reads as `EIO` or 53 bytes too big
|
||
|
||
**Status:** Fixed (unverified). No daemon carrying the change has read a file with a provisional
|
||
size yet.
|
||
**Found:** 2026-09-29, checking a 222-file copy into `Documents/pi-backup` on 2.7.0. `find -printf
|
||
%s` showed 10 small files 52 to 59 bytes bigger than their originals. `cat` of one of them failed
|
||
with `Input/output error`, and the journal had `the revision's block table disagrees with the
|
||
node size ... fsize=221 reader_size=169` followed by `repair download did not match the node
|
||
size`. The node's DB row and `claimed_size` were correct (169). A few minutes later the same files
|
||
reported the right size and matched the originals byte for byte.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/filesystem.rs` (`read`, `serve_open`),
|
||
`crates/pdfs-fuse/src/lib.rs` (`Core::upgrade_sizes`, `Core::run_size_upgrade`).
|
||
|
||
**Cause.** Three things together.
|
||
|
||
- A listing built from the cheap enumeration (B12) has no `claimed_size`, so a file reports its
|
||
ciphertext size until the size upgrade lands. The upgrade's own documentation said reads were
|
||
unaffected because the revision reader carries its own size.
|
||
- B84 made that untrue. `read_block` now treats a revision that disagrees with the size it was
|
||
given as damage: it repairs from the whole-file download, and when that does not match either,
|
||
it fails the read. With a provisional size the two never match, so every read failed.
|
||
- `getattr` normally settles the size before answering (B14), which kept this rare. But only
|
||
`MAX_SIZE_UPGRADES` (8) folders may upgrade at once, and past that cap `upgrade_sizes` returned
|
||
nothing. The `stat` was answered with the ciphertext size at once, and nothing retried the
|
||
folder until the next `stat`. A recursive walk over many fresh folders goes past the cap.
|
||
|
||
A write open had the same fault: its handle started at the provisional size, so the file would
|
||
gain the encryption overhead and reading the base would fail.
|
||
|
||
**Fix.** `read` and a write `open` settle a provisional size before using it
|
||
(`Core::resolve_provisional_size`, one node fetch, on the worker that already goes to the
|
||
network). A folder past the cap is now queued instead of dropped. When a running upgrade ends,
|
||
its thread takes over the oldest queued folder, so at most 8 threads still run.
|
||
Regression tests: `a_folder_past_the_cap_is_queued_not_dropped`,
|
||
`a_finished_batch_hands_its_thread_the_oldest_queued_one`. The read and open paths need a live
|
||
mount and have no unit test; the acceptance case `regression B100` covers them on a real mount.
|
||
|
||
---
|
||
|
||
### 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.
|
||
**Found:** 2026-09-27. The Activity page listed the same six conflict copies every day, e.g.
|
||
`Conflict [EMBER] Oshi no Ko S2 - 12 (sync-conflict 1786815864).mkv` with `Differs from … · 24 times
|
||
since Sep 20, 13:13 · Resolved`. `find` showed all six copies still on disk, in `~/Videos` and
|
||
`~/Documents`. Both are on-demand sync folders. `pdfs conflicts` answered `No conflicts.`. The
|
||
journal showed the daemon mounting at 17:29:33, and the entries were stamped 17:30. That is the
|
||
sweep's 30-second warmup.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/sweep.rs` (`flag_conflict`, `sweep_conflicts_once`),
|
||
`crates/pdfs-fuse/src/conflicts.rs` (`list_conflicts`), `crates/pdfs-fuse/src/control.rs`
|
||
(`ResolveConflict`).
|
||
|
||
**Cause.** There were two faults.
|
||
|
||
- The sweep kept its "already flagged" set only in memory. Every daemon start forgot it, so the
|
||
first pass logged every open conflict again.
|
||
- The sweep flags copies anywhere in the account's own tree. `list_conflicts` only reported copies
|
||
below My Files, and resolving only accepted paths under the primary mount. A copy in a synced
|
||
folder was logged but never listed, so the GUI read it as resolved. It also could not be
|
||
resolved from the GUI or the CLI.
|
||
|
||
**Fix.** `Core::conflict_path` names a copy by its mountpoint-relative path under My Files, or
|
||
by its absolute local path inside a synced folder. It reuses the roots that search already
|
||
resolves hits through. The listing uses it, and the sweep flags only copies it can name. So the
|
||
feed never holds a conflict the listing cannot answer for. `ResolveConflict` routes an absolute
|
||
path to the on-demand mount that serves it. For a copy in a mirror folder, it says to edit the
|
||
local file instead. Before logging, `flag_conflict` checks the activity log
|
||
(`Db::activity_has`), so a restart does not log the same copy again. The copy's name carries its
|
||
own timestamp, so the name identifies the copy. No schema change.
|
||
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
|
||
|
||
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
|
||
superseded upload yet.
|
||
**Found:** 2026-09-23. The Sync page showed `Unconfirmed 654050.crdownload — 0 bytes of 23.8 MiB`
|
||
for hours after Brave had finished and renamed the download. `pdfs transfers` listed the same
|
||
entry, `pdfs diagnostics` showed every worker idle, and `/proc/<pid>/task` showed `pdfs-drain-1`
|
||
in state `R` at 100% CPU, holding a deleted staging blob open.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/transfers.rs` (`CountingReader::read`).
|
||
|
||
**Cause.** A write that lands while a revision is on the wire cancels that upload through the
|
||
flag `CountingReader` checks before every read. The reader reported the cancel as
|
||
`ErrorKind::Interrupted`. Every read loop treats that kind as "try again", including the SDK's
|
||
`read_full_block`, and the flag never clears, so the loop never ended. The `TransferGuard` was
|
||
never dropped, so the transfer stayed in the registry, and the drain thread was lost until the
|
||
daemon restarted. A browser download is the natural trigger: the `.crdownload` file keeps growing,
|
||
so each new write supersedes the upload of the previous one.
|
||
|
||
**Fix.** The cancel is reported as `ErrorKind::Other`, which the SDK turns into an error.
|
||
`drain_revision` already recognises a cancelled upload and returns without counting a failure.
|
||
Regression test: `a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry`.
|
||
The acceptance case "regression B98" appends to a 64 MiB file while its upload is on the wire. The
|
||
superseded transfer has to end, and the file has to land at its full size. The case skips when the
|
||
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
|
||
|
||
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
|
||
library yet.
|
||
**Found:** 2026-09-19, while reading the journal for issues that were still open. `pdfs diagnostics`
|
||
had reported a `Refresh` running for 23 minutes with `in_flight` at 5, and every mount that ended
|
||
in a `systemctl --user restart` was followed by ~95 `skipping undecryptable photo` warnings.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/control.rs` (`Refresh` handler), `crates/pdfs-fuse/src/photos.rs`
|
||
(`refresh_timeline`), `crates/pdfs-fuse/src/diagnostics.rs` (`request_kind`).
|
||
|
||
**Cause.** Two unrelated faults with one symptom each.
|
||
|
||
The stall: every `Refresh` arm is individually cheap — a directory refresh resolves and
|
||
invalidates one listing, a photos refresh clears one state key and spawns the real work — but
|
||
nothing stopped several of them running at once. A front end that asks again while the first
|
||
refresh is still going gets a second handler that does the whole thing again: `invalidate_photos`,
|
||
optionally `photos_mark_all_unresolved` over the entire timeline, `invalidate_albums`. Each of
|
||
those takes the database lock the already-running refresh holds for minutes at a time, so the
|
||
duplicates queue behind it, hold a control handler slot, and appear as a wedged `Refresh`. The
|
||
report could not even say which view was wedged: `request_kind` reads only serde's external tag,
|
||
so all three scopes logged as a bare `Refresh`.
|
||
|
||
The warning burst: `refresh_timeline` resolves photo metadata in chunks and keeps going through
|
||
teardown. The runtime is cancelled under it, every decrypt task in flight fails with "task was
|
||
cancelled", and the SDK logs one `skipping undecryptable photo` per node. Nothing was corrupt —
|
||
the library was fine on the next mount, because `photos_replace` preserves prior metadata and
|
||
leaves `resolved_at` null so the next refresh re-reads exactly those photos.
|
||
|
||
**What changed.** A refresh scope is now single-flight: `RefreshGuard` holds the scope key
|
||
(`dir:<path>`, `trash`, `photos`, `photos:full`) for as long as one is being served, and a second
|
||
request for that same key is answered `refreshing` immediately instead of repeating the work. A
|
||
non-full photos refresh is additionally skipped while `timeline_refreshing()` is true, since that
|
||
work outlives the request that started it. `request_kind` now names the scope, so the diagnostics
|
||
report says `Refresh(Photos)` rather than `Refresh`. And the resolve loop stops asking for chunks
|
||
once `Shutdown::is_stopping()`, logging `stopped` and the resolved-versus-wanted counts on the way
|
||
out.
|
||
|
||
---
|
||
|
||
### 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
|
||
appeared once per blob per mount, forever. The staging directory held 138 blobs / 575 MiB, of
|
||
which 95 (~445 MiB) referenced no node at all; the oldest were two months old.
|
||
|
||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`reconcile_staging`).
|
||
|
||
**Cause.** Startup reconciliation is deliberately conservative: staged bytes are the only copy of
|
||
an un-uploaded write, so a blob it cannot match to a queued op is kept rather than deleted. But
|
||
"kept" had no end. A blob whose node is gone from the database can never be matched by any future
|
||
boot, so the warning was both permanent and per-blob, and the bytes accumulated with no ceiling.
|
||
|
||
**What changed.** A blob that names no node and whose mtime is older than `STAGING_ORPHAN_RETAIN`
|
||
(30 days) is retired, with its size logged. A month is long enough that a write stranded by a bug
|
||
can still be recovered by hand. Younger orphans are still kept, but they are now counted and
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-19, while working out why 43 uploads were parked. Most of them were
|
||
`index.tmp` and `preview.tmp` files from a Google Photos Takeout import — ordinary files whose
|
||
final name simply ends in `.tmp`.
|
||
|
||
**Where:** `crates/pdfs-core/src/syncignore.rs` (`TRANSIENT_SUFFIXES`).
|
||
|
||
**Cause.** One suffix list served two mechanisms with very different consequences. On a mirror
|
||
mount, calling a name transient means "do not reconcile it yet", which is reversible and costs
|
||
nothing if wrong. On an on-demand mount it means "park the create until the writer renames it"
|
||
(B70) — and a file whose real, final name ends in `.tmp` is never renamed, so the park stands
|
||
forever and the bytes never go up.
|
||
|
||
**What changed.** `.tmp` and `.temp` are out of `TRANSIENT_SUFFIXES`. The remaining suffixes
|
||
(`.crdownload`, `.part`, `.partial`, `.download`, `.swp`, `.swx`) belong to writers that always
|
||
rename. A `.tmp` write from a program that does rename is still handled — by the rename itself,
|
||
which supersedes the create.
|
||
|
||
**Test:** the acceptance case "regression B95" writes `index.tmp` and `preview.temp` and waits for
|
||
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
|
||
|
||
**Status:** Fixed (unverified).
|
||
**Found:** 2026-09-19, while reading the journal: 43 rows in `pending_op` carried the
|
||
`PARK_UNTIL` sentinel, some for weeks. `pdfs status` reported them as parked uploads that never
|
||
moved.
|
||
|
||
**Where:** `crates/pdfs-core/src/db/ops.rs` (`parked_create_ops`, `PARK_EXPIRY_MS`),
|
||
`crates/pdfs-fuse/src/drain.rs` (`sweep_parked_creates`).
|
||
|
||
**Cause.** B70 parks a transient create by writing `PARK_UNTIL` into `next_attempt_at`, and the
|
||
only thing that clears it is the transient-to-final rename (`Db::set_create_hold`). If the rename
|
||
never happens — the writer crashed, the user deleted the temp file, or the name was never
|
||
transient in the first place (B95) — the row is invisible to `claim_next_due_op` and stays in the
|
||
queue permanently, holding its staged blob.
|
||
|
||
**What changed.** The primary drain worker sweeps parked creates when the queue is idle, which is
|
||
exactly when a park that has outlived its writer matters. A parked create whose node is gone from
|
||
the database has its queued ops discarded. One whose node still exists, that is older than
|
||
`PARK_EXPIRY_MS` (1 hour) and that nobody has open, is un-parked rather than dropped — drain
|
||
invariant 1 says staged bytes are only dropped once their op has landed, so the sweep lets the
|
||
bytes through under the transient name instead of destroying them. An open node is left parked
|
||
whatever its age: it is still being written.
|
||
|
||
**Test:** the acceptance case "regression B94" deletes a `*.part` file whose create is parked and
|
||
waits for the create to leave the queue. That is the delete exit. The hour-long expiry is not
|
||
driven.
|
||
|
||
---
|
||
|
||
### 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.
|
||
**Found:** 2026-09-19, by reading `photos_replace` before making a refresh skip photos it has
|
||
already resolved. Skipping one means writing a `TimelineRow` that carries only the uid and the
|
||
capture time, which is exactly the row a failed resolve already produced.
|
||
|
||
**Where:** `crates/pdfs-core/src/db/photos.rs` (`Learned`, `photos_replace`).
|
||
|
||
**Cause.** `photos_replace` keeps what a refresh could not learn — ratio, thumbnail verdict, media
|
||
type, favourite flag, content hash, photo relation all survive a `None`. The `name` did not:
|
||
`Learned` never held it, the SELECT never read it, and the INSERT bound `row.name` straight
|
||
through. So a photo the SDK could not decrypt (it logs and skips the node) had its stored name
|
||
overwritten with `NULL` on every refresh. That is not a cosmetic loss — both the tab split and the
|
||
grouping are derived from the name. `PhotoKind::classify` is extension-first, so a nameless RAW
|
||
file classifies as a still photo and leaves the Raw tab; `name_stem` returns `None`, so the RAW and
|
||
the JPEG of one shot stop being one shot; and `redate` skips nameless photos outright.
|
||
|
||
**What changed.** `Learned` carries the name like everything else, and the kind and the group stem
|
||
are computed from the carried name rather than the incoming row. A row that *was* resolved is now
|
||
authoritative instead: its fields are taken as they are, so a photo unlinked from its main on
|
||
another device finally loses its `main_uid` — a carry could never express "the server says none".
|
||
|
||
---
|
||
|
||
### 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.
|
||
**Found:** 2026-09-19, while checking why a 12,445-photo timeline still showed the pre-grouping
|
||
layout after pressing Refresh. `pdfs refresh photos` failed after about two minutes with:
|
||
|
||
```
|
||
Caused by:
|
||
0: io: Resource temporarily unavailable (os error 11)
|
||
1: Resource temporarily unavailable (os error 11)
|
||
```
|
||
|
||
**Where:** `crates/pdfs-fuse/src/control.rs` (`RefreshScope::Photos`),
|
||
`crates/pdfs-core/src/control.rs` (`READ_TIMEOUT`, the new `PhotosRefreshStatus`),
|
||
`crates/pdfs-gui/src/app/main.rs` (`refresh_then`), `crates/pdfs-cli/src/main.rs` (`cmd_refresh`).
|
||
|
||
**Cause.** `Refresh { Photos }` awaited `refresh_timeline()` before replying, on the reasoning that
|
||
somebody who presses Refresh wants the truth rather than a stale page. But the refresh resolves
|
||
every photo's node in batches of `TIMELINE_ENRICH_CHUNK` (200), so a 12,445-photo library is 63
|
||
server round-trips — minutes of work. The client's read bound is `READ_TIMEOUT` = 120 s, so the
|
||
socket timed out with `EAGAIN` while the refresh was running perfectly. In the GUI the timeout was
|
||
invisible: `refresh_then` ignores the reply and loads the page anyway, which re-read the *old*
|
||
timeline and made Refresh look like a dead button.
|
||
|
||
**What changed.** The daemon starts the refresh (`spawn_timeline_refresh`, which already dedupes)
|
||
and replies `refreshing` at once. `Request::PhotosRefreshStatus` reports whether one is running;
|
||
the gallery polls it every 2 s and reloads the page when it clears, and `pdfs refresh photos`
|
||
polls in the same way so the command still means "the timeline is up to date when I return".
|
||
`refresh_timeline` now logs `photos`, `chunks` and `elapsed_ms`.
|
||
|
||
**What changed, second pass (2026-09-19).** The refresh is incremental. `photos.resolved_at`
|
||
(schema v31) records that a photo's node was read, and `refresh_timeline` resolves only the photos
|
||
that have no such mark — the ones it has never read, and the ones a remote event marked stale.
|
||
A library that has already been read costs one `enumerate_timeline` and no `enumerate_nodes` call
|
||
at all.
|
||
|
||
The favourite question resolved by moving it off the refresh and onto the event feed:
|
||
`classify_photo_events` turns a `NodeUpdated` into `photos_mark_unresolved(uid)` instead of a
|
||
blanket "re-read everything", so a favourite toggled on a phone costs one node read rather than
|
||
63. A local toggle is claimed as our own echo (`note_self_change`), because the feed replays it and
|
||
a `NodeUpdated` carries nothing that says what changed. `ContinuityLost` / `ScopeAccessLost` and
|
||
`pdfs refresh photos --full` drop every mark, which is the one way back to a whole-library read.
|
||
`TIMELINE_ENRICH_CHUNK` is 150, matching the SDK's own `MAX_BATCH_COUNT` — a chunk of 200 was split
|
||
there into a full request and a 50-uid tail.
|
||
|
||
**Still open.** `invalidate_albums()` drives the same pattern per album
|
||
(`crates/pdfs-fuse/src/albums.rs:96` and `:191`). It is bounded by album size rather than library
|
||
size, so it is nowhere near as expensive, but the technique that fixed this would apply there too.
|
||
|
||
---
|
||
|
||
### 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,
|
||
and is restarted by systemd when it stops answering.
|
||
**Found:** 2026-09-18, from a user report — the mount stopped responding and only came back after
|
||
`systemctl --user restart`.
|
||
**Where:** `crates/pdfs-fuse/src/{workers.rs,diagnostics.rs,supervisor.rs,systemd.rs,mount.rs}`,
|
||
`packaging/proton-drive.service`.
|
||
|
||
**Symptom.** The mount stops answering. The process is alive and is not spinning; there is no
|
||
panic, no error, and no log line after the moment it stopped. The accounting for the hung run:
|
||
|
||
```
|
||
Consumed 43min 39.045s CPU time over 20h 8min 15.733s wall clock time,
|
||
5.2G memory peak, 65.7M memory swap peak
|
||
```
|
||
|
||
It went silent at 17:10 and stayed that way until the manual stop at 13:18:54 the next day. A
|
||
restart picked everything back up, which rules out corrupt local state.
|
||
|
||
**What made it undiagnosable.** Nothing in the daemon could be asked what it was doing:
|
||
|
||
- the worker pool published no state at all — not which threads were busy, not with what, not for
|
||
how long, not how deep the queues were;
|
||
- control requests were not tracked, so a handler that never returned looked exactly like a
|
||
handler that was never asked for;
|
||
- there was no watchdog, so a daemon that stopped answering stayed "active (running)" for twenty
|
||
hours;
|
||
- resident size was never sampled, so the 5.2 G peak was only visible in systemd's post-mortem.
|
||
|
||
**Related, and a real bug on its own.** At 2026-09-18 09:58:35 a meta worker panicked during
|
||
shutdown:
|
||
|
||
```
|
||
thread 'pdfs-fuse-meta-0' (3044) panicked at ...:
|
||
A Tokio 1.x context was found, but it is being shutdown.
|
||
```
|
||
|
||
The pool was flagged closed at teardown but never joined, so the process could drop the tokio
|
||
runtime while a worker was still inside a job. `catch_unwind` caught it, which is why it was only
|
||
ever a log line — but it is teardown ordering, not an error.
|
||
|
||
**What changed.**
|
||
|
||
- `Workers` publishes per-thread state (busy, job label, age) plus queue depths and per-lane
|
||
completion counts, all outside the queue lock, and every FUSE handler labels the job it hands
|
||
over.
|
||
- `Request::Diagnostics` / `pdfs diagnostics` reports that, the in-flight control requests with
|
||
their ages, the handler count against its limit, resident size and the pending-op count. It is
|
||
built to answer while the daemon is wedged: atomics and `try_lock` only, with the one database
|
||
read on a throwaway thread behind a 500 ms deadline.
|
||
- A supervisor thread WARNs when a worker has held one job for 120 s, when a control request has
|
||
run that long, or when a lane has a queue but completed nothing since the last tick; it samples
|
||
RSS every 5 minutes.
|
||
- The supervisor answers the systemd watchdog, but only after a real round trip over the control
|
||
socket — so the ping means "a client can still talk to this daemon" rather than "a thread is
|
||
still scheduled". `WatchdogSec=120` and `Restart=always` turn the next hang into a restart
|
||
instead of a morning of downtime, and `MemoryHigh=2G`/`MemoryMax=6G` bound the growth.
|
||
- Teardown joins the worker pool, with a 10 s deadline, before the caller drops the runtime.
|
||
|
||
**Still open.** None of this says *why* it hung. The candidates the evidence does not yet separate:
|
||
the 5.2 G working set (a leak, or a legitimately huge listing retained), a lock held across an
|
||
await somewhere in the control plane, and the ~150 `rt.block_on` sites on the control path that
|
||
have no op-level deadline of their own — each individual HTTP call is bounded by the SDK's 30 s
|
||
API / 300 s storage timeouts, but a sequence of them is not. The next occurrence should be a
|
||
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
|
||
|
||
**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".
|
||
**Found:** 2026-09-16, from a user report of `systemctl --user restart` failing in a loop.
|
||
**Where:** `crates/pdfs-core/src/localindex.rs` (`default_excludes`, `scan`),
|
||
`crates/pdfs-fuse/src/background.rs` (`scan_local_once`).
|
||
|
||
**Symptom.** Stopping the service times out, SIGKILL does not help, and every subsequent start
|
||
fails with `another Proton Drive daemon is already using .../cache.db`:
|
||
|
||
```
|
||
systemd[2196]: proton-drive.service: State 'final-sigterm' timed out. Killing.
|
||
systemd[2196]: proton-drive.service: Killing process 2221 (pdfs) with signal SIGKILL.
|
||
systemd[2196]: proton-drive.service: Processes still around after final SIGKILL. Entering failed mode.
|
||
systemd[2196]: proton-drive.service: Unit process 2221 (pdfs) remains running after unit stopped.
|
||
pdfs[73921]: ERROR mount failed; retrying in 5s error="open cache db: another Proton Drive daemon
|
||
is already using /home/narl/.local/state/proton-drive-linux/cache.db"
|
||
```
|
||
|
||
**Diagnosis.** The surviving process is a zombie that still has a live thread:
|
||
|
||
```
|
||
$ cat /proc/2221/status | grep -E 'State|Threads'
|
||
State: Z (zombie)
|
||
Threads: 2
|
||
$ cat /proc/2221/task/73231/comm; cat /proc/2221/task/73231/stack
|
||
pdfs-localindex
|
||
request_wait_answer
|
||
```
|
||
|
||
`pdfs-localindex` is parked in a FUSE request nobody will answer, in uninterruptible `D` state, so
|
||
it ignores SIGKILL. The thread group leader exits, the thread does not, the process cannot be
|
||
reaped — and it keeps the `cache.db` lock, which is what turns one stuck scan into a permanent
|
||
restart loop. The same dead connection also blocked an unrelated `duf` in `fuse_statfs`
|
||
(`/sys/fs/fuse/connections/69/waiting` was 2).
|
||
|
||
**Cause.** `scan_local_once` walks `$HOME`, and `default_excludes` only excluded the Drive
|
||
mountpoint plus our own state and cache dirs. The reporter's home also held
|
||
`~/remote/narl.io` (sshfs) and `~/remote/gdrive` (rclone) — third-party FUSE mounts the walk
|
||
descends into.
|
||
|
||
`scan` does set `same_file_system(true)`, which looks like it should already cover this. It does
|
||
not: in `ignore` 0.4.33 the check is `is_same_file_system` → `device_num(path)` →
|
||
`path.metadata()` (`walk.rs:2164`), i.e. **a stat of the mountpoint**, performed in `run_one`
|
||
*after* the directory has been queued. To discover that a mount is foreign, the walker first has
|
||
to touch it. Against a network FUSE mount whose server is gone, that stat never returns.
|
||
|
||
**Fix.** `localindex::nested_mount_points` reads `/proc/self/mounts` and returns every mountpoint
|
||
strictly below the scan root; `scan_local_once` appends those to the excludes. `filter_entry` runs
|
||
before the device stat (`walk.rs:1926`, before `send`), so a path-only rejection keeps the walker
|
||
from ever touching the mount. Mountpoint fields are octal-unescaped, because a `\040` left in
|
||
place would silently fail to match and put the mount back in the walk. Re-read per scan, not
|
||
cached at startup: a mount that appeared since the daemon started is the likeliest to be a
|
||
half-alive network filesystem.
|
||
|
||
This changes nothing about what gets indexed. A nested mount is a different device, so
|
||
`same_file_system(true)` was always going to skip it — the exclude only removes the stat that had
|
||
to happen first.
|
||
|
||
**Why there is no second line of defence.** Once a thread is in uninterruptible FUSE wait, no
|
||
userspace change rescues it: not a join timeout, not `std::process::exit`, not SIGKILL. The kernel
|
||
will not reap the task until the syscall returns, which needs someone to answer the request or
|
||
abort the connection (`echo 1 > /sys/fs/fuse/connections/<id>/abort`, root-only). Avoiding entry is
|
||
the entire fix. That makes any *new* code path that stats a user-supplied or mount-crossing path
|
||
from a daemon thread a repeat of this bug — mirror-folder scanning is the obvious next candidate.
|
||
|
||
**Recovery for an already-wedged daemon.** As root, abort the connection with waiters
|
||
(`grep . /sys/fs/fuse/connections/*/waiting` finds it), which releases the thread, lets the zombie
|
||
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
|
||
|
||
**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).
|
||
|
||
`CLAUDE.md` states that `proton-drive-linux/Cargo.toml` declares `proton-sdk = "0.1"` /
|
||
`proton-drive-rs = "0.1"` and that `Cargo.lock` pins **0.1.11** from the registry, against a local
|
||
`proton-sdk-rs/` at 0.1.12. Neither half is true any more: the lock pins **0.6.0**, bumped by
|
||
1.7.1 (see its changelog entry, "Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.6.0")
|
||
and never reflected in the working memory.
|
||
|
||
**Why it matters beyond tidiness.** The whole point of that section is to tell you which source
|
||
you are reasoning about when you read SDK code. Verifying B85 meant establishing what `read_at`
|
||
does in the version the client actually builds against; following `CLAUDE.md` sends you to
|
||
`proton-drive-rs-0.1.11` in the registry cache, and to a local checkout described as one patch
|
||
ahead of it when it is in fact five minor versions behind. The conclusion happened to be the same
|
||
in every version — but that was luck, and the next question of this shape may not be.
|
||
|
||
**Fix:** correct the versions, and prefer describing where to *look* (`grep -A2 'name =
|
||
"proton-drive-rs"' Cargo.lock`) over restating a number that goes stale on every dependency bump.
|
||
|
||
---
|
||
|
||
### 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.
|
||
`pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions are still open.
|
||
**Found:** 2026-08-17, while fixing B86.
|
||
**Where:** `crates/pdfs-fuse/src/control.rs`, every handler still calling `rel_to_mount`.
|
||
|
||
B86's second half added `Core::rooted_at` and `control.rs::route_to_mount`, which resolve a path
|
||
against whichever mount the daemon owns it under and answer the request in that mount's inode
|
||
space. Only `ListDir` and `RefreshScope::Dir` were routed through it, because those two were what
|
||
the B86 repro needed: an escape hatch for a folder serving a stale listing.
|
||
|
||
Every other path-addressed control request — `Pin`, `Unpin`, `Rename`, `Move`, `Delete`,
|
||
`CreateFolder`, `OpenFile`, the sharing and public-link requests, `ListRevisions` — still uses the
|
||
primary-only `rel_to_mount`, so an absolute path inside a secondary on-demand mount is rejected
|
||
with "is not under the mountpoint". The user can now *see* such a folder's contents and cannot act
|
||
on them by path.
|
||
|
||
**Why it was left.** Routing a mutating request is not the same change as routing a read. Each of
|
||
those handlers resolves an inode, checks access, performs a remote operation and then invalidates
|
||
local state, and several of them invalidate `self.state()` — the primary — explicitly. Routing
|
||
them means auditing each for which mount's state it is really talking about, which is a larger and
|
||
riskier change than the one B86 needed. Doing it half-way would be worse than not doing it.
|
||
|
||
**Required fix/test:** move the remaining handlers onto `route_to_mount`, auditing each for
|
||
per-mount state assumptions as it goes; the `for_each_state`/uid-keyed helpers are the pattern for
|
||
the ones that touch more than their own inode space. Test: a `pdfs pin` and a `pdfs rename` by
|
||
absolute path inside a secondary on-demand mount, plus a path under no mount still erroring.
|
||
|
||
**Fix so far (`rm`, `rename`, `mkdir`):** the three handlers go through `control.rs::route_change`,
|
||
which is `route_to_mount` plus the mirror-folder hint and escape check that `ResolveConflict`
|
||
already had. The audit found nothing primary-only in `Core::delete`, `Core::rename` or
|
||
`Core::create_folder`. `Core::rooted_at` swaps in the fork's state, notifier, session flag and size
|
||
upgrades, the same per-mount fields `Core::fork_state` makes fresh. So `self.state()` in a routed
|
||
call already is the fork's inode space. My files and the on-demand folders do not overlap, so
|
||
`delete` forgetting the node in its own space alone is enough. `rename` already forgets it
|
||
everywhere (B74).
|
||
|
||
Routing raised a hazard the primary mount never had. An on-demand folder's root has a parent on
|
||
Drive, so `pdfs rm ~/Downloads` would have passed `source_parent_uid` and trashed the whole synced
|
||
folder from under its mount. `rm` and `rename` therefore go through `route_node`, which refuses a
|
||
path naming a mount's root. Test: `only_a_path_below_a_mount_root_names_a_node`. There is still no
|
||
handler-level test, because a `Core` can only be built against a live client.
|
||
|
||
**Verified live (2026-10-02)** in `~/Downloads`, an on-demand folder. `pdfs mkdir` created a
|
||
folder. `pdfs rename` of a file whose upload was still queued reached Drive under the new name, at
|
||
6 bytes and with no conflict. `pdfs rm` trashed a file and then the folder. `pdfs rm ~/Downloads`,
|
||
`pdfs rename ~/Downloads …` and `pdfs rm ~/ProtonDrive` were refused as the root of a synced
|
||
location, and a path outside every mount as not under the mountpoint. `mkdir` and `rm` in
|
||
`~/ProtonDrive` still work. Not verified: the refusal in a mirrored folder, because the account has
|
||
none. The acceptance case "regression B88" repeats this on every location of a run, and an
|
||
`--account` run checks the refusal in the mirror folders it registers.
|
||
|
||
---
|
||
|
||
### 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.
|
||
**Where:** `crates/pdfs-fuse/src/reads.rs` (`Core::block_geometry`, `read_block`).
|
||
|
||
B85's fix takes block boundaries from the revision instead of assuming 4 MiB. The geometry comes
|
||
from `RevisionReader::block_sizes()`, and a reader is expensive to open — link details, ancestor
|
||
keys, an S2K node-key unlock and the block table, which is exactly the per-read work the block
|
||
cache exists to avoid (B12). So the geometry is *learned* as a side effect of the first read,
|
||
which has to open a reader anyway, and written to a `<key>.geom` sidecar for later reads.
|
||
|
||
The first read of a file therefore still plans on `BlockGeometry::uniform`. On a file whose blocks
|
||
are not 4 MiB that plan is wrong, and it costs exactly one straddling fetch: the reader fetches
|
||
two of the revision's blocks to answer for one client block, and the block written to the cache is
|
||
at an offset the *next* read — now planning correctly — will not ask for, so it ages out unused.
|
||
Every read after the first is right.
|
||
|
||
**Why it is not being fixed now.** The alternatives are all worse than the cost. Resolving a
|
||
reader before planning reintroduces B12's per-read key derivation on every file. Persisting the
|
||
geometry at upload only covers files this client wrote, which are the ones the fallback is already
|
||
correct for. Fetching the block table alone still costs the node-key unlock.
|
||
|
||
**Worth doing if:** non-4-MiB revisions turn out to be common on real accounts rather than a
|
||
handful of files on one, or if the block table becomes reachable without unlocking the node key.
|
||
Measure before deciding — one wasted fetch per file per revision is not obviously worth paying
|
||
anything to remove.
|
||
|
||
---
|
||
|
||
### 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.
|
||
**Where:** `crates/pdfs-fuse/src/sync.rs` upload path (no listing invalidation) meets the cached
|
||
children snapshot the on-demand mount enumerates from.
|
||
|
||
**Repro.** With folder 6 (`~/pdfs-live-mirror`) in mirror mode: create `c1.txt` and `c2.txt`,
|
||
`pdfs sync now 6` — the activity feed reports `c1.txt new file`, `c2.txt new file` and
|
||
`sync_entry` gains rows carrying real `remote_uid`s. Switch the folder to on-demand and list it:
|
||
|
||
```
|
||
$ ls ~/pdfs-live-mirror
|
||
b25.txt
|
||
b40.bin # c1.txt, c2.txt and the conflict copy are missing
|
||
```
|
||
|
||
`ls ~/pdfs-live-mirror/c1.txt` returns `ENOENT`. Restarting the daemon does not help — the stale
|
||
children snapshot is in the database, not in memory. Switching the folder back to mirror proves
|
||
the files were on the server the whole time: the pass downloads all five, and `b40.bin` comes back
|
||
md5-identical.
|
||
|
||
**Effect.** No data loss — the mirror side is correct and the files are intact remotely — but a
|
||
user who switches a synced folder to on-demand sees an incomplete folder, and anything that walks
|
||
it (a backup, an indexer, `rm -r`) sees the same incomplete folder. It also blocks B42's live
|
||
verification, because `pdfs rm` cannot name a file the mount will not admit exists.
|
||
|
||
**Second, smaller gap in the same repro:** `pdfs refresh` and `pdfs ls` reject any path that is
|
||
not under the *primary* mountpoint (`Error: … is not under the mountpoint`), so a secondary
|
||
on-demand mount has no manual escape hatch either.
|
||
|
||
**Required fix/test:** invalidate (or update) the parent's cached children when the sync engine
|
||
creates a remote node, the same way the FUSE write path does; and let `refresh`/`ls` resolve a
|
||
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)
|
||
|
||
Two halves, matching the two halves of the report.
|
||
|
||
**The stale listing.** `Core::invalidate_children_of` (`pdfs-fuse/src/lib.rs`) drops a folder's
|
||
cached children *by uid*. The existing `invalidate_parent_listing` could not serve here for two
|
||
reasons: it names the folder by inode, and a mirror folder has no inode space at all at the moment
|
||
the engine writes to it; and the listing that outlived the pass was not a hot-cache map but the
|
||
`listed` flag on the folder's DB row — which is why restarting the daemon did not help. The new
|
||
helper clears that flag unconditionally and then invalidates whichever mounts happen to hold the
|
||
folder resident. The sync engine calls it after every remote mutation it makes: folder creation
|
||
and file upload (`apply_one`, `upload_new`), and the three remote-trash sites, whose parent uid
|
||
comes from the pass's folder-uid map or the baseline (`parent_uid_of`).
|
||
|
||
**The missing escape hatch.** `StateRegistry` now carries each mount's `size_upgrades` map as well
|
||
as its state, notifier and liveness flag — that set is exactly the per-mount half of a `Core`
|
||
(everything `fork_state` replaces) — and `StateRegistry::covering_parts` returns it for the mount
|
||
that most specifically covers a path. `Core::rooted_at` swaps those fields into a clone of the
|
||
`Core`, giving a view rooted at the owning mount plus the path relative to *its* root. `pdfs ls`
|
||
and `pdfs refresh` route through it (`control.rs::route_to_mount`), so a secondary on-demand mount
|
||
can be named at last; a path under nothing this daemon has mounted still gets the familiar "is not
|
||
under the mountpoint" error. The other path-addressed control requests deliberately still use the
|
||
primary-only `rel_to_mount` — routing the mutating ones is a larger change and was not what
|
||
blocked the repro.
|
||
|
||
Test: `covering_parts_route_a_path_to_the_mount_that_owns_it` covers the routing rule (nested
|
||
mount wins, suffix relative to it, its own inode space and upgrade batches, a path under no mount
|
||
routes nowhere). The listing-invalidation half is the live repro, which is what is still pending.
|
||
|
||
**Test:** the managed matrix of an `--account` run replays the repro. Before it switches a mirror
|
||
folder to on-demand, it writes `pdfs-mode-switch.bin` there and forces a pass. After the switch it
|
||
checks that the mount lists the file with its bytes, as "regression B86" under "mode switches".
|
||
Only the third pairing of the full matrix switches a folder that an earlier pairing had
|
||
on-demand, which is the stale case. `--quick` does not get there.
|
||
|
||
---
|
||
|
||
### 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 `<key>.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
|
||
impossible: a disagreement is repaired from the whole-file download, or the read fails `EIO`.
|
||
Re-verified on 1.8.1 after the release build: `118454731_p0.png`, `121677947_p0.png`,
|
||
`123349685_p0.png` and `128451683_p0.png` each delivered exactly their `stat` size
|
||
(19471492, 11811519, 22055175, 11393178 bytes) with no `Invalid argument` reply in the journal.
|
||
**Found:** 2026-08-16, chasing what looked like log noise. GNOME's file indexer
|
||
(`localsearch-3`) was filling the journal with `libpng error: Read Error`,
|
||
`libpng error: IDAT: CRC error` and `File too small to be a PNG` on files under
|
||
`~/ProtonDrive/Pictures`, interleaved with 49 `ERROR fuser::reply: Failed to send FUSE reply:
|
||
Invalid argument (os error 22)` from the daemon in one afternoon. The indexer was right and we
|
||
were wrong.
|
||
|
||
**Where:** `Core::read_block` (`crates/pdfs-fuse/src/reads.rs:566`), the assembly loop in
|
||
`Core::read_range_remote` (`reads.rs:759-769`); upstream, `RevisionReader::read_at` /
|
||
`splice_block` and `rank_block_sizes` (`proton-drive-rs` 0.6.0, `revision.rs`,
|
||
`transport.rs:256`).
|
||
|
||
**Repro.** Deterministic per file, no account state needed beyond the files themselves:
|
||
|
||
```bash
|
||
stat -c %s FILE # what the mount reports
|
||
cat FILE | wc -c # what the mount can actually deliver
|
||
```
|
||
|
||
| file | `nodes.size` / `stat` | bytes readable |
|
||
|---|---|---|
|
||
| `Pictures/old/92433593_p0.png` | 4317497 | 4194304 (tail block yields **0**) |
|
||
| `Pictures/old/79779657_p0.png` | 11574982 | 10567052 |
|
||
| `Pictures/old/2022-07-09_04-53.png` | 6388959 | 4389310 |
|
||
|
||
`pdfs pin` on the third file, then re-read: **6388959 bytes, a fully valid PNG** — every chunk
|
||
CRC passes, IEND is reached, the IDAT stream inflates to 44.8 MB. So the remote data is intact,
|
||
`nodes.size` is right, and the whole-file (manifest-verified) download path is right. Only the
|
||
block/range path truncates. Pinning is the workaround.
|
||
|
||
**Root cause.** `read_block` asks the SDK for `blen = BLOCK_SIZE.min(fsize - bstart)` bytes and
|
||
never checks how many it got:
|
||
|
||
```rust
|
||
let bytes = reader.read_at(bstart, blen).await.map_err(...)?; // reads.rs:577
|
||
```
|
||
|
||
`RevisionReader::read_at` clamps the range to its own `file_size` — summed from the *believed*
|
||
per-block sizes `rank_block_sizes` resolved — and `splice_block` clamps again to
|
||
`plaintext.len()`. Both are silent. A short block then vanishes in the assembly loop: with
|
||
`block.len()` short, `e = (end.min(bstart + block.len()) - bstart)` collapses, and for an empty
|
||
block `s < e` is false, so nothing is appended at all. The FUSE read returns fewer bytes than
|
||
asked for, the kernel hands userspace a short read, and every tool reads a short read as EOF.
|
||
The `EINVAL` reply errors are the same mismatch seen from the other side.
|
||
|
||
Nothing on this path validates a block against the length the file's own size implies
|
||
(`block_len(fsize, bidx)` — which the *cache* does check, in `ContentCache::cached_block`, and
|
||
which is why a short blob on disk is rejected and refetched while a short blob from the network
|
||
is served).
|
||
|
||
**Not yet known:** which rung of `rank_block_sizes` these revisions land on. The three
|
||
reader-vs-node deltas fit no constant ratio, so this is per-revision metadata rather than block
|
||
padding — the terminal `block_count <= 1` fallback (`vec![4 MiB; block_count]`) would explain
|
||
the file that stops at exactly 4 MiB but not the other two. Needs a live probe printing
|
||
`RevisionReader::block_sizes()` against `nodes.size` for these three uids. That answer decides
|
||
whether the real fix belongs in this repo or in `proton-sdk-rs`.
|
||
|
||
**Fix (2026-08-16).** `Core::read_block` no longer trusts the range path blindly.
|
||
|
||
1. **The disagreement is caught before any bytes are read.** If `reader.size() != fsize`, the
|
||
revision's block table does not describe this file and *no* block of it can be trusted — not
|
||
just the short ones. Sizes that overstate a block shift every block after it and return
|
||
full-length reads of the wrong bytes, which no length check would ever catch (the SDK's
|
||
`rank_block_sizes` documents exactly this failure). So the whole file leaves the range path.
|
||
2. **A short block is caught as a second net,** compared against `block_len(fsize, bidx)` — now
|
||
`pub` in `pdfs-core::cache`, because it is the contract the read path shares with the cache
|
||
rather than a detail of the cache. The assembly loop in `read_range_remote` re-checks the same
|
||
thing and fails `EIO` rather than contributing fewer bytes than the range it covers, which is
|
||
the line that actually turned a bad block into a truncated file.
|
||
3. **A file that fails either check is repaired, not just refused.** `Core::repair_block` pulls
|
||
the whole revision through `download_file_to` — the manifest-verified path, which does no
|
||
boundary arithmetic and is why pinning worked — into a scratch file, checks it against
|
||
`nodes.size`, and adopts it as the cached blob via `store_file`. Every later read of the file
|
||
is then answered by `ContentCache::read_range` before the block path is consulted at all. One
|
||
download per file, not per block: `content_repairs` holds a lock per file so the concurrent
|
||
block reads that all discover the same bad table share it. Bounded by `REPAIR_MAX` (512 MiB) —
|
||
past that one unlucky read would pull down gigabytes, so it fails `EIO` and says to pin.
|
||
|
||
Nothing short is cached or promoted: `read_block` only reaches `store_block` and the `block_ring`
|
||
insert with bytes that passed. `store_block` itself deliberately does *not* check — its fixtures
|
||
store 4 KiB "blocks" of notional 1 GiB files to keep the budget tests cheap, and the read side
|
||
(`cached_block`, `has_block`) already rejects a wrong-length block as a miss. Validation is the
|
||
reader's job, at the point of fetch.
|
||
|
||
`getattr` needs no change after all: `pdfs pin` proved `nodes.size` is the correct one and the
|
||
reader's belief is the wrong one, so the fix is to stop serving the reader's belief — not to
|
||
publish it through `stat`.
|
||
|
||
**Verified live (2026-08-16).** Four multi-block PNGs under `Pictures/wallpaper/` were read whole
|
||
through the mount on the running 1.8.1 daemon; every one delivered exactly its `stat` size
|
||
(5558846, 1098729, 2015463, 2291122 bytes), and no `fuser::reply … Invalid argument` appeared. The
|
||
journal shows the second net firing and the repair path serving each of them:
|
||
|
||
```
|
||
WARN block came back shorter than the file's size implies bidx=0 blen=4194304 got=4939506 …
|
||
INFO served a short block from a repaired whole-file download bidx=0 …
|
||
```
|
||
|
||
**Answered, and not as expected.** The open question was which rung of `rank_block_sizes` these
|
||
revisions take. The evidence says the reader is not the confused party: `reader.size()` equals
|
||
`fsize` in every case — the gate in step 1 never fires — and the blocks it returns are *larger*
|
||
than 4 MiB, not smaller (4939506 for a whole 4.9 MB file; 5239804 and 5670228 as first blocks).
|
||
This client's fixed 4 MiB block geometry is the thing that is wrong. Filed as **B85**; the repair
|
||
path above is what keeps those files readable in the meantime, at the cost of a whole-file
|
||
download on first read.
|
||
|
||
---
|
||
|
||
### 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
|
||
1.8.0 (schema migrated to v27, all 28 rows window-stamped, `WARN` lines per op);
|
||
the resolution half has not yet been observed draining that backlog.
|
||
**Found:** 2026-08-16, running `scripts/fuse-acceptance.sh --live` on a
|
||
throwaway CLI-made mount. Four regression cases (B69, B70, B74, B79) failed with
|
||
`TimeoutError: daemon mutation queue did not drain` — the suite's
|
||
`wait_for_queue()` polls the *global* `status.mount.pending_uploads`, which had
|
||
been sitting at 28 for 30 days on the development machine. `pdfs status` called
|
||
them 28 queued uploads holding 18.4 GiB; `pdfs transfers` showed nothing;
|
||
`failing_ops` and `parked_uploads` were both 0.
|
||
**Where:** `run_pending_drain` / `run_authorized_drain`
|
||
(`crates/pdfs-fuse/src/drain.rs`), `require_uid_writable`
|
||
(`crates/pdfs-fuse/src/lib.rs`), `Db::effective_node_access`
|
||
(`crates/pdfs-core/src/db/share_access.rs`),
|
||
`Db::defer_op_without_attempt` (`crates/pdfs-core/src/db/ops.rs`).
|
||
|
||
**Root cause.** `effective_node_access` returns `Ok(None)` when the uid has no
|
||
row in `nodes`, and `require_uid_access` maps that to `EACCES` — "I have never
|
||
heard of this node" and "you may not write this node" are the same answer. The
|
||
drain reads `EACCES` as `DrainDisposition::AccessDeferred` and calls
|
||
`defer_op_without_attempt`, which by design touches neither `attempts` nor
|
||
`last_error`. So the op was re-deferred every `DRAIN_ACCESS_RECHECK` (5s)
|
||
indefinitely, and every channel that could have surfaced it stayed empty:
|
||
`attempts` 0, `last_error` NULL, `parked` counts only `PARK_UNTIL` rows,
|
||
`failing` counts only `attempts >= FAILING_ATTEMPTS`, and the one log line was
|
||
`debug!`. On the machine that found it, all 28 uids were absent from `nodes`:
|
||
|
||
```sql
|
||
select (select count(*) from pending_op p
|
||
where exists(select 1 from nodes n where n.uid = p.uid)) as in_nodes,
|
||
(select count(*) from pending_op) as total;
|
||
-- 0 | 28
|
||
```
|
||
|
||
**Fix, part 1 — stop guessing.** `WriteAuthority { Writable, Denied, Unknown }`
|
||
replaces the `Result<(), Errno>` the drain's access check used to return
|
||
(`Core::uid_write_authority`). `require_uid_writable` still collapses both
|
||
refusals to `EACCES`, which is right for a syscall: a stale handle must not be
|
||
admitted because the tree forgot the node. The drain does not collapse them —
|
||
`Unknown` becomes `DrainDisposition::AuthorityUnknown(uid)`, and
|
||
`Core::resolve_unknown_authority` asks the remote:
|
||
|
||
- node still there → `upsert_node` re-interns it and the op is deferred for one
|
||
recheck, so the next pass gets a real access answer;
|
||
- node gone (`Ok(None)` / `is_gone`) → `record_op_failure` **immediately**, with
|
||
a message naming the vanished uid, rather than waiting out the 5-minute window
|
||
to say something vaguer;
|
||
- could not ask → ordinary deferral.
|
||
|
||
The authority asked about is the op's own (`pending_op_authorities`), so a
|
||
`create`/`mkdir` refetches its *parent*, not the node it has not made yet. The
|
||
row and its staged blob survive every branch.
|
||
|
||
**Fix, part 2 — say something.** Schema v27 adds `pending_op.access_deferred_since`.
|
||
`Db::defer_op_for_access` stamps it on the first deferral of a run and returns
|
||
the stamp on every one; `Core::defer_for_access` warns on the first deferral and,
|
||
once the run passes `DRAIN_ACCESS_DEFER_LIMIT` (5 min), reports it through
|
||
`record_op_failure` so it enters the ordinary backoff and shows up in
|
||
`failing_ops` and the status error text. `record_op_failure` clears the window so
|
||
each escalation re-arms it rather than firing every recheck, and an attempt the
|
||
access check admits clears it too (`clear_op_access_deferral`). The row and its
|
||
staged blob are untouched on every path — this changes only what the user can
|
||
see.
|
||
|
||
The deferral window stays as the backstop for everything that does not resolve,
|
||
including a refetch that keeps not helping, and `AccessDeferral` now carries the
|
||
reason into both the log line and the recorded `last_error`.
|
||
|
||
**Still open:** the acceptance runner waits on the *global* queue
|
||
(`ManagedSyncPair.wait_for_queue`), so any unrelated stuck op fails those four
|
||
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
|
||
|
||
**Status:** Fixed (unverified) 2026-08-12 — unit-tested, not driven against a
|
||
real corrupted database (nobody has one).
|
||
**Found:** 2026-08-12, writing a test for B80. The test built a two-node cycle
|
||
and called `search`; `cargo test -p pdfs-core` then sat at
|
||
`search_excludes_a_parent_cycle` for 29 minutes with **zero CPU** before it was
|
||
killed. Two earlier runs had already been abandoned to the same stall without
|
||
the cause being understood — it looks exactly like a cargo lock contention
|
||
problem, which is what it was first blamed on.
|
||
**Where:** `path_of` (`crates/pdfs-core/src/db/utils.rs`), `upsert_node_tx`'s
|
||
`descendants` CTE (`db/nodes.rs`).
|
||
|
||
**Root cause.** Both walks are recursive CTEs using `UNION ALL`, which does not
|
||
deduplicate, so `a.parent = b, b.parent = a` never terminates. The index side
|
||
was never exposed — `node_is_indexable_tx` rejects a cycle — but a query shorter
|
||
than `TRIGRAM_MIN` (3 chars) takes the `LIKE` lane, and **that lane reads
|
||
`nodes` directly and never consults the index**. Every row it returns is then
|
||
handed to `path_of`. So typing two characters into the prompt was enough, and
|
||
the spin happens while holding the daemon's only SQLite connection: the mount,
|
||
the drain, and every control request stop with it.
|
||
|
||
The API is what would produce such a cycle (a rename/move race, a malformed
|
||
event), so this is not a "corrupt your own DB" scenario only.
|
||
|
||
**Fix.** `path_of` is depth-capped at 256 and returns the truncated path rather
|
||
than hanging; `path_relative_to` already carried a 1024 cap, which is the
|
||
precedent. The `descendants` CTE in `upsert_node_tx` got the same guard for the
|
||
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
|
||
|
||
**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
|
||
device-folder hit would render as `My files / Downloads`, a folder that does not
|
||
exist under the primary mount, while activating the same row opens
|
||
`~/Downloads/…`. Label and action disagreed.
|
||
**Where:** `Hit::location()` (`crates/pdfs-gui/src/prompt.rs`), shared by the
|
||
GTK prompt and the dmenu front-end.
|
||
|
||
The label was built from `SearchHit::path` with a hardcoded `My files /` prefix.
|
||
The daemon already resolves a device/sync-folder hit to a real local path in
|
||
`mounted_path` (`Core::mounted_search_path`), which is also what activation
|
||
uses; the label now derives from that when it is set, and falls back to
|
||
`My files` only for the primary mount.
|
||
|
||
**Second defect, same area.** Pin rows set `is_dir` from `Pin::recursive`
|
||
(`prompt.rs`, `dmenu.rs`). That flag is pin *policy* — whether the subtree is
|
||
kept on disk — not node kind, so a folder pinned non-recursively drew a file
|
||
icon and sent an invalid `OpenFile`. `Pin` now carries an `is_dir: Option<bool>`
|
||
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
|
||
|
||
**Status:** Fixed (unverified) 2026-08-12 — migration replayed against a
|
||
read-only copy of the live DB; not yet run by the daemon itself.
|
||
**Found:** 2026-08-12, auditing `cache.db` after a user report that search felt
|
||
unreliable. `pdfs search tickets` returned only the My Files folder
|
||
`Documents/Tickets`, never the `tickets.pdf` sitting in the device folder
|
||
`Downloads`. Counting the tables explained it:
|
||
|
||
```
|
||
nodes 9252
|
||
nodes_fts 6547 ← 2705 rows never indexed
|
||
nodes in orphan subtrees 2705 ← exactly the missing set
|
||
```
|
||
|
||
**Where:** `node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs`).
|
||
|
||
**Root cause — the same one as B79.** A node was indexed only if walking
|
||
`parent_uid` reached a row with `parent_uid IS NULL`. Exactly one row is stored
|
||
that way (the My Files root). A device folder's root lives in `device` /
|
||
`sync_folder` and is **never persisted as a `nodes` row**, so `Downloads`,
|
||
`Pictures`, `Documents`, `Music` and `Videos` walk up to a parent that does not
|
||
exist and were judged unindexable, along with everything beneath them. Stale
|
||
`pdfs-acceptance-*` roots hit the same path.
|
||
|
||
The v16 backfill did *not* share the bug — it rooted its walk at any node whose
|
||
parent is null **or absent**. So the index was correct immediately after
|
||
migrating and then lost each subtree the first time one of its nodes was
|
||
rewritten. That divergence between backfill and write path is why this survived:
|
||
a fresh install looks fine.
|
||
|
||
**Fix.** Indexability now requires only that the walk terminates with nothing
|
||
trashed on the way; a cycle is still rejected explicitly (B82). `MIGRATION_V21`
|
||
(`SCHEMA_VERSION` → 21) rebuilds `nodes_fts` so existing installs recover
|
||
without waiting for each node to be rewritten, and fixes two gaps in the v16
|
||
walk it replays: descendants of a trashed folder stay out, and the walk is
|
||
depth-capped.
|
||
|
||
Replayed against a `.backup` of the live DB: **9276 non-trashed nodes → 9276
|
||
indexed** (from 6547), 300 rows under `Downloads`, `tickets.pdf` at
|
||
`Downloads/tickets.pdf`.
|
||
|
||
**Watch for.** That path is the second thing this uncovered: the backfill first
|
||
seeded an uncached-parent root with an empty path, so the migration indexed
|
||
`tickets.pdf` while a later upsert indexed `Downloads/tickets.pdf` — the same
|
||
node with two paths depending on which code wrote it. Any future rebuild of this
|
||
index has to match `path_of`, not invent its own root rule.
|
||
|
||
**Related, not a defect.** The local half of the prompt indexes 110,187 files,
|
||
63,399 of them (58%) the Go module cache. Candidates are capped at 1000 and
|
||
scored *after* SQLite, so for a common term the pool filled with vendored files
|
||
before a real one was considered — for "test", 367 of the 500 best-ranked
|
||
candidates came from `go/pkg/mod`. `SKIP_DIRS` gained `dist`/`build`, a new
|
||
`SKIP_PATH_SUFFIXES` covers `go/pkg/mod`, and both candidate functions gained a
|
||
whole-query prefix lane ahead of the existing single-character one.
|
||
|
||
**Test:** the acceptance case "regression B80" writes a file with a unique token in its name,
|
||
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
|
||
|
||
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
|
||
package since)
|
||
**Found:** 2026-07-31, user on packaged 1.2.1: `touch ~/Documents/test` →
|
||
`Permission denied`, while the same operation under `~/ProtonDrive` succeeds.
|
||
**Where:** `crates/pdfs-fuse/src/state.rs`, `State::hydrate_access`
|
||
|
||
Not a mode-bit problem — the mount root reads `drwxr-xr-x` and the kernel's
|
||
`default_permissions` check passes. The denial comes from the queue guard:
|
||
`serve_create` → `Core::require_uid_writable`, which **intersects the access
|
||
every live inode space reports for that uid** (`lib.rs:721`) before anything
|
||
reaches `pending_op`.
|
||
|
||
The on-demand fork itself says `Owner`. The **primary** state says `Unknown`:
|
||
|
||
- `Core::hydrate` (`lib.rs:1012`) loads *every* `nodes` row into the primary
|
||
state, including the subtrees that belong to on-demand device folders — they
|
||
are on the same volume, in the same table.
|
||
- A device folder's `parent_uid` is the **device root**, which is never
|
||
persisted as a node. In this account's DB all six device-folder roots
|
||
(`Downloads`, `Pictures`, `Music`, `Documents`, `Videos`, `narl`) have
|
||
`parent_uid = <device root>` with no matching row, so pass 2 parks them at
|
||
`ORPHAN_INO`.
|
||
- `hydrate_access` then hit its "parent is not resident" branch, which had no
|
||
answer and fell back to `Access::Unknown` — fail closed.
|
||
- `Unknown` is not writable, so the intersection denies every create, mkdir,
|
||
write, rename, setattr and trash in every on-demand mount, permanently: those
|
||
entries are never re-interned in the primary state, so nothing recomputes them.
|
||
|
||
`~/ProtonDrive` was unaffected because its nodes descend from `ROOT_INO`, which
|
||
`ProtonFs::new` hardcodes to `Owner`.
|
||
|
||
Two facts make this a *classification* bug rather than a policy one: the
|
||
persisted authority `Db::effective_node_access` already answers `Owner` for
|
||
exactly these uids (no `share_access` row covers them — the table holds only
|
||
`virtual~sharedwithme` and one accepted share), and the design table in
|
||
`mount-architecture.md` §2.2 says *not under a share, no role → Owner, fail
|
||
open*. The in-memory path disagreed with both.
|
||
|
||
**Fix:** the non-resident-parent branch now mirrors the persisted authority — a
|
||
recorded `share_access` row above the node still wins, otherwise fail **open**
|
||
to `Owner` on the mount's own volume and fail **closed** to `Unknown` on a
|
||
foreign one (`State::is_own_volume`). Foreign-volume shared content keeps its
|
||
fail-closed behavior, which is what B34 needs.
|
||
|
||
**Second-order effect, also fixed by the same change:** `downgrade_known_shared_access`
|
||
selects roots with `entry.access != Access::Owner`, so a `ScopeAccessLost` /
|
||
`SharedWithMeUpdated` event would additionally have force-downgraded every
|
||
device-folder tree to `Viewer` in memory.
|
||
|
||
**Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`,
|
||
`foreign_volume_nodes_without_a_resident_parent_fail_closed`,
|
||
`persisted_authority_outranks_the_own_volume_fail_open`. The first fails on the
|
||
pre-fix branch with `left: Unknown, right: Owner`. The standing live guard is the
|
||
`regression B79` acceptance case, which finds a mounted on-demand device folder
|
||
through `pdfs locations --json` and writes into it (skipping when this machine
|
||
has none).
|
||
|
||
**Verified live (2026-07-31):** the packaged 1.2.1 daemon denies
|
||
`touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while
|
||
`~/ProtonDrive` accepts them; a local `--release` build of this fix accepts all
|
||
three on the same account and DB, the write drains (`pending_op` back to 0, the
|
||
new nodes carry real `G88km…` remote uids rather than local ones), and the
|
||
daemon log has no `403`/EACCES. `~/ProtonDrive/Shared with me/` stayed
|
||
`dr-xr-xr-x`, and the accepted editor-role share under it stayed writable — the
|
||
foreign-volume fail-closed path is unchanged.
|
||
|
||
**Side note for whoever runs this next:** stopping the daemon and touching a
|
||
file in an on-demand *mountpoint* while it is unmounted leaves that file in the
|
||
local directory, and `restore_ondemand_mounts` then refuses to mount over the
|
||
non-empty dir (`WARN … local dir is not empty; refusing to mount over it`).
|
||
Empty the directory and restart the unit.
|
||
|
||
---
|
||
|
||
### 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
|
||
visible — is still open.
|
||
**Found:** 2026-07-29, user reported `upload profile: proton api error NotEnoughPermissions (http 422): Cannot create file at the root of a device`
|
||
|
||
The background task that backs up the machine's profile (sync folder mappings, pins, cache budget) currently attempts to write `profile.json` directly to the device root node (`device.root_uid`) on the Drive backend. The Proton Drive API has started enforcing a restriction that files cannot be created directly at the root of a device; they must be placed inside a folder.
|
||
|
||
**Consequence:** The upload is rejected with HTTP 422. The daemon retries when changes happen but the profile is never successfully backed up. Local changes (pinning, adding a sync folder) still take effect locally, but they will not be restorable on a new machine.
|
||
|
||
**Fix applied (matches the original `RECOVERY.md` spec):** the profile now lives
|
||
in a `.proton-drive-linux` folder inside the device root.
|
||
|
||
- `pdfs_core::profile::PROFILE_DIR_NAME` — the new constant, next to
|
||
`PROFILE_FILE_NAME`.
|
||
- `profile.rs:ensure_profile_dir` — reuses the folder by name via the existing
|
||
`find_device_child_folder`, creating it on the first save. Reuse-by-identity
|
||
matters: a second folder of the same name would split the record in two with
|
||
nothing to say which is current.
|
||
- `profile.rs:save_profile` — both upload branches (new file, new revision) now
|
||
address the folder, never the device root.
|
||
- `profile.rs:load_profile` — reads the folder first, then falls back to a
|
||
device-root `profile.json` written by an older client. The fallback is
|
||
read-only and the legacy document is **not** trashed: another machine may
|
||
still be running a pre-fix client that reads it.
|
||
- `profile.rs:list_restorable_folders` — filters `.proton-drive-linux` out, or
|
||
the restore picker would offer to sync the bookkeeping back onto the machine
|
||
it describes.
|
||
- `devices.rs:add_sync_folder` — refuses a local folder named
|
||
`.proton-drive-linux`, which would otherwise be reused as the remote profile
|
||
folder by name and then reconciled (i.e. overwritten) by the sync engine.
|
||
|
||
**Verified live (2026-07-31)** against the real account: first save logged
|
||
`created device profile folder name=".proton-drive-linux"` then
|
||
`profile backed up folders=6` with no 422; a second trigger reused the folder
|
||
(no second create) and uploaded a revision; `pdfs sync restore --json` lists
|
||
exactly the six device folders and not the profile folder.
|
||
|
||
**Tests:** `profile::tests::the_profile_is_never_uploaded_to_the_device_root`,
|
||
`loading_falls_back_to_a_legacy_device_root_profile`,
|
||
`the_profile_folder_is_not_offered_as_user_data` — source-level guards in the
|
||
style of `lib.rs`'s queue-guard ordering tests, because both upload branches are
|
||
pure network calls with nothing to assert offline.
|
||
|
||
**Not covered by this fix:** B68 also asks for backup *health* to be visible
|
||
(currently a `WARN` in the log and nothing in the UI), and for the
|
||
replacement-machine / fresh-state restore to be re-run end to end.
|
||
|
||
---
|
||
|
||
### 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 |
|
||
|---|---|
|
||
| `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 |
|
||
|
||
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.
|
||
|
||
**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 |
|
||
|---|---|---|
|
||
| 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 |
|
||
|
||
**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.
|
||
|
||
**`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.
|
||
|
||
**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`).
|
||
|
||
---
|
||
|
||
### 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
|
||
trigger.
|
||
**Impact:** on a *sync folder* mount (not `~/ProtonDrive`), a file whose create
|
||
was still queued reads back as **0 bytes for as long as the daemon runs**, even
|
||
though the bytes are on Drive intact. This is the write-to-temp-then-rename
|
||
pattern used by every browser download, most editor saves, `rsync`, and `git`.
|
||
|
||
**Not data loss.** An earlier revision of this entry called it permanent data
|
||
loss; that was wrong and is corrected here. The uploaded revision is complete and
|
||
correct: for every "lost" file the node row carried the right `size` and
|
||
`active_revision_state: "Active"`, and a daemon restart made all of them read
|
||
back with the expected sha256. Nothing had to be recovered. The defect was
|
||
entirely local and entirely in the read path.
|
||
|
||
**Reproduction** (on an on-demand mount, per file: write, close, rename, wait for
|
||
`pdfs status` to report the queue drained, then read back):
|
||
|
||
| scenario | result |
|
||
|---|---|
|
||
| plain name, never renamed | content intact |
|
||
| plain name, renamed after close | **0 bytes until restart** |
|
||
| `.crdownload`, renamed after close | **0 bytes until restart** |
|
||
| `.crdownload`, never renamed | content intact |
|
||
| already-settled file, renamed | content intact |
|
||
| any of the above, after a daemon restart | content intact |
|
||
|
||
The gap between `close()` and `rename()` does not matter — 0 s, 2 s and 20 s all
|
||
behave the same. The boundary is not timing but state: the affected node is one
|
||
whose **create op was still queued**, on a mount that is not the primary one.
|
||
|
||
**Root cause — one drain thread, many inode spaces.** The daemon mounts
|
||
`~/ProtonDrive` plus one FUSE session per `ondemand` sync folder. Each of those
|
||
is a `Core::fork_state` clone with its **own** `State` — its own `entries`,
|
||
`by_uid`, `children`, `next_ino` — while sharing `db`, `cache`, `pending` and
|
||
`client`. There is exactly **one** `pdfs-drain` thread in the process, owned by
|
||
the primary mount, and it serves the whole shared `pending_op` table.
|
||
|
||
So when a create queued by a sync-folder mount lands, the drain calls
|
||
`adopt_real_uid` — which looked only at `self.state`, the *primary* mount's inode
|
||
space. The fork's entry keeps its `local~…` placeholder uid forever. And
|
||
`Core::read_range` (`crates/pdfs-fuse/src/reads.rs:314`) short-circuits a local
|
||
uid to an empty vec:
|
||
|
||
```rust
|
||
if is_local_uid(uid) { return Ok(Vec::new()); }
|
||
```
|
||
|
||
which is why the read is silent, instant, and zero-length. A restart rebuilds
|
||
every state from the DB, where `remap_local_uid` had already written the real
|
||
uid, so the file reads correctly from then on.
|
||
|
||
Confirmed live: `read handler uid=local~1785067300752-0 fsize=140000` three
|
||
seconds after `pending create landed`, and at the same moment
|
||
`adopt_real_uid … hit=None strays=[]` — the primary state had no entry for the
|
||
uid at all. `/proc/<pid>/task/*/comm` showed 1 `pdfs-drain` against 8 fuser
|
||
sessions.
|
||
|
||
**Fix:** `Core` gained a `states: Arc<StateRegistry>` — a `Vec` of weakly-held
|
||
mounts that every mount publishes itself into (`Core::register_state`, called
|
||
from `mount.rs` for the primary and from `fork_state` for each fork), plus
|
||
`Core::for_each_state`, which walks every live inode space one lock at a time.
|
||
A uid is unique across mounts, so at most one state matches and the rest are
|
||
no-ops. Five drain sites were converted: `adopt_real_uid`, `adopt_drained_name`,
|
||
the conflict-copy `invalidate_listing`, the `active_writes` baseline rebase in
|
||
`refresh_after_upload`, and that function's closing `intern`.
|
||
|
||
**It was completely silent.** No `ERROR`, no `WARN`, no failed op, no conflict
|
||
copy, no retry — `pdfs status` reported the queue drained, because it had. Any
|
||
diagnosis that relies on the journal or on queue state will not see this.
|
||
|
||
**Regression cover:** `scripts/fuse-acceptance.py`, case
|
||
`regression B74: rename after close keeps its content` — the narrowest form
|
||
(plain names, no overwrite, no transient suffix). It failed in two consecutive
|
||
live runs before the fix and passes after it. The pre-existing B70 case failed on
|
||
the same cause and now passes too.
|
||
|
||
**Audit of the rest of the daemon.** Every background-thread `state.lock()` was
|
||
reviewed for the same shape. The rule that came out of it: **a uid is unique
|
||
across mounts, so uid-keyed work must be broadcast; an inode number is only
|
||
meaningful to the mount that minted it, so inode-keyed work must not be.** What
|
||
changed:
|
||
|
||
| file | site | why |
|
||
|---|---|---|
|
||
| `background.rs` | `apply_event` | the read-side twin of this bug — remote deletes, trashes and renames only ever reached the primary mount's tree |
|
||
| `sweep.rs` | conflict-copy `forget_or_unlink` | a conflict copy inside a sync folder lives in that fork's inode space |
|
||
| `sweep.rs` | `is_busy` | answered "idle" for every file open on a fork, letting the sweep delete one with a writer attached |
|
||
| `sharing.rs` | `rel_path_for_uid` | a share of a node in a sync folder could not be named |
|
||
| `drain.rs` | `node_place` | fell through to a DB lookup for fork-resident nodes |
|
||
| `lib.rs` | `queue_trash`, `rename`, `remove_replaced` (×2), `drop_local`, `restore` | uid-keyed `forget`/invalidate |
|
||
|
||
Deliberately left on `self.state`: `upload.rs` (`parent_ino`/`pino` are inode
|
||
numbers), the size-upgrade path in `lib.rs`, and `drain.rs`'s `root_uid`, which
|
||
is correctly primary-only. The 38 `filesystem.rs` sites are FUSE callbacks —
|
||
they already run on the right mount's session thread. `parent_is_gone` never
|
||
touches state at all; an earlier note here saying it did was wrong.
|
||
|
||
The registry is now a `StateRegistry` type with a unit test
|
||
(`state_registry_walks_every_live_mount_and_reaps_the_rest`) covering the case
|
||
this bug was: a fork that fails to appear in the walk, or one that lingers after
|
||
unmount.
|
||
|
||
**One regression came out of the audit, and is fixed.** Broadcasting
|
||
`apply_event`'s parent-listing invalidation meant forks started receiving
|
||
invalidations they had never received — and it invalidated a folder once per
|
||
event, while a burst of our own uploads produces one event per file all naming
|
||
the *same* folder. Every following lookup in that folder then re-enumerated it
|
||
from the API: quadratic in the folder's size.
|
||
|
||
Two mitigations, both in `background.rs`. A batch is collapsed to one
|
||
invalidation per folder — `apply_event` records the parent in `DirtyParents` and
|
||
`flush_dirty_parents` publishes it once after the batch loop (every `break` path
|
||
falls through to it). And a change the daemon made *itself* is recognised as its
|
||
own echo rather than treated as foreign: the drain records it
|
||
(`Core::note_self_change`, consumed once within `SELF_CHANGE_TTL_MS`), which
|
||
stops the feed's report of our own upload from evicting the content blob we just
|
||
sent. Neither weakens the result — the state after a batch is what it always was.
|
||
|
||
**The hang that showed up alongside this was a different bug, and this entry
|
||
originally misattributed it.** `readdir stability` at 133.94 s and a concurrency
|
||
case that blew past its timeout were blamed here on the invalidation storm. They
|
||
were not: the mount was freezing because network work runs on fuser's single
|
||
dispatch thread, which is B75. Fixing that took the concurrency case from two
|
||
timeouts in three runs to 16.9 / 24.1 / 29.1 s and `readdir stability` to 62.9 s.
|
||
The coalescing above is still worth having — it is a real amplification — but it
|
||
was never the stall.
|
||
|
||
**Note on B70:** B70's own fix was never implicated. The `.crdownload` park/un-park
|
||
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.
|
||
|
||
---
|
||
|
||
### 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.
|
||
|
||
`copy_file_range` has since been implemented: the daemon copies a range whose bytes are on this
|
||
machine (an open write handle, a queued write, the cache) into the destination's scratch file,
|
||
and answers `EOPNOTSUPP` for anything it would have to download. Unlike `ENOSYS`, that errno makes
|
||
the kernel copy that one call through read and write and keeps asking the daemon next time.
|
||
A warm `cp` of a 256 MiB file inside the mount went from about 100 MiB/s to about 450 MiB/s
|
||
(sim, debug build; `a_copy_inside_the_mount_measures_its_speed`).
|
||
|
||
**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<bool>` (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/<pid>/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<StoredNode>` 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<NodeUid>` 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:** Fixed, not verified live
|
||
**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.
|
||
|
||
**Fix (2026-10-09, issue #35).** A rotation the keyring refuses is kept as unsaved and retried
|
||
in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob
|
||
has moved on (B201). The refusal is an error in the log naming `pdfs login`, and the daemon's stop
|
||
writes a rotation still unsaved, logging an error if that fails too. The re-login path is B201's:
|
||
an old session never writes into a newer login.
|
||
|
||
Until it is written, the rotation also waits in the kernel keyring (`kernelkey::UNSAVED_TOKENS`),
|
||
so a crash or SIGKILL before a retry lands no longer loses it. The next resume, by the daemon or
|
||
`pdfs unlock`, puts it back in when the blob still holds the refresh token it spent, and writes
|
||
it. One that belongs to an older login is dropped. The kernel keyring lasts until the user's last
|
||
session ends, so a refused write followed by a crash and a logout still loses the rotation.
|
||
|
||
While a rotation waits, `Response::Status` carries `tokens_unsaved`. The tray says "Sign-in not
|
||
saved — unlock your keyring" and needs attention, the app's status says the same, `pdfs status`
|
||
prints a `Keyring` line and `pdfs diagnose` warns.
|
||
|
||
**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it` and
|
||
`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`, and the tray's
|
||
`tests::a_sign_in_the_keyring_refused_needs_attention`.
|
||
|
||
---
|
||
|
||
### 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.
|
||
|
||
B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name
|
||
that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything
|
||
under them from classification, so the B55 guarantee holds without the wedge.
|
||
|
||
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(<mount>/<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<LocalSig>` — 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 <ts>)` 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<Mount>` *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<File>` 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 <ts>)` 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 <dir>` 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<u64>` to `HashMap<u64, Arc<SizeUpgrade>>`,
|
||
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 <daemon pid> -- 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 <mount> && 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 <dir>` 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 | `<D as digest::DynDigest>::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 <pid>` 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 <newname>/` 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 <ts>)` 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 <mount> && 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.
|