Skip to content
Lesson 3.5Typed Memory, Ownership, and Object Layouts

Rust: Cleanup and Helpful Errors

Use scope-bound cleanup, Drop, structured library errors, and application context without hiding failures.

Lesson 3.5
Read or listenPrint chapter or book
About 22 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 3.5Key ideas, reference and questions

A one-page summary of “Rust: Cleanup and Helpful Errors” you can keep beside your notes. Plain Markdown.

Lesson 3.5 — Rust: Cleanup and Helpful Errors: cheatsheet

  • Remember: An error enum names a cause. Application context names the attempted operation. ? connects the layers without converting failure into a made-up success.

Quick reference

  • Release one owned resource at scope exit: An owner or guard with Drop — Abort and process termination may skip cleanup.
  • Temporarily change borrowed local data: A guard holds &mut T and saved state — Remote writes have additional failure and concurrency conditions.
  • Report failed completion: Explicit finish / close / restore returning Result — Drop itself has no returned result.
  • Let library callers distinguish causes: Structured enum, optionally derived with thiserror — The public variants become part of the interface.
  • Explain an application operation: anyhow::Context around a Result or Option — Add useful context while retaining the original cause.
  • Stop on the first failure: ? — Its error must convert to the function's return error.

Review

  • Q: A guard owns a saved local value and a temporary replacement. Where can it centralize restoration? A: Its Drop implementation at ordinary scope exit.
  • Q: A function returns an error early while holding a cleanup guard. What normally happens? A: The guard is dropped as the function leaves its scope.
Download cheatsheet (.md)

The previous lesson gave values clear owners and useful interfaces. Ownership also answers a practical question: who releases a resource when a function finishes early? Error handling answers another: what can the caller learn from that failure? We connect both here using local data. The examples do not open another process or change executable code.

Let an owner pair acquisition with cleanup

Section titled “Let an owner pair acquisition with cleanup”

Suppose a function temporarily changes a byte in its own buffer. Before it returns, it must put back the old value. Writing the restore statement at every return is easy to get wrong when another failure path is added.

Rust’s RAII pattern puts cleanup in the owner of a resource. The name comes from “Resource Acquisition Is Initialization”: creating an owner establishes its responsibility; leaving its scope ends that responsibility. Owned files and lock guards already use this pattern. A custom guard can use Drop to perform a small cleanup action when the guard leaves scope.

struct ByteGuard<'a> {
byte: &'a mut u8,
saved: u8,
}
impl<'a> ByteGuard<'a> {
fn replace(byte: &'a mut u8, replacement: u8) -> Self {
let saved = *byte;
*byte = replacement;
Self { byte, saved }
}
}
impl Drop for ByteGuard<'_> {
fn drop(&mut self) {
*self.byte = self.saved;
}
}
fn inspect(byte: &mut u8, stop_early: bool) -> Result<(), &'static str> {
let guard = ByteGuard::replace(byte, 90);
println!("temporary value: {}", *guard.byte);
if stop_early {
return Err("stopped early");
}
Ok(())
}
fn main() {
let mut byte = 75;
let result = inspect(&mut byte, true);
println!("result: {result:?}; restored byte: {byte}");
}

Predict first

main sets byte to 75 and calls inspect(&mut byte, true). The guard stores 90, then inspect takes the early return Err("stopped early"). No line in inspect writes 75.

What does main print as the restored byte?

Answer: 75. Returning leaves the scope of inspect, and leaving the scope drops guard. Its drop method writes the saved 75 back through the borrow before main reads the byte.

inspect(&mut byte, true): the early returnmain’s byte starts at 75. Follow the byte and the guard’s saved copy through the early return.
  1. 1 ByteGuard::replace(byte, 90)

    90main’s byte, was 75
    75guard.saved

    The guard copies 75 into saved, then writes 90 through its exclusive borrow of main’s byte.

  2. 2 return Err("stopped early")

    90main’s byte
    75guard.saved
    leaving scopeearly return

    inspect returns before its last line, and no line in inspect writes 75. The byte still holds 90 as the guard goes out of scope.

  3. 3 Scope ends: Drop runs

    75main’s byte, restored
    droppedguard

    Rust calls drop on the guard, which runs *self.byte = self.saved. main then reads 75. The Ok(()) return leaves the scope the same way.

main’s byte starts at 75. Follow the byte and the guard’s saved copy through the early return.

The Ok(()) return leaves the scope the same way, so it also ends with 75, and main can borrow the byte again after either return. This example’s cleanup cannot fail: it writes through a valid exclusive reference to a local byte.

