Wick gives you two things: a transport (Wick.Native,
Wick.Fusermount) that moves bytes between the kernel and the BEAM,
and a codec (Wick.Protocol) that turns those bytes into request
structs and back into reply frames. It deliberately stops there — it
does not impose a filesystem abstraction, so you write the event loop
that ties the two together. This guide walks through a complete,
read-only filesystem to show exactly what that loop looks like.
Linux only
The transport binds the Linux FUSE kernel ABI, so a running FUSE
server needs Linux, a mounted /dev/fuse, and fusermount3 on the
PATH (the fuse3 package on most distributions). Wick.Protocol
is pure Elixir and runs anywhere, which is why the codec is testable
without a kernel.
The request/response loop
A FUSE server is an event loop over a single mounted file descriptor:
Wick.Fusermount.mount/2mounts the filesystem and returns a handle wrapping the/dev/fusefd.Wick.Native.select_read/1arms one read-readiness notification. When a request is waiting, the owning process receives{:select, handle, :undefined, :ready_input}.Wick.Native.read_frame/1reads exactly one request frame.Wick.Protocol.decode_request/1decodes the frame into{:ok, opcode, header, request}—opcodeis an atom,headeris aWick.Protocol.InHeader, andrequestis an opcode-specific struct fromWick.Protocol.Request.- You build a reply struct, encode it with
Wick.Protocol.encode_response/3, and write it back withWick.Native.write_frame/2. Errors are sent as a negative POSIX errno viaWick.Protocol.encode_error/2. - Re-arm with
select_read/1and wait for the next request.
The notification is one-shot — you must re-arm after every frame.
The INIT handshake
The kernel's very first request after a mount is :init, and nothing
else happens until you answer it. Reply with a
Wick.Protocol.Response.Init that echoes the kernel's major version,
clamps the minor down to what the codec understands (31), and
advertises your max_write. Keep max_write comfortably below the
128 KiB read_frame/1 ceiling so a maximum-sized WRITE request
still fits in one frame — 64 KiB is the safe libfuse default.
A complete example
HelloFS is a read-only filesystem with a single file, hello,
containing "Hello from Wick!\n". It implements just the read-path
opcodes: INIT, LOOKUP, GETATTR, OPEN/OPENDIR, READ,
READDIR, plus the no-reply housekeeping opcodes. Everything else is
answered with ENOSYS, which tells the kernel to stop asking.
defmodule HelloFS do
@moduledoc "A read-only FUSE filesystem exposing a single file, `hello`."
use GenServer
alias Wick.Fusermount
alias Wick.Native
alias Wick.Protocol
alias Wick.Protocol.{Attr, Request, Response}
@root_ino 1
@file_ino 2
@filename "hello"
@contents "Hello from Wick!\n"
# POSIX mode bits: S_IFDIR | 0755 and S_IFREG | 0444.
@dir_mode 0o040755
@file_mode 0o100444
# dirent d_type values.
@dt_dir 4
@dt_reg 8
# errno values (the kernel wants the negative).
@enoent 2
@enosys 38
# Protocol version the codec speaks, and a safe max_write.
@major 7
@minor 31
@max_write 64 * 1024
def start_link(mount_point) when is_binary(mount_point) do
GenServer.start_link(__MODULE__, mount_point)
end
@impl GenServer
def init(mount_point) do
Process.flag(:trap_exit, true)
case Fusermount.mount(mount_point, ["fsname=hellofs", "subtype=hellofs"]) do
{:ok, fd} ->
:ok = Native.select_read(fd)
{:ok, %{fd: fd, mount_point: mount_point}}
{:error, reason} ->
{:stop, {:mount_failed, reason}}
end
end
@impl GenServer
def handle_info({:select, fd, _ref, :ready_input}, %{fd: fd} = state) do
case Native.read_frame(fd) do
{:ok, frame} ->
dispatch(frame, state)
:ok = Native.select_read(fd)
{:noreply, state}
{:error, :eagain} ->
:ok = Native.select_read(fd)
{:noreply, state}
# The kernel unmounted us — shut down cleanly.
{:error, :enodev} ->
{:stop, :normal, state}
{:error, reason} ->
{:stop, {:read_failed, reason}, state}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl GenServer
def terminate(_reason, state) do
Fusermount.unmount(state.mount_point, lazy: true)
:ok
end
# ── Frame dispatch ──────────────────────────────────────────────
defp dispatch(frame, state) do
case Protocol.decode_request(frame) do
{:ok, opcode, header, request} ->
handle(opcode, header, request, state)
# An opcode the codec doesn't decode: reply ENOSYS so the kernel
# stops sending it. The unique is still in the raw header.
{:error, {:unknown_opcode, _n}} ->
reply_error(state.fd, unique(frame), @enosys)
{:error, _reason} ->
:ok
end
end
# INIT must be answered before anything else works.
defp handle(:init, header, %Request.Init{} = req, state) do
reply(state.fd, header.unique, %Response.Init{
major: @major,
minor: min(req.minor, @minor),
max_readahead: req.max_readahead,
flags: 0,
max_background: 0,
congestion_threshold: 0,
max_write: @max_write,
time_gran: 1,
max_pages: 0,
map_alignment: 0
})
end
defp handle(:lookup, %{nodeid: @root_ino} = header, %Request.Lookup{name: @filename}, state) do
reply(state.fd, header.unique, %Response.Entry{
nodeid: @file_ino,
entry_valid: 1,
attr_valid: 1,
attr: file_attr()
})
end
defp handle(:lookup, header, %Request.Lookup{}, state) do
reply_error(state.fd, header.unique, @enoent)
end
defp handle(:getattr, header, %Request.GetAttr{}, state) do
case attr_for(header.nodeid) do
nil -> reply_error(state.fd, header.unique, @enoent)
attr -> reply(state.fd, header.unique, %Response.AttrReply{attr_valid: 1, attr: attr})
end
end
# OPEN and OPENDIR share the fuse_open_in layout. This filesystem is
# stateless, so hand back a zero file handle.
defp handle(op, header, %Request.Open{}, state) when op in [:open, :opendir] do
reply(state.fd, header.unique, %Response.Open{fh: 0})
end
defp handle(:read, %{nodeid: @file_ino} = header, %Request.Read{} = req, state) do
reply(state.fd, header.unique, %Response.Read{data: slice(@contents, req.offset, req.size)})
end
defp handle(:read, header, %Request.Read{}, state) do
reply_error(state.fd, header.unique, @enoent)
end
defp handle(:readdir, %{nodeid: @root_ino} = header, %Request.Readdir{} = req, state) do
reply(state.fd, header.unique, %Response.Readdir{entries: dirents(req.offset, req.size)})
end
defp handle(:readdir, header, %Request.Readdir{}, state) do
reply_error(state.fd, header.unique, @enoent)
end
# RELEASE / RELEASEDIR / FLUSH have nothing to do here — reply empty.
defp handle(op, header, _request, state) when op in [:release, :releasedir, :flush] do
reply(state.fd, header.unique, %Response.Empty{})
end
# FORGET and BATCH_FORGET never get a reply.
defp handle(op, _header, _request, _state) when op in [:forget, :batch_forget] do
:ok
end
defp handle(:statfs, header, %Request.Statfs{}, state) do
reply(state.fd, header.unique, %Response.Statfs{
files: 1,
bsize: 4096,
namelen: 255,
frsize: 4096
})
end
# Everything else: not implemented.
defp handle(_op, header, _request, state) do
reply_error(state.fd, header.unique, @enosys)
end
# ── Attributes ──────────────────────────────────────────────────
defp attr_for(@root_ino), do: dir_attr()
defp attr_for(@file_ino), do: file_attr()
defp attr_for(_), do: nil
defp dir_attr, do: %Attr{ino: @root_ino, mode: @dir_mode, nlink: 2, blksize: 4096}
defp file_attr do
size = byte_size(@contents)
%Attr{ino: @file_ino, size: size, blocks: div(size + 511, 512), mode: @file_mode, nlink: 1, blksize: 4096}
end
# ── Directory and file helpers ──────────────────────────────────
# `off` is the cookie the kernel sends back to resume after this
# entry; skip everything at or before the requested `offset`, and
# stop before the reply exceeds the kernel's `size` budget.
defp dirents(offset, size) do
[
{".", 1, @dt_dir, @root_ino},
{"..", 2, @dt_dir, @root_ino},
{@filename, 3, @dt_reg, @file_ino}
]
|> Enum.filter(fn {_name, off, _type, _ino} -> off > offset end)
|> Enum.reduce_while({[], 0}, fn {name, off, type, ino}, {acc, used} ->
need = dirent_size(byte_size(name))
if used + need > size do
{:halt, {acc, used}}
else
entry = %Response.Dirent{ino: ino, off: off, type: type, name: name}
{:cont, {[entry | acc], used + need}}
end
end)
|> elem(0)
|> Enum.reverse()
end
defp dirent_size(namelen), do: 24 + namelen + rem(8 - rem(24 + namelen, 8), 8)
defp slice(bin, offset, _size) when offset >= byte_size(bin), do: ""
defp slice(bin, offset, size), do: binary_part(bin, offset, min(size, byte_size(bin) - offset))
# ── Reply helpers ───────────────────────────────────────────────
defp reply(fd, unique, struct) do
bytes = unique |> Protocol.encode_response(struct, 0) |> :erlang.iolist_to_binary()
Native.write_frame(fd, bytes)
end
defp reply_error(fd, unique, errno) do
Native.write_frame(fd, Protocol.encode_error(unique, -errno))
end
defp unique(<<_len::little-32, _opcode::little-32, unique::little-64, _rest::binary>>), do: unique
endRunning it
The mount point must exist before you mount:
File.mkdir_p!("/tmp/hellofs")
{:ok, _pid} = HelloFS.start_link("/tmp/hellofs")Then, from a shell:
$ ls /tmp/hellofs
hello
$ cat /tmp/hellofs/hello
Hello from Wick!
Stopping the GenServer runs terminate/2, which unmounts. The unmount
is lazy (MNT_DETACH) because the process still holds the FUSE fd; the
kernel detaches the mount immediately and finalises it once the fd is
released.
Replying with errors
Any handler can return an errno instead of a reply struct — encode it
with Wick.Protocol.encode_error/2, passing the negative value:
# "No such file or directory"
Native.write_frame(fd, Protocol.encode_error(header.unique, -2))encode_error/2 and encode_response(unique, nil, errno) are
equivalent; both emit a header-only frame with no body, which is what
the kernel expects for an error reply.
Going further
HelloFS is read-only. To build a writable filesystem, handle the
write-path opcodes — CREATE, MKDIR, WRITE, SETATTR, UNLINK,
RMDIR, RENAME — and reply with the matching structs. The full set
of decodable requests and encodable replies lives in
Wick.Protocol.Request and Wick.Protocol.Response; Wick.Protocol
documents the wire rules and the supported opcode set.