#276 Jul 2026

276. AtomicBool::swap — Let Exactly One Thread Claim the Job

“Check the flag, then set it” has a gap where two threads both see false — and your run-once code runs twice. swap sets and reads the flag in one atomic step.

Say only one thread should print a deprecation warning, spawn the background worker, or run cleanup. The obvious flag check is broken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::atomic::{AtomicBool, Ordering};

static WARNED: AtomicBool = AtomicBool::new(false);

fn warn_once() {
    // RACE: both threads can see false
    if !WARNED.load(Ordering::Relaxed) {
        WARNED.store(true, Ordering::Relaxed);
        eprintln!("legacy config detected");
    }
}

Same check-then-act race as yesterday’s fetch_max story: two threads load false, both pass the if, both warn. The fix is again a single atomic call:

1
2
3
4
5
6
fn warn_once() {
    // one atomic op: first caller wins
    if !WARNED.swap(true, Ordering::Relaxed) {
        eprintln!("legacy config detected");
    }
}

swap stores the new value and returns the previous one as a single atomic operation. Only the first caller gets false back — everyone else sees true and skips the block. No mutex, no window to slip through:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};

static CLAIMED: AtomicBool = AtomicBool::new(false);
static RUNS: AtomicU32 = AtomicU32::new(0);

std::thread::scope(|s| {
    for _ in 0..8 {
        s.spawn(|| {
            if !CLAIMED.swap(true, Ordering::Relaxed) {
                RUNS.fetch_add(1, Ordering::Relaxed);
            }
        });
    }
});
assert_eq!(RUNS.load(Ordering::Relaxed), 1);

Relaxed is fine while the flag itself is the only shared state. The moment the winning thread writes data that losers will read, you need Acquire/Release ordering — or just reach for OnceLock, which handles that (and blocking until init finishes) for you.

Where swap beats Once/OnceLock: it produces no value, it never blocks the losers, and you can re-arm it — store(false, ...) resets the flag for the next round. Think “claim ticket”, not “lazy init”.

Works on every Atomic* type, stable since Rust 1.0.

#275 Jul 2026

275. fetch_max — Track a High-Water Mark Without the Race

“Check if it’s bigger, then store it” is two operations — and another thread can sneak between them. fetch_max is the whole thing in one atomic call.

Tracking peak latency across worker threads looks harmless:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::atomic::{AtomicU64, Ordering};

static PEAK: AtomicU64 = AtomicU64::new(0);

fn record(latency: u64) {
    // RACE: another thread can store
    // between the load and the store
    if latency > PEAK.load(Ordering::Relaxed) {
        PEAK.store(latency, Ordering::Relaxed);
    }
}

Thread A reads 10, decides its 31 is a new peak. Thread B reads 10, decides its 50 is too. B stores 50, then A stores 31 — your peak just went down. Classic check-then-act race, and it’ll pass every test on your laptop.

The fix is one line:

1
2
3
fn record(latency: u64) {
    PEAK.fetch_max(latency, Ordering::Relaxed);
}

fetch_max compares and stores as a single atomic operation — no window for another thread to slip through. Like every fetch_* method it returns the previous value, which gives you new-record detection for free:

1
2
3
4
let prev = PEAK.fetch_max(latency, Ordering::Relaxed);
if prev < latency {
    println!("new record: {latency}ms");
}

fetch_min is the mirror image for low-water marks. One gotcha: initialize it to the identity for min — u64::MAX, not 0 — or nothing will ever be smaller than your starting value:

1
2
3
4
5
let floor = AtomicU64::new(u64::MAX);
for l in [12, 5, 9] {
    floor.fetch_min(l, Ordering::Relaxed);
}
assert_eq!(floor.load(Ordering::Relaxed), 5);

This morning’s bite 274 covered fetch_update for arbitrary update rules — but check the shelf first: if your rule is just “keep the bigger one”, fetch_max is a single hardware-friendly call with no closure and no retry loop.

Available on all integer Atomic* types, stable since Rust 1.45.

#274 Jul 2026

274. fetch_update — The CAS Loop You Keep Writing by Hand

There’s no fetch_add variant that stops at a cap, so you write a compare_exchange_weak loop by hand. fetch_update is that loop, done right, in one call.

The Atomic* types ship fetch_add, fetch_or, fetch_min — but the moment your update rule isn’t one of those, you’re hand-rolling a compare-and-swap loop. A rate limiter that counts hits but saturates at a cap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::atomic::{AtomicU32, Ordering};

