Filesystem reads and writes, and their typed terminal outcomes.
These effects wait. Every one of them is legal in init!, where it blocks
startup until the answer is in -- which is what loading assets wants -- and
in tasks, where it parks the task while the frame loop keeps drawing. They
are refused in update! and render!, with a message naming the effect and
the fix.
? gives up on the first failure, so the function answers with a Try and
the task closure turns that into the one message it owes:
Task.spawn!(input, || match load_level!(dir) { Ok(msg) => msg, Err(_) => LevelFailed }).
Paths are used as the app gives them, resolved against the process working
directory, and nothing here is sandboxed. Capture is the one part of this
platform with an output root, and it confines captures only; see
write_text!.
The whole file is copied into the string, so this is capped well below
what read_bytes! will read: at most 64 kibibytes, and a file past that
is TooLarge rather than truncated. One that is not valid UTF-8 is
NotUtf8 rather than an invalid Str.
Legal in init!, where it blocks startup, and in tasks, where it parks
the task; refused in update! and render!.
At most 16 mebibytes, and a file past that is TooLarge. The ceiling is
far above read_text!'s because nothing is copied: the buffer the read
filled is the buffer Roc gets.
That is also why there is a second bound. The delivered list owns
host-backed storage through Roc ARC, and at most 32 such allocations are
live at once; a read made while all 32 are held answers Busy without
touching the disk. Retaining a sublist retains the complete source
allocation and so holds a slot, which List.release_excess_capacity
releases by copying out the part worth keeping.
Legal in init!, where it blocks startup, and in tasks, where it parks
the task; refused in update! and render!.
List one directory without recursively walking its children.
Entry order is the filesystem's observed order and is not sorted.
Recursion is the app's to drive: only the app knows which subtrees are
worth descending into, and a host-side walk would be one unbounded wait.
A listing is bounded at 8192 entries and at one mebibyte of encoded
names, whichever binds first; a directory past either is TooLarge
rather than a partial listing. It is delivered through the same 32
file-byte slots a byte read uses, so it can answer Busy for the same
reason.
Legal in init!, where it blocks startup, and in tasks, where it parks
the task; refused in update! and render!.
What one path is, how big it is, and when it last changed.
Symbolic links are followed, so a link to a file is File and a link to
nothing is NotFound. That differs from list!, which reports the kind
the directory itself records and so calls a symbolic link Other;
metadata! answers about the thing at the end of the path, which is
what an app deciding whether to read it wants to know.
Polling modified is how an app hot-reloads a shader, a level, or a
dataset it did not write. Do it inside a task, and sleep between stats,
so the watching costs a parked task rather than a stat every frame:
watch! : Str, Time.Timestamp => Msg
watch! = |path, seen| {
var $outcome = Unchanged
while $outcome == Unchanged {
Task.sleep!(250)
$outcome = match Files.metadata!(path) {
Ok(meta) if meta.modified != seen => Modified(meta.modified)
Ok(_) => Unchanged
Err(NotFound) => Unchanged
Err(other) => Stopped(other)
}
}
match $outcome {
Modified(at) => Changed(path, at)
Stopped(err) => WatchFailed(err)
Unchanged => crash("watch!: the loop only ends once the path changed or the stat failed")
}
}
The loop carries a sentinel tag rather than the message itself, so its
while condition can compare it, and the match after the loop turns
that sentinel into the one message the task owes.
A loop rather than a recursive call, because a task runs on a
fixed-size coroutine stack. NotFound keeps waiting rather than giving
up: an editor saving a file often replaces it, so the path can be
missing for a moment. update! spawns the watcher again when it handles
Changed, and each live watcher holds one of the host's thirty-two task
slots for as long as it watches.
Legal in init!, where it blocks startup, and in tasks, where it parks
the task; refused in update! and render!.
Replace a file's contents with a Str, creating it if it is not there.
The write replaces the whole file: there is no append, and no partial
write is reported as success. Missing parent directories are created,
the same as for every file the host writes itself, so an app's first
write_text!("saves/slot1.json", ...) does not need a separate step to
make saves/.
The path is used as the app gave it, resolved against the process
working directory, exactly as read_text! resolves one. Files is not
sandboxed in either direction: an app that can read /etc/hosts can
write /tmp/out.txt. The one output root this platform enforces belongs
to Capture, whose paths are computed by recording machinery rather
than written out by the app, and it confines captures only.
Legal in init!, where it blocks startup, and in tasks, where it parks
the task; refused in update! and render!.
Replace a file's contents with ordinary Roc bytes.
The same path rules, the same whole-file replacement, and the same
parent-directory creation as write_text!; only the payload differs.
Nothing about the bytes is inspected, so this is the call for a PNG, a
save blob, or anything else that is not text.
Legal in init!, where it blocks startup, and in tasks, where it parks
the task; refused in update! and render!.
NotFound is no file at that path. TooLarge is a file past
read_text!'s 64 kibibyte ceiling, which is a refusal rather than a
failure: nothing went wrong and the file is there. NotUtf8 is a file
that was read and is not valid UTF-8, reported rather than delivered as
an invalid Str. Busy is the host's thirty-two file-delivery slots all
being held by byte lists an app has retained; nothing was read, and the
same call later can succeed. Unavailable is the app shutting down
while the read was parked.
ReadFailed is every other refusal, and a permission the process does
not have is one of them: the host does not distinguish it from a read
that failed for any other reason. metadata! does, so a path that may be
unreadable can be stat'd first to tell the two apart.
The same tags as ReadTextError minus NotUtf8, since nothing about
the bytes is inspected, and with a much larger ceiling: TooLarge here
is a file past 16 mebibytes. ReadFailed covers a permission denial in
exactly the same way.
NotADirectory is a path that is there and is a file. TooLarge is a
directory whose listing would exceed 8192 entries or one mebibyte of
encoded names, whichever binds first. The rest mean what they mean for a
read, ReadFailed included.
What one path is, how big it is, and when it last changed.
size_bytes is the file's length; for a directory it is whatever the
filesystem reports for the directory itself, which is not the size of
what is inside it. modified is wall-clock time, so it is comparable
with Time.now! and with a modified this app recorded earlier, and it
is not comparable with input.time.
NotFound is nothing at that path, including a path a component of
which is a file rather than a directory, and a name this filesystem
cannot represent. PermissionDenied is a directory on the way to the
path this process may not look inside -- the one failure a stat can name
that a read cannot. Unavailable is the app shutting down while the
stat was parked, and ReadFailed is every other refusal.
There is no Busy: a stat holds no host-owned payload, so there is no
delivery slot for it to run out of.
Why a write did not leave the file on disk. This is Files' own
WriteError; Stdout and Stderr declare a different one under the
same name, for the different things a stream write can refuse.
NotFound means a component of the path is a file rather than a
directory, or names something that cannot be created; the missing
directories a write would otherwise trip over are created for it.
NoSpace is the filesystem being full or over quota, and WriteFailed
is every other refusal the host cannot name more precisely.