Diagnose a problem

Work down this page: the two commands first, then the symptom that matches yours. The cases are ordered by how often each has actually bitten.

Before anything else, check that your setup is one Wusel is tested on — Platform support lists which distributions, desktops and Nextcloud versions those are.

First stop: wusel doctor

Before working through the cases below, run:

$ wusel doctor

It gathers, in one pass, what a support case needs and what the sections below otherwise ask you to collect by hand: the running daemon and its per-thread kernel wait-channels, the mount table, the FUSE connection’s waiting count, a timed check that the mountpoint still answers, the effective configuration, and — from the running daemon over a per-user socket — the engine’s own view of what each object is doing. A PASS/WARN/FAIL summary sits on top; the headline check is the one that matters most, and it is the reason doctor sometimes pauses for two seconds before it prints: it samples the mount twice.

No single instant can tell a wedged mount from a busy one. A request that is merely in flight has a reply parked for it from the moment it arrives, so waiting and the parked-reply total agree on any mount that is doing work. What separates the two is whether the work moves. If the same job — the same inode, intent and step — is still handed out to a worker two seconds later, or a reply is still parked with nothing running behind it at all, the mount has stopped answering and that is a FAIL. If the work moved on, it was load, and doctor says so. Both samples are printed in the ENGINE section, so the report shows its own evidence.

To send the result to us, write the bundle to files and attach them:

$ wusel doctor -o wusel-doctor
wrote wusel-doctor.txt and wusel-doctor.json

The report is redacted by default and safe to attach: the engine’s view is name-free (it speaks in inode numbers, never file names), no directory of the mount is ever listed, no secret is read (only the server and login, never the password), and the home path and username are masked in the text. The header states exactly what is and is not redacted. --no-logs omits the journal tail; --include-listing turns redaction off for a consenting deep dive.

Every probe is best-effort: one that cannot run says why, and the rest go on — so the report is still useful on a half-broken system, which is exactly when it is needed.

"Is my file saved?" — wusel status

doctor answers is the mount healthy. When the question is what is happening to my files, use:

$ wusel status
wusel status — account default

UPLOADS (1)
  PARKED     Documents/report.odt
               Permanent (after 3 attempt(s))

DOWNLOADS (1)
  caching    Videos/talk.mp4 (412.0 MiB)

buffers: 1 open, 1 with unsaved changes

It names files, which is exactly why it is a separate command and not a flag on doctor: this output is for you, in your terminal, and there is no way to write it to a file for someone else.

The UPLOADS section is the one to check after saving something. It is read from the state database rather than from the daemon, so it is reported even when nothing is mounted, and it distinguishes three states: waiting (owed, the uploader will get to it), sending (on its way), and PARKED — an upload given up after a permanent failure such as a conflict, missing permissions or a full quota. A parked upload is the one case that needs you: the file reads as saved locally, it is not on the server, and nothing will retry it on its own. Your bytes are safe in the buffer either way.

DOWNLOADS separates the two things a user would call downloading: reading is a range being served live to whoever asked for it, caching is a whole-file hydration running in the background with nobody waiting for it. Individual reads last milliseconds, so a single run only catches a sample of them — wusel status --watch redraws once a second.

The desktop integration is two independent channels

Both ride the session (user) D-Bus and both are fail-soft — without them the mount itself keeps working:

Notifications

org.freedesktop.Notifications, shown by the desktop shell. Needs nothing but the user bus.

File-manager status (cloud provider)

libcloudproviders — the sidebar entry with idle/syncing/error state in Nautilus. Needs the user bus plus a one-time system-wide registration on every machine:

sudo wusel desktop install-provider
nautilus -q   # restart Nautilus so it re-reads the provider list

On success the daemon logs at startup:

INFO wusel_desktop: registered as a cloud provider (file-manager status) bus_name=…

No notifications, no cloud entry — daemon started over SSH (or cron)

A mount started from an SSH terminal on a desktop machine runs outside the graphical session’s environment (DBUS_SESSION_BUS_ADDRESS is not set there). Since systemd distributions share one user bus per user, the daemon falls back to it automatically ($XDG_RUNTIME_DIR/bus; the log then shows session bus found via XDG_RUNTIME_DIR) — SSH-started mounts integrate with the desktop out of the box.

If even that fails, the daemon warns (no session D-Bus — desktop integration disabled …); the manual override is:

export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
wusel mount

Verify the channel end-to-end without mounting:

wusel desktop notify            # sends test notifications (info/warning/error)

If the test notifications appear in the graphical session, conflict and error notices from the daemon will too.

Notifications work, but Nautilus shows only a plain mount

The cloud-provider registration is missing on this machine (it is per-machine, not per-account-config): run the install-provider step from above, restart Nautilus, and check the startup log for the registered as a cloud provider line.

ls works, but cat/image viewers fail with Input/output error