const CAP: u32 = 3;
let hits = AtomicU32::new(0);

let mut cur = hits.load(Ordering::Relaxed);
while cur < CAP {
    match hits.compare_exchange_weak(
        cur,
        cur + 1,
        Ordering::Relaxed,
        Ordering::Relaxed,
    ) {
        Ok(_) => break,
        Err(actual) => cur = actual,
    }
}

Easy to get subtly wrong: forget to reload on failure, spin forever, or check the cap on the stale value. fetch_update owns the loop — you supply only the transform, returning Some(new) to store or None to leave it alone:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let hits = AtomicU32::new(0);

for _ in 0..5 {
    let _ = hits.fetch_update(
        Ordering::Relaxed,
        Ordering::Relaxed,
        |n| (n < CAP).then(|| n + 1),
    );
}

assert_eq!(hits.load(Ordering::Relaxed), CAP);

The return value tells you what happened: Ok(prev) if your closure returned Some and the store went through, Err(prev) if it returned None — either way you get the previous value, so “did we hit the cap?” is just .is_err().

Two things to keep in mind. The closure can run more than once under contention (another thread changed the value between your load and the swap), so keep it pure — no side effects. And it takes two Orderings: the first for the successful store, the second for the loads; (Relaxed, Relaxed) is fine for counters.

Works on every Atomic* type, stable since Rust 1.45.

#273 Jul 2026

273. remove_file — Deleting Something That Might Already Be Gone

create_dir_all (bite 272) is idempotent by design. Its deletion twins are not: remove_file and remove_dir_all fail with NotFound — and the exists() guard is the same race all over again.

Cleanup code usually doesn’t care whether the thing was there:

1
2
// Error: NotFound — first run, nothing to clean.
fs::remove_file("cache/state.bin")?;

So people reach for the same guard the whole filesystem run has been warning about:

1
2
3
if path.exists() {
    fs::remove_file(path)?; // another process can win the race
}

Check-then-act again (bite 270): if anything else deletes the file between exists() and remove_file, you’re back to the NotFound error the guard was supposed to prevent.

The fix is to call the operation unconditionally and treat “already gone” as success:

1
2
3
4
5
6
7
8
use std::io::{self, ErrorKind};

fn remove_if_exists(path: &Path) -> io::Result<()> {
    match fs::remove_file(path) {
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
        other => other,
    }
}

Now it’s safe to call on every shutdown, every retry, every run — the outcome you wanted (“the file is not there”) is the same whether the call deleted it or found nothing.

Three details worth knowing:

  • Same pattern for directories. fs::remove_dir_all also errors on a missing path; wrap it the same way. (Since Rust 1.62 its internals are TOCTOU-hardened on most platforms, so the recursive walk doesn’t race — but the top-level NotFound is still yours to handle.)
  • Only swallow NotFound. A PermissionDenied or “directory not empty” is a real failure — the match above still propagates everything else, which a bare let _ = fs::remove_file(...) would silently eat.
  • Deletion may not be immediate. On Windows, a file open by another process is marked for deletion and disappears when the last handle closes — so don’t follow a successful remove_file with code that assumes the name is instantly reusable.

The asymmetry is worth remembering: create_dir_all bakes idempotence in, the remove_* family makes you opt in with one ErrorKind match.

#272 Jul 2026

272. fs::create_dir_all — mkdir -p Without the exists() Check

fs::create_dir fails if the parent is missing and fails if the directory already exists. create_dir_all handles both — recursive and idempotent in one call.

The single-level version trips over both ends of the problem:

1
2
3
4
5
// Error: NotFound — `cache/images` doesn't exist yet.
fs::create_dir("cache/images/thumbs")?;

// Error: AlreadyExists — on the second run.
fs::create_dir("cache")?;

So people reach for the guard:

1
2
3
if !dir.exists() {
    fs::create_dir(dir)?; // still one level at a time
}

That’s a check-then-act race (bite 270 again): another process can create the directory between the check and the call, and you still can’t build a nested path in one go.

create_dir_all is the whole fix:

1
2
fs::create_dir_all("cache/images/thumbs")?; // creates all three
fs::create_dir_all("cache/images/thumbs")?; // Ok — already there

It creates every missing component along the path, and it returns Ok when the directory already exists — no exists() probe, no AlreadyExists special-casing, safe to call on every startup.

