A FUSE filesystem implemented in Common Lisp, where every filesystem operation on the mount is a Lisp closure.
It fills a struct fuse_operations (a table of ~15 function pointers) with
closures created by
cffi-callback-closures,
so when you ls, cat,
echo >, mkdir, or rm on the mountpoint, the kernel calls into Lisp for
every syscall. It's the FUSE "table of cooperating callbacks driving real OS
behavior" pattern.
One mount with three subtrees:
/mem a real read/write in-memory filesystem (ramfs)
/lisp a live view of the running image (+ an eval REPL)
/compute files that are pure functions of their path
Verified live:
$ ls /tmp/lispfs
compute lisp mem
# /compute — the path IS the program
$ cat /tmp/lispfs/compute/fib/30
832040
$ cat /tmp/lispfs/compute/primes/30
2 3 5 7 11 13 17 19 23 29
$ cat /tmp/lispfs/compute/reverse/hello
olleh
# /lisp — browse the running Lisp; eval through the filesystem
$ cat /tmp/lispfs/lisp/room
Dynamic space usage: 66,029,648 bytes
Bytes consed: 430,040,608
$ echo '(loop for i below 5 collect (* i i))' > /tmp/lispfs/lisp/eval
$ cat /tmp/lispfs/lisp/eval
(0 1 4 9 16)
# /mem — a genuine read/write filesystem
$ echo "hello fuse" > /tmp/lispfs/mem/note.txt
$ echo "second line" >> /tmp/lispfs/mem/note.txt
$ cat /tmp/lispfs/mem/note.txt
hello fuse
second line
$ mkdir /tmp/lispfs/mem/sub && mv /tmp/lispfs/mem/note.txt /tmp/lispfs/mem/sub/-
src/— the VFS core (pure Lisp, no FUSE). Aprobe/mutator protocol; arouterthat dispatches by the first path component to a sub-backend; and the three backends (mem,lisp-backend,compute). Fully unit-tested in-process — no mount required:(asdf:test-system :lispfs)
-
fuse/— the FUSE binding.grovel.lispreads the real macFUSE headers forstruct fuse_operations's size,struct stat's field offsets, and the errno/mode constants.fuse.lispdefines each operation as a libffi closure that translates the C call to/from the VFS protocol, then fills the struct and callsfuse_main_real. Each op is wrapped so a Lisp error returns-EIOrather than crashing the mount. This is thelispfs-fusesystem (inlispfs-fuse.asd), kept separate from the core so loadinglispfs.asdpulls in no FFI dependencies.
You only need one method: probe. A backend is any object; for a path (given
as a list of components — /a/b → ("a" "b"), the root → ()) it returns one
of:
(values :dir (list "name" ...)) ; a directory and its entries
(values :file octet-vector) ; a file and its bytes
:enoent ; nothing here
A complete read-only example — files computed on demand:
(defpackage #:myfs (:use #:cl #:lispfs))
(in-package #:myfs)
(defclass greeter () ())
(defmethod probe ((fs greeter) path)
(cond
((null path) ; "/" — list the files
(values :dir '("hello" "time" "random")))
((equal path '("hello"))
(values :file (string->octets "Hello, world!
")))
((equal path '("time"))
(values :file (string->octets (format nil "~A~%" (get-universal-time)))))
((equal path '("random"))
(values :file (string->octets (format nil "~D~%" (random 1000000)))))
(t :enoent)))Mount it (blocks until you unmount — see Running):
(asdf:load-system :lispfs-fuse)
(lispfs.fuse:mount "/tmp/myfs" :root (make-instance 'greeter))$ ls /tmp/myfs
hello random time
$ cat /tmp/myfs/hello
Hello, world!
$ cat /tmp/myfs/time # recomputed by your closure on every read
3962...Make it writable by implementing the mutators you want — each returns 0
on success or an error keyword (:enoent, :eexist, :eisdir, …):
(defmethod vfs-write ((fs greeter) path octets offset) ...)
(defmethod vfs-create ((fs greeter) path) ...)
(defmethod vfs-mkdir ((fs greeter) path) ...)
;; also: vfs-unlink, vfs-rmdir, vfs-truncate, vfs-renameFiles are read-only until then. If you just want a real read/write area without
writing any of these, reuse mem-backend.
Compose several backends under names with a router (this is exactly what
make-default-root does):
(lispfs.fuse:mount "/tmp/myfs"
:root (make-instance 'router
:mounts (list (cons "greet" (make-instance 'greeter))
(cons "scratch" (make-instance 'mem-backend)))))
;; => /tmp/myfs/greet/hello and a writable /tmp/myfs/scratch/That's it — a read-only filesystem is one probe method; the libffi-closure
plumbing underneath stays invisible.
The FUSE layer is cross-platform (FUSE 2.9 high-level API). Verified on:
- macOS — macFUSE (
brew install --cask macfuse; a kernel extension — needs admin, a reboot, and a one-time security approval in System Settings). - Linux —
libfuse-dev(FUSE 2.9) +libffi-dev+ a C compiler. Verified on Debian 13 / aarch64 (Raspberry Pi 4), SBCL 2.5.2.
Plus cffi, cffi-grovel, cffi-libffi, and the sibling
cffi-callback-closures.
Portability note: the struct field widths (
mode_t,off_t,size_t, …) are groveled from the system headers (fuse/grovel.lisp), so they are correct on every platform — that's what makes one codebase serve both macFUSE and Linux libfuse.On non-x86 Linux, use upstream
cffi/cffi-libffirather than Debian'scl-cffi: the latter'scffi-libffigrovel references the x86-onlyFFI_UNIX64and fails to compile on aarch64.
The core builds anywhere. The mount needs FUSE and a source registry that finds
cffi-callback-closures (and, on aarch64, an upstream cffi):
cd lispfs
LISPFS_REGISTRY=/path/to/checkouts \
sbcl --load fuse/mount.lisp --end-toplevel-options /tmp/lispfs # blocks
# ... in another shell, poke at /tmp/lispfs ...
umount /tmp/lispfs # macOS
fusermount -u /tmp/lispfs # Linux(LISPFS_REGISTRY is the directory tree ASDF should scan; defaults to
~/Projects/common-lisp/.)
- Mounted single-threaded + foreground (
-s -f), so the op closures run on the thread that calledmount— a real Lisp thread, which keeps GC/threading simple. - I/O is bulk-copied.
read/writemove data withmemcpy(viacffi:with-pointer-to-vector-data), not byte-by-byte, and themembackend uses a doubling capacity buffer so sequential writes are amortized O(1). Measured on a Pi 4: ~42 MB/s write, ~217 MB/s read for a 32 MB file (the earlier naive versions were ~0.2 MB/s and crawled).probereturns the logical size alongside the (possibly larger) buffer so readers never see past end-of-file. getattrreports the mounting user as owner so writes are permitted (works on both macOS and Linux).- macOS sprinkles
._*AppleDouble metadata files into writable dirs; that's the OS, not us. Linux doesn't.
MIT.