A scope guard restores the local byte on either return. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

fn inspect(byte: &mut u8, stop: bool) -> Result<(), &'static str> {
    let guard = ByteGuard::replace(byte, 90);
    println!("temporary value: {}", *guard.byte);
    if stop { return Err("stopped early"); }
    Ok(())
}
// ByteGuard::drop restores guard.saved through guard.byte.

Drop restores 75 before the caller can use its borrow again.

Final values: byte = 75; stop = false; guard = dropped; saved = 75; result = Ok(()).

Read every step of the lesson’s example
  1. Step 1, line 1. The caller lends its local byte to inspect.

    byte = 75; stop = false; guard = not created.

  2. Step 2, line 2. The guard saves 75, borrows the byte exclusively and installs the temporary 90.

    byte = 90; stop = false; guard = owns cleanup duty; saved = 75.

  3. Step 3, line 3. Printing reads the temporary value through the guard’s exclusive reference.

    byte = 90; stop = false; guard = owns cleanup duty; saved = 75.

  4. Step 4, line 5. The success expression begins leaving this scope.

    byte = 90; stop = false; guard = owns cleanup duty; saved = 75; result = Ok(()).

  5. Step 5, line 7. Drop restores 75 before the caller can use its borrow again.

    byte = 75; stop = false; guard = dropped; saved = 75; result = Ok(()).

Drop is not a universal recovery guarantee. Destructors normally run on ordinary returns and panic unwinding; an abort, process termination, or deliberately forgotten owner can skip them. A remote-memory patch also has thread, memory-protection, and write-failure conditions that this local guard does not address. The handle owner in Lesson 3.1 and hook removal in Lesson 8.10 show those separate responsibilities.

The trait method has the signature fn drop(&mut self). It cannot return a Result to the caller. If releasing a resource can fail in a way the caller must act on, provide an explicit close, finish, or restore operation that returns Result; keep Drop as the final fallback appropriate for that resource. Avoid panicking in a destructor, especially while another panic is already unwinding.

For a buffered file, for example, an explicit flush() or sync_all() can report an I/O failure. Merely letting the file owner leave scope does not prove that a save reached durable storage. The required completion step depends on what your caller is trying to guarantee.

A library caller may need to handle invalid text differently from an out-of-range number. Returning a structured error enum preserves that distinction. The thiserror crate derives the standard error implementation and formats the message you supply. It does not change how Result or ? works.

use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AmountError {
#[error("amount is not an unsigned integer")]
Parse(#[from] ParseIntError),
#[error("amount {value} exceeds the maximum {maximum}")]
TooLarge { value: u16, maximum: u16 },
}
pub fn parse_amount(text: &str, maximum: u16) -> Result<u16, AmountError> {
let value = text.parse::<u16>()?;
if value > maximum {
return Err(AmountError::TooLarge { value, maximum });
}
Ok(value)
}

For "oops", parsing produces ParseIntError; #[from] supplies the conversion that ? needs to return AmountError::Parse. For "80" with a maximum of 60, parsing succeeds but the range check returns TooLarge. For "40", the caller receives Ok(40). u16 first limits representable values; maximum then limits the amounts this application accepts.

Keep a typed reason, then add application context. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

fn parse_limit(text: &str) -> Result<u32, LimitError> {
    let n: u32 = text.parse()?;
    if n > 60 { return Err(LimitError::TooLarge(n)); }
    Ok(n)
}
let limit = parse_limit(text).context("load sampler limit")?;

The application receives success. Context does not change the number.

Final values: text = 12; n = 12; library = Ok(12); application = limit = 12.

Read every step of the lesson’s example
  1. Step 1, line 1. The library returns a named error enum that another caller can match.

    text = 12.

  2. Step 2, line 2. Parsing succeeded; ? yields the number.

    text = 12; n = 12.

  3. Step 3, line 4. The library returns its validated amount.

    text = 12; n = 12; library = Ok(12).

  4. Step 4, line 6. The application receives success. Context does not change the number.

    text = 12; n = 12; library = Ok(12); application = limit = 12.

thiserror is an external dependency. Add it to the small example project’s manifest with cargo add thiserror; a library exposing this error type keeps the enum in its public interface. The derive requires Debug, and #[from] also identifies the underlying error as a source.

The derive writes code you can also write yourself. A parser shared by several tools, such as a packet, PE (Portable Executable, the header-and-sections layout Windows uses for .exe and .dll files), or byte-pattern parser, can implement the two traits directly:

use std::{error::Error, fmt};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParseError {
Truncated,
TooLarge(usize),
InvalidUtf8,
}
impl fmt::Display for ParseError {
fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Truncated => output.write_str("the input ended too soon"),
Self::TooLarge(length) => {
write!(output, "the declared length ({length} bytes) is too large")
}
Self::InvalidUtf8 => output.write_str("the text is not valid UTF-8"),
}
}
}
impl Error for ParseError {}

