cd /news/developer-tools/ratatop-day-3-disks-statvfs-and-my-f… · home topics developer-tools article
[ARTICLE · art-81692] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

ratatop day 3: disks, statvfs, and my first unsafe block

Maneshwar, a developer building git-lrc, a Micro AI code reviewer, documented day three of creating a btop-style system monitor in Rust with ratatui. To display disk usage, he chose to call the statvfs syscall directly via the libc crate, using his first unsafe block, rather than shelling out to df. He highlights the importance of CString for NUL-terminated strings, the safety of mem::zeroed, and the necessity of SAFETY comments in unsafe code.

read10 min views1 publishedJul 31, 2026

Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback.

Day three of building a btop-style system monitor in Rust with ratatui.

Day one was the CPU box.

Day two was memory, where I learned that MemFree

is a number nobody should look at.

Today: disks.

And this one broke a rule I had set for myself on day one.

My rule was simple.

Read /proc

and /sys

, never shell out to another tool.

CPU came from /proc/stat

.

Memory came from /proc/meminfo

. Disks would come from... something.

I went looking for the file. /proc/mounts

tells you what is mounted.

/proc/diskstats

tells you how much I/O happened.

/sys/block/*

tells you device sizes.

None of them tell you how full a filesystem is.

That number does not exist as a file anywhere. It only exists behind a syscall: statvfs(2)

.

The kernel computes it on demand from the filesystem's own metadata, and the only way to ask is to actually make the call.

So I had two options.

Shell out to df

and parse it, which breaks my rule and drags in output-format fragility across distros.

Or make the syscall myself, which means the libc

crate, which means unsafe

.

I picked the syscall.

df

itself is just a statvfs

wrapper with a printf on top.

Spawning a process to read a number the kernel would hand me directly is silly.

Rust's standard library has no statvfs

.

So this is FFI territory.

fn usage(mount: &str) -> Option<(u64, u64)> {
    let path = CString::new(mount).ok()?;
    // SAFETY: `path` is a valid NUL-terminated string that outlives the call,
    // and `vfs` is fully initialised by statvfs before we read it. A failed call
    // is reported through the return value, which we check.
    let vfs = unsafe {
        let mut vfs: libc::statvfs = std::mem::zeroed();
        if libc::statvfs(path.as_ptr(), &mut vfs) != 0 {
            return None;
        }
        vfs
    };

    let frsize = vfs.f_frsize as u64;
    let total = (vfs.f_blocks as u64).saturating_mul(frsize);
    let free = (vfs.f_bavail as u64).saturating_mul(frsize);
    Some((total, free.min(total)))
}

Three things worth pointing out, because they are the parts I had to actually think about.

** CString::new is not optional.** C wants a NUL-terminated string. Rust

&str

is not NUL-terminated, it carries a length. If you hand C a pointer to raw Rust string bytes, it reads until it happens to find a zero byte, which could be well past the end of your string. CString

does the conversion properly, and it returns a Result

because a Rust string containing an interior NUL cannot be represented at all.** mem::zeroed() looks worse than it is.** We are handing C a buffer to fill in.

statvfs

writes every field before returning success. Zeroing first means that even in some bizarre partial-write scenario we get zeros rather than whatever was on the stack.The SAFETY comment is not decoration. Rust convention is that every

unsafe

block gets a comment explaining why it is actually safe. Writing that comment is a genuinely useful exercise. It forces you to enumerate your assumptions, and if you cannot write the comment, that is your answer about whether the code is correct.The nice part is that the unsafety is contained.

One function, ten lines, and a safe Option<(u64, u64)>

comes out the other end. Everything upstream of it is ordinary safe Rust that cannot tell the difference.

Now the fun part.

statvfs

gives you three block counts, and picking the wrong one gives you the wrong answer.

f_blocks

: total blocks in the filesystemf_bfree

: blocks that are genuinely unusedf_bavail

: blocks available Those last two are not the same, and the gap is bigger than you would guess. Real numbers from my root filesystem:

total   91.1 GiB
bfree   23.7 GiB
bavail  19.0 GiB

That is 4.67 GiB, exactly 5.1% of the disk, that is free but that I am not allowed to use.

ext4 reserves 5% of the filesystem for root by default. The idea is that if a runaway process fills the disk, root still has room to log in and clean up, and the filesystem has room to avoid the fragmentation that comes with running completely full.

Which number you subtract changes the headline figure:

used via f_bavail   79.1%
used via f_bfree    74.0%

Five percentage points, from one field name.

btop uses f_bavail

, and so does df

.

That is the honest choice for a monitor: report the space you can actually use, and treat root's reserve as spoken for.

Showing 74% when df

says 79% would just make the tool look broken.

Next problem. /proc/self/mounts

on my laptop has around 40 entries.

Four of them are disks.

The rest is proc

, sysfs

, cgroup2

, tmpfs

, a dozen snap squashfs images, and a couple of FUSE mounts from the desktop session.

The obvious move is a blocklist of filesystem types to skip.

I started writing one, then realised the kernel already publishes exactly this information:

$ cat /proc/filesystems
nodev   sysfs
nodev   tmpfs
nodev   proc
nodev   cgroup2
    ext4
    vfat

Anything flagged nodev

is not backed by a block device.

That is the kernel telling you, authoritatively, which filesystem types cannot possibly be a disk.

So instead of maintaining a list that goes stale, I read theirs:

let mut types: Vec<String> = raw
    .lines()
    .filter_map(|line| line.strip_prefix("nodev"))
    .map(|name| name.trim().to_string())
    .collect();

New pseudo-filesystem lands in a future kernel? Excluded automatically, no code change.

There is a second filter that does a lot of work for one line: the mount's device has to start with /

. A real partition is /dev/nvme0n1p2

.

Pseudo-filesystems put a bare name there instead, like proc

or gvfsd-fuse

. No slash, not a disk.

Small one, but it would have been a confusing bug.

Mount a disk at a path with a space in it, and /proc/self/mounts

does not give you the space:

/dev/sdb1 /mnt/my\040disk ext4 rw,relatime 0 0

\040

is octal for a space. The kernel escapes spaces, tabs, newlines and backslashes this way, because the file is whitespace-separated and an unescaped space would make the line unparseable.

If you pass that raw string to statvfs

, it fails. No such path exists. The disk silently vanishes from your monitor and you have no idea why.

So you decode it before using it. Four characters starting with a backslash, parse the next three as octal, emit the byte.

Usage is a syscall. Throughput is back to reading files.

btop reads /sys/block/<device>/stat

, which gives you a device's cumulative counters:

234345 84965 16346864 796898 486240 189824 21513416 23255863 0 2406558 24197776

The fields I care about are index 2 (sectors read), index 6 (sectors written), and index 9 (io_ticks

, milliseconds the device spent with a request in flight).

Two traps here.

A sector is always 512 bytes in this file. Not the device's real block size. My NVMe drive uses 4096-byte blocks, but these counters are still expressed in 512-byte units, for backwards compatibility with software written when that was universal. Multiply by 4096 and your throughput numbers come out eight times too high.

Partitions are nested. /sys/block/nvme0n1/stat

exists for the whole drive. The partition I actually mounted lives at /sys/block/nvme0n1/nvme0n1p2/stat

, one level deeper. So the lookup checks the top level first, then scans one level down.

io_ticks

is the nice one. It is busy-milliseconds, so dividing by the elapsed wall-clock milliseconds gives you utilisation directly:

let busy_ms = counters.io_ticks.saturating_sub(previous.io_ticks) as f64;
disk.io_util = (busy_ms / (seconds * 1000.0) * 100.0).clamp(0.0, 100.0);

Same delta pattern as the CPU box on day one. Third time I have written saturating_sub

on a monotonic counter in this project, and it has earned its keep every time: devices get unplugged and re-added, and counters reset to zero when they do.

btop lists swap in the disks box, which surprised me until it made sense. You care about how full it is, in the same way and for the same reasons.

But it has no filesystem, so statvfs

is meaningless. And it may be a file, a partition, or several of both, so there is no single block device to read I/O from.

It comes from /proc/meminfo

instead:

SwapTotal:      21092348 kB
SwapFree:       19857688 kB

So swap is a row that shares the layout but skips two of the three data sources.

In my code it is a Disk

with is_swap: true

, and the render function checks that flag before drawing the I/O graph.

Drawing an empty graph there would imply a measurement of zero, when the truth is there is no measurement at all.

The distinction between "zero" and "unknown" is one of those things that seems pedantic until your tool confidently reports the wrong thing.

Genuinely almost nothing to say here, which is the point.

Three rows per disk, laid out with the same Layout

calls as the other boxes:

let [name_row, io_row, used_row] = Layout::vertical([
    Constraint::Length(1),
    Constraint::Length(1),
    Constraint::Length(1),
])
.areas(area);

The Meter

widget from day one and the BrailleGraph

from day one both dropped straight in with zero changes. Third box, same two widgets. That is the payoff for writing them as plain structs with one render

method.

One layout decision worth mentioning: the box only draws disks that completely fit.

let capacity = (inner.height / ROWS_PER_DISK) as usize;

Integer division, so a disk with room for only two of its three rows is not drawn at all. A half-rendered disk looks like a bug, whereas a missing one just looks like a small window.

Network. /proc/net/dev

is another counter file, so the delta machinery is already there, but auto-scaling units across five orders of magnitude will be new.

Then processes, which is the big one.

Reading /proc/[pid]/stat

for every process, every tick, and sorting it.

Code so far:

A reimplementation of btop in Rust, built on ratatui.

The goal is a resource monitor that feels like btop: the four-box layout, braille graphs with gradient fills, mouse-driven navigation, and a themable process manager, but written as a modern Rust TUI.

The CPU box works. It samples real data and renders btop's layout:

Per-core braille history graphs, a gradient load bar with sub-cell resolution frequency, package temperature, battery, and load averages.

Everything else, the memory, network, and process boxes, is still to come.

/proc

and /sys

on Linux first, read directly rather than shelling out. btop's src/linux/btop_collect.cpp

is the reference for what to read and how often; the BSD/macOS collectors are out of scope for v1.Today's lesson, if there is one: the rule I set myself ("only read files, never shell out") was a good rule that had exactly one legitimate exception, and finding out why it was an exception taught me more than following it would have.

Sometimes the file you are looking for does not exist because the number was never a file.

AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.

⭐ Star it on GitHub:

| 🇩🇰 Dansk | 🇪🇸 Español | 🇮🇷 Farsi | 🇫🇮 Suomi | 🇯🇵 日本語 | 🇳🇴 Norsk | 🇵🇹 Português | 🇷🇺 Русский | 🇦🇱 Shqip | 🇨🇳 中文 | 🇮🇳 हिन्दी |

GenAI today is a race car without brakes. It accelerates fast -- you describe something, and large blocks of code appear instantly. But AI agents silently break things: they remove logic, relax constraints, introduce expensive cloud calls, leak credentials, and change behavior -- without telling you. You often find out in production.

** git-lrc is your braking system.** It hooks into

git commit

and runs an AI review on every diff In short, git-lrc helps Prevent Outages, Breaches, and Technical Debt Before They Happen

At a glance: 10 risk categories · 100+ failure patterns tracked · every commit…

── more in #developer-tools 4 stories · sorted by recency
── more on @maneshwar 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/ratatop-day-3-disks-…] indexed:0 read:10min 2026-07-31 ·