One status protocol for every file manager

Every desktop tells the user two things about a file in a virtual mount: its sync state (online-only, cached, uploading, …) and, for a Team/Group folder, that the folder is one. Wusel first surfaced both on Linux as extended attributes — user.wusel.state and user.wusel.kind, read straight off the FUSE mount with getxattr. This page records why that channel was replaced by a query over the wusel-ipc socket, and what the shared protocol has to guarantee to be worth it. The attributes are gone; the socket is the only status channel.

Why not an attribute on the file

The xattr channel is simple and local, but it has a defect that is not fixable while the status lives on the file: an extended attribute is copied with the file. A metadata-preserving copy out of the mount — cp -a, or a file manager set to keep attributes — reads user.wusel.kind=group-folder through getxattr and writes it onto the destination, a plain file on an ordinary disk that is now, falsely and permanently, tagged as a group folder. The emblem is supposed to describe a file’s relationship to the running mount; an attribute cannot, because it outlives that relationship.

Status is not a property of the bytes. It is a property of the object as this running daemon sees it right now — and that is exactly what a live query answers and a stored attribute cannot.

The decision

There is one source of status, the engine, and one way to ask it: the wusel-ipc Unix-domain socket the daemon already serves. Every file manager integration — the macOS File Provider extension today, a Nautilus plugin next, KDE’s Dolphin and the Windows shell later — is a thin projector: it asks the socket for an object’s status and maps the answer onto its own decoration vocabulary. None of them link the engine, embed sync logic, or invent their own status; none of them write anything onto the file.

Why a socket, and not a named pipe

The name is a trap: a named pipe means one thing on Windows and quite another on Unix. Windows' is connection-oriented, bidirectional and instanced per client — the functional equivalent of a Unix-domain socket. Unix’s is a FIFO (mkfifo), and a FIFO cannot carry this protocol:

  • No connections. A FIFO is one shared byte stream with no accept(), so there is no way to tell which client sent a frame and no way to answer that client. watch and notices each hold a stream open per subscriber; on FIFOs that means one FIFO per subscriber, plus a hand-rolled rendezvous to set it up — reimplementing what accept() gives away.

  • Atomicity stops at PIPE_BUF — 4096 bytes on Linux, against frames of up to MAX_FRAME (8 MiB), which a fetch response reaches. Two concurrent writers interleave mid-frame and corrupt the stream. A SOCK_STREAM connection has exactly one writer by construction.

  • No peer credentials. SO_PEERCRED does not exist on a FIFO, so the one guarantee that holds wherever the socket is put — the peer is this user — could not be made at all.

  • No descriptor passing. SCM_RIGHTS over AF_UNIX is what would let a frontend later be handed an open file descriptor instead of copied bytes.

So the portable statement is not "socket versus named pipe" but connection-oriented local IPC: spelled AF_UNIX here, and a named pipe on Windows, where the security descriptor set at CreateNamedPipe and ImpersonateNamedPipeClient are what stand in for the mode bits and SO_PEERCRED. The wire itself ports unchanged — it is length-prefixed frames over anything that reads and writes bytes, which is why read_frame and write_frame take impl Read / impl Write rather than a socket.

This is the encapsulation the xattr channel quietly gave up. The interesting work — deciding whether a file is online-only, cached, uploading, a group-folder root — is the engine’s, is hard, and is identical on every platform. Putting it behind one protocol means it is written and tested once. A new file manager is then a rendering exercise, not a re-implementation, and it cannot drift from the others because there is nothing to drift.

The property that motivated the change falls out for free: a status obtained by asking the daemon about a path is meaningless for a path that is no longer under the mount. A copied-out file has nothing to carry and nothing to query — no leak, on any platform, without special-casing the copy.

What the protocol must guarantee

A protocol meant to outlast several independently-versioned clients is only as good as its contract. Three points carry the weight.

Carry the richest truth, let the client reduce it

The wire carries the full sync state — every value the engine distinguishes (see File states) — as a neutral superset. A client draws as much as its platform can: one that only knows "available" vs "needs downloading" ignores the rest; one with a syncing spinner reads uploading. The wire must never carry a reduced set shaped by whichever client happens to have the poorest emblems, because that stranding is irreversible for the richer clients. The macOS extension today renders only pinned/stale and pushes the active transfer states as notifications instead — that is a rendering choice in one client, not a limit of the wire.

Keep the axes separate

Sync state and folder kind are two questions, not one. A group folder is still online-only or cached like anything else. They travel as two fields (state and folder_kind), never folded into a single conflated value, so a client can badge a group-folder root and show its sync emblem.

Version the wire

The clients will not upgrade in lockstep — a system Nautilus plugin and a sandboxed File Provider extension update on entirely different schedules. A schema version on the wire lets an old client and a new daemon (or the reverse) detect the mismatch and refuse it, instead of silently mis-reading a field. Additive fields with defaults are not a break; a changed shape is.

Why the client must cache

Asking per file is not free, and a file manager asks per visible file, in its draw path. The prototype (crates/wusel-ipc/tests/status_latency.rs) measures the naive port of the xattr read — one socket stat per file — against a trivial client-side cache, over the real engine and a real socket:

Draw path, 200-file folder Cost

Cold — one socket round-trip per file

≈ 46 ms (≈ 230 µs/file)

Warm — client cache hit per file

≈ 65 µs (whole folder)

The socket itself is fast; the per-file cost is the engine’s work behind it (resolve the path, stat, read the state). At ~230 µs each, a few hundred files is tens of milliseconds of synchronous stutter in the draw path — so a cache is not an optimisation here, it is a requirement. Two things make it cheap and correct:

  • One listing primes the whole folder. enumerate already carries each child’s state and folder_kind, so drawing a freshly-opened folder is a single round-trip that fills the cache for every file in it — not one stat per file.

  • The change stream keeps it warm. The cache is invalidated by the watch / changes events the engine already emits, so a hit stays correct without re-asking. A cache that drifts would draw a stale emblem — the whole risk of caching — which is why the prototype asserts the cached answer equals the socket’s.

This is the one place the socket model costs more than the xattr did, and it is the client’s cost to bear, once, in shared plumbing.

State of play

The two lines of work have met: the socket carries a real folder_kind, filled from the same is_group_folder_root read that answers the sync state. The contract was on the wire before the engine could fill it, and filling it changed one function in wusel-ipc and nothing in any client — which is the case for fixing a protocol before its implementations, made concrete.

The Nautilus extension speaks the socket, with the cache the prototype measured, and the extended attributes have been removed — there is no second channel left to keep in step. The File states reference accordingly describes what a client renders, not the interface it reads: the interface is this protocol.