Chapter 9: Error Handling with Result
The hosted modes have exceptions. Freestanding does not: throw and exception catch arms are rejected with E_FREESTANDING_EXCEPTIONS. This chapter explains why, then shows what replaces them — Result[T, E], the ? operator, error conversion with From, and the try … attempt … catch Err(e) block — with the mistakes the compiler catches and two it does not.
Why there are no exceptions
An exception needs unwind tables (.eh_frame), an unwinder that walks them and restores registers, and something sensible to do when nothing catches the exception. A freestanding image has none of these: linking an unwinder would bring back the runtime freestanding exists to avoid, and an interrupt handler on a per-CPU stack has no caller to unwind into.
So freestanding splits errors in two:
- a recoverable error is a value — the function returns
Result[T, E]and the caller decides; - an unrecoverable condition is a panic — the package's panic policy halts the core, faults, or calls your
@panic_handler(chapter 17).
Error types
Result[T, E] is a built-in enum with the variants Ok(value) and Err(error). The error type is yours to choose; usually it is an enum per subsystem:
@mode(freestanding)
enum UartError {
Timeout,
Framing,
Parity,
Overrun,
}
enum PciError {
NoDevice(bus: uint8),
BadVendor(vendor: uint16),
}
func readByte(lsr: uint8, data: uint8): Result[uint8, UartError] {
if ((lsr & 0x01u8) == 0u8) {
return Err(UartError.Timeout) # nothing received
}
if ((lsr & 0x08u8) != 0u8) {
return Err(UartError.Framing)
}
return Ok(data)
}
func describe(r: Result[uint8, UartError]): uint8 {
return match r {
Ok(b) => b,
Err(UartError.Timeout) => 0x00u8,
Err(e) => 0xFFu8,
}
}
Listing 9-1: Subsystem error enums and a function that returns Result
Propagating errors with ?
expression? on a Result:
- evaluates to
vwhen the result isOk(v); - returns
Err(e)from the enclosing function when it isErr(e).
func readWord(lsr: uint8, hi: uint8, lo: uint8): Result[uint16, UartError] {
let high = readByte(lsr, hi)?
let low = readByte(lsr, lo)?
return Ok(((high as uint16) << 8) | (low as uint16))
}
Listing 9-2: Two fallible reads composed with ?
? only works where it can return an error. In a function that returns a plain value it is rejected:
@mode(freestanding)
enum UartError { Timeout, Framing }
func readByte(): Result[uint8, UartError] {
return Err(UartError.Timeout)
}
func pollKeyboard(): uint8 {
let scancode = readByte()?
return scancode
}
error[E_PROPAGATE_WRONG_RETURN]: E_PROPAGATE_WRONG_RETURN: the '?' operator can only be used inside a function returning Result (§1-5.7)
--> src/main.lpfree:10:20
|
10 | let scancode = readByte()?
| ^^^^^^^^^^^
|
pollKeyboard has to decide what an error means for its caller. Either return Result too, or handle the error where it happens:
# 0 means "no key" to the caller.
func pollKeyboard(): uint8 {
return match readByte() {
Ok(scancode) => scancode,
Err(e) => 0u8,
}
}
Listing 9-3: Handling the error at the boundary with match
Converting errors with From
Higher layers usually want one error type that covers several subsystems. ? converts automatically when the function's error type implements From[E] for the error being propagated. Without the conversion:
@mode(freestanding)
enum UartError { Timeout, Framing }
enum KernelError { Uart, OutOfMemory }
func readByte(): Result[uint8, UartError] {
return Err(UartError.Timeout)
}
func readConsoleId(): Result[uint8, KernelError] {
let id = readByte()?
return Ok(id)
}
error[E_PROPAGATE_MISSING_FROM]: E_PROPAGATE_MISSING_FROM: '?' propagates an error of type 'UartError' out of a function whose error type is 'KernelError', which does not implement `From[UartError]` (§1-5.7)
--> src/main.lpfree:11:14
|
11 | let id = readByte()?
| ^^^^^^^^^^^
|
From[E] is an interface with one static method, from(err: E). In 1.25 an enum declaration cannot carry implements, so make the kernel-level error a struct that records which subsystem failed and how, and give it one from per subsystem error:
enum UartError { Timeout, Framing }
enum PciError { NoDevice(bus: uint8), BadVendor(vendor: uint16) }
enum Subsystem { Uart, Pci }
struct KernelError implements From[UartError], From[PciError] {
var subsystem: Subsystem;
var code: uint32;
static func from(err: UartError): KernelError {
let code = match err {
UartError.Timeout => 1u32,
UartError.Framing => 2u32,
}
return KernelError(subsystem = Subsystem.Uart, code = code)
}
static func from(err: PciError): KernelError {
let code = match err {
PciError.NoDevice(bus) => bus as uint32,
PciError.BadVendor(vendor) => vendor as uint32,
}
return KernelError(subsystem = Subsystem.Pci, code = code)
}
}
func readByte(): Result[uint8, UartError] {
return Err(UartError.Timeout)
}
func findNic(): Result[uint16, PciError] {
return Ok(0x8139u16)
}
func initDevices(): Result[uint16, KernelError] {
let consoleId = readByte()? # UartError -> KernelError.from(...)
let nic = findNic()? # PciError -> KernelError.from(...)
return Ok(nic + (consoleId as uint16))
}
Listing 9-4: A kernel error type with From conversions
The compiler inserts KernelError.from(e) at each ? whose error type differs. Low-level drivers keep precise error enums; the glue layer above them speaks one type.
try … attempt … catch Err(e)
? sends every error out of the function. When a block of steps should share one local handler instead, use a try block whose only handler is a catch Err(e) arm. Inside it, attempt expression unwraps an Ok or jumps to the arm with the error (Language Reference §1-5.10). This form needs no unwinder — it is a match and a jump — so it is allowed in freestanding, with an optional finally:
enum ProbeError { Absent, Timeout }
func probePort(port: uint16): Result[uint8, ProbeError] {
if (port == 0u16) {
return Err(ProbeError.Absent)
}
return Ok(0x5Au8)
}
var probesRun: uint32 = 0
# Probe two ports; any failure falls back to "no device" (0).
func detect(primary: uint16, secondary: uint16): uint8 {
var found: uint8 = 0
try {
let a = attempt probePort(primary)
let b = attempt probePort(secondary)
found = a | b
} catch Err(e) {
found = 0u8
} finally {
probesRun = probesRun + 1u32
}
return found
}
Listing 9-5: A local handler for a sequence of fallible steps
What is not allowed is the exception form, catch (Type as e):
@mode(freestanding)
func decode(scancode: uint8): uint8 {
return scancode & 0x7Fu8
}
func pollKeyboard(scancode: uint8): uint8 {
try {
return decode(scancode)
} catch (ValueError as e) {
return 0u8
}
}
error[E_FREESTANDING_EXCEPTIONS]: E_FREESTANDING_EXCEPTIONS: an exception `catch` arm is not available in `@mode(freestanding)` — the mode has no unwinder and no `.eh_frame`. Use `Result[T, E]` with `?` or `attempt` (§1-5.10).
--> src/main.lpfree:10:7
|
10 | } catch (ValueError as e) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
Nothing in decode can fail, so the fix is to delete the try; when something can, make it return a Result and use attempt or ?. throw is rejected the same way (chapter 6).
Two mistakes the compiler does not catch
Ignoring a Result. Calling a fallible function as a statement discards its result, and 1.25 does not warn:
enum DiskError { Timeout, BadSector(lba: uint64) }
func flushCache(): Result[void, DiskError] {
return Err(DiskError.Timeout)
}
func shutdown(): void {
flushCache() # the error is silently dropped
# ... power off ...
}
Listing 9-6: A dropped error — compiles without a warning
If the shutdown path cannot do anything about a failed flush, say so explicitly — match and ignore the arm with a comment, or count the failure — so the next reader knows it was a decision.
? on an Option. The Language Reference allows ? only on Result (E_PROPAGATE_NOT_RESULT). In 1.25, ? on an Option inside a Result-returning function is accepted, and a None is propagated as the error value itself — Err(None) in a hosted run — not as a value of your error type:
enum LookupError { NoSuchIrq }
func slotOf(irq: uint8): Option[int] {
if (irq < 16u8) {
return Some(irq as int)
}
return None
}
func handlerSlot(irq: uint8): Result[int, LookupError] {
let slot = slotOf(irq)? # a None becomes Err(None), not NoSuchIrq
return Ok(slot)
}
Listing 9-7: ? applied to an Option
Convert the absence into your error explicitly:
func handlerSlot(irq: uint8): Result[int, LookupError] {
let slot = slotOf(irq)
if (slot.is_none()) {
return Err(LookupError.NoSuchIrq)
}
return Ok(slot.unwrap())
}
Listing 9-8: Turning None into a typed error
Patterns
Allocation failure. The @allocator returns uint?; wrap it once and the rest of the kernel sees a Result (chapter 7, Listing 7-5).
Parsing. Every step of parsing a header can fail, and the failures compose with ?:
enum ParseError {
Truncated,
BadMagic(magic: uint32),
UnsupportedClass(value: uint8),
}
# The first bytes of an ELF image, as received from a boot module.
struct Image {
var bytes: buffer[uint8, 64];
var length: int;
}
func byteAt(img: Image, offset: int): Result[uint8, ParseError] {
if (offset >= img.length) {
return Err(ParseError.Truncated)
}
return Ok(img.bytes[offset])
}
func checkMagic(img: Image): Result[void, ParseError] {
let m0 = byteAt(img, 0)?
let m1 = byteAt(img, 1)?
let m2 = byteAt(img, 2)?
let m3 = byteAt(img, 3)?
let magic = ((m0 as uint32) << 24) | ((m1 as uint32) << 16) | ((m2 as uint32) << 8) | (m3 as uint32)
if (magic != 0x7F454C46u32) {
return Err(ParseError.BadMagic(magic = magic))
}
return Ok(())
}
func elfClass(img: Image): Result[uint8, ParseError] {
checkMagic(img)?
let cls = byteAt(img, 4)?
if (cls != 2u8) {
return Err(ParseError.UnsupportedClass(value = cls)) # only ELF64
}
return Ok(cls)
}
Listing 9-9: A header parser built from small fallible steps
Result[void, E] is the type of a step that produces nothing but can fail; it returns Ok(()).
Panics: panic() and unreachable
The Language Reference defines panic(msg): noreturn in every mode. The 1.25 Stage0 compiler does not declare it in any mode:
@mode(freestanding)
func checkPaging(cr3: uint64): void {
if (cr3 == 0u64) {
panic("CR3 is zero: no page table loaded")
}
}
error[E0001]: undefined identifier `panic`
--> src/main.lpfree:5:9
|
5 | panic("CR3 is zero: no page table loaded")
| ^^^^^
|
⚠ Planned.
panic(msg)is specified (Language Reference §3-3.6) but not available in Loup 1.25.
What does work is unreachable: reaching it is a panic stop under the package's [build] panic policy — the core halts by default, or your @panic_handler runs under panic = "custom", with the source location (chapter 17). Write the message where a person will see it first, then stop:
func serialWrite(s: string): void {
for (c in s) {
outb(0x3F8u16, c as uint8)
}
}
func checkPaging(cr3: uint64): void {
if (cr3 == 0u64) {
serialWrite("fatal: CR3 is zero, no page table loaded\n")
unreachable
}
}
Listing 9-10: Reporting, then stopping with unreachable
When to stop and when to return Err:
- Stop when an invariant the rest of the program depends on is broken — a corrupt page table, a vector that fired with no handler installed, a state the code has proven impossible.
- Return
Errwhen the environment failed in a way some caller can handle — a device timed out, a PCI device is missing, a user-supplied length is wrong, a large buffer is unavailable and a smaller one would do.
Low-level drivers return precise errors; the subsystem glue converts them; the top-level loop logs, retries, falls back, or — when nothing else is safe — stops.
Error return traces — the list of ? sites an error passed through — are captured only in builds made with --error-traces (on by default in --debug builds); [freestanding] error_trace_depth sizes the fixed store (§3-3.21). In other builds, calling errorTraceDepth() is E_ERROR_TRACES_DISABLED.
Summary
throwandcatch (T as e)areE_FREESTANDING_EXCEPTIONS; recoverable errors areResult[T, E]values.?returns anErrfrom the enclosing function, which must returnResult(E_PROPAGATE_WRONG_RETURN).- Implement
From[E](onestatic func fromper source error) on a struct to let?convert errors across layers (E_PROPAGATE_MISSING_FROMotherwise). try { … attempt … } catch Err(e) { … } finally { … }handles a sequence of fallible steps locally and is allowed in freestanding.- 1.25 does not warn about an ignored
Result, and?on anOptionpropagatesNoneas the error — convert explicitly. panic()is not available yet; report the problem and useunreachable, which stops under the package's panic policy.
Next: ownership verbs.