Debug shows the exact failure variant while you debug a parser. Display gives the learner a useful sentence such as “the input ended too soon.” Implementing Error lets the scanner, proxy, or PE inspector keep that parser failure underneath its own context instead of flattening it into a string. The portable rust-labs implementation follows this pattern and tests the message produced for an oversized field.

lib.rs

rust-labs/src/ · Rust, 371 lines

Show the codeHide the code
#![doc = "Portable, safe Rust exercises used by Game Hacking Academy."]

use std::{error::Error, fmt};

/// One position in a byte pattern.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PatternByte {
    /// Match this byte exactly.
    Exact(u8),
    /// Match any one byte.
    Any,
}

/// Returns every offset at which `pattern` matches `bytes`.
#[must_use]
pub fn find_pattern(bytes: &[u8], pattern: &[PatternByte]) -> Vec<usize> {
    if pattern.is_empty() || pattern.len() > bytes.len() {
        return Vec::new();
    }

    bytes
        .windows(pattern.len())
        .enumerate()
        .filter_map(|(offset, window)| {
            window
                .iter()
                .zip(pattern)
                .all(|(found, expected)| match expected {
                    PatternByte::Exact(wanted) => found == wanted,
                    PatternByte::Any => true,
                })
                .then_some(offset)
        })
        .collect()
}

/// Errors produced by the small binary cursor.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParseError {
    /// The input ended before the requested value.
    Truncated,
    /// A length field exceeded the caller's limit.
    TooLarge(usize),
    /// A byte sequence was not UTF-8.
    InvalidUtf8,
}

impl fmt::Display for ParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Truncated => formatter.write_str("the input ended before the requested value"),
            Self::TooLarge(length) => write!(
                formatter,
                "the declared payload length ({length} bytes) exceeds the configured limit"
            ),
            Self::InvalidUtf8 => formatter.write_str("the payload is not valid UTF-8"),
        }
    }
}

impl Error for ParseError {}

/// A bounds-checked reader over a borrowed byte slice.
#[derive(Debug)]
pub struct Cursor<'a> {
    bytes: &'a [u8],
    position: usize,
}

impl<'a> Cursor<'a> {
    /// Starts reading at the beginning of `bytes`.
    #[must_use]
    pub const fn new(bytes: &'a [u8]) -> Self {
        Self { bytes, position: 0 }
    }

    /// Returns the current byte offset.
    #[must_use]
    pub const fn position(&self) -> usize {
        self.position
    }

    /// Takes exactly `count` bytes.
    ///
    /// # Errors
    ///
    /// Returns [`ParseError::Truncated`] when the requested range overflows or
    /// extends beyond the input.
    pub fn take(&mut self, count: usize) -> Result<&'a [u8], ParseError> {
        let end = self
            .position
            .checked_add(count)
            .ok_or(ParseError::Truncated)?;
        let output = self
            .bytes
            .get(self.position..end)
            .ok_or(ParseError::Truncated)?;
        self.position = end;
        Ok(output)
    }

    /// Reads an unsigned big-endian 32-bit integer.
    ///
    /// # Errors
    ///
    /// Returns [`ParseError::Truncated`] when fewer than four bytes remain.
    pub fn u32_be(&mut self) -> Result<u32, ParseError> {
        let bytes: [u8; 4] = self
            .take(4)?
            .try_into()
            .map_err(|_| ParseError::Truncated)?;
        Ok(u32::from_be_bytes(bytes))
    }

    /// Reads a big-endian-length-prefixed UTF-8 string.
    ///
    /// # Errors
    ///
    /// Returns [`ParseError::Truncated`] for incomplete data,
    /// [`ParseError::TooLarge`] when the length exceeds `maximum`, or
    /// [`ParseError::InvalidUtf8`] when the payload is not valid UTF-8.
    pub fn string(&mut self, maximum: usize) -> Result<String, ParseError> {
        let length =
            usize::try_from(self.u32_be()?).map_err(|_| ParseError::TooLarge(usize::MAX))?;
        if length > maximum {
            return Err(ParseError::TooLarge(length));
        }
        std::str::from_utf8(self.take(length)?)
            .map(str::to_owned)
            .map_err(|_| ParseError::InvalidUtf8)
    }
}

