Skip to content

File I/O

Basalt opens a file into a one-word handle, and every read and write on that handle names the byte offset it starts at. Two whole-file calls sit on top: ReadEntireFile pushes a file’s bytes into an arena you supply, and WriteEntireFile writes out a String8 or a String8List.

There is no file cursor. Every transfer carries its own offset, so two threads may read one open file and synchronise nothing.

Reading a file whole takes no handle and no buffer of your own.

const Result<String8, FileError> read = ReadEntireFile(arena, Str("assets/level.json"));
if (!read.ok)
{
ConsoleWriteLine(ConsoleStream::Stderr, ErrorToString(read.error)); // "file not found"
return;
}
ParseLevel(read.value); // bytes owned by `arena`

The bytes live in the arena you passed, so their lifetime is that arena’s: nothing to free, and nothing to close — ReadEntireFile opens the file and closes it before it returns.

Writing is the mirror:

const Result<void, FileError> written = WriteEntireFile(Str("save/slot0.dat"), bytes);

The file is created if it is absent and truncated if it is present, on both platforms. A call named “write the whole file” that left the tail of a longer previous file behind would not be writing the whole file.

An empty file is a success. ReadEntireFile returns Ok with an empty String8 for a zero-byte file and Err(FileError::NotFound) for a missing one, so a caller never has to guess which of the two it got.

Below those two calls is a handle you open and close yourself.

const Result<File, FileError> opened = FileOpen(path, FileAccess::Read);
if (!opened.ok)
{
return;
}
const File file = opened.value;
// ... reads and writes ...
FileClose(file);

File is one u64 and nothing else: no constructor, no destructor, no ownership. So it is Arena::Push<T>-compatible, memcpy-able and passed by value, and every operation on it is a free function with a File prefix rather than a method.

A zeroed File is the invalid handle. File file = {}; and a freshly pushed struct both hold something safe to close: FileClose on it does nothing, and that is not a contract violation.

Passing one to FileRead, FileWrite or FileGetSize is a violation. It asserts in a debug build. In a release build — where the assert compiles out — FileGetSize returns 0 and the two transfers return Err(AccessDenied) rather than faulting, because a zeroed handle is reachable through any zero-initialised struct.

Two things follow from a handle being a plain value:

  • You close it. FileOpen and FileClose pair by hand. There is no scope guard over a File, because a type with a destructor would stop being the POD value the paragraph above describes.
  • A copy copies the handle, not ownership. Closing one copy invalidates every copy, and nothing detects a use of a closed handle. That is the contract of a raw OS handle.
  • A child process does not inherit it. On both platforms the handle is close-on-exec, so a process you spawn does not hold your files open.

FileAccess picks the disposition, and there are exactly three.

Mode Effect
FileAccess::Read Opens an existing file for reading. An absent file gives NotFound.
FileAccess::Write Opens for writing, creating if absent. Existing contents are kept.
FileAccess::WriteTruncate Opens for writing, creating if absent, truncating to zero if present.

A handle opened for one direction refuses the other: FileRead on a Write handle and FileWrite on a Read handle both return Err(AccessDenied).

A directory is not a file in any of the three modes: opening one gives AccessDenied on both platforms. Linux would otherwise grant the read open — that is how opendir starts — and you would discover it as a file that reads back empty, so FileOpen refuses it outright instead.

FileRead and FileWrite take the byte offset as a parameter, and the destination is a Span, so the transfer length and the buffer size cannot disagree.

u8 header[16] = {};
const Result<u64, FileError> read = FileRead(file, 0, Span<u8>(header, sizeof(header)));

There is no FileSeek and no FileGetPosition, and one guarantee rests on their absence: two threads may call FileRead on one File with no synchronisation, and each gets the bytes at its own offset. On Linux a positional read does not touch the descriptor’s offset. On Windows the underlying call does move the file pointer, but nothing in this API ever reads that pointer, so nothing observes the collision. Adding a seek later would retroactively turn every concurrent read on Windows into a data race, which is why the cursor is the part that was dropped.

The guarantee is about correctness, not throughput. On Windows the kernel serialises the operations on a handle opened without FILE_FLAG_OVERLAPPED, and every handle this layer opens is such a handle: N threads reading one file get correct data at the speed of one. On Linux the reads overlap.

The rest of the concurrency contract is narrower:

There is no append mode. On Linux a descriptor opened for append ignores the offset you supply, which would make the signature lie, so FileAccess has no Append and the idiom is explicit instead:

const Result<void, FileError> written = FileWrite(file, FileGetSize(file), bytes);

Two appenders racing is visible in that line rather than hidden behind a flag.

A short read means end of file, and only that

Section titled “A short read means end of file, and only that”

FileRead returns Ok with the number of bytes it actually transferred. Fewer than you asked for means the file ended there — asking for a range that runs past the end returns the short count. A failure is Err, never a short count.

const Result<u64, FileError> read = FileRead(file, offset, destination);
if (!read.ok)
{
return; // AccessDenied (wrong handle mode) or ReadFailed (the device, or a non-seekable descriptor)
}
// The first `read.value` bytes of `destination` are valid. `read.value < destination.size` means end of file.

FileWrite has no end of file. It returns Ok when every byte reached the operating system and Err otherwise — DiskFull when the volume or your quota is out of room, AccessDenied on a Read handle, WriteFailed for anything else. ReadEntireFile and WriteEntireFile pass these errors through: a failing volume gives Err(ReadFailed), not Ok over a truncated file.

FileGetSize returns 0 in two different situations: a genuinely empty file, and a file whose size the operating system does not report. A Linux /proc entry is the everyday specimen of the second — it reports zero and reads back several lines of text.

