DEV Community

Cover image for ratatop day 3: disks, statvfs, and my first unsafe block
Athreya aka Maneshwar
Athreya aka Maneshwar

Posted on

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

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.

There is no /proc file for this

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.

My first unsafe block

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)))
}
Enter fullscreen mode Exit fullscreen mode

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.

Your filesystem is lying to you by exactly 5%

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 filesystem
  • f_bfree: blocks that are genuinely unused
  • f_bavail: blocks available to an unprivileged process

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
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode

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.

Which mounts are even real?

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

The kernel escapes your mount paths

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
Enter fullscreen mode Exit fullscreen mode

\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.

I/O comes from somewhere else entirely

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Swap is a disk that isn't one

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
Enter fullscreen mode Exit fullscreen mode

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.

The ratatui side was the easy part

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

What's next

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:

GitHub logo lovestaco / ratatop

Keep an eye on what's cooking in your system

ratatop


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.

Status

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

ratatop running

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.

Design notes

  • Data source: /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.
  • Graphs are the hard part. btop's look comes from braille sub-cell plotting with a color gradient applied along the value axis, not from a stock chart widget. Expect…

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:

GitHub logo HexmosTech / git-lrc

Free, Micro AI Code Reviews That Run on Git Commit




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 before it lands. 60-second setup. Completely free.

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…

Top comments (1)

Collapse
 
innovationsiyu profile image
Siyu

The rule and its exception are the best part of this post. 'Only read /proc and /sys, never shell out' is a clean constraint, and discovering that disk usage literally does not exist in any file and requires a syscall is the kind of understanding you cannot get from following the rule blindly. The ext4 reserved blocks discovery is the same shape. You followed the data and found out why the naive reading was five points wrong. This is exactly the kind of working style that traditional profiles cannot capture. A resume says 'Rust developer'. It does not say 'sets strict constraints for themselves, then investigates why the exceptions exist, and documents the reasoning for others'. The impression management process in Opportunity Skill is designed to capture precisely this layer. Your agent observes how you approach problems, what rules you set, where you make exceptions and why, and distils those patterns into structured impressions. Another agent searching for 'a systems programmer who documents edge cases and reasons from first principles rather than copying solutions' would match against that signal. The SAFETY comment discipline in unsafe Rust is a perfect metaphor for the whole approach.