/// A point or direction in three dimensions.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Vec3 {
    pub x: f32,
    pub y: f32,
    pub z: f32,
}

impl Vec3 {
    /// Subtracts another vector.
    #[must_use]
    pub fn subtract(self, other: Self) -> Self {
        Self {
            x: self.x - other.x,
            y: self.y - other.y,
            z: self.z - other.z,
        }
    }

    /// Rejects non-finite or implausibly large coordinates.
    #[must_use]
    pub fn is_reasonable(self) -> bool {
        [self.x, self.y, self.z]
            .into_iter()
            .all(|value| value.is_finite() && value.abs() < 1_000_000.0)
    }
}

/// Horizontal and vertical camera angles in degrees.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Angles {
    pub yaw: f32,
    pub pitch: f32,
}

/// Calculates the angles from `camera` to `target`.
#[must_use]
pub fn angles_to(camera: Vec3, target: Vec3) -> Option<Angles> {
    if !camera.is_reasonable() || !target.is_reasonable() {
        return None;
    }

    let delta = target.subtract(camera);
    let horizontal = delta.x.hypot(delta.y);
    if horizontal < f32::EPSILON && delta.z.abs() < f32::EPSILON {
        return None;
    }

    Some(Angles {
        yaw: delta.y.atan2(delta.x).to_degrees(),
        pitch: delta.z.atan2(horizontal).to_degrees(),
    })
}

/// Finds the signed shortest turn from one angle to another.
#[must_use]
pub fn shortest_angle_delta(current: f32, desired: f32) -> f32 {
    (desired - current + 180.0).rem_euclid(360.0) - 180.0
}

/// A row-major 4×4 matrix.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4 {
    pub values: [[f32; 4]; 4],
}

/// Normalized depth interval used by the target graphics API.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DepthConvention {
    /// OpenGL-style normalized depth from -1 through 1.
    OpenGl,
    /// Direct3D-style normalized depth from 0 through 1.
    Direct3D,
}

impl DepthConvention {
    /// Returns whether a finite normalized depth lies inside the clip volume.
    #[must_use]
    pub fn contains(self, depth: f32) -> bool {
        depth.is_finite()
            && match self {
                Self::OpenGl => (-1.0..=1.0).contains(&depth),
                Self::Direct3D => (0.0..=1.0).contains(&depth),
            }
    }
}

/// A projected screen position.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScreenPoint {
    pub x: f32,
    pub y: f32,
    pub depth: f32,
}

