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

> Source: <https://dev.to/lovestaco/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block-496e>
> Published: 2026-07-31 10:17:57+00:00

*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](https://ratatui.rs).

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.

``` php
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 filesystem`f_bfree`

: blocks that are genuinely unused`f_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:

``` bash
$ 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:

``` js
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:

``` js
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.

``` js
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](https://github.com/aristocratos/btop) in Rust, built on
[ratatui](https://ratatui.rs).

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](https://github.com/HexmosTech/git-lrc/readme/README.da.md) | [🇪🇸 Español](https://github.com/HexmosTech/git-lrc/readme/README.es.md) | [🇮🇷 Farsi](https://github.com/HexmosTech/git-lrc/readme/README.fa.md) | [🇫🇮 Suomi](https://github.com/HexmosTech/git-lrc/readme/README.fi.md) | [🇯🇵 日本語](https://github.com/HexmosTech/git-lrc/readme/README.ja.md) | [🇳🇴 Norsk](https://github.com/HexmosTech/git-lrc/readme/README.nn.md) | [🇵🇹 Português](https://github.com/HexmosTech/git-lrc/readme/README.pt.md) | [🇷🇺 Русский](https://github.com/HexmosTech/git-lrc/readme/README.ru.md) | [🇦🇱 Shqip](https://github.com/HexmosTech/git-lrc/readme/README.sq.md) | [🇨🇳 中文](https://github.com/HexmosTech/git-lrc/readme/README.zh.md) | [🇮🇳 हिन्दी](https://github.com/HexmosTech/git-lrc/readme/README.hi.md) |

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](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · [100+ failure patterns tracked](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · every commit…
