Files and binds
A file on Redoubt is something a server serves over 9P, reached through a connection in the
session’s namespace. Elixir’s File, IO and Path work unchanged on top: beamlet’s file
natives speak 9P to the file server, one instance per volume, which keeps the data in walfs on
the SSD (walfsd), or in littlefs on a flash medium (littlefsd). There are no permission bits, no owners, no symlinks and no hard links. Access is
by capability (holding the connection) and by label (the volume’s labels against the caller’s);
a bind puts a connection the session already holds at another path.
Purpose
People and agents work with files more than with anything else, and the shape of the file system
is where Redoubt differs most visibly from Unix. This page says what File does over 9P, which
Unix habits carry over and which do not (chmod, ln -s, an atomic rename between two
directories), how labels decide who may read and write a volume, and how a directory is shared
with another principal.
How to use it
Ordinary Elixir:
File.write!("/home/alice/notes.txt", "buy milk\n")
File.read!("/home/alice/notes.txt")
File.ls!("/home/alice")
File.cp!("/home/alice/notes.txt", "/work/notes.txt") # across volumes: a copy loop
File.rename!("/home/alice/a.txt", "/home/alice/b.txt") # one volume: the server renames
The shell’s commands are the same operations with short names: cat, cp, mv, rm, mkdir,
ls, stat (the shell). A bind names a connection the session holds under another
prefix:
{home, _rest} = ns_lookup("/home/alice") # the connection behind the prefix
bind("/h", home)
File.ls!("/h/projects") # the same files as /home/alice/projects
Redoubt-only operations are in Redoubt.File: copy_file, a copy the file server makes itself.
File.rename within one volume is already the file server’s own rename, between two directories
of it; per-file metadata through the server’s set_attr and get_attr is planned.
What it can and cannot do
Files over 9P
Status: built · partly tested: the host tests run beamlet's platform against the real `littlefsd` on the fake kernel, and OTP's `prim_file` over the natives runs in a boot in bench:beamlet-files; Elixir's `File` over them runs in a session once the steward's sessions do · tested (32)
- host:beamlet-redoubt::files_are_written_read_listed_renamed_and_removed
- host:beamlet-redoubt::a_rename_the_volume_refuses_is_eacces
- host:beamlet-redoubt::opening_to_write_replaces_and_appending_adds
- host:beamlet-redoubt::a_read_past_one_answer_comes_in_pieces
- host:beamlet-redoubt::the_position_lives_in_the_vm_and_moves_with_reads_and_seeks
- host:beamlet-redoubt::a_path_under_no_binding_is_enoent
- host:beamlet-redoubt::a_path_above_a_binding_is_a_directory_the_namespace_answers
- host:beamlet-redoubt::two_askers_wait_at_once_and_each_gets_its_own_answer
- host:beamlet-redoubt::the_vms_own_calls_wait_in_place
- host:beamlet-redoubt::closed_files_give_their_fids_back
- host:beamlet-redoubt::an_abandoned_operation_stops_at_its_next_answer
- host:beamlet-redoubt::a_file_operation_on_the_consoles_connection_is_answered
- host:beamlet-redoubt::a_write_answered_busy_goes_again_after_the_retry_interval
- host:beamlet-redoubt::a_close_answered_busy_is_retried_and_its_fid_is_kept_until_the_clunk_is_served
- host:beamlet-redoubt::a_request_unanswered_past_file_wait_fails_etimedout_and_the_connection_still_serves
- host:beamlet-redoubt::a_request_busy_past_file_wait_fails_busy
- host:beamlet-redoubt::an_operations_bound_is_the_sum_of_its_requests
- host:beamlet-redoubt::a_give_up_on_a_walked_fid_answers_its_asker_before_the_clunk
- host:beamlet-redoubt::a_fid_given_up_on_is_clunked_when_its_late_answer_comes
- host:beamlet-redoubt::a_stat_reports_only_what_9p_has
- host:beamlet-redoubt::what_has_no_9p_field_is_refused_visibly
- host:beamlet-redoubt::a_field_set_on_a_missing_file_is_enoent
- host:beamlet-redoubt::a_directory_that_is_there_is_eexist_to_make
- host:beamlet-redoubt::mkdir_p_of_a_nested_new_path_makes_it
- host:beamlet-redoubt::a_create_refused_is_its_refusal
- host:beamlet-redoubt::every_row_of_the_error_table_maps_to_its_posix_error
- host:beamlet-redoubt::a_bind_argument_puts_a_handed_volume_in_the_namespace
- host:beamlet-vm::a_completion_reaches_the_process_that_asked_and_no_other
- host:beamlet-vm::a_message_does_not_end_a_wait_for_a_file
- host:beamlet-vm::a_process_killed_while_it_waits_has_its_operation_dropped
- bench:beamlet-files
- bench:lease-writes
flowchart LR
F["File, IO<br/>(Elixir)"] -.-> FM[":file, file_io_server<br/>(OTP, unchanged)"]
FM -.-> PF["prim_file natives<br/>(beamlet)"]
PF -.-> C["the 9P client<br/>(beamlet's Platform)"]
C -.->|"9P over call and lend"| LFSD["walfsd, one per volume"]
LFSD -.-> LFS["walfs"]
LFS -.->|"typed calls"| B["blkd"]
B -.-> D["virtio block device"]
Figure: the file I/O path from File to the block device. The links are built; they are drawn
dashed until Elixir’s File runs over them in a session. The servers are
the file server’s and blkd’s pages; littlefsd
serves the same 9P and typed calls over littlefs.
File.read!/1 becomes OTP’s file module, whose prim_file calls beamlet implements over its
9P client (beamlet;
userland/otp/redoubt/src/files.rs). The client
resolves the path in the session’s namespace, walks the rest of it on that connection, and reads.
A path above a binding, such as /, is a directory the namespace answers itself; a path
neither inside nor above one is :enoent (sessions).
- A fid is the file descriptor, and a directory fid is a capability. The file position lives in the VM, because 9P reads and writes carry explicit offsets; nothing else is kept between calls. A closed file’s fid is clunked, and comes back when the server has let it go.
- Everything moves in pieces: a read asks at most what one answer carries (the 64 KiB
msizeless its header), a write at most a page, each one request on the VM’s hub (asynchronous underneath), so only the Erlang process that asked waits for it. - Every hub request ends in exactly one answer to its caller, within its bound. The
operation waits at most
FILE_WAIT_US(10 s) for each request,busyretries counted, and for a connection’s place atMAX_WAITERS; past that it fails with the name of what it waited for (etimedoutan answer,ebusya share,emfilea place), the connection kept and a later answer dropped, after the caller has its own; the fid that request walked or held is clunked then, as a closed file’s is, or its number given back if the walk it asked for failed, so the server lets go of every fid the hub gave up on and the connection’s pool recovers (a give-up that kept the fid leaked one at the server and one in the pool peretimedout, and under sixteen agents’ timeouts drained the account’s working pool until the server refused the late starters’ first read). The bound is each request’s, so an operation’s is the sum over its requests: a write to append is a walk, a create or open, a stat, a write per page and a clunk, five or six requests, and one of them took 47 s to fail under sixteen agents’ load before it was answered. A fid the operation still holds at its end (a walk whose open the server refused, a request given up on while it waited to go again) is clunked as a closed file’s is, off the caller’s path: the answer is the caller’s at the bound, and the clunk is cleanup, the fid given back when the server answers it. The VM’s own calls, its code loading, wait in place with no bound of their own: the hub answers by construction, and a second clock on the same wait would hide a fault of the hub’s as a timeout of the VM’s. A server whose answer cannot be asked for again therefore parks a request for strictly less than the bound, by a stated margin:ipd’sdataandctlreads forDATA_WAIT_USandCTL_WAIT_US, 8 s, the 2 s margin the kernel’s delivery of the reply, so a read’stimeoutreaches the hub before the hub gives up, and the bytes that arrive after it wait in the socket’s buffer for the read made next (pinned and abandoned calls). None of it waits on the VM’s thread. The file server’srenameis a typed call, which no hub carries: it is made on the VM’s thread. - Plain 9P2000, with no Unix extensions: a
stathas a name, a length, a modification time and a qid (the server’s identity and version for the file), and nothing else. Custom per-file metadata is the file server’s typedset_attrandget_attr, kept in the volume’s attributes. - An error is a Redoubt error first. The file server refuses with its own reasons
(
not_found,not_permitted,exists,not_dir,removedfor a fid whose file was removed,too_largefor an attribute over its limit), and labels and budgets add theirs, each a name of the one table (wire). Everywhere but theFileboundary a Redoubt error keeps its name; there, beamlet’s platform maps each name to the POSIX error OTP’sfileexpects, by the table’s last column.
| Operation | What happens |
|---|---|
File.stat, :file.read_file_info | what 9P and the file server have: the type (from the qid), the size and the modification time (0 until the file server keeps one: the residual below); access, mode, uid, gid, links, inode and major_device are :undefined, since no server says what a connection may do; attributes through get_attr |
File.ls | a read of a directory fid; entries the caller may not read are left out |
File.rm of an open file | succeeds: an “in use” refusal would tell one client about another |
File.chmod, File.chown, File.write_stat, File.touch of a file that is there | :ok, and nothing changes: there are no mode or owner bits, access is by capability, and no time is kept (the residual below). Each native refuses enotsup, which OTP’s write_file_info takes as done; on a path that is not there each is {:error, :enoent}, so File.touch in a missing directory fails |
File.ln_s, File.ln | {:error, :enotsup}: 9P2000 has no links, and binds do their job |
Refuse visibly; report only real fields. The File API does not emulate POSIX: no program
should rely on a permission bit that no server enforces, because authority on Redoubt is the
capability, never a mode bit. :undefined is within OTP’s own file_info type for exactly these
fields, so nothing is invented. This is the one statement of the rule; file transfer follows it
(file transfer). Below File the refusal is
visible: each native that would set a mode, an owner or a time answers enotsup for a file that
is there. OTP’s own write_file_info takes that answer as done, as it does on a system without
the field, so standard-library code that writes a file’s info back (the mode preservation in
File.cp and File.cp_r, File.touch) succeeds and sets nothing, and is never fed a fake
value; the M5 (self-hosted development) case that compiles a Mix project on the box catches any
caller that breaks. The host tests above hold the undefined fields, the natives’ enotsups and a
missing path’s enoent, and bench:beamlet-files sees mode undefined in a boot, a file’s info
written back as ok and written to a missing file as enoent.
Residuals, each a departure from the table, until the file server serves what it needs:
- A request that times out retires its fid. An operation whose request went unanswered for
FILE_WAIT_USkeeps that fid out of use for the connection’s life, as a lost reply’s is, since the server may still hold it; a connection hasMAX_FIDS(64) of them, so a server that never answers costs its connection at most 64 timed-out requests before every operation on it isemfile. The VM’s own waits (the console’s writes) consult the console’s retry, not a file operation’s deadline: a timed-out operation is noticed when the VM next idles or takes a completion, bounded by the console’s progress. - No times are set and none is stored. The 9P skeleton refuses
Twstat, so a time given toFile.write_statorFile.touchis not kept, though the call is:ok, and a cut at a position (:file.truncate/1) other than an open’s is{:error, :enotsup}; and no file server has a clock to keep a modification time by, so it reads as 0 (1970).
Copying, moving, removing and binds
Status: built · partly tested: in a session on the machine a copy across volumes is shown only from a read-only volume and into a vault, and a move between two writable volumes, which no session holds, runs on the host only · tested (6)
- bench:shell-commands
- host:beamlet-redoubt::a_copy_within_one_volume_is_the_servers
- host:beamlet-redoubt::a_bind_is_the_files_namespace_and_one_connection
- host:beamlet-redoubt::binds_past_the_cap_are_refused_and_a_bound_prefix_is_still_replaced
- host:beamlet-redoubt::a_served_servers_binding_and_handle_go_with_its_budget
- host:beamlet-redoubt::a_rename_the_volume_refuses_is_eacces
Two paths on different prefixes are usually on different servers, and one server cannot act on another’s files. So what an operation costs depends on where its two ends are:
| Operation | Within one volume | Across volumes |
|---|---|---|
copy (cp, Redoubt.File.copy_file) | the file server’s copy_file: no bytes cross into the VM, though the VM’s thread waits while the server copies, at most the home’s quota (8 MiB in the image) or a vault’s room; cp over a file that is there copies through the VM | a read and write loop in the VM (File.cp and File.cp_r always loop) |
rename or move (File.rename, mv) | the file server’s rename, atomic, within one directory or between two | File.rename returns {:error, :exdev}; a move is the caller’s copy and remove (mv does both), not atomic |
remove (rm, rm_rf) | a 9P remove, recursively for rm_rf | |
make a directory (mkdir, mkdir_p) | a 9P create with the directory bit |
Binds. bind(prefix, conn) adds an entry to the session’s own namespace: the connection
conn, already held, appears at prefix. It changes nothing on any server and creates no
authority, and no other process sees it. It replaces what Unix does with symbolic links, hard
links and bind mounts: giving something a second name. A bind at a prefix already bound replaces
what was there, and the table holds at most 64 entries, so a bind of a new prefix past them is
{:error, :system_limit} (beamlet). A bind lasts as long as the session,
or as long as the budget the connection came through when it came through one the session
carved (the send right a launch with serve in that budget returned, or a handle a call on
such a connection brought): destroying the budget unbinds every prefix naming the connection, so
a path there is :enoent and not a dead server’s refusal, and the session’s handle to it is
closed when its last copy goes (a pipeline’s piped, bound at /dev/pipe;
a_served_servers_binding_and_handle_go_with_its_budget).
- Replacing is within the session’s own authority. A bind over
/bootor/dev/conschanges what the session’s ownexecreads its program from and mints its child’s console at, as any path does: a session can already run any code it writes, so a program read from elsewhere gains nothing (native programs)./bootin a session is the boot bundle only while the session leaves it so. - A child gets only what its launcher writes into its startup block: the shell’s
execwrites/dev/consalone, so a session’s binds reach a child only if the session passes them on (native programs). - One connection, one badge. A connection bound at two prefixes is one connection, with one
badge and one label set, so every server’s check is the same through either name. A handle the
VM holds that is no connection yet is attached once, over 9P; a server that does not speak it,
or a budget, is
not_a_connection.
Labels on files
Status: planned · M1 (sessions over SSH, kept apart)
Labels are per volume: each volume has its own file server instance and its own label set, fixed when the volume is set up (the file server). The file server checks every request against the caller’s label set, which the kernel stamps on the message (R14 (unforgeable sender)), by the servers’ label rule (labels):
- Read needs the caller’s labels to include the volume’s. A session with
{tax}reads an unlabelled volume and thetaxvolume; an unlabelled session reads only unlabelled volumes. - Write needs the caller’s labels to equal the volume’s. A vault session cannot write its principal’s unlabelled home, so it cannot copy labelled data down into it.
- Metadata follows the data. Names, sizes and listings of a labelled volume are refused to a caller that cannot read it, so an unlabelled caller can neither read labelled data nor learn that it exists.
Open: none.
Sharing a directory
Status: planned · M6 (persist, install, share)
Alice shares a directory with Bob through the steward. The only delegation primitive is
new_connection(root, quota): it mints a connection rooted at a subdirectory, with its own byte
quota, and returns it with a random id (the serving library).
- Alice asks the steward to share
/home/alice/sharedwith Bob. - The steward mints the share into a revocation scope: a budget with no pages, processes or weight, made under Alice’s budget only to be destroyed later. It passes the connection to Bob.
- Bob can narrow it further: asking the file server for
shared/subgives a handle that keeps the share’s stamp (R9 (stamps)). - Alice un-shares: the steward destroys the scope, and every handle stamped with it dies,
subincluded, wherever the copies went (R10 (destruction)).
Labels still decide access: a share of a labelled volume reaches only a principal whose sessions carry the label. A project, a principal sponsored by several members, shares its own volume the same way, one revocation scope per member (the steward).
Open: none.
Why
Capabilities instead of permission bits. A mode and an owner are a question every server asks
about every file (“may this user do that?”), and the answer depends on a global table of users.
A connection is the answer given once: holding it is the permission, narrowed by the server when
it was minted (a subdirectory, read-only). There is nothing to chmod because there is no table
to consult.
No symlinks or hard links. A symbolic link is a path the server follows on the client’s
behalf, which is how a program is tricked into writing where it should not (a link from /tmp
into someone’s home). A hard link gives one file two parents, which breaks per-directory quotas
and makes “remove” mean two things. A bind gives something a second name in one process’s
namespace, and cannot point anywhere that process could not already reach.
Rename is not always atomic, and the docs say so. A rename between two servers cannot be atomic without a transaction protocol between them, and a transaction protocol between file servers would be the largest shared code in the system. Redoubt keeps each server alone and says which renames are copies.
Removing an open file succeeds. If a server refused to remove a file another client has open, the refusal would tell one client something about another: a channel. So a remove always does what it says.