Three details worth knowing:

  • It’s concurrency-tolerant. If another thread or process creates one of the components mid-walk, create_dir_all shrugs and keeps going. The exists() guard can’t promise that.
  • A file in the way is still an error. If cache/images exists but is a regular file, you get an error instead of silent breakage — exactly what you want.
  • It won’t touch what’s already there. Permissions of existing directories are left alone; only newly created components get the default (or DirBuilder-configured) mode.

Same moral as the rest of this filesystem run: don’t interrogate the filesystem and then act on the answer — call the operation that already handles every case.

#271 Jul 2026

271. fs::rename — Replace a File Atomically, Never Expose a Half-Write

fs::write truncates the file before writing. Crash halfway — or get read halfway — and the world sees a torn file. fs::rename is the atomic swap that fixes it.

The obvious way to save state overwrites in place:

1
2
// Truncates state.json to 0 bytes, then writes.
fs::write("state.json", &json)?;

Between the truncate and the final byte, state.json is incomplete. A concurrent reader gets garbage; a crash or power loss leaves it that way permanently. For a config file, a cache, or anything another process watches, that window is a real bug.

The fix is the classic write-then-rename dance:

1
2
fs::write("state.json.tmp", &json)?;   // build the full file aside
fs::rename("state.json.tmp", "state.json")?; // atomic swap

On every major platform, rename over an existing file is atomic: any observer sees either the old complete file or the new complete file, never a mix and never a missing file. If the process dies before the rename, the old file is untouched and only a .tmp straggler is left behind.

Three details worth knowing:

  • Keep the temp file in the same directory. rename can’t cross filesystems — a temp file in /tmp and a target on another mount fails with an error (ErrorKind::CrossesDevices). Same directory guarantees same filesystem, and keeps the swap atomic.
  • Durability needs one more step. Atomic ≠ flushed. If you need the data to survive power loss, open the temp file yourself and call sync_all() before renaming, instead of using fs::write.
  • Unique temp names matter under concurrency. Two processes writing state.json.tmp will trample each other. Give each writer its own name (PID, random suffix) — and create it with File::create_new (bite 169) so collisions fail loudly.

Same moral as try_exists in bite 270: don’t check-then-act on the filesystem — make the filesystem do the transition in one atomic step.

#270 Jul 2026

270. Path::try_exists — exists() Can't Tell "Missing" From "Couldn't Check"

path.exists() returns false for a file that’s missing — and for a file that’s right there behind a directory you can’t read. try_exists() finally separates the two.

Path::exists() is implemented as “did fs::metadata succeed?”. Any failure — permission denied on a parent directory, an I/O error, an interrupted syscall — collapses into false:

1
2
3
4
5
6
let cfg = Path::new("/etc/app/config.toml");

if !cfg.exists() {
    // Missing? Or unreadable? No way to know.
    write_default_config(cfg)?;
}

If the app lacks permission to look inside /etc/app, this code concludes the config is absent and happily tries to overwrite it — or silently skips loading settings that exist. The error didn’t go away; it was converted into wrong control flow.

Path::try_exists() (stable since 1.63) returns io::Result<bool>, so “no” and “don’t know” are different answers:

1
2
3
4
5
match cfg.try_exists() {
    Ok(true)  => load_config(cfg)?,
    Ok(false) => write_default_config(cfg)?,
    Err(e)    => return Err(e.into()), // surface it
}

Ok(false) now means the path is definitely absent — the lookup succeeded and found nothing. Everything else is an Err you can log, retry, or propagate with ?.

Two details worth knowing:

  • Broken symlinks report Ok(false)try_exists() follows symlinks, so a link pointing at nothing counts as “the target doesn’t exist”. Use symlink_metadata if the link itself is what you care about.
  • TOCTOU still applies — any exists-then-act sequence can race with other processes. For “create only if absent”, skip the check entirely and use OpenOptions::new().create_new(true) (bite 169), which makes the filesystem decide atomically.

Same theme as file_type() in bite 269: the std methods returning plain bool are quietly eating errors, and each has a Result sibling that doesn’t.

#269 Jul 2026

269. DirEntry::file_type — Stop Paying a stat() Per Entry

Filtering read_dir entries with path.is_file() costs a full metadata lookup per entry — and quietly reports false when the check fails. DirEntry::file_type() is free on most platforms and tells you when it couldn’t answer.

This morning’s bite 268 collected read_dir entries into paths. The next thing most code does is filter them, and the obvious way has two hidden problems:

