---
title: "Three Tickets, One Filesystem"
date: "2026-05-15"
approved: true
publish_date: "2026-05-15"
excerpt: "I'd carefully planned a throwaway version, a proper version, and a daft version. Then a tool I'd never heard of made the throwaway version pointless and the proper version cheap."
tags: ["fuse", "c++", "storage", "homelab", "samba", "nfs"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-05-15-three-tickets-one-filesystem"
---

# Three Tickets, One Filesystem

![Three paper tickets dissolving into a glowing directory tree growing from a wooden crate](./images/three-tickets-one-filesystem-header.png)

There is a 6TB disk going into Luke, one of two deliberately boring home servers I run. Four of those terabytes join a RAID array. The remaining two were always going to be something I called, in the ticket, "volatile scratch space" — a networked dumping ground with no durability promise whatsoever. You write a file there, and it stays until the disk gets tight, at which point the oldest thing gets deleted to make room. Least-recently-used eviction. A bin that empties itself from the back.

![A single 6TB hard drive resting on the steel chassis of a small home server, dramatic low-key lighting, macro, shallow depth of field, the platter implied beneath the casing](images/three-tickets-one-filesystem-1.png)

This needed three tickets — and why I deleted two of them before writing a line of their code is the whole story.

## The plan I'd talked myself into

HOME-59 was the ticket I'd actually committed to: provision the partition, and run a `systemd` timer every few minutes that checks how full the disk is and, if it's over 80%, deletes the least-recently-accessed files until it's back under 60%. `find`, `sort`, `rm`, a watermark or two. Crude, but I could picture every line of it. That's why it was the plan.

The reason it was *crude* rather than *correct* is that the correct version is an actual filesystem. Something that intercepts the write itself and evicts *before* accepting it, so the disk can never overfill in the first place. I knew that was the right shape. I also knew that "write a filesystem" sounded like several weekends I didn't have, so I'd done the sensible thing and filed it as a separate, later, someday ticket: HOME-60. The grown-up version, deferred behind the version I could actually face.

Then, almost in passing, a chat with Claude used the word FUSE.

## A word I didn't know

FUSE — Filesystem in Userspace — lets you implement a filesystem as an ordinary program. No kernel module, no C that can panic the box, no several weekends. You wrap a normal directory, intercept the operations you care about, and pass the rest straight through. I had genuinely never heard of it. Twenty years of Linux and the thing that would have changed my estimate had simply never come up.

That discovery did two things. It made HOME-60 — the proper filesystem — suddenly look like an afternoon, not a campaign. And it made me cheerful enough to write HOME-61: the same filesystem on my Mac, backed by a disk image instead of a partition, for no reason other than that it would be funny to have. Three tickets now. A throwaway, a proper version, and a joke.

My instinct was that HOME-60 would be a useful way to *inform* HOME-61 — build it properly on the server, learn the shape, then port it. Reasonable. But the more I looked at the running order, the more one ticket stuck out. If the proper version was now cheap, the timer wasn't a stepping stone to it. It was a thing I'd build, demonstrate, and then immediately delete in HOME-60's own description, which literally said "decommission the systemd timer from HOME-59." I'd written the instruction to throw away my own work before I'd done the work.

So I skipped it. Squished all three tickets into one: provision the partition, write the FUSE filesystem, design it so the Mac port is a build flag rather than a rewrite. The crude version never got built. It turns out the cheapest implementation of a stepping stone is to notice you don't need it.

## The cap that isn't the disk

The core of the thing is small. An in-memory list of files, most-recently-used at the front, least at the back, plus a hash map so any file can be found and moved to the front in constant time. Touch a file, it jumps to the front. Need space, you delete from the back.

The one decision I'm glad I made early: the filesystem tracks its own size against a *logical cap*, not against the real disk. It adds up the bytes it's holding and evicts to stay under a number I give it. That number has nothing to do with how big the partition is.

This sounds like a footnote. It's the reason the whole thing is testable. A cap of 50MB can be exercised in seconds on any disk, on any machine, without a 6TB drive or a partition or root. The eviction logic doesn't know or care that production is two terabytes. It just knows it's been told the ceiling is 50MB and it's holding 49.

The size for each entry is recorded when a file is created or overwritten and updated on truncate or rename; the logical cap is always tracking real on-disk bytes, not stale metadata.

Before the eviction loop runs, there is a guard for an empty list. The snippet below assumes the list isn't empty; production code checks for that before calling in. Two additional guards are threaded through: `keep` is the path currently being written (never evict the file you're in the middle of accepting), and `is_protected` checks for a `.noevict` marker that pins a file permanently. Without those two guards the loop is just the list walk; they exist so a long-running write can't be deleted out from under itself and so you can anchor something important.

```cpp
// Walk from the LRU tail towards the front, freeing bytes,
// never touching the file currently being written or anything
// under a .noevict marker.
auto it = std::prev(order_.end());
while (freed < bytes_needed) {
    bool at_begin = (it == order_.begin());
    auto cur = it;
    if (!at_begin) --it;            // step back before erasing cur
    if (cur->path != keep && !is_protected(cur->path)) {
        freed += cur->size;
        victims.push_back(cur->path);
        order_.erase(cur);
    }
    if (at_begin) break;
}
```

