Wusel, one month on: off the single thread, onto four distributions
A month ago we explained why we built Wusel: a virtual filesystem that mounts Nextcloud into the Linux desktop, where files look present and are fetched only when something opens them. That post was about the why. This one is about what happened next.
Three releases later, and the honest summary is about testing rather than code. We had been running 0.1.0 on our own machines well before that post, and it did what we needed — but a handful of colleagues who have long since made peace with far worse tooling is a narrow sample, and our accounts were not the large ones. Almost everything below came back from a wider set of testers, and from data sets considerably bigger than anything we had pointed it at ourselves. Several of these defects are invisible below a certain size.
The short version
If you read nothing else:
-
It is properly concurrent now. 0.1.0 served one filesystem request at a time, so a large copy blocked an unrelated
ls. Every callback is now an intent handed to a state machine with worker pools underneath. Two transfers overlap instead of queueing, and hydrating a file costs one request where it used to cost thirty-two. → The ceiling nobody sees until they hit it -
The thread that decides never waits — and the compiler enforces it. Every operation is a short script of named steps; the decider that runs them lives in a crate with no dependencies at all, so performing I/O there is not a rule to remember but a thing you cannot express. It is also what makes a macOS or Windows frontend a port rather than a rewrite. → One rule: the thread that decides never waits
-
Saving a file no longer waits for the server.
close()returns once the change is durable locally; the upload runs behind it and is resumed after a restart if it has to be. → Saving a file stopped waiting for the server -
A large account is usable from the first second. Background refreshes used to share a queue with the reads you are blocked on; on a real account a folder once took over half an hour to open, with 1552 revalidations in flight. → The folder that took half an hour
-
An outage now says so. The network going away used to look exactly like a hung mount. One notification per incident, and one when it comes back. → Telling the user the truth
-
Four distributions, from a signed repository. Fedora, openSUSE Tumbleweed, Debian and Ubuntu, on
x86_64andaarch64. Add it once and updates arrive withdnf upgrade,zypper duporapt upgrade. → Beyond Fedora -
The manual was rebuilt from scratch. Sixteen pages became thirty-four, and there is finally a reference section listing every command and every setting. → The manual, rebuilt
-
Your entire Nextcloud on a server — for the disk cost of the files you actually touch. This is the one we are really proud of. A real mount, always there, fetching on access: an account far larger than the machine's disk is not a problem to work around, it is just Tuesday. Re-export it over Samba, point a backup job at it, let a script drop reports where colleagues can see them. There is no equivalent on Linux today — the official client's headless path is a sync command you run on a timer, which means a full local copy. We read their source before saying that, and we link the lines. → What about a server with no desktop?
The rest of this post is the detail, including the two releases we got wrong on the way and what they taught us.
The ceiling nobody sees until they hit it
Wusel 0.1.0 served one filesystem request at a time. FUSE hands you a session loop, the obvious thing is to answer each callback as it arrives, and for a demo that is completely fine.
On a real desktop it is not. A file manager listing a folder issues many reads at once — it sniffs each file's content type to pick an icon. With one dispatch thread they serialise behind whichever is slowest. Copy a large file out of the mount and ls in an unrelated directory waits for it. Hydrate a video and the emblems on every other file stop updating. Nothing is broken; everything is just slower than it has any reason to be.
We had mitigations: reads served only their requested byte range, directory revalidation ran off-thread. Both were bandages over a hard serialisation point.
0.2.0 removed the point, and the way it did is the part of this release we most enjoyed building.
The measurable results first, against a real Nextcloud over a deliberately throttled 3 Mbit link: two concurrent transfers now overlap (7.6 s against 8.7 s sequential), the mount stays responsive during both a large download and a large upload, and hydrating a file costs one GET where it used to cost thirty-two.
One rule: the thread that decides never waits
The obvious fix for a serialised dispatcher is to share the state behind locks and let several threads in. We rejected that — not because it cannot be made to work, but because it hands every future contributor a lock hierarchy to keep correct, and it caps out anyway, since SQLite serialises writers regardless.
Instead there is one rule, and it has no exceptions:
The deciding thread performs no I/O. None. Not network, not files, not the database.
The temptation is to soften that. A local SQLite read is only microseconds. A 4 MiB file read is only milliseconds. Both are true on a healthy machine — and the machines this runs on are business laptops with home directories on NFS, virus scanners holding files open, and network storage that stalls for seconds without warning. None of those latencies are knowable in advance, and no benchmark on a developer workstation will ever show them.
So the rule is absolute, and the deciding thread becomes a pure decider: hold state, resolve collisions, hand out work, process completions. Everything that can block on a stranger's machine is somewhere else by construction rather than by assumption. That is the whole idea, and every consequence below falls out of it.
One kind of work. A lookup and a 200 MB upload run through the same mechanism — job, transition, completion. There are no two classes of I/O with two sets of rules, and no borderline cases to argue about in review.
Operations become scripts, not state graphs. Each one is a short, named sequence of steps, and between any two the thread does nothing but decide. read is four steps; flush — the longest — is seven:
1 DbRead node row + scratch metadata
2 FileIo size of the scratch
3 Net PUT or chunked upload, with its precondition
4 on 412 → sub-script: conflict resolution
5 DbWrite record the new ETag and size
6 FileIo copy the scratch into the blob cache
7 Reply the object is idle again
In 0.1.0 that sequence was seven nested blocking calls inside one flush, any of which could stand for minutes. As a script it is seven named steps, each separately readable and separately testable — and each one drawn, step by step with the executing thread colour-coded, in the operation scripts.
The compiler enforces the rule. This is the part we are quietly pleased with. The decider lives in its own crate with no dependencies at all — no engine, no database, no HTTP client, no FUSE binding. cargo tree -p wusel-fsm is one line. "The deciding thread performs no I/O" is therefore not a convention someone has to remember in review; it is a thing you cannot express, because there is nothing in scope to do it with.
None of the ingredients are new — a pure core with an effectful shell around it is an old idea, and the Rust ecosystem has been writing sans-I/O protocol implementations for years. What we had not seen was a filesystem dispatcher built that way, with an intent alphabet that collapses the operating system's vocabulary into what an operation is actually trying to achieve: flush, fsync and release are one intent, because they are one operation. A frontend translates its platform's callbacks into that alphabet and formats the replies. It carries no engine logic at all.
Which is what turns portability from an aspiration into arithmetic. A macOS File Provider or a Windows Cloud Filter frontend has to map its own callbacks onto the same twelve intents — and that is the whole job. The engine does not change, and neither does the machine that decides. We have not written those frontends. But we know what writing one costs, which is a different position from hoping.
Saving a file stopped waiting for the server
In 0.1.0, close() returned when the upload finished. That is the intuitive semantic and it is wrong for a network filesystem: copying five files into the mount stalled the file manager, and a slow server blocked every save in the session.
Now close() returns as soon as the change is durable on local disk, and the upload runs behind it. The change is never at risk — it is written to the buffer and recorded in the state database before close returns, so it survives a crash, and a failed upload keeps the bytes. Transient failures retry with backoff; a permanent one (no quota, revoked permission, a conflict) is parked and you are told once, because a file that reads as saved and is not on the server is exactly the case where silence is unacceptable.
Anyone who wants the old behaviour has [sync] upload = sync.
The folder that took half an hour
This one deserves its own section, because it is the clearest example of a defect that simply does not exist below a certain scale. Nobody on our own accounts ever saw it.
Directory revalidation was dispatched as ordinary network work — into the same queue as the reads a user is actively blocked on. On a large account, every cached listing passes its revalidation interval at roughly the same moment after a restart. Thousands of revalidations queued up, and a click on a folder went to the back of that queue.
Measured, on a real account: a directory took over half an hour to appear, with 1552 revalidations in flight. The mount was not hung. It was working perfectly, on the wrong things, in the wrong order.
The network pool now serves interactive work first and reaches for background work only when nothing else wants the capacity, and the refresh backlog is capped — a revalidation nobody is waiting for is dropped rather than queued behind hundreds of its own kind. No configuration change is needed, and none should ever have been.
Telling the user the truth
The failure that generated the most confusion was not a crash. It was the network going away.
An outage does not present as an error. The file manager simply stops drawing the folder and the application stops opening its document, so the mount looks hung — and a user who reads it that way starts killing the daemon. Meanwhile the engine knew perfectly well: it logged a connection error per failed request and carried on. Nobody reads the journal.
Reachability now lives in one place, every request reports to it, and three rules keep thousands of events down to one notification: only transport failures count (a server answering with a 500 is reachable — that is a different problem), a blip is not an outage (the clock starts on the first failure and the notice fires if it is still failing ten seconds later), and one notice per incident, with the recovery announced too.
Two commands came out of the same thinking. wusel status names what the mount is doing by file name — uploads still owed, files coming down, work in flight — for the person whose files these are. wusel doctor is its opposite: a redacted, name-free diagnostic bundle built to be attached to a ticket. It samples the mount twice, two seconds apart, because no single instant can tell a wedged mount from a busy one; what separates them is whether the work moves.
Beyond Fedora
0.1.0 shipped as a Fedora RPM and nothing else, which is a fine way to say "we have not thought about distribution yet".
Wusel is now published as a signed repository through the openSUSE Build Service, for Fedora 44, openSUSE Tumbleweed, Debian 13 and Ubuntu 26.04, on both x86_64 and aarch64. You add it once and every later release arrives with dnf upgrade, zypper dup or apt upgrade, verified against the repository's key — like anything else on the machine. An Arch PKGBUILD is in the repository; the AUR is next.
The recipes are hand-written and all three do the same two things — compile the binary and the native Nautilus extension — then stage the same file layout. Two details we would repeat: the file-manager extension is installed through its own Makefile target rather than listed again per package format, and the file-manager registration is generated by running the freshly built binary, so it cannot drift from the code that reads it.
One packaging fix is worth naming because it is easy to get wrong in the other direction. The GNOME pieces used to be Recommends, which dnf and apt install by default — so on a server or a KDE machine, a client whose only real dependency is fuse3 pulled in several hundred packages including GNOME Shell. They are Suggests now. A headless box gets the mount and about five packages.
The manual, rebuilt
Sixteen pages had grown organically, which is a polite way of saying they mixed instruction, reference and explanation on the same page and answered "how do I install this" in three different places, none of them completely.
They are now thirty-four pages built on Diátaxis: tutorials, how-to guides, reference, explanation, each page doing one thing. Two gaps closed with it.
The first is that reference did not exist. There was no page listing the config.toml keys or the CLI commands — both were reachable only by reading prose written to explain something else. Now every command, every setting and every path Wusel touches is in a table.
The second is that the tutorial assumed GNOME. A colleague on KDE or on a headless server bounced off the prerequisites box. There are now three, and they genuinely differ: on GNOME you watch emblems change, on another desktop you read the same state out of an extended attribute and exclude the mount from Baloo, and on a server the password goes to a file because nothing ever unlocks a keyring there, and the service needs loginctl enable-linger to survive your logout.
While we were at it we removed a habit we had not noticed: the docs were written from the maintainer's desk, which runs macOS. Linux users kept reading past instructions that did not concern them. Linux is the subject now; developing from a Mac has one page, and nobody else is routed to it.
What about a server with no desktop?
One of the three tutorials is for a headless machine, and it turned out to be
the most interesting of them — because the two things that differ there are
exactly the two things a desktop quietly does for you. There is no unlocked
keyring, so the app password goes to a 0600 file on purpose rather than by
accident. And there is no login session to hang a user service off, so the mount
needs loginctl enable-linger to survive you closing SSH.
We were curious how the official client handles this, so we read it. The answer is more nuanced than "it doesn't".
Everything below refers to
nextcloud/desktopat commit20219b9, onmaster, read on 26 August 2026 — v34.0.3 was the current release. Line links are pinned to that commit, so they will still show what we read after the code moves on. If you are reading this later, check whether it still says the same thing; we would be glad to be out of date here.
The project ships nextcloudcmd, and Qt is not the obstacle people assume — the
tool builds on
QCoreApplication, the
non-GUI application class Qt has always had. No display server required.
What it is, though, is a one-shot. The sync engine's finished signal
exits the process:
QObject::connect(&engine, &SyncEngine::finished,
[&app](bool result) { app.exit(result ? EXIT_SUCCESS : EXIT_FAILURE); });
Sync once, exit. Keeping a folder current means driving it from a timer — no daemon, no push notifications from the server, no live updates.
And on Linux it means a full local copy, which on a server is the part that
actually costs you. The client picks its virtual-files backend at runtime, in a
fixed preference order —
bestAvailableVfsMode()
tries the Windows Cloud Files API first, then the "suffix" backend, then xattr.
Windows is out on Linux, so suffix wins: placeholders whose names carry a
.nextcloud extension until you open them. Whether the xattr backend ever takes
precedence there is answered by
a comment in the client's own source
— it would need user.nextcloud.hydrate_exec to be "adopted by at least KDE and
Gnome". That adoption has not happened.
None of this is a criticism of a tool built for a different job. nextcloudcmd
is a sync command, and it is a good one. But a fetch-on-access mount is a
different proposition on a server: the box needs disk for what it actually
touches, not for the whole account, and it stays mounted between runs because
there are no runs. Re-exporting a Nextcloud share over Samba, letting a backup
job walk it, dropping reports somewhere colleagues can see them — those want a
filesystem that is simply there.
And the arithmetic is the fun part. A sync client's storage requirement is the
size of the account; a mount's is the size of what gets opened. Put a
half-terabyte Nextcloud on a small VPS and nothing about that sentence is
strange — the tree is fully visible, ls and find work on all of it, and the
disk only fills with the files something actually read. For the machines most of
us have sitting in a rack doing one job, that is the difference between "we
could" and "we can't".
Three bugs that reported success
Shipping 0.3.0 and 0.3.1 in one day turned up three defects with the same shape, and it is the most useful thing we learned all month: the dangerous failure is the one that looks like success.
The first killed all four package builds with detected dubious ownership — the CI job runs as root in a container while the checkout belongs to the runner's user, so git refused to touch it. Loud, immediate, fixed in minutes. That is what a good failure looks like.
The second was not loud. Our upload tool checked the package out of the build service, copied the new sources in, and let osc addremove sort it out — but addremove only stages a deletion for a file missing from the working copy, and the previous release's files were still there. The package ended up holding two source versions. Harmless for RPM, which has one spec either way; fatal for Debian, where two .dsc files leave the service with no single source package to build. It excluded those repositories — and an excluded build reports no failure at all. It simply vanishes from the results list. Two of four distributions had stopped building, and the summary looked fine.
The third was quieter still. dh_clean deletes *.orig — patch leftovers, normally — and cargo vendor writes one per crate whose manifest it rewrites, 289 of them here, each listed in that crate's checksum manifest. Building a Debian source package runs clean first, so the tarball shipped without them and every rebuild from it failed a checksum. The RPM was unaffected. 0.3.0 went out with two of four distributions broken while the release notes promised four.
The lesson we are keeping: a build service can report a repository as published when the repository has finished publishing, not when a package came out of it. The only check that means anything is whether the download tree actually holds a file. Every URL in the install instructions on this release was fetched before the documentation went out — both repository files, both signing keys, both Release files — because deriving them from a documented URL scheme is exactly how you ship instructions that have never been run.
Try it
On Fedora:
sudo dnf config-manager addrepo --from-repofile=https://download.opensuse.org/repositories/home:/cdhermann:/wusel/Fedora_44/home:cdhermann:wusel.repo
sudo dnf install wusel
wusel login https://cloud.example.org
systemctl --user enable --now wusel@default
Your Nextcloud is at ~/Wusel, nothing was downloaded, and the folder can be far larger than your disk. Debian, Ubuntu and openSUSE are two commands away in the installation guide.
Wusel is Apache-2.0 and the source is the whole product: github.com/itbh-at/wusel, documentation. There is no licence check, no paid tier inside the client, and there never will be — what a company can sell here is signed store builds and support, not a gate on the code.
Still on the list: a KDE plugin so Dolphin draws the same emblems, proactive refresh of pinned files, advisory locking against the Nextcloud web editor, and eventually the native macOS and Windows frontends the architecture was cut for. The mount is Linux-only today, and that is the platform that needed it most.
Comments