1
2
3
4
5
6
for entry in fs::read_dir("logs")? {
    let path = entry?.path();
    if path.is_file() {          // stat() per entry
        process(&path);
    }
}

First, Path::is_file() asks the filesystem for full metadata — an extra syscall for every entry, on top of the directory read that already happened. Second, it returns plain bool: if the metadata lookup fails (permissions, a file deleted mid-loop), you get false, indistinguishable from “this is a directory”. Errors vanish.

The entry you already have knows its own type:

1
2
3
4
5
6
for entry in fs::read_dir("logs")? {
    let entry = entry?;
    if entry.file_type()?.is_file() {
        process(&entry.path());
    }
}

Two things improved:

  • no extra syscall — on Linux, readdir returns each entry’s type alongside its name (d_type), and Windows directory entries carry file attributes. file_type() just hands you what the directory read already produced. (A few filesystems don’t fill d_type in; std falls back to a metadata call only then.)
  • errors surfacefile_type() returns io::Result<FileType>, so a failed lookup is a ?-able error instead of a silent false

One behavioral difference to know: file_type() does not follow symlinks — a symlink to a file reports is_symlink(), not is_file(), while Path::is_file() traverses the link and reports on the target. If following symlinks is what you actually want, that’s the one case for fs::metadata(entry.path()) — deliberately, not by accident.

#268 Jul 2026

268. fs::read_dir — The Filesystem Doesn't Sort for You

fs::read_dir looks like ls, but ls sorts its output and read_dir doesn’t. You get entries in whatever order the filesystem feels like — and that order changes between machines.

The docs say it plainly: “The order in which this iterator returns entries is platform and filesystem dependent.” On your dev box the entries may happen to come back alphabetical; on ext4 with hashed directories, or in CI, they won’t. Code like this works until it doesn’t:

1
2
3
4
// Process migrations in order... on SOME machines
for entry in fs::read_dir("migrations")? {
    apply(&entry?.path()); // 002 may run before 001
}

The fix is to collect and sort — PathBuf is Ord, so it’s two lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::fs;

let mut paths: Vec<_> = fs::read_dir("migrations")?
    .map(|res| res.map(|e| e.path()))
    .collect::<Result<Vec<_>, _>>()?;
paths.sort();

for p in &paths {
    apply(p); // 001, then 002, on every machine
}

Two details worth noticing along the way:

  • each item the iterator yields is a Result — reading an individual entry can fail even after opening the directory succeeded. Collecting into Result<Vec<_>, _> surfaces the first error instead of hiding it in the loop
  • DirEntry::path() hands back the entry joined to the base you passed in (migrations/001.sql, not 001.sql) — ready to open, no join needed

Sorting PathBufs compares component-wise, which is what you want for nested listings. Just remember it’s lexicographic: 10.sql sorts before 2.sql — zero-pad your file names or sort by a parsed key.

#267 Jul 2026

267. Path::file_name — rsplit('/') Hands You Empty Strings and '..'

Grabbing the last path segment with rsplit('/') returns "" for logs/ and ".." for logs/.. — two values that were never file names. Path::file_name reasons in components and returns None instead.

The string version looks harmless:

1
2
3
4
5
6
7
8
let name = "logs/app.log".rsplit('/').next();
assert_eq!(name, Some("app.log")); // so far so good

let name = "logs/".rsplit('/').next();
assert_eq!(name, Some("")); // empty "file name"

let name = "logs/..".rsplit('/').next();
assert_eq!(name, Some("..")); // that's a traversal, not a file

Feed either of those into a join or a delete-by-name and you have a bug — or worse. Path::file_name works on components, not characters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::ffi::OsStr;
use std::path::Path;

let p = Path::new("logs/app.log");
assert_eq!(p.file_name(), Some(OsStr::new("app.log")));

// Trailing slash? Still the same last component
let dir = Path::new("logs/");
assert_eq!(dir.file_name(), Some(OsStr::new("logs")));

// '..' is not a name — you get None, not a footgun
assert_eq!(Path::new("logs/..").file_name(), None);
assert_eq!(Path::new("/").file_name(), None);

The rules it applies:

  • the last component wins, so a trailing separator changes nothing: logs/logs
  • a path ending in .. has no final name to give you → None
  • the root itself has no name → None

Like Path::extension from bite 266, the return type is Option<&OsStr>: the “there is no file name here” case arrives as a None the compiler makes you handle, instead of an empty string or a .. sneaking into your filesystem calls.