Building a REPL OS in Common Lisp
Building a REPL OS in Common Lisp, Part 6: Mounts
Part 6 of a series where we build a custom operating system that runs inside a Lisp REPL.
The Problem#
Our virtual filesystem persists, but it’s still isolated. We can’t read or modify the actual source code of the REPL itself. We can’t access files on the real filesystem.
This is limiting. What if we want to edit repl.lisp from within the REPL OS? What if we want to load a script from our real home directory?
In this article, we’ll add mounts to get the ability to map virtual paths to real directories. (mount "/sys" "/path/to/repl/") will let us read and write actual files through our virtual filesystem interface.
What Are Mounts?#
In Unix, a mount connects a directory to a storage device or another filesystem. /dev/sda1 might be mounted at /home, making the disk’s contents accessible through that path.
We’ll do something similar: map virtual paths to real directories. When you access /sys/repl.lisp, we’ll actually read /path/to/repl/repl.lisp from the real filesystem.
The Mount Table#
We need a way to store the mappings:
(defvar *mounts* (make-hash-table :test 'equal))
(defun mount (virtual-path real-path)
"Mount a real directory at a virtual path."
(setf (gethash virtual-path *mounts*) real-path))
(defun unmount (virtual-path)
"Unmount a virtual path."
(remhash virtual-path *mounts*))
Simple. A hash table maps virtual paths to real paths. mount adds an entry, unmount removes it.
Resolving Mounts#
When someone accesses a path like /sys/repl.lisp, we need to check if it’s under a mount point and translate it to a real path:
(defun resolve-mount (path)
"If path is under a mount, return (values real-path t). Otherwise (values nil nil)."
(maphash (lambda (vpath rpath)
(when (and (>= (length path) (length vpath))
(string= vpath (subseq path 0 (length vpath)))
(or (= (length path) (length vpath))
(char= (char path (length vpath)) #\/)))
(let ((remainder (if (= (length path) (length vpath))
""
(subseq path (1+ (length vpath))))))
(return-from resolve-mount
(values (if (string= remainder "")
rpath
(merge-pathnames remainder rpath))
t)))))
*mounts*)
(values nil nil))
This iterates through all mounts, checking if the path starts with any mount point. If /sys is mounted to /path/to/repl/, then:
/sysresolves to/path/to/repl//sys/repl.lispresolves to/path/to/repl/repl.lisp/home/notes.txtreturns(values nil nil), not mounted
We return multiple values: the real path and a boolean indicating whether a mount was found. This is a common Lisp pattern. multiple-value-bind extracts both values at the call site.
Real Filesystem Operations#
We need functions to interact with the real filesystem:
(defun read-real-file (real-path)
"Read contents of a real file."
(with-open-file (in real-path :if-does-not-exist nil)
(when in
(let ((contents (make-string (file-length in))))
(read-sequence contents in)
contents))))
(defun write-real-file (real-path contents)
"Write contents to a real file."
(with-open-file (out real-path
:direction :output
:if-exists :supersede
:if-does-not-exist :create)
(write-string contents out))
t)
(defun list-real-directory (real-path)
"List contents of a real directory. Returns list of (name . type) pairs."
(when (probe-file real-path)
(loop for path in (directory (merge-pathnames "*.*" real-path))
collect (cons (file-namestring path)
(if (uiop:directory-pathname-p path) :directory :file)))))
These are thin wrappers around Common Lisp’s file I/O. Nothing fancy.
Mount-Aware Commands#
Now we update our commands to check for mounts. Here’s ls:
(register-command
(make-command
:name 'ls
:description "List directory contents: (ls \"/home\")"
:function (lambda (&optional (path "/"))
(multiple-value-bind (real-path mounted) (resolve-mount path)
(if mounted
(let ((entries (list-real-directory real-path)))
(if entries
(dolist (entry entries)
(format t "~% ~A~A"
(car entry)
(if (eq (cdr entry) :directory) "/" "")))
(format t "~% (empty)")))
(let ((node (find-node path)))
(if (and node (eq (node-type node) :directory))
(if (node-children node)
(dolist (child (node-children node))
(format t "~% ~A~A"
(node-name child)
(if (eq (node-type child) :directory)
"/" "")))
(format t "~% (empty)"))
(format t "~%Not a directory: ~A" path))))))))
The pattern: call resolve-mount, then branch on whether we’re dealing with the real filesystem or the virtual one.
cat and write follow the same pattern. load-file now works with mounted paths too: you can load scripts from the real filesystem.
The Mount Commands#
We expose mount management to the user:
(register-command
(make-command
:name 'mount
:description "Mount a real directory: (mount \"/sys\" \"/path/to/repl/\")"
:function (lambda (virtual-path real-path)
(mount virtual-path real-path)
(format t "~%Mounted ~A at ~A" real-path virtual-path))))
(register-command
(make-command
:name 'unmount
:description "Unmount a virtual path: (unmount \"/sys\")"
:function (lambda (virtual-path)
(unmount virtual-path)
(format t "~%Unmounted ~A" virtual-path))))
(register-command
(make-command
:name 'mounts
:description "List all mounts"
:function (lambda ()
(let ((count 0))
(maphash (lambda (vpath rpath)
(incf count)
(format t "~% ~A -> ~A" vpath rpath))
*mounts*)
(when (zerop count)
(format t "~% (no mounts)"))))))
Auto-Mount on Startup#
We automatically mount /sys to the project directory on startup:
(defun my-repl ()
"Start the REPL. Type (help) for commands, (quit) to exit."
(load-fs)
(mount "/sys" (asdf:system-source-directory :repl))
(format t "~%Welcome to REPL OS v6")
...)
Now the REPL’s own source code is always accessible at /sys.
Running It#
Filesystem loaded from /path/to/repl/.repl-fs.dat
Welcome to REPL OS v6
Type (help) for commands, or any Lisp expression.
λ (mounts)
/sys -> /path/to/repl/
λ (ls "/sys")
packages.lisp
commands.lisp
threads.lisp
fs.lisp
repl.lisp
repl.asd
λ (cat "/sys/packages.lisp")
(defpackage :repl
(:use :cl)
(:export
...
λ (write "/sys/test.txt" "Hello from the REPL OS!")
Wrote to /sys/test.txt
And just like that, we created a real file on disk from within our REPL OS.
Extending the OS From Within#
Now for the real payoff. We can write a new command to a real file and load it:
λ (write "/sys/fortune.lisp" "(register-command (make-command :name 'fortune :description \"Display a fortune\" :function (lambda () (format t \"~%~A\" (nth (random 3) '(\"The best time to plant a tree was 20 years ago.\" \"Simplicity is the ultimate sophistication.\" \"A year from now you will wish you had started today.\"))))))")
Wrote to /sys/fortune.lisp
λ (load-file "/sys/fortune.lisp")
Loaded /sys/fortune.lisp
λ (fortune)
Simplicity is the ultimate sophistication.
The command now exists. And because we wrote it to /sys (which is mounted to the real project directory), the file persists on disk. Next time we start the REPL, we can load it again, or we can add it to an init script.
This is different from Part 5 where we stored commands in the virtual filesystem. Those files persist in .repl-fs.dat, but they’re trapped inside our serialized data structure. Files in /sys are real files you can edit with any text editor.
We can now:
- Write multi-line commands in our favorite editor
- Save them to the project directory
- Load them from within the REPL OS
The OS can finally modify itself using real tools. Our REPL OS is self-hosted.
The Two-World Limitation#
There’s something to acknowledge: we now have two worlds.
Virtual filesystem: The in-memory tree we persist to .repl-fs.dat. Commands like mkdir, touch, and rm operate here.
Mounted paths: Real directories mapped into our namespace.
Some commands bridge both worlds: ls, cat, write, and load-file check if a path is mounted and dispatch accordingly. They work seamlessly whether you’re accessing /home/notes.txt (virtual) or /sys/repl.lisp (mounted).
But the traversal commands don’t:
treeshows only the virtual filesystem, not mounted contentfindsearches only the virtual filesystemls "/"shows/home(virtual) but not/sys(mounted)
This isn’t ideal. The user sees a fractured view. But unifying these properly requires rethinking our data structures. Every path operation would need to consider both worlds seamlessly.
For now, we acknowledge the limitation. Mounts work. You can access real files. The interface is consistent where it matters (ls, cat, write, load-file). The traversal commands (tree, find) only see the virtual world.
What We Built#
- Mount table: hash table mapping virtual paths to real paths
- resolve-mount: translate virtual paths to real paths when under a mount
- Real file operations: read, write, and list real directories
- Mount-aware commands:
ls,cat,write,load-filework with mounts - Mount management:
mount,unmount,mountscommands - Auto-mount:
/sysmounted automatically on startup
We can now read and write real files from within the REPL OS. We can view our own source code, edit it, and load scripts from anywhere on the system.
What’s Next#
The two-world split is a design debt. In Part 7, we could unify the filesystem: make tree and find see everything, make ls "/" show both virtual directories and mount points. This would require a more sophisticated abstraction layer.
But that’s for another time. For now, we have a working mount system that bridges our virtual OS with the real filesystem.
Next: Part 7: Unified Filesystem (maybe)