Historically two distinct causes; both are visible in the logs now:

  • Transport errors — the HTTP error: message names the full cause chain ([connect] … → connection reset, TLS, DNS, timeout).

  • A server or proxy that ignores Range requests — the daemon warns once: server ignored a Range request and sent the full body … and serves reads correctly anyway. Every partial read then transfers the whole file, so fix the server/proxy config for performance (check for header-stripping proxies, response compression of media types, or server-side encryption).

Everything feels slow

  • Watch the actual traffic: GET lines (path + byte range) appear at debug level, PROPFIND likewise. A directory listing is one PROPFIND per level — many GETs mean file content is being read (thumbnailers included).

  • Opening a file caches it: the read is served live, and the whole file is hydrated into the LRU cache in the background (emblem online-only → cached). Browsing a folder with a thumbnailer therefore pulls what it reads — bounded by the cache size, not by network. Exclude the mount from indexers if that traffic is unwanted.

  • On a Range-ignoring server (see above) every partial read transfers the whole file — the warning line tells you.

The folder stalls and nothing happens — is it hung, or is it the network?

Almost always the network, and the mount says so on its own: when the server stays unreachable for about ten seconds, a "Connection lost" notification names the server, and the file manager’s account status switches to error. A second notification announces the connection coming back. Both are localized; neither repeats while the outage lasts (one per incident, not one per failed request).

Two things follow from that, when a mount seems stuck:

  • You got the notification — this is an outage, not a defect. Files that are kept offline stay readable; everything else waits. Nothing has to be killed or unmounted, and no work is lost: pending uploads are retried when the connection returns.

  • You got no notification — either the server is answering (then it is not a connectivity problem: look at the journal for the status it answers with), or the notification channel itself is not working. Check that with wusel desktop notify; if the test notices do not appear either, see No notifications, no cloud entry — daemon started over SSH (or cron).

The journal carries the detail in both cases — the failing URL and the reason ([connect] … dns error, [timeout] …):

journalctl --user -u wusel@default -g 'unreachable|connect|timeout' -n 50

wusel doctor also reports connectivity, and is the better first step if the mount answers to nothing at all.

Cache vs. server problems

To rule the cache out (or reset a tester’s machine to a fresh-connect state) without touching credentials or config:

wusel cache clear            # whole account: metadata, content, pins
wusel cache clear Photos     # one subtree, recursively

Run it while the account is not mounted — including for a single path, where that requirement is a known limitation rather than a technical necessity. If a problem survives cache clear, it is not a caching problem.

What a conflict should look like

An upload that loses against a newer server version keeps both files: the server version stays, the local edit is saved as name (conflicted copy <timestamp>).ext next to it. Expect

With text_merge enabled a clean 3-way merge is attempted first; it needs a cached base version of the file, otherwise it falls back to the conflicted copy.

A file shows an "uploading" or "sync-error" emblem

Uploads are asynchronous by default: saving a file returns as soon as the change is safe on local disk, and the transfer to the server runs behind it. While it is on its way the file carries an uploading emblem; once it lands the emblem goes back to cached.

A sync-error emblem means the upload was refused for good — most often wrong permissions on the target, a name conflict, or a full quota (see the WARN … upload failed permanently log line and the desktop notification). The change is not lost: the bytes are kept locally, and saving the file again re-tries the upload once the cause is fixed. Transient problems (the server briefly down, a dropped connection) are not shown as errors — they retry on their own until they land.

To make saving wait for the upload and report its result directly — the pre-async behaviour — set [sync] upload = sync in config.toml.

If it is large files in particular that fail (small ones upload fine), the cause is usually a reverse proxy mishandling the chunked-upload endpoint — see Uploads fail, stall, or spawn conflicted copies.

wusel doctor (see First stop: wusel doctor) lists any objects still owed to the server and how their uploads stand.

Uploads fail, stall, or spawn conflicted copies

Large files upload with Nextcloud’s chunked-upload protocol: many small PUT`s to `/remote.php/dav/uploads/, then one assembling MOVE. Almost every upload problem seen in the field has been a reverse proxy in front of Nextcloud mishandling that endpoint — not wusel or Nextcloud themselves. Work through the client switches first (no server access needed); then, if you administer the server, the proxy checks.

wusel already tolerates the most common proxy misbehaviour on its own: if the assembling MOVE returns an error (a proxy timeout surfaced as 403/5xx, or a WAF) but the file did assemble in full, the upload is counted as the success it is — no sync-error emblem, no conflicted copy. The switches below are for when uploads still fail or crawl.

Client side (no server access needed)

Force HTTP/1.1

Some proxies drop or truncate HTTP/2 request bodies, so a chunk reaches Nextcloud as 0 bytes — its log then reads Expected file size of N bytes, but 0 bytes read. HTTP/1.1 avoids this entirely. In config.toml:

