Files
omarchycn/bin/omarchy-disk-speedtest
T
2521b11fdd Add a disk speed test under a new Trigger > Tests menu (#6607)
* Extract the speed test gauge cluster into a shared SpeedTestOverlay

The dial cluster -- scrim, ignition sweep, self-ranging dials, run-again
button -- moves from the network speed test panel into qs.Ui with the
labels, unit, title, scale stops, and layer namespace as parameters, so
other measurements can wear the same cluster. The network panel keeps
its process handling and becomes a thin dressing of the overlay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a disk speed test and move speed tests under Trigger > Tests

omarchy-disk-speedtest streams live write and read MB/s once a second by
sampling the backing block device's kernel I/O counters while dd workers
generate the traffic, the same way the network test samples the
interface counters. The stress data is an incompressible urandom chunk
staged in RAM, written with fdatasync per pass and fadvise drop-behind:
O_DIRECT silently falls back to the page cache on btrfs, and zeros never
reach a compressed filesystem at all. Scratch files are created
exclusively per invocation and removed even when a dismissal interrupts
the run mid-phase.

The omarchy.disk-speedtest panel dresses the shared SpeedTestOverlay
with write and read dials in MB/s, titled with the hardware model of
the disk under test.

The menu grows a Trigger > Tests submenu holding the new Disk Speed
Test and the Network Speed Test, which moves there from Setup > Network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the disk speed test reproducible, direct, and read-first

Successive runs could swing 40% because the settled figure was just the
last one-second sample of a single buffered dd stream, taken while btrfs
copy-on-write churned the extent allocator on every rewrite pass and the
fadvise cache-eviction dance stayed advisory.

The test files are now marked NOCOW, which is what makes O_DIRECT truly
direct on btrfs -- with checksums on it silently falls back to the page
cache -- and lets every rewrite land in place. Four parallel workers per
phase give the device a queue depth it can stretch out on, and the
figure the dial settles on is the steady-state average over the whole
phase with the first warm-up second excluded, not whatever rate the
final second happened to catch. Together this tightens successive runs
from +/-40% to a few percent of each other, at the device's actual
spec throughput.

The read phase now runs first, staged against freshly written files,
with the read dial on the left. Workers also only loop while the main
script lives, so a dismissal that loses the kill race can no longer
leave an orphan hammering the disk forever, and any worker dying before
the deadline fails the run instead of passing off partial figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the menu aliases from the speed test entries

Aliases are reserved for established alternate names users already
type, kept for compatibility -- not something new entries pick up by
default. Note that in the menu definition header and AGENTS.md so the
next entry doesn't repeat it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Group dial readouts with thousands separators

A gen5 disk reads five digits; 11,450 scans, 11450 doesn't. Uses the
locale's grouping separator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Trim redundant overlay props and unused imports from the speed test panels

The network panel restated the overlay's default unit and scale stops,
and both panels carried imports and an omarchyPath property nothing
uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Publish the specific speed test error whichever handler fires last

Process exit and stderr stream-finished have no guaranteed order, so a
failure that beat the collector showed the generic message forever even
when the command emitted an actionable one; the collector now replaces
it once the text lands. Also stop clearing the error on every stdout
line: only a new run should do that, or buffered output delivered after
a failed exit erases the failure message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Arm the disk speed test cleanup before any scratch file exists

A preflight failure -- tmpfs target, missing device statistics, not
enough free space -- exited between mktemp and the trap, leaking the
scratch files. Cleanup also now unlinks before stopping the workers and
sweeps once more after, so even a cleanup cut short by an impatient
SIGKILL leaves no names behind and a final worker pass cannot recreate
one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:04:42 +02:00

233 lines
7.1 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Measure live disk read and write speed
# omarchy:args=[target-dir]
set -e
if [[ -n ${1:-} && ! -d $1 ]]; then
echo "Usage: omarchy-disk-speedtest [target-dir]" >&2
exit 2
fi
target_dir="${1:-${XDG_CACHE_HOME:-$HOME/.cache}/omarchy}"
phase_seconds=8
parallel=4
chunk_mb=4
file_mb=256
mkdir -p "$target_dir"
worker_pids=()
chunk_file=""
test_files=()
stop_workers() {
local pid
for pid in "${worker_pids[@]}"; do
[[ -n $pid ]] || continue
pkill -TERM -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
done
for pid in "${worker_pids[@]}"; do
[[ -n $pid ]] || continue
wait "$pid" 2>/dev/null || true
done
worker_pids=()
}
alive_workers() {
local pid count=0
for pid in "${worker_pids[@]}"; do
kill -0 "$pid" 2>/dev/null && count=$((count + 1))
done
echo "$count"
}
cleanup() {
# Unlink before stopping the workers, so even a cleanup cut short by an
# impatient SIGKILL has already taken the names off the filesystem. A live
# write worker's next dd pass recreates its file by name, so sweep again
# once they are gone.
rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}"
stop_workers
rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}"
}
# Armed before any scratch file exists, so a failed preflight check below
# cannot leak them.
trap cleanup EXIT
trap 'exit 143' TERM INT
# Exclusive per-invocation scratch files: predictable names could clobber a
# user's file, follow a planted symlink, or let overlapping runs delete each
# other's active files out from under the measurement. Each worker gets its
# own on-disk file so the phases run at a queue depth the device can actually
# stretch out on, like the network test's parallel curl workers.
#
# The files are marked NOCOW where the filesystem supports it (btrfs), which
# turns off copy-on-write, checksums, and compression for them. That is what
# makes O_DIRECT truly direct on btrfs -- with checksums on it silently falls
# back to the page cache -- and it makes every rewrite land in place instead
# of churning the extent allocator, which run-to-run reproducibility depends
# on.
chunk_file=$(mktemp /dev/shm/omarchy-disk-speedtest-XXXXXX.src)
for (( i = 0; i < parallel; i++ )); do
file=$(mktemp "$target_dir/disk-speedtest-XXXXXX.dat")
chattr +C "$file" 2>/dev/null || true
test_files+=("$file")
done
format_rate() {
awk -v value="$1" 'BEGIN {
if (value <= 0) print "0.0"
else if (value < 10) printf "%.1f\n", value
else printf "%.0f\n", value
}'
}
# Resolve the block device backing the target directory, so throughput can be
# sampled from its kernel I/O counters the same way the network speed test
# samples the interface counters.
source_dev=$(findmnt -no SOURCE --target "$target_dir" 2>/dev/null)
source_dev=${source_dev%%\[*} # Strip btrfs subvolume suffix: /dev/sda2[/@home]
if [[ $source_dev != /dev/* ]]; then
echo "Cannot find a disk behind $target_dir" >&2
exit 1
fi
dev=$(readlink -f "$source_dev")
dev=${dev##*/}
if [[ ! -r /sys/class/block/$dev/stat ]]; then
echo "No I/O statistics for $dev" >&2
exit 1
fi
available_mb=$(df --output=avail -m "$target_dir" | tail -1 | tr -d ' ')
if (( available_mb < parallel * file_mb * 2 )); then
echo "Need at least $((parallel * file_mb * 2))MB free on $target_dir" >&2
exit 1
fi
# Name the physical disk under test, walking dm-crypt/LVM layers and the
# partition table up to the whole device that carries the hardware model.
disk=$dev
while slave=$(ls "/sys/class/block/$disk/slaves" 2>/dev/null | head -1); [[ -n $slave ]]; do
disk=$slave
done
if [[ -f /sys/class/block/$disk/partition ]]; then
parent=$(readlink -f "/sys/class/block/$disk")
parent=${parent%/*}
disk=${parent##*/}
fi
model=$(lsblk -dno MODEL "/dev/$disk" 2>/dev/null | sed 's/^ *//; s/ *$//')
echo "disk ${model:-$disk}"
# The stress data must be incompressible so nothing between the write call
# and the flash can shrink it. Staging a urandom chunk in RAM also keeps the
# source out of the measurement -- reading tmpfs is a memcpy.
dd if=/dev/urandom of="$chunk_file" bs=${chunk_mb}M count=$((file_mb / chunk_mb)) status=none
# Workers loop only while the main script lives: if cleanup ever loses the
# race with a kill, an orphaned worker finishes its current pass and stops
# instead of hammering the disk forever.
write_worker() {
local file=$1
while kill -0 $$ 2>/dev/null; do
dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null || return
done
}
read_worker() {
local file=$1
while kill -0 $$ 2>/dev/null; do
dd if="$file" of=/dev/null bs=${chunk_mb}M iflag=direct status=none 2>/dev/null || return
done
}
device_sectors() {
local -a stats
read -r -a stats < "/sys/class/block/$dev/stat"
if [[ $1 == "read" ]]; then
echo "${stats[2]}"
else
echo "${stats[6]}"
fi
}
run_phase() {
local phase=$1
local file before after deadline rate alive samples=0
local baseline_sectors baseline_time end_time
for file in "${test_files[@]}"; do
"${phase}_worker" "$file" 2>/dev/null &
worker_pids+=("$!")
done
before=$(device_sectors "$phase")
deadline=$((SECONDS + phase_seconds))
while (( SECONDS < deadline )) && (( $(alive_workers) > 0 )); do
sleep 1
after=$(device_sectors "$phase")
end_time=$EPOCHREALTIME
rate=$(awk -v before="$before" -v after="$after" 'BEGIN {
if (after < before) print 0
else print (after - before) * 512 / 1000000
}')
echo "$phase $(format_rate "$rate")"
samples=$((samples + 1))
# The first second is warm-up -- governor ramp, crypt workers spinning
# up -- so the steady-state average starts after it.
if (( samples == 1 )); then
baseline_sectors=$after
baseline_time=$end_time
fi
before=$after
done
# The workers only stop on their own when dd fails (quota, I/O error, full
# disk), so any worker gone before the deadline is a failed measurement,
# not a finished one.
alive=$(alive_workers)
stop_workers
if (( alive < parallel )); then
echo "Disk $phase test failed before finishing" >&2
exit 1
fi
# The figure the dial settles on is the steady-state mean over the whole
# phase, not whatever rate the final second happened to catch.
if (( samples > 1 )); then
rate=$(awk -v before="$baseline_sectors" -v after="$after" -v start="$baseline_time" -v end="$end_time" 'BEGIN {
secs = end - start
if (secs <= 0 || after < before) print 0
else print (after - before) * 512 / 1000000 / secs
}')
echo "$phase $(format_rate "$rate")"
fi
}
# The read phase runs first, so its data must be staged before any measuring
# starts. Direct I/O leaves nothing in the page cache to serve reads from.
for file in "${test_files[@]}"; do
dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null &
worker_pids+=("$!")
done
stage_failed=0
for pid in "${worker_pids[@]}"; do
wait "$pid" || stage_failed=1
done
worker_pids=()
if (( stage_failed )) || [[ ! -s ${test_files[0]} ]]; then
echo "Direct disk I/O is not available on $target_dir" >&2
exit 1
fi
run_phase read
run_phase write