lsdsk
Groups disks by the controller they hang off, compares every link against what
both ends of it could do, and reports what is worth acting on. It also names the
mainboard, from DMI, which is what makes its placement advice actionable. Linux
and Windows, no subprocesses, no network. It runs anywhere --replay is all you
need, macOS included; only reading real hardware is the two.
Bare lsdsk gives a PERSON the interactive view and a PROGRAM the printed
page. It looks at whether both ends are a terminal: where they are it opens
the full-screen application, because the whole machine on one page is more than
a reader takes in at once; where they are not - a pipe, a redirect, a CI log -
it prints the page. The exit code is the findings' in both, so lsdsk; echo $?
means the same thing either way. lsdsk report asks for the page by name,
whatever the terminal looks like.
Being a subprocess does NOT mean you get the page. Say lsdsk report in
anything unattended. Some callers hand their child a pseudo-terminal on both ends, and
there lsdsk cannot tell a program from a person: it opens the application and
waits for a keypress nobody can send. Measured in a notebook, where a CI job
hung on a bare !lsdsk for 900 seconds and was killed - IPython runs !cmd
under pexpect, so every notebook frontend does this. script, expect and
pty-allocating job runners allocate the same way, so treat them the same.
So !lsdsk report in a notebook cell, and lsdsk report in any scheduled or
unattended job, rather than reasoning about whether that caller counts as a
terminal. Getting it wrong costs a hang, and getting it needlessly right costs
nothing.
lsdsk report takes --replay like any other command, and the global options
still come first: lsdsk --no-record report --replay file.json.
That distinction matters when you TELL somebody to run it. If they want the
page on screen rather than the application, say lsdsk report.
For a TICKET or a handover, still send a snapshot rather than redirected text:
lsdsk snapshot -o file.json captures the raw reading, so the recipient can
replay every section at any width and any privilege question is settled by the
capture itself. A lsdsk > report.txt is a picture of one moment at one width.
A snapshot names the machine and every drive in it, so check who can read the
ticket before attaching one; lsdsk snapshot says the same on stderr when it
writes the file.
The page is the whole report: mainboard, problem summary, the PCI fabric,
the controller table, disk identities, wear and error counters, SMART
attributes, PCIe slots, counter trends, and every finding with its reasoning, in
that order. It already contains trend and slots, so running those again
after a bare lsdsk adds nothing. Run it first and nothing else, for a handover, a
ticket, or any machine you do not already know. Every subcommand is one section
of it, for when you already know which section you want. Unprivileged it still
renders every section and never aborts; what it could not read shows - and the
header names it, so check that line before quoting a counter as zero.
Bare lsdsk takes no --format: it is a group rather than a command. For a
structured capture of everything, use lsdsk snapshot -o file.json; for one
section's envelope, lsdsk <section> --format json.
Running it
uvx lsdsk # at a terminal the interactive view, elsewhere the page
uvx lsdsk report # everything on one page, by name. Start here
uvx lsdsk topology # the problem summary and the PCI fabric, storage-only by default
uvx lsdsk findings # every finding, with reasoning and a remedy
uvx lsdsk health # wear, temperature, hours, error counters
uvx lsdsk smart # every disk's SMART attributes against its thresholds
uvx lsdsk controllers # controllers, PCIe placement, free ports
uvx lsdsk slots # every PCIe port: what holds it, what is free
uvx lsdsk disks # one row per disk
uvx lsdsk trend # what each error counter is DOING, not just its total
uvx lsdsk record # store one reading, print nothing, for a timer
uvx lsdsk tui # the same eight views, interactive (1-8, left/right, q)
uvx lsdsk snapshot -o m.json # capture the raw reading
uvx lsdsk info # version, homepage and the shell command name
uvx lsdsk config # show effective configuration (--section thresholds for one)
uvx lsdsk config-deploy --target user # write ~/.config/lsdsk so it can be edited (app|host need root)
uvx lsdsk config-generate-examples --destination DIR # scaffold examples
uvx lsdsk --replay m.json # render a capture from any machine
--replay works in either position on every command that reads a machine and
means the same thing, so lsdsk --replay m.json health and
lsdsk health --replay m.json are the same run; give both and the command's own
wins. snapshot is the exception: it captures the machine it runs on and
refuses --replay outright, as below. --profile is accepted after config and
config-deploy, but config-generate-examples takes it only before the command
and exits 2 otherwise. --history-file and --no-record exist only before the
command, so lsdsk --history-file /var/lib/lsdsk/history.json record is right and
lsdsk record --history-file ... is the usage error that exits 2. Give that
path in full: a bare filename writes the store into whatever directory you
happened to be in. When in
doubt, lsdsk <command> --help lists what that command takes.
fail and logdemo also exist. They are not diagnostic commands: they are the
vehicles the traceback and logging tests drive through the real entry point.
Never reach for them to answer a question about hardware.
Reading lsdsk topology
It is the PCI fabric, drawn root-down. Every root complex is a port of the processor on the board, the bridges between are the PATH a drive's traffic takes, and a controller's drives are its own table nested under it rather than another level of fabric.
showing storage and the bridges above it; --tree-density to change the detail level
legacy = no PCIe capability
linux-sas-hba 2 root complexes (0000:00, 0000:ff) root ports to PCIe Gen3x8 95 PCI devices
| address capable running name
+-+-- 0000:00:03.0 Gen3x8 (7.88 GB/s) Gen3x8 (7.88 GB/s) Intel Corporation Xeon E5 v2 PCI Express Root Port
~ | +-- 0000:03:00.0 Gen4x8 (15.75 GB/s) Gen3x8 (7.88 GB/s) Broadcom / LSI Fusion-MPT 12GSAS/PCIe Secure SAS38
| device model size kind bus port disk link temp worn
~ | /dev/sda Samsung SSD 870 EVO 4TB 3.6TiB SSD SATA 12G 6G 6G 36C 1%
The first line is the BOARD, not a bus. It names the board where DMI gave a name and the machine where it did not, how many root complexes the MACHINE has, the best link the board's own root ports publish where one was read, and how many PCI devices the capture holds. A tree whose first line were a bus label would start one level below the thing that explains it.
The default draws the LEAST of the fabric, so a missing device is usually the setting rather than the tool. The note above the tree says which:
storage-only, the shipped default - storage and the bridges above it.storage-and-siblings- those plus the devices sharing a bridge with storage.full- every PCI device.
So a graphics card, a NIC or a Thunderbolt leg is absent by design at the
default. Ask for more with --tree-density full before concluding lsdsk cannot
see a device, and in the interactive view press d, which cycles the three from
least detail to most. Do not report "lsdsk does not show my GPU" as a defect
without saying which density you ran.
The two hop columns are what the DEVICE can do and what it NEGOTIATED, in the tool's own words:
capable- the best link this device publishes.running- what it actually negotiated.
Where the two differ the device is below its own maximum, which is the
comparison this view exists to draw. Read them against the PORT above it, which
is the row one level up: in the sample the HBA is capable of Gen4x8 and
running Gen3x8 because the root port above it is a Gen3 port, so the card is
not faulty and the board is the ceiling.
A hop column holds a SYMBOL when there is no figure, and the section spells out whichever one it drew. Neither is a fault, and reading one as a fault is a work order against hardware that is fine:
- = not read- nobody published the register. Windows exposes a PCIe link for endpoints and for BRIDGES not at all, so every bridge row reads this there. It means UNMEASURED, never "down".legacy = no PCIe capability- the device has no PCIe capability at all, which is an ordinary legacy PCI part. It is a reading, not a gap.
The distinction matters because those two look alike and mean opposite things
about the EVIDENCE. legacy is a positive reading - the platform answered, and
the answer was "this part has no PCIe capability". - is the platform declining
to answer, so nothing has been established about that link either way. No rule
fires on either, and no device is ever called slow on a symbol.
The detail panel adds two more, and each panel explains only the ones it
drew. A cell reading n/a is a value that CANNOT exist for that subject,
which is not the same fact as a reading nobody took: the four numbered ATA
attributes on an NVMe drive, which publishes one fixed log page and no attribute
table at all, and the occupant fields of a socket the panel has just reported
empty. Read as a missed reading it sends somebody looking for a fault.
A figure written <= Gen3x8 (7.88 GB/s) is a CEILING rather than a placeholder.
An uplink is the lower of what the card supports and what the bridge above it
does, so with one end unread it is whatever the other end said and can only be
too high. The end that WAS read is a real measurement, which is why the figure
is marked instead of dashed. On Windows this is every PCIe controller, because
Windows publishes a link capability for an endpoint and none for a bridge.
The marker column on the far left is the finding, not the link. A ! or ~
sits on the row a finding names, so a drive with a perfectly good link can carry
one for wear or for CRC errors. Read lsdsk findings for what it is about
rather than inferring it from the row it sits on.
Devices with no hardware behind them
A zram swap device, a loop mount, a ZFS zvol, a device-mapper or mdraid node: the kernel provides these itself, so they have no controller, no link and no SMART. lsdsk reads them, says so, and does not judge them - no rule fires on one and no finding names one, because there is nothing to compare against.
It decides by asking the kernel where the device sits, never by its name. A
device with no hardware parent resolves under /sys/devices/virtual; a real one
resolves under its PCI path. So an optical drive, named sr0, is ordinary
hardware occupying a real port and is counted as one.
They are folded away rather than listed, because a Proxmox host has more of them than drives. The topology tree and the disk table each end with a line saying how many and of what:
kernel-virtual devices, with no controller and no counters
12 not listed: 8 loop, 3 zd, 1 zram (--expand-virtual lists them)
--expand-virtual gives each one a row, before or after topology, disks and
tui. To make that the default on a host whose zvols are the point, set the
configuration key - lsdsk --set display.expand_virtual=true disks for one run,
or expand_virtual = true under [display] in the deployed
config.d/70-display.toml to keep it:
uvx lsdsk config-deploy --target user # then edit ~/.config/lsdsk/config.d/70-display.toml
This setting changes the screen and nothing else. The devices are always
read, always counted in the header, and always present in --format json. A
program never has to ask for them.
--format json gives a machine-readable envelope on every command that
produces data, including info, snapshot, record and all three
config commands. tui, fail and logdemo have none, having no data to
structure, and report has none deliberately: it is the whole page, whose
machine-readable form is snapshot.
It carries ok, command, data and skipped,
so a caller can tell a complete answer from a partial one.
The envelope's four keys, and what a caller may rely on. ok is true when
the command did everything asked of it, and false when something was left
undone - it is NOT "the hardware is healthy", so never alert on it. skipped is
a list of sentences saying what was not done and why, empty when nothing was. A
reading one DEVICE refused appears there too, as <reading>: <subject> - <reason>,
so a run made as root can still be incomplete: a drive behind some RAID drivers
refuses SMART passthrough, and an AHCI port count is read by mapping the
controller's own registers, which some hosts deny outright. Those entries name
the drive or the PCI address, so a check does not have to interrogate the machine
again to find out which one said no.
command names the command that produced the payload. data is that command's
own result.
A finding, inside data.findings, has five string fields: severity,
subject, title, detail, action. severity is exactly one of
critical, warning, hint - those three words, lower case, and no others.
That is what a monitor branches on, and it is the only way to separate critical
from warning, because the exit code cannot: 1 means "warning OR critical", so
a check that must fire only on critical has to read the field.
lsdsk findings --format json |
python3 -c 'import json,sys
d = json.load(sys.stdin)
sys.exit(any(f["severity"] == "critical" for f in d["data"]["findings"]))'
Keep every continuation line at column 0. An indented one inside python3 -c is
an IndentationError, which exits 1 - the same code these checks use for
"found", so the monitor reports a critical on a machine that has none.
A disk, inside data.disks, carries node, path, model, serial,
firmware, wwn, size_bytes, kind, bus, controller_address, link,
pcie, health and readings_refused. Every one but node, path,
model, bus and readings_refused may be null, which means it was not read
rather than that it is zero. readings_refused is a list, empty on a drive that
answered everything, and each entry is an object of reading and reason: what
was asked for, and what the operating system said when it would not give it. bus is one of
sata, sas, nvme, usb, virtual, unknown; kind is ssd, hdd or
unknown. link is an object of negotiated_gbps, drive_max_gbps and
port_max_gbps, and a speed rule only fires when both ends are known.
A controller, inside data.controllers, carries kind, which is one of
ahci, sas, nvme, raid, ide, other or unknown - read from the PCI
class code, so unknown means unclassified rather than absent. It carries
address, name, vendor, driver, firmware, link, port_count,
ports_used, upstream, upstream_address, upstream_name and
readings_refused beside it. A filter for "the controllers that can carry disks"
is is_storage_controller below, not a list of these values spelled out again.
data.virtual_disks is a second list of the same shape, holding the devices
with no hardware behind them. It is always populated, whatever --expand-virtual
or display.expand_virtual says, and data.disks never contains one - which is
what a check for "nothing without a transport among the real drives" reads:
lsdsk disks --format json |
python3 -c 'import json,sys
d = json.load(sys.stdin)["data"]
sys.exit(any(x["bus"] == "virtual" for x in d["disks"]))'
That check is Linux-only, deliberately. On Windows bus is virtual for a
HYPERVISOR disk, which is the machine's real storage, so the same line would
fail on a healthy guest. It is data.virtual_disks, not the bus value, that
means "provided by the kernel with nothing behind it" - so the check that holds
on either platform asserts the two lists stay disjoint:
lsdsk disks --format json |
python3 -c 'import json,sys
d = json.load(sys.stdin)["data"]
virtual = {x["node"] for x in d["virtual_disks"]}
sys.exit(any(x["node"] in virtual for x in d["disks"]))'
snapshot is the exception to all of this. With -o it writes the raw
reading - the bytes the platform gave, for --replay - which is a different
document from the envelope above and is not a list of disks.
A monitor must read data.privileged too, or it reports clean on a blind
run. Unprivileged, no SMART is read, so no wear or counter finding is ever
raised and the check above passes on a machine nobody looked inside.
data.privileged and data.devices_accessible are both booleans in the same
payload: treat privileged false as "unknown", never as "healthy".
In a Python program, skip the subprocess. lsdsk.adapters.hw.snapshot
gives an inventory and lsdsk.domain.diagnostics.diagnose gives the findings as
objects, with the same rules the CLI runs:
from pathlib import Path
from lsdsk.adapters.hw import snapshot
from lsdsk.domain.diagnostics import diagnose
inventory = snapshot.load(Path("capture.json")) # or snapshot.collect() for this machine
findings = diagnose(inventory) # each has .severity, .subject, .title, .detail, .action
Both calls raise lsdsk.domain.errors.ConfigurationError - load for a file
that is unreadable, malformed, or not a snapshot this version parses, and
collect for a platform with no hardware reader, which raises the
UnsupportedPlatformError subclass. A path that is not there at all raises the
MissingFileError subclass, so a caller that wants to tell an absent snapshot
from a present but malformed one catches that before the base class rather than
reading the message. collect also lets a PermissionError
through when the read is refused outright, which is the exit 13 the CLI leaves.
Catch ConfigurationError and PermissionError around either call in anything
long-running.
diagnose returns a tuple and takes two more keyword arguments the CLI fills
in: thresholds, and history for the trend rules. Omit them and you get the
SHIPPED defaults with no history, which is not what the same machine's lsdsk
would report if its configuration deploys different thresholds.
Do not hand-roll what lsdsk.domain.diagnostics already exports.
count_by_severity(findings) returns a count per Severity including the zeros,
which is the whole split at once rather than the one-severity test the shell
check above can make.
is_storage_controller(kind) is the test the CLI filters controllers with.
format_pcie_sentence(speed_gtps, width) writes a link the one way the whole tool
writes it, so a sentence you compose agrees with the table beside it.
interface_demand_gbytes(disk) and attached_demand_gbytes(controller, inventory)
are the demand figures the oversubscription rule reasons from. Every rule is
callable on its own, which is how you run one without the rest:
diagnose_disk_link(disk, inventory),
diagnose_controller_link(controller, inventory),
diagnose_controller_oversubscription(controller, inventory),
diagnose_port_allocation(inventory), diagnose_health(disk, series, thresholds)
and diagnose_firmware_consistency(inventory, thresholds). DEFAULT_THRESHOLDS is
what those rules fall back to when no Thresholds is passed, and its fields carry
the shipped figures: wear_warning_percent 80, wear_critical_percent 95,
crc_errors_significant 100. Build your own with Thresholds(...) and pass it,
rather than reading a field off the default and comparing yourself.
lsdsk.adapters.hw.snapshot holds more than load and collect.
read_current_machine() returns the raw reading as a dict and save(capture, path) writes it, which is the pair lsdsk snapshot is made of.
parse_capture(reading) types that dict and build_from(reading) turns it into an
inventory, so a capture already in memory never has to reach a file.
current_platform() names the reader this machine has. SCHEMA_VERSION and
OLDEST_READABLE_SCHEMA are the capture version this build writes and the oldest
it still reads, which is what tells you whether an archived capture will replay
before you try it.
The package's own __all__ holds get_config and print_info, which are the
configuration loader and the info command's printer; neither is what you want
for hardware. Reading a machine needs privileges exactly as the CLI does, and
snapshot.load needs none. Run lsdsk --help and
lsdsk <command> --help for current options rather than trusting a list here.
The global options are --replay, --profile, --history-file,
--no-record, --expand-virtual, --tree-density, --traceback, --env-file,
--version and --set SECTION.KEY=VALUE, the last being how you move a
judgement for one run. Four also work after the subcommand: --replay on any
command that reads a machine, --profile on the config commands,
--expand-virtual on topology, disks and tui, and --tree-density on
topology alone - given globally it reaches every view that draws the fabric,
including a bare lsdsk. Every other global option is refused after the
subcommand with exit 2.
The figures the rules turn on are all seven [thresholds] keys, so none of them
is fixed: wear_warning_percent 80, wear_critical_percent 95,
crc_errors_significant 100 (below it a CRC count is a hint), quiet_expected_min
10.0 (the line the whole "were due" idea rests on: fewer expected than this and
the tool refuses to call a counter quiet), wear_projection_min_points 2 (wear is
an integer, so one point is one unit of resolution and a rate from it is noise -
under this much measured movement no wear-out date is projected), min_span_hours
1 and mixed_firmware_threshold 2. Override one for a run with
lsdsk --set thresholds.crc_errors_significant=10 findings, or permanently by
editing the deployed config.d/60-thresholds.toml.
Exit codes: 0 nothing actionable, 1 a warning or critical. Hints never set a
non-zero code; a hint is a ceiling, not a fault.
Only the eight section commands, report and bare lsdsk set 0/1 from findings.
record, snapshot and the config-* commands exit 0 on success whatever the
hardware says, so never read their code as a verdict about the machine. They are
not always 0, though: one that cannot write what it was asked to write leaves
13 or 1, and for record that code is the only channel there is, since it
prints nothing at all in human mode. An internal error is NOT one of the things
1 can mean: a crash leaves 70, so a check can act on 1 as a verdict
without reading stderr first to find out whether the tool merely broke. A crash
writes no failure envelope of its own, because the handler that answers it never
reads --format: the exception goes to stderr in both modes and stdout holds
nothing, or a truncated report if the crash landed mid-write.
| Code | Means |
|---|---|
0 |
A reporting command found nothing actionable. record, snapshot and config-* exit 0 on success regardless |
1 |
A reporting command found a warning or a critical. So does a record or snapshot whose write failed for a reason other than permission; see below. NOT a crash - that is 70 |
2 |
The command line was wrong. USAGE_ERROR in the envelope. See below, this one is misread constantly |
13 |
Something needed privilege this run lacks: config-deploy --target app or host without root, a diagnostic run whose hardware read the kernel refused outright, or a snapshot or record whose destination refuses to be written. A field that merely could not be read is different - it degrades to - and names itself in skipped |
22 |
lsdsk config --section named a section that does not exist, a --profile was rejected, or a global --replay was given to snapshot. --set SECTION.KEY=VALUE is a different option and is not what produces this |
70 |
An error inside lsdsk itself: an exception no command handled. A bug to report and never a statement about the machine, so route it to whoever owns the tool rather than to storage. An OSError keeps its own code instead, because its errno means something - EPERM is itself 1 |
78 |
The file is not a snapshot this version reads, or this platform has no hardware reader |
141 |
The process reading the output closed the pipe before the command finished. Neither a verdict nor a refusal; see the ranking below |
2 does not mean the file was missing. It is the CLI framework's usage
error and an absent --replay path is only one of its causes: an unknown
option, an unknown command, a missing required argument, an invalid --format
value, a --history-file that exists but cannot be opened (a store whose CONTENT is
wrong is a different thing: it warns and the run continues), a malformed --set
and a
--history-file or --no-record placed after the subcommand all produce it
too. A wrapper that reads 2 as "the capture is
absent" will page whoever owns the capture pipeline when the actual fault is a
typo in the wrapper's own command line, and will keep doing so until somebody
reads the message on stderr. Read that message before concluding anything; it
names which it was.
A usage error answers in JSON too, when the command line asked for it.
lsdsk disks --bogus --format json writes one object on stdout and exits 2:
{"ok":false,"command":"disks","error":{"type":"USAGE_ERROR","message":"No such option '--bogus'."}}
with the framework's own usage text on stderr, where it does not disturb a
parser. The parser refuses before any command callback runs, so there is no
--format value to consult and the intent is read from the command line itself:
--format json anywhere before a bare -- gets the envelope, and anything
ambiguous gets none. error.type is always the exit code's own name, so
USAGE_ERROR is 2, PERMISSION_DENIED is 13, INVALID_ARGUMENT is 22 and
CONFIG_ERROR is 78; a caller may branch on the name or on the code and the two
cannot disagree.
A failed write is reported as a SKIP, not as an error, because the command
still did something. lsdsk record --format json whose store cannot be written
exits 13 and still prints its action envelope - ok false, data naming the
store and how many drives were read, and the refusal as a sentence in skipped -
rather than the error object above. So a caller reading error.type alone sees
nothing here: read ok first, then skipped for why.
141 is what a departed reader leaves, and which code wins does not depend on
the format. lsdsk findings ... | head -5 leaves 141 rather than the verdict
it reached, because a verdict that was not delivered is not a verdict: leaving 1
there would tell a monitoring check it had a complete answer when it had five
lines of one. So 0 and 1 yield. A refusal is the other way round - 2, 13,
22 and 78 stand whoever was reading, because there was never any output for
that reader to lose; an unknown option is an unknown option whether it went into
head or into a file, and lsdsk disks --bogus | head -c 0 duly leaves 2 -
read through ${PIPESTATUS[0]} or under set -o pipefail, because a bare $?
after a pipe is the READER's status and reports head succeeding whatever
lsdsk left. 70
stands too, for its own reason rather than that one: a crash says nothing about
what the output CONTAINED, so the reason a verdict yields does not reach it, and a
check piping lsdsk into head or jq reads the crash rather than its own reader
leaving. The one crash that does NOT leave 70 is the one the departed reader
CAUSED - a failed write raises a broken pipe, which carries its own code, so that
leaves 141. Both halves hold identically in human and
--format json output, which is the point of them: an exit code that changed with
the output format would be useless to a wrapper that uses both.
That ranking is about a code the run DECIDED. A command already writing when the
reader leaves exits 141 at that failing write, before it has decided anything -
so lsdsk config --section nosuch piped into a reader that has gone leaves 141
and not 22, because it prints the configuration before it discovers the section
is missing. Judge a command line by running it with its output going somewhere
that stays.
Do not parse what did arrive, either. Whether any of the output reached the
reader before the pipe closed is a question about buffering rather than about the
command, so a run that leaves 141 may have delivered a whole document, half of
one, or nothing at all. 141 means "what you asked for was not delivered"; the
only sound response is to run it again somewhere the output survives.
The exit code can fall from 1 to 0 with the hardware untouched, and if
somebody alerts on it they need to know. Two ways. A fault the recorded history
proves is over is downgraded to a hint, and a hint is not actionable, so a host
whose only complaint was a dead cable fault goes quiet - correctly. And an
unprivileged run reads no SMART at all, so the findings are never raised in the
first place; that one is a blind run, not good news. Tell them to pin the
privilege level and read the header for - columns rather than trusting 0.
An ELEVATED run writes to disk. Reading the counters needs root, so an
unelevated run records nothing at all and its store never appears. A run that
can read them records every drive it read, owner readable only and capped per
drive (history.max_samples_per_drive), and only when some drive's own clock -
its power-on hours - has moved on since the last one. That covers the drives
whose own clock stood still too, and such a drive has its newest row REPLACED
rather than gaining one: two readings inside one power-on hour hold one hour of
information. So the store does not grow a row per drive per run, and a drive's
newest row is always its latest reading. That is what makes lsdsk trend
possible.
Where it lives depends on who is running. A root run on Linux or macOS uses
/var/lib/lsdsk/history.json; anyone else gets the per-user state directory:
$XDG_STATE_HOME/lsdsk/history.json or ~/.local/state/lsdsk/history.json on
Linux, ~/Library/Application Support/bitranox/lsdsk/ on macOS,
%LOCALAPPDATA%\bitranox\lsdsk\ on Windows. Reading
the counters needs root, so on a server it is the ROOT path that fills while the
per-user one stays empty. When a store is refused it is therefore
/var/lib/lsdsk/history.json you move aside on a server. The two paths are
separate files, so an unprivileged run neither reads nor is refused by the root
one; it has its own, which stays empty because recording needs root. lsdsk record --format json prints the path this run resolved, under
store.
A REPORTING
command does not write when replaying somebody else's snapshot or when
--format json is asked for. Global --no-record turns it off and
--history-file moves it; both go before the subcommand. --no-record still
reads the history and still grades against it, so it suppresses the write
without blinding the verdict.
lsdsk record is the exception, and a read-only pipeline must exclude it.
Storing a reading is its entire purpose, so it writes even under --replay and
even under --format json - lsdsk record --replay other.json is how you fold
somebody else's capture into the history deliberately. It prints nothing in its
human form, which is what suits it to a timer, but --format json gives it the
same envelope every other command has, with recorded, store and drives
inside data.
So the rule is "a REPORTING command asked for JSON does not mutate state", not
"lsdsk does not mutate state": put topology, disks, health, smart,
findings, slots, controllers or trend in that pipeline, and leave
record, snapshot and the two config-* commands out of it, all four of
which write by design whatever --format says. snapshot also REFUSES a global
--replay with exit 22 rather than obeying it: it always captures the machine
it runs on, so there is no snapshot of somebody else's capture to take. Copy the
file instead. info and plain config write
nothing either and are safe to include; of the three config commands, config-deploy
and config-generate-examples are the two that create files.
A run that cannot READ the history will not WRITE it either, and says so.
On stderr you get Warning: ignoring counter history: <why> followed by
Not recording this run, so <path> is left as it is. The causes are a store
belonging to a different hostname, one written by a newer lsdsk, one too large
to read, and one that is not valid JSON. Two things follow, and both matter to
whoever is paged. The hardware is still diagnosed and the exit code still
reflects the findings, so this is not a failed run. And the file is INTACT:
nothing was overwritten, so there is nothing to restore from backup, and the
counters simply stop accumulating until somebody acts. To resume recording,
move the file aside or point --history-file somewhere else - a renamed host
is the common cause, because the default path carries no hostname.
Topology, link speeds, capacity and firmware read unprivileged. It never guesses
a value it could not read, so anything below shows - instead, and the header
says so.
Four things need root or Administrator, not one:
| Needs privilege | Because | Costs you |
|---|---|---|
| SMART attributes and wear | ATA and NVMe passthrough ioctls | Those columns, and every finding drawn from them |
Error counters, so trend and record |
Same passthrough read | An unelevated run records nothing at all |
| PCIe slot numbers, connector detection | Config space past the first 64 bytes | The slot column, FREE, and any card move |
| The AHCI ports-implemented bitmap | A memory mapping of the controller's BAR5 | A SATA controller's free-port count |
The last one is refused on some hosts even as root, so a - there is not proof
the run was unprivileged.
Root does not always help. In a container the device nodes do not exist, so elevating changes nothing. Check what kind of machine you are on before arranging access you cannot use.
On Windows a port's own speed and width are not a privilege question. Windows
publishes link registers for PCIe endpoints and none at all for bridges, so a
port's capable and running columns stay - and upstream stays null however
the run was started. Measured on one board: eight bridges, not one with a link
speed, and no registry key, WMI class or user-mode API that has them. Never tell
a Windows user to re-run elevated to reveal a port's capability - they will come
back with the same - and think something is broken.
An unprivileged run that reports nothing is not a clean bill of health. It never read SMART, so those findings were never raised. That is a blind run, not a quiet one.
Check what kind of machine you are on first
The banner says so, and it changes the whole reading:
- Bare metal. Everything applies.
- A container. The disks and controllers shown belong to the host, seen through a shared kernel. The faults are real and worth reporting, but they are the host's faults, so investigate and act there. Health data is usually absent because the device nodes do not exist in the container, and elevating does not change that.
- A virtual machine. The disks, controllers and link speeds are the hypervisor's invention, so the link and placement rules are suppressed entirely: a cable warning about an emulated controller is noise. Health data can still be real if a device was passed through. Diagnose the host.
A container or a guest adds a caveat line under the banner; bare metal adds
nothing, so silence is the bare-metal answer rather than a missing check.
--format json states it outright in data.environment, one of bare_metal,
virtual_machine, container or unknown, with data.environment_detail
naming the hypervisor. Treat unknown as "not established", not as bare metal.
Never carry a link or placement recommendation from a guest to the host. Run it on the host.
A count is not a rate. Run lsdsk trend before advising anything
Every error counter is a lifetime total held in the drive's own non-volatile table. It survives reboots, power cycles and reinstalls, and the host cannot clear it. So a large number tells you how much damage there has ever been and nothing at all about when. A fault that ended two years ago and one corrupting data right now produce the same figure.
lsdsk trend is the answer, and it answers today:
uvx lsdsk trend
device counter total change span per hour verdict
/dev/sdd interface CRC 2196127 +16642 15h 1109 rising
/dev/sdj interface CRC 462640 +0 16h - no new in 16h, 235 were due
/dev/sde interface CRC 430 +0 15h - too soon to say, this drive's rate would not have produced even one in 15h
Those two top
…(truncated)