So the obvious implementation — ask the size, allocate that much, read once — silently returns nothing for a file that has bytes. ReadEntireFile takes two paths instead:

Reported size What happens Cost
Non-zero Trusted. Pushes exactly that many bytes, reads into them, pops back any shortfall. One allocation, no copy
Zero Not believed. Reads one chunk: nothing back means the file is empty, anything back means the length is unknown and it keeps reading, then flattens into your arena. One extra allocation, one copy

The common case pays nothing for the fallback, and reading a /proc entry through ReadEntireFile works.

A reported size is a fact about a moment that has already passed. Zero is the one value you can act on without a race — if the size is zero you do not yet know the contents, which is exactly why it triggers a read rather than an empty return.

A file that grows during the read comes back truncated

Section titled “A file that grows during the read comes back truncated”

The second WriteEntireFile overload takes a String8List and writes every node in order, with no separator.

String8List lines = {};
Str8ListPushf(arena, lines, "version %d\n", version);
Str8ListPush(arena, lines, body);
const Result<void, FileError> written = WriteEntireFile(path, lines);

Those are the same bytes Str8ListJoin would produce, without materialising them: joining first allocates the whole text in your arena, and the overload allocates one 64 KiB staging buffer on a scratch arena instead. The staging exists because a String8List node may be a handful of bytes, and one syscall per node is what it avoids. The overload returns the first drain’s error as-is — DiskFull included — and Ok only when every byte of list.total_size reached the operating system.

The String8 overload buffers nothing — those bytes are already contiguous, so a staging copy would be pure cost.

Paths are UTF-8 String8, passed through unchanged. There is no path type, no normalisation and no separator rewriting. Forward slashes work on both platforms, because Win32 converts them while resolving the name.

You never handle UTF-16. On Windows the conversion happens inside FileOpen, on a scratch arena, and dies with the call.

A path containing a NUL byte is Err(NotFound) on both platforms. The operating system would stop at the NUL and open the file named by the prefix, so FileOpen refuses the path before asking.

One FileError covers opening, reading and writing. Every variant is producible — a variant this layer could never return would be a value you write a branch for and never reach.

NotFound and AccessDenied are the two most call sites branch on. ErrorToString maps any variant to a static string, takes no arena and allocates nothing, so it still works when the failure itself is OutOfMemory.

Three variants only a transfer produces:

  • ReadFailed — the device failed mid-read, or the descriptor cannot seek. End of file is never this.
  • WriteFailed — the device failed mid-write, or accepted nothing.
  • DiskFull — the volume, or your quota, has no room. Distinct from WriteFailed so you can tell the user which.

AccessDenied also comes back from a transfer, when the handle was opened for the other direction.

Situation What to reach for
Checking whether a file exists Open it and branch on FileError::NotFound. One syscall instead of two, and the answer cannot go stale between the check and the open.
A file too large to push into an arena Not this layer — memory mapping, which is not in v0.1
A file another process is appending to Your own loop on FileRead until it returns Ok(0)
A file that must never be observed half-written Not this layer — write-to-temp-then-rename, which needs a move operation that does not exist yet
A write that must be on the device before you continue Not this layer — there is no flush operation yet
Reading and writing through one handle Not this layer — a handle is opened for one direction; open it twice
Listing a directory, or reacting to changes on disk Not this layer
Writing incrementally in small pieces over a program’s life Collect into a String8List and write once, or drive FileWrite with your own offset
Many threads reading one file in parallel on Windows Not this layer — the reads are correct but the kernel runs them one at a time

Parameters, failure behaviour and the variants each call can produce are documented in basalt/core/File.h.

Signature Purpose
ReadEntireFile(arena, path) Opens, reads and closes. Returns the bytes in arena, or a FileError.
WriteEntireFile(path, data) Creates or truncates, then writes one String8.
WriteEntireFile(path, list) Creates or truncates, then writes every node of a String8List, staged through one buffer.
Signature Purpose
FileOpen(path, access) Opens a UTF-8 path with one of the three FileAccess dispositions.
FileClose(file) Closes the file. A no-op on an invalid handle.
FileIsValid(file) Whether the handle designates an open file.
FileGetSize(file) Size in bytes. Zero means an empty file, or a size the operating system does not report.
Signature Purpose
FileRead(file, offset, destination) Ok with the bytes actually read — a short count means end of file — or AccessDenied / ReadFailed.
FileWrite(file, offset, source) Ok when every byte reached the operating system, or AccessDenied / DiskFull / WriteFailed.
Variant Meaning
FileError::NotFound No such file, or a directory along the path is absent, or the path contains a NUL. On Windows, also an over-long path.
FileError::AccessDenied The file may not be opened in the requested mode — including a path that names a directory — or the handle was opened for the other direction.
FileError::PathTooLong The path exceeds what the platform accepts. Linux only in practice: CreateFileW has no length-specific error, so Windows reports an over-long path as NotFound.
FileError::TooManyOpenFiles The process or the system is out of handles.
FileError::OpenFailed The open failed for a reason none of the above names.
FileError::ReadFailed The device failed mid-read, or the descriptor cannot seek. Never end of file.
FileError::WriteFailed The device failed mid-write, or accepted nothing.
FileError::DiskFull The volume or your quota has no room for the write.
FileError::OutOfMemory An arena push failed.
ErrorToString(error) A static description of any variant. Allocates nothing.
Name Value
FILE_IO_BUFFER_BYTES 64 KiB — the chunk of the unknown-length read and the staging buffer of the String8List write.