Files
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

152 lines
4.3 KiB
QML

import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
// The shared gauge-cluster overlay dressed for the disk speed test: read and
// write dials in MB/s, titled with the model of the disk under test. One
// omarchy-disk-speedtest run streams both phases and cleans up after itself,
// so dismissal only has to stop the process.
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
property bool running: false
property bool expectedStop: false
property bool pendingRun: false
property string phase: "" // "read" | "write" | ""
property string diskName: ""
property string writeMBps: ""
property string readMBps: ""
property string error: ""
property string stderrText: ""
function open(payloadJson) {
opened = true
runTest()
}
// Host-initiated close (`shell hide`). The user-initiated paths (Esc, the
// scrim) route through shell.hide so the host's open-panel state stays
// consistent, and land back here.
function close() {
opened = false
pendingRun = false
// Clear the phase before killing the process, so onExited reads the stop
// as a dismissal rather than a failed run.
phase = ""
running = false
if (proc.running) {
expectedStop = true
proc.running = false
}
}
function dismiss() {
if (shell && typeof shell.hide === "function")
shell.hide((manifest && manifest.id) || "omarchy.disk-speedtest")
else close()
}
function runTest() {
if (proc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the fresh run for onExited.
if (expectedStop) pendingRun = true
return
}
error = ""
diskName = ""
writeMBps = ""
readMBps = ""
stderrText = ""
phase = "read"
running = true
proc.running = true
}
function toRate(raw) {
var value = parseFloat(raw)
return isFinite(value) && value > 0 ? value : 0
}
// Lines are "disk <model>", then "read <MB/s>" once a second, then
// "write <MB/s>". The phase follows whichever figure is streaming, and each
// phase's final line is its steady-state average, which the dial settles on.
function updateLine(line) {
var parts = String(line).trim().split(/\s+/)
if (parts.length < 2) return
if (parts[0] === "disk") {
diskName = parts.slice(1).join(" ")
return
}
var value = parseFloat(parts[1])
if (!isFinite(value) || value < 0) return
if (parts[0] === "write") {
phase = "write"
writeMBps = String(value)
} else if (parts[0] === "read") {
phase = "read"
readMBps = String(value)
}
}
Process {
id: proc
command: ["omarchy-disk-speedtest"]
stdout: SplitParser { onRead: function(line) { root.updateLine(line) } }
// Exit and stream-finished have no guaranteed order: when a failed exit
// beat the collector and published the generic message, replace it with
// the specific one once it lands.
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.stderrText = String(text || "").trim()
if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText
}
}
onExited: function(exitCode) {
if (root.pendingRun) {
root.pendingRun = false
root.expectedStop = false
if (root.opened) Qt.callLater(root.runTest)
return
}
if (!root.expectedStop && exitCode !== 0) {
root.error = root.stderrText || "Disk speed test failed"
root.phase = ""
root.running = false
return
}
root.expectedStop = false
root.phase = ""
root.running = false
}
}
SpeedTestOverlay {
fontFamily: Style.font.family
layerNamespace: "omarchy-disk-speedtest"
title: root.diskName
leftLabel: "READ"
rightLabel: "WRITE"
unit: "MB/s"
runAgainTooltip: "Measure again"
running: root.running
leftValue: root.toRate(root.readMBps)
rightValue: root.toRate(root.writeMBps)
leftLive: root.running && root.phase === "read"
rightLive: root.running && root.phase === "write"
error: root.error
open: root.opened
scaleStops: [500, 1000, 2500, 5000, 10000, 15000]
onCloseRequested: root.dismiss()
onRunAgainRequested: root.runTest()
}
}