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.
The trash is a flat list of uids, but a delete has a shape: a folder, and
often items inside it that were trashed separately beforehand. Restoring
one of those in isolation is not what it means — a folder came back
without the contents deleted with it, and a file came back inside a
folder that is still trashed, which is to say nowhere the user can see.
- Schema 29: `trash.parent_uid`, filled by the trash refresh from the
node's parent, plus an index for the walk.
- `expand_restore` takes the connected piece of the trashed tree around
what was asked for (every trashed descendant, every trashed ancestor)
and orders it into waves, shallowest first: the server cannot put a
node back under a parent that is still trashed. A uid the listing does
not know about is restored on its own, because the listing is
materialised in chunks and can be stale.
- `Core::restore` restores wave by wave, still streaming per-node
outcomes, so one node the server refuses leaves the rest restored.
The SDK bump brings the 6cbf2f44 port: permanent API errors are no
longer retried on block transfers, expired block URLs are re-minted on
the whole-file and public-link download paths, and a server-supplied
Retry-After is capped at 120 s.
The "This computer" indexer walks $HOME, and `default_excludes` only kept it
out of the Drive mountpoint and our own state/cache dirs. A home directory
that also holds third-party FUSE mounts (sshfs, rclone) got walked into.
`scan` does ask for `same_file_system(true)`, which looks sufficient and is
not: `ignore` implements it as `is_same_file_system` -> `device_num(path)` ->
`path.metadata()`, run after the directory is queued. Learning that a mount is
foreign requires stat'ing it first, and against a mount whose server is gone
that stat never returns.
The consequence is worse than a stalled scan. The blocked thread sits in
uninterruptible FUSE wait, so it ignores SIGKILL: the daemon can never exit,
systemd's stop times out, and the unreapable process keeps the cache.db lock
the next start needs, turning one dead mount into a permanent restart loop.
No userspace recovery exists once a thread is in that state, so the fix has to
be to never enter it.
Exclude every mountpoint strictly below the scan root, read by path from
/proc/self/mounts (octal-unescaped, so a path with a space still matches).
`filter_entry` runs before the device stat, so the walker never touches them.
Re-read per scan rather than cached at startup: a mount that appeared since
the daemon started is the likeliest to be half-alive.
This does not change what is indexed — a nested mount is a different device,
so it was already going to be skipped. Only the stat in front of that decision
goes away.
Refs: docs/BUGS.md B90
- keyring 3 -> keyring-core 1 + dbus-secret-service-keyring-store 1
(crypto-rust). Same libdbus Secret Service backend; with no target the
store matches items keyring 3 wrote, so stored sessions survive.
- ksni 0.2 -> 0.3 via the blocking API, with assume_sni_available so a
tray that starts before the SNI watcher waits instead of failing. Drops
the clap 2 / atty chain (Dependabot atty advisory).
- gtk4 0.11, libadwaita 0.9, webkit6 0.6, rusqlite 0.40, fuser 0.18,
directories 6, sha1/sha2 0.11, base64 0.23.
- A queued rename that also changes folder now lands as one `move-multiple`
request carrying the target name instead of rename-then-move, so there is
no half-applied state and no second call for stale name requirements to
hit (B46 follow-up). A destination name collision moves under a conflict
name in one step; same-folder renames still use the rename endpoint.
- Shared with me shows the inviter and invite date (GUI subtitle, `pdfs
shared-with-me`), with a warning when the invitation signature does not
verify against the claimed inviter's keys. `DirEntry` gains `shared_by`,
`shared_at` and `shared_by_unverified`, all serde-defaulted.
- Bump `proton-sdk` / `proton-drive-rs` to 0.6.3 (anonymous-node moves,
`TrashTime` trashed detection).
The login backoff had no wake-up path. A unit started at boot sits in
wait_for_session and reaches the ceiling within ~90s; the user then signs
in and `enable --now` is a no-op on an already-active unit, so the daemon
kept sleeping and the mount appeared up to a full interval late.
- service::enable_start now runs `enable` + `restart` instead of
`enable --now`, so the GUI login and the tray's Connect interrupt the
sleep immediately and still start a unit that is down.
- `pdfs login` restarts the unit when it is already active. It stays out
of `enable` on purpose, so a headless setup driving `pdfs daemon` by
hand is left alone.
- Lower the backoff ceiling from 60s to 15s. It is still an 80% cut in
keyring/D-Bus wakeups (28,800/day -> 5,760/day) and it bounds the
worst-case latency for paths that never touch systemd.
Also corrects the flat-3s-poll figure in the doc comment: 86400/3 is
28,800 checks a day, not 48,000.
The daemon's wait-for-login loop slept a flat 3 seconds between checks and
never gave up, so an enabled service with no saved session polled the keyring
28,800 times a day. Each check is more than a sleep: auth::load() builds a
fresh keyring::Entry, so every iteration opens a new D-Bus connection to the
Secret Service and negotiates a session on it.
Back off from 3s to a 60s ceiling and say "not logged in" once at INFO rather
than on every iteration, which also stops the journal filling with tens of
thousands of identical lines.
The wait is extracted into wait_for_session() taking the load and sleep calls
as parameters, so the schedule is testable without a keyring and without real
time passing.
Refs #21