[tls]
http1_only = true

Concurrent reads still parallelise across the connection pool, so the only cost is a few more connections. Try this first — it fixes the widest class of proxy-induced upload failures without touching the server.

Keep the 3-way merge off

[sync] text_merge is off by default and best left off: it needs a cached base version of the file and can resolve surprisingly. Off, wusel behaves like the reference client — a conflict keeps both versions as a conflicted copy. Confirm config.toml has no text_merge = true.

Make saving wait for the upload

[sync] upload = sync makes write-back synchronous, so a save blocks until the upload lands and reports its real result at that moment instead of failing quietly in the background. Handy while diagnosing.

Clean up stale conflicted copies

repeated failed attempts can leave name (conflicted copy …) files; they hold nothing the original does not. Deleting them stops them confusing the picture (and, on the server, reclaims the space).

Server side (if you administer the Nextcloud/proxy)

The tell-tale is in Nextcloud’s own log — …/data/nextcloud.log, or on Nextcloud AIO docker exec nextcloud-aio-nextcloud tail -f /mnt/ncdata/nextcloud.log:

Expected file size of N bytes, but 0 bytes read … and written …

That is the request body being lost in the proxy — the chunk left the client but arrived at Nextcloud empty. It hits every upload client (the official desktop client too), which is how you tell it apart from a wusel bug. Common causes, in the order worth trying — reload the proxy and re-test one large upload after each:

  1. Disable HTTP/3 (QUIC). nginx’s HTTP/3 has request-body bugs; comment out http3 on; and any listen … quic lines. Clients fall back to HTTP/2.

  2. Disable TLS 0-RTT. ssl_early_data on; can lose or duplicate request bodies; comment it out.

  3. Response and request buffering are different directions — do not confuse them. Keep proxy_buffering off (the response side: downloads, Range, streaming, WebSocket). Only if the two steps above did not help, turn the request side on: proxy_request_buffering on;. It makes the proxy read each chunk fully before forwarding, so Nextcloud never sees a half-body; with chunked upload the buffered pieces are small.

  4. No body-size cap, generous timeouts: client_max_body_size 0; and e.g. proxy_read_timeout 3600s;.

  5. WAF / ModSecurity: a rule that flags the long MOVE, or the DELETE of an upload folder, returns 403 — and a blocked DELETE leaves orphaned chunks in /dav/uploads/ that slowly eat quota. Exempt /remote.php/dav/uploads/ from body inspection.

One 403 you can rule out immediately: the chunked-upload endpoint /remote.php/dav/uploads/<user>/ requires the account’s real user id, not a login alias such as an email — unlike /dav/files/, which accepts the alias. wusel resolves the user id itself (logged at startup as resolved the DAV user id …), so this is already handled; only if you reproduce with a hand-built URL, take the id from occ user:list, not the email.

Log levels — and turning debug on in production

The default is INFO: the production narrative (mount/unmount, the startup cache summary, uploads, push connection, conflicts, warnings, errors) without per-operation noise. More detail is opt-in via the RUST_LOG environment variable:

Level What it adds

info (default)

Lifecycle + state-changing events; quiet during pure browsing/reading.

debug

The activity narrative: every WebDAV request (GET/PUT/PROPFIND/…), open and readdir per path, write-buffer lifecycle, readahead escalation, background sync decisions. Cache-served operations appear as open/readdir lines only.

trace

Additionally the read-by-read provenance: one line per ~128 KiB kernel read served from the cache blob or the readahead buffer. Very chatty — use for short, targeted sessions.

Foreground (a shell):

RUST_LOG=debug wusel mount

Production (the systemd user service): set the variable in a drop-in and restart —

systemctl --user edit wusel@default   # opens the drop-in editor; add:
#   [Service]
#   Environment=RUST_LOG=debug
systemctl --user restart wusel@default
journalctl --user -u wusel@default -f # follow the log

Remove the drop-in (or the Environment= line) and restart to return to INFO.

Scoping to one crate

RUST_LOG is a comma-separated list of directives. A bare level is the global floor for every crate; crate=level overrides one crate. Combine them to raise detail for one component while keeping the rest quiet — the practical way to use trace without drowning in read-by-read lines:

RUST_LOG=info,wusel_core=trace wusel mount   # everything at info, engine at trace
RUST_LOG=info,wusel_fuse=debug wusel mount   # only the FUSE layer verbose

The crate targets are wusel (CLI/daemon), wusel_core (engine: WebDAV, cache, state, push), wusel_fuse (the mount), wusel_desktop (notifications + file-manager status).

Always keep a global floor. RUST_LOG=wusel_core=debug on its own raises wusel_core but drops every other crate to the default (off) — so the wusel_fuse error you are chasing never prints. Write RUST_LOG=info,wusel_core=debug (or simply RUST_LOG=debug) instead.