A write open copies the queued revision's blob into its scratch file.
When the upload landed between the sample and the copy, the drain had
already deleted the blob, and the open failed with EIO (B274, seen once
in CI). The drain drops the pending entry before the blob, so a failed
copy whose entry moved on now goes around again, as the re-check after
the copy already does.
Two simulations raced the daemon rather than it:
- a_file_deleted_after_a_restart_leaves_drive_with_what_its_create_sent
let the create's reply through before the kernel's release attached
the second bytes. That attach resets the attempts, so the retry
landed before the test could see the failure. It now waits for the
bytes to be attached.
- a_file_deleted_offline_while_open_still_reads_and_takes_writes read
the cache as the queue emptied, which is a moment before the
eviction. It now waits for the cache to empty.
close(2) returns before the kernel sends release, and release takes the
write handle out of the open writes a few milliseconds before the
revision is in the queue. A `pdfs copy` checked in that gap found the
edit nowhere and copied the version Drive had. A folder copy looked only
at the queue, so it also missed files below it still open with unsaved
writes.
release now 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, and a move checks the same
set.
A copy inside the mount went through the kernel's read/write loop, one
FUSE round trip per page, because copy_file_range answered ENOSYS. The
daemon now copies a range whose source bytes are local (an open write
handle, a queued write, the cache) into the destination's scratch file,
the same way write stages them. A range it would have to download gets
EOPNOTSUPP, which makes the kernel copy that one call itself and keeps
the operation enabled. The copy still uploads: Drive cannot copy into a
file cp has already created.
In the simulation, a warm cp of a 256 MiB file went from about
100 MiB/s to about 450 MiB/s (debug build).
A read on a write handle was answered on the dispatch loop to keep its
order with the handle's writes, and when the bytes it asked for were
neither written nor cached it downloaded them there, holding every request
of the mount for up to the read deadline. The loop now answers from the
scratch file and the local caches only, and hands a read that needs a
download to a transfer worker with the handle as it was when the read came
in (B227).
flush answered ok at once and release synced the scratch file, but close(2)
only returns what flush answers, so a full disk at close was reported to
nobody. flush now syncs a handle with writes on a worker and returns ENOSPC,
EDQUOT or EIO; release then finds the pages on disk instead of writing a
whole file back on the dispatch loop. fsync on a descriptor opened for
reading no longer fails with EBADF (B222).
fallocate passed any mode through and tracked only a punched hole or
growth: a zeroed range came back from the remote base at commit, and a
collapse or insert corrupted the file. Unsupported modes now get EOPNOTSUPP,
zeroed ranges count as written, the real errno is returned, and the state
lock is not held across the syscall (B224).
A cold listing allocated the local id of every new child in a write
transaction while it held the global state lock, then marked the folder
listed, still under it. Every inline handler of every mount waited on that
transaction, and the transaction on the write connection the drain shares.
The ids are now allocated before the lock is taken, and the folder is marked
listed through the outbox after its children's rows, so a crash in between
no longer leaves it recorded as complete and empty (B221).
A partial write whose base was not in the content cache queued an
incomplete revision, and every write open over it was refused with EIO
until the drain uploaded it. CI's one-client-lan simulation hit this on
seed 1: a create over a file whose partial edit had not drained yet.
The write open now copies the blob and fills its gaps from the remote
base with Core::fill_gaps, as the drain does, and fails only when that
fill fails (B204).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say
`docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more
cited offline.md, devices.md, features.md, mount-architecture.md,
plan.md and improvements.md, planning notes that were never in the
repository. Those citations are removed; the comments keep their text.
An upload that landed let go of its blob before the tree had its node. A
write open in between took the base it sampled at its start, the revision
the upload replaced, found none of its bytes cached, and queued an
incomplete revision; the next write open failed with EIO.
The drain keeps the landing node aside until the tree has it, and a write
open with no queued revision takes its base from that node, or from the
tree as it is when the handle goes in. The fake Drive can hold the drain
in that window (`hold_next_landing`).
A new folder, a new file, a rename or a delete through the mount no longer
waits for Drive: it is recorded locally, the call returns at once, and the
drain sends it as it does offline. `"local_first": false` in config.json
brings back the old path for this release.
Fixes found by the simulation runs on the way:
- B129: a file deleted while its first upload is on the wire is trashed once
the upload lands.
- B132, B140: files made in a new folder survive a restart; a landed folder
hands its queued children over in the stored node too.
- B138: a create waits for the rename or trash that frees its name.
- B139: a file closed while its first upload lands keeps its last write.
- B141: a rename a worker holds stays queued, and the newer one waits behind
it instead of racing it.
- B142: a rename of a file whose create lands meanwhile goes to the uid Drive
gave it instead of answering EBUSY.
- B143: a create whose read-back fails as the link drops is adopted before
the next op for it is sent, so its next write is not a conflict copy.
On-demand sync folders kept their own State, which the drain, the
event applier and the sweeps had to visit one by one (B74). Every mount
now serves from the primary's tree. A session keeps only its root, which
the kernel knows as inode 1, and its notification channel; work that
changes the tree tells every mounted session. Size upgrades become
daemon-wide, and moves compare mounts by root instead of by state.
Found by the seeded simulation runs (docs/BUGS.md).
- B122: a rename across folders goes to Drive as one move-multiple
request, so a sibling holding the new name in the old folder no longer
fails it with EIO. The drain uses the same request.
- B123: a second write open on a file just written no longer fails with
EIO. An empty file counts as complete, our own renames, moves and
online creates record their echo, a duplicated event is skipped, a
rename keeps the file's mtime while its revision stays, and a landed
upload is cached before open handles are rebased onto it.
- B124: a node put back after a failed replacing rename leaves `hidden`
and its folder is listed again, so it shows in the mount.
- B131: a landed create is read back with the same short retry as an
online create, and its bytes are cached under the real uid before an
open handle is repointed at it.
- B135: an online rename settles the tree through the state guard, so
the database has both folders unlisted before the reply. A lookup
right after it no longer rebuilds the destination without the file.
The simulation runs no longer tolerate B122 or B131.
`Core` held a concrete `ProtonDriveClient`, so no test could make Drive
answer late, answer stale or not answer. This is the first step of
phase 0 in docs/MILESTONE-3.0.0.md.
`DriveApi` (`crates/pdfs-fuse/src/drive.rs`) covers the calls the sync
core makes: listings, events, creates, uploads, downloads, renames,
moves, trashes and restores. `ProtonDriveClient` implements it by
forwarding. `Core::drive` holds it, and every call of those methods now
goes through it. Sharing, Photos, devices, invitations and public links
stay on `Core::client`.
The trait takes readers and writers as trait objects, and
`impl dyn DriveApi` puts the client's generic signatures back, so call
sites read the same. Block reads go through `RevisionRead` instead of
the SDK's `RevisionReader`, which only the SDK can construct.
No behaviour change.
The SDK caches each node, including the name hash it sends with the next
rename or move, and only an event evicts it. Renames and moves didn't
update that copy. So after a rename, the next change to the same node
went out with the old name hash, Drive refused it as out of date (422),
and the retry kept sending the stale hash until the event feed came
round, up to 10 seconds later.
- Every remote rename and move goes through `Core::rename_remote` or
`Core::move_remote`. Both drop the SDK's copy of the node after a
successful change and before each out-of-date retry, so the retry
sends the current hash.
- A plain move was not retried at all, so a move right after a rename
failed with EIO through the mount and with "out of date" from
`pdfs move`. It now retries like a rename.
- The drain's combined move-and-rename also drops the SDK's copy once
it lands.
- A rename that also changes folders through the mount replies only
once both halves have landed. When the move fails, the file keeps
its new name in the source folder, the tree is told so, and the
rename reports EIO. A dropped link still queues the change.
B111's cause in docs/BUGS.md is corrected to the same SDK cache. The
acceptance case "regression B121" renames and moves a file at once
through the mount and through `pdfs`, and checks names and bytes.
- B114: `pdfs rm`, `pdfs rename` and `pdfs move` tell each kernel
session that the old name is gone, so it stops resolving at once
instead of for up to 30 seconds (`Core::forget_everywhere`,
`Core::relocate_everywhere`).
- B115: `pdfs ls` and the app's file browser wait up to 10 seconds for
the listing's size fetch, as `stat` does, so they show a file's real
size instead of its encrypted one.
- B116: trashing a folder also drops the creates queued in its
subfolders. A parked one stayed queued with its bytes, and a due one
would have landed in the root.
- B117: `pdfs move` and `pdfs rename` move the node in each mount
instead of forgetting it, so its queued upload keeps its path in
`pdfs sync queue` and is not put off once after the resume.
- B118: when a create or mkdir reads back nothing, the mount asks again
and then queues it as it does offline, instead of failing with ENOENT
for something Drive has made.
- B119: a landed upload keeps its staged bytes as the file's cached
content, unless it is larger than a sixteenth of the cache, so
reading a file just written does not need the network.
- B120: a landed folder's row passes to its real uid in the same
transaction, so a file queued inside it is not put off for five
seconds.
docs/BUGS.md records B117 to B120. The account run of 2026-10-03
verified B114 to B117 and B119 in My files.
The write handle a create opens was given no revision id, so the drain's
conflict check fell back to (mtime, size). Drive advances a node's mtime
on a move or rename, so a new file moved before its first upload drained
landed as a (sync-conflict) copy next to its own empty original. Take the
minted node's revision id, and rebase handles still open on a queued
create onto the node it landed as (B113).
The mount only ever went from offline to online: nothing marked it
offline again, so after the Wi-Fi dropped every create, mkdir, delete and
rename called Drive, waited out the SDK's retries for up to two minutes
and answered EIO. Background loops kept polling the dead link.
Connectivity now lives in pdfs-fuse's new link module and goes both
ways. A call that gets no answer (a transport error or a request
timeout) marks the mount offline; a probe thread checks every 2 s,
backing off to 30 s, and on recovery re-lists folders served from the
database, retries failed operations and wakes the drain. Interactive
calls get a 20 s deadline, so a dead link costs one slow call instead of
many.
While offline:
- create, mkdir, unlink, rmdir and rename fall back to the offline
queue instead of answering EIO;
- folders list from the database even when not marked listed, and are
re-listed once the link returns;
- reads of uncached data wait up to 30 s for the link, then retry once
within the 120 s read deadline;
- statfs serves the last quota instead of fetching, and quota refreshes
are single-flight;
- the event and photo event loops wait for the link instead of polling.
A create whose request timed out may have landed anyway. When the drain
replays it and finds a same-named twin of the same kind, created within
the last two minutes and still empty, it adopts that node instead of
making a conflict copy. The drain also leaves alone a name still held
by a queued trash.
A create that answered ENOENT left no trace in the log. It now warns
when the parent folder is no longer known, and when the parent left the
tree while its children were being listed, naming the parent inode and
the new name.
The acceptance script's metadata throughput floor was 20 operations per
second, which a FUSE mount cannot reach: each create and unlink waits for
Drive before it returns, about 350 ms apiece. On a FUSE mount the floor
is now 3 operations per second; other paths keep 20. The mount type is
read from /proc/self/mountinfo.
A read that settles a provisional size invalidated the file's page cache
while the kernel held that read's page lock, so the worker waited on
itself forever (B110). Size upgrades now invalidate attributes only.
Renaming a node right after renaming it could be refused by Drive as out
of date, because the name hash read just before still held the old name.
Every remote rename now goes through Core::rename_remote, which retries
InvalidRequirements for up to ten seconds (B111).
Fixes from the account acceptance runs:
- B104: a reader open across a rename-over keeps working; the replaced
node is unlinked like any other open file instead of forgotten.
- B105: a drain worker holding a revision withdrawn by an unlink no
longer uploads it as a recovered-… conflict copy (Db::op_exists).
- B106: an open victim's queued ops are withdrawn at trash time, so a
repeated editor save no longer fails with AlreadyExists; the staged
blob goes with the last close.
- B107: a remote read gives up after 120 s with EIO instead of hanging
its caller; the timer is built inside the runtime.
- B108: a mirror upload whose local file was deleted after the scan is
skipped rather than counted as an error.
The acceptance script reports the failing line for non-assert errors,
ignores queue ops that predate the run, purges with delete-forever
first, and diagnoses a malformed SQLite database against a local replay.
The FUSE acceptance suite now runs on the signed-in account by default and
cleans up after itself; its first live runs found three daemon bugs.
- B101: a pwrite past EOF now authors the hole it leaves, so truncated bytes
no longer come back from the base. A write attached to a create while that
create uploads becomes a revision of the real uid instead of being dropped.
- B102: removing a folder drops the queued revisions of files below it, found
through the node tree, with their blobs and in-flight uploads. `pdfs rm`
now drops queued ops too.
- B103: each server call of a trash refresh times out after 120 s, so one hung
call can no longer freeze the trash listing until restart.
Acceptance suite: account mode with a manifest, lock and reaper; cleanup by
uid; queued ops naming a test root count as leftovers; new edge-case,
throughput and B100/B101 cases; the wide-directory case is split from the
deep tree and gets a doubled budget. Adds `pdfs trash --json`.
A file listed by the cheap enumeration reports its ciphertext size until
the size upgrade lands. Since B84 a read on that size fails with EIO, and
a write open started its handle at it. Reads and write opens now fetch
the real size first. A folder past the size-upgrade cap is queued instead
of dropped, so a large copy no longer leaves most folders provisional
(B100).
On 2026-09-18 the daemon hung: 43 minutes of CPU over 20 hours, 5.2 GiB
resident, silent from 17:10 until a manual restart the next day, and no
panic. Nothing in the journal said what it had been waiting on, and
nothing in the daemon could be asked.
- The worker pool publishes per-thread state: whether the thread is busy,
how long it has been in that state, and the label of the job in hand.
Every FUSE handler that hands work to the pool now names its job
(`read`, `lookup`, `rename`), so a stuck worker is named rather than
merely counted. Queue depths and per-lane completion counts are
mirrored outside the queue lock.
- `Request::Diagnostics` / `pdfs diagnostics` reports those, plus
resident size, the control handler count against its limit, the
in-flight control requests with their ages, and the pending-op count.
- Built to answer while the daemon is wedged: atomics and `try_lock`
only, and the one field that needs the database reads it on a throwaway
thread with a 500 ms deadline.
B69 — spurious `(sync-conflict)` copies came from `(mtime, size)` being used as a
revision identity. The server re-stamps a sealed revision with its own commit
time, so the drain diverted a write into a conflict copy of its own base; and
`plan_file` classified a no-baseline (local, remote) pair as `Conflict`, forking
every pre-existing file on first reconcile.
- `Baseline` carries `revision_id` (from the SDK's new `active_revision_id`),
captured at write-open and preserved across supersede/rebaseline.
`revision_changed` trusts the id and only falls back to `(mtime, size)` for
old sidecars.
- Planner gains `FilePlan::AdoptBaseline`: equal-size no-baseline pairs are
recorded, not conflicted. Divergent sizes stay a real conflict.
- New `sweep.rs` (`pdfs-conflict-sweep`, 5-min cadence) removes conflict copies
proven identical by size + `content_sha1` and surfaces divergent ones once as
an `ActivityKind::Conflict` entry. Never removes what it cannot prove.
B70 — browsers downloading into an on-demand mount had their in-flight temps
sealed as canonical revisions, exiling the finished file to a conflict copy.
`syncignore::is_transient_name` now recognises download/scratch temps, and the
on-demand `release`/`rename` paths hold them (`set_create_hold`) instead of
sealing until the name is finalized.
Workspace deps bumped to proton-sdk/proton-drive-rs 0.2.2 (registry, no patch).
Live validation against a real account still pending for both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8P42asymU158CPnhjRwri
- Durable scratch/staging publication and preservation on failure.
- Transactional pending-operation supersession.
- Conflict copies cannot overwrite older copies or lose the source.
- Single-entry wipe protection and incomplete-scan aborts.
- Failed local deletion retains its baseline.
- Downloads are fsynced before baseline settlement.
- Future database schemas are refused.
- Event cursors advance only after successful invalidation and persistence.
- IPC frame, timeout, and concurrency limits.
- Atomic config saves; malformed configs are preserved.
- Sensitive directories require correct ownership, real directories, and 0700.
- Debian package now includes service and tray autostart files.
- Release workflow enforces versions, tests, lint, formatting, and offline acceptance.
- CI now runs offline filesystem acceptance.