That loop is the entire idea. The reason a naive range-based `for` loop fails here is that erasing `cur` invalidates the iterator you'd otherwise advance next; stepping `it` backwards *before* the erase keeps the walk safe. It's the kind of thing that's obvious once it's written and a segfault until it is.

## Keeping the clever bit stupid

The LRU core does no file I/O. It doesn't open, read, write, or delete anything. It's just the list, the map, and the arithmetic. The filesystem layer asks it "which paths should I evict?" and does the actual deleting itself.

This wasn't tidiness for its own sake. It meant I could unit-test the entire eviction brain on my Mac — a machine with no FUSE installed at all — because that test never touches a filesystem. The clever part has no dependencies, so the clever part is the part I could prove correct in the place I was sitting.

![An abstract visualisation of a doubly linked list as glowing nodes in a dark space, one node being pulled out from the far end, cold blue and amber tones](images/three-tickets-one-filesystem-2.png)

## macOS doesn't ship FUSE

Which left the unavoidable bit. The actual filesystem code — the part that talks to FUSE — does not compile on my Mac, because macOS doesn't ship the library. (macFUSE exists, but my target is Linux's libfuse3, and I didn't want to maintain two FUSE backends just to run tests on the laptop.) I write on a Mac. The thing runs on Linux.

The tempting move is a cross-compiler: a toolchain on the Mac that emits Linux binaries. I didn't, and the reason is worth saying plainly. A cross-compiler would prove the code *links*. It would not prove the code *works*, because you can't mount a filesystem you can't run. To actually exercise it I'd need the target environment, not just the target instruction set — and the honest way to get the target environment is to use the target environment.

This also resolved the HOME-61 question neatly. The Mac port is still a build flag away in the source — the LRU core and the FUSE layer are cleanly separated — but the container gave me a Linux mount I could actually run, so the joke ticket stayed filed and the Docker harness became the real test bed.

So the test harness is a container. Ubuntu, the exact libraries the server installs, built and *run* with enough privilege to genuinely mount the thing and evict real files:

```bash
docker run --rm --platform linux/amd64 --privileged --device /dev/fuse \
  -v "$here":/src:ro ubuntu:24.04 bash -euc '
    apt-get update -y && apt-get install -y build-essential cmake pkg-config libfuse3-dev fuse3
    cmake -S /src -B /tmp/build
    cmake --build /tmp/build
    ctest --test-dir /tmp/build
    bash /src/test/loopback-test.sh   # mount it, fill it, watch it evict
  '
```

That script is committed to the repo, because a test you ran once on your machine and can't hand to anyone is a story, not a test. A green run there means it builds and works on the server, for the same reason the server is boring on purpose: it's the same Ubuntu, the same packages, no surprises. The loopback test fills a 1MB mount, reads one old file to rescue it, and confirms the file it *didn't* touch is the one that got deleted. It passes. That sentence cost more than it reads.

## Sharing a filesystem that lies

The last problem is the one I'd have got wrong a year ago. The scratch space has to be reachable over the network, and there are two obvious ways: NFS, the Unix-native one, and Samba, the one macOS speaks without complaint.

The catch is that the thing being shared isn't a real disk. It's my filesystem — a userspace process that's quietly deleting files. Samba re-exports it cleanly, because Samba is just another program doing ordinary opens and reads; every access flows through my code and the eviction logic stays honest. Kernel NFS re-exporting a userspace filesystem is the brittle path: it needs special permission flags (`allow_other`, `export_support`), it gets unhappy when the userspace process restarts, and a client can read files in a way that bypasses recency tracking entirely — because NFS can serve cached attributes without going through the FUSE `getattr` path, so an NFS read may never register as an access to my layer at all. I picked Samba and wrote down *why* in the commit, because the next person to wonder "why no NFS?" is me in six months.

There's a related honesty I had to write into the deployment. My home setup has a rule — everything runs in a container. A FUSE filesystem can't; it needs the host kernel and a real mountpoint. So it's an explicit exception, documented as one, sitting next to the other things that earn exceptions for the same reason: disk encryption, the OS itself. A principle you can't state the exceptions to isn't a principle, it's a slogan.

The slightly delicate part is keeping the Samba container alive when the FUSE process underneath it restarts. The current plan is bind-mount propagation (`rshared`) so the remount is visible inside the container without a restart, with systemd unit ordering to ensure the filesystem is mounted before Samba begins serving. That part is still reasoned rather than confirmed on the real hardware.

## What I haven't proven

The filesystem compiles where it'll run. The eviction is correct under test. The Mac port is a build flag away, exactly as intended. But two things are still only reasoned, not seen: the behaviour on the real partition, and the mount-sharing arrangement described above. Both of those only become real on the actual server, on the actual disk. I've resisted calling the job done, because "it builds and the logic is sound" and "it works in production" are separated by exactly the gap this paragraph describes.

The thing I'll keep from this isn't about filesystems. It's that I had a perfectly good plan, costed and sequenced, and the single most useful input was a word I didn't know existed. The timer wasn't a bad design. It was the best design available to someone who hadn't heard of the better one — and the moment I had, the right move was to delete two-thirds of my own plan and not feel clever about it.

---

*Martin Harris connects cloud infrastructure to the real world. If you've ever filed a careful three-stage plan and then deleted the first two stages because a stranger said one word, you're in the right place.*


{{donate}}