/// Projects a 3D point with a row-major view-projection matrix.
///
/// The matrix contract assumes visible points produce positive clip `w`.
/// Callers must supply the normalized depth interval used by the target API.
#[must_use]
pub fn world_to_screen(
    point: Vec3,
    matrix: Mat4,
    width: f32,
    height: f32,
    depth_convention: DepthConvention,
) -> Option<ScreenPoint> {
    if !point.is_reasonable()
        || !width.is_finite()
        || !height.is_finite()
        || width <= 0.0
        || height <= 0.0
    {
        return None;
    }

    let m = matrix.values;
    let clip_x = point.x * m[0][0] + point.y * m[0][1] + point.z * m[0][2] + m[0][3];
    let clip_y = point.x * m[1][0] + point.y * m[1][1] + point.z * m[1][2] + m[1][3];
    let clip_z = point.x * m[2][0] + point.y * m[2][1] + point.z * m[2][2] + m[2][3];
    let clip_w = point.x * m[3][0] + point.y * m[3][1] + point.z * m[3][2] + m[3][3];

    if !clip_w.is_finite() || clip_w <= 0.001 {
        return None;
    }

    let ndc_x = clip_x / clip_w;
    let ndc_y = clip_y / clip_w;
    let ndc_z = clip_z / clip_w;
    if !ndc_x.is_finite() || !ndc_y.is_finite() || !depth_convention.contains(ndc_z) {
        return None;
    }

    Some(ScreenPoint {
        x: ndc_x.midpoint(1.0) * width,
        y: (-ndc_y).midpoint(1.0) * height,
        depth: ndc_z,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pattern_matches_exact_and_wildcard_bytes() {
        let bytes = [0x10, 0x20, 0x30, 0x20, 0xFF];
        let pattern = [PatternByte::Exact(0x20), PatternByte::Any];
        assert_eq!(find_pattern(&bytes, &pattern), vec![1, 3]);
    }

    #[test]
    fn pattern_can_start_at_the_last_possible_offset() {
        let bytes = [0x10, 0x20, 0x30];
        let pattern = [PatternByte::Exact(0x20), PatternByte::Exact(0x30)];
        assert_eq!(find_pattern(&bytes, &pattern), vec![1]);
    }

    #[test]
    fn cursor_reads_bounded_text() {
        let bytes = [0, 0, 0, 3, b'r', b'u', b's', b't'];
        let mut cursor = Cursor::new(&bytes);
        assert_eq!(cursor.string(8), Ok(String::from("rus")));
        assert_eq!(cursor.position(), 7);
    }

    #[test]
    fn cursor_rejects_an_oversized_field() {
        let bytes = [0, 0, 4, 0];
        let mut cursor = Cursor::new(&bytes);
        assert_eq!(cursor.string(32), Err(ParseError::TooLarge(1_024)));
    }

    #[test]
    fn cursor_error_explains_the_failed_boundary() {
        let error = ParseError::TooLarge(1_024);
        assert_eq!(
            error.to_string(),
            "the declared payload length (1024 bytes) exceeds the configured limit"
        );
    }

    #[test]
    fn angle_wrap_uses_the_shortest_turn() {
        assert!((shortest_angle_delta(179.0, -179.0) - 2.0).abs() < 0.001);
    }

    #[test]
    fn angle_to_point_on_positive_y_is_ninety_degrees() {
        let origin = Vec3 {
            x: 0.0,
            y: 0.0,
            z: 0.0,
        };
        let target = Vec3 {
            x: 0.0,
            y: 10.0,
            z: 0.0,
        };
        let angles = angles_to(origin, target).expect("different points");
        assert!((angles.yaw - 90.0).abs() < 0.001);
        assert!(angles.pitch.abs() < 0.001);
    }

    #[test]
    fn identity_projection_maps_origin_to_screen_center() {
        let identity = Mat4 {
            values: [
                [1.0, 0.0, 0.0, 0.0],
                [0.0, 1.0, 0.0, 0.0],
                [0.0, 0.0, 1.0, 0.0],
                [0.0, 0.0, 0.0, 1.0],
            ],
        };
        let point = world_to_screen(
            Vec3 {
                x: 0.0,
                y: 0.0,
                z: 0.0,
            },
            identity,
            1920.0,
            1080.0,
            DepthConvention::OpenGl,
        )
        .expect("origin projects");
        assert!((point.x - 960.0).abs() < f32::EPSILON);
        assert!((point.y - 540.0).abs() < f32::EPSILON);
    }

    #[test]
    fn depth_conventions_reject_different_clip_ranges() {
        assert!(DepthConvention::OpenGl.contains(-0.5));
        assert!(!DepthConvention::Direct3D.contains(-0.5));
        assert!(DepthConvention::Direct3D.contains(0.5));
        assert!(!DepthConvention::OpenGl.contains(f32::NAN));
    }
}

An application adds the operation that failed

Section titled “An application adds the operation that failed”

A command-line program usually cares about presenting a useful chain of causes. It can use anyhow at that boundary while the library keeps its structured errors. Context adds the operation the program was attempting.

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = std::env::args()
.nth(1)
.context("usage: amount-viewer <amount>")?;
let value = parse_amount(&text, 60)
.context("could not read the requested amount")?;
println!("accepted amount: {value}");
Ok(())
}

This main uses parse_amount from the preceding listing. Add anyhow with cargo add anyhow. When parse_amount fails, the context describes the attempted operation and preserves the original error underneath it. The Option returned by .nth(1) also accepts .context(...): an absent argument becomes a useful failure instead of an unchecked unwrap().

Context should add information. “Could not read the requested amount” tells the user which operation failed; repeating “error happened” at every layer does not. Keep thiserror enums when callers need to match cases. Use anyhow or a similar context wrapper at an application boundary when a reportable cause chain is the main need.

Need Pattern Limit to remember
Release one owned resource at scope exit An owner or guard with Drop Abort and process termination may skip cleanup.
Temporarily change borrowed local data A guard holds &mut T and saved state Remote writes have additional failure and concurrency conditions.
Report failed completion Explicit finish / close / restore returning Result Drop itself has no returned result.
Let library callers distinguish causes Structured enum, optionally derived with thiserror The public variants become part of the interface.
Explain an application operation anyhow::Context around a Result or Option Add useful context while retaining the original cause.
Stop on the first failure ? Its error must convert to the function’s return error.

The official Drop documentation, thiserror documentation, and anyhow documentation define these contracts. Later lessons apply them to files, process handles, and reversible changes to your own test targets.

Knowledge check

Reason about the concept

A guard owns a saved local value and a temporary replacement. Where can it centralize restoration?

Finished Lesson 